Merge remote-tracking branch 'origin/V2' into PaymentSelfhost
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# Node modules and build artifacts
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
build
|
||||
bin
|
||||
target
|
||||
out
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Java compiled files
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# Test reports
|
||||
test-results
|
||||
coverage
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
.dockerignore
|
||||
|
||||
# Temporary files
|
||||
tmp
|
||||
temp
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Runtime database and config files (locked by running app)
|
||||
app/core/configs/**
|
||||
stirling/**
|
||||
stirling-pdf-DB*.mv.db
|
||||
stirling-pdf-DB*.trace.db
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
@@ -17,18 +17,11 @@ on:
|
||||
branches: [main, V2]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/src/desktop/**'
|
||||
- 'frontend/tsconfig.desktop.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
push:
|
||||
branches: [main, V2]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -47,21 +40,19 @@ jobs:
|
||||
"windows")
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
# "macos")
|
||||
# echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
# ;;
|
||||
"macos")
|
||||
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"linux")
|
||||
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# For PR/push events, build all platforms
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
|
||||
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build:
|
||||
@@ -96,7 +87,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
|
||||
|
||||
@@ -183,80 +174,96 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# Disabled Mac builds - Import Apple Developer Certificate
|
||||
# - name: Import Apple Developer Certificate
|
||||
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
|
||||
# env:
|
||||
# APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
# APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
# APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
# run: |
|
||||
# echo "Importing Apple Developer Certificate..."
|
||||
# echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
# security default-keychain -s build.keychain
|
||||
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
# security set-keychain-settings -t 3600 -u build.keychain
|
||||
# security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
# security find-identity -v -p codesigning build.keychain
|
||||
# - name: Verify Certificate
|
||||
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
|
||||
# run: |
|
||||
# echo "Verifying Apple Developer Certificate..."
|
||||
# CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||
# echo "Certificate Info: $CERT_INFO"
|
||||
# CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
# echo "Certificate ID: $CERT_ID"
|
||||
# echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
# echo "Certificate imported."
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
echo "Importing Apple Developer Certificate..."
|
||||
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Import certificate
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
# - name: Check DMG creation dependencies (macOS only)
|
||||
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
|
||||
# run: |
|
||||
# echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
|
||||
# echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
|
||||
# echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
|
||||
# echo "Available disk space: $(df -h /tmp | tail -1)"
|
||||
# echo "macOS version: $(sw_vers -productVersion)"
|
||||
# echo "Available tools:"
|
||||
# ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
- name: Verify Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "Verifying Apple Developer Certificate..."
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
|
||||
echo "Certificate Info: $CERT_INFO"
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
echo "Certificate ID: $CERT_ID"
|
||||
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
|
||||
echo "Certificate imported successfully."
|
||||
|
||||
- name: Build Tauri app
|
||||
- name: Check DMG creation dependencies (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
|
||||
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
|
||||
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
|
||||
echo "Available disk space: $(df -h /tmp | tail -1)"
|
||||
echo "macOS version: $(sw_vers -productVersion)"
|
||||
echo "Available tools:"
|
||||
ls -la /usr/bin/hd* || echo "No hd* tools found"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
SIGN: 1
|
||||
CI: true
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
echo "🔍 Verifying notarization status..."
|
||||
cd ./frontend/src-tauri/target
|
||||
DMG_FILE=$(find . -name "*.dmg" | head -1)
|
||||
if [ -n "$DMG_FILE" ]; then
|
||||
echo "Found DMG: $DMG_FILE"
|
||||
echo "Checking notarization ticket..."
|
||||
spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
|
||||
stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
|
||||
else
|
||||
echo "⚠️ No DMG file found to verify"
|
||||
fi
|
||||
|
||||
- name: Rename artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ./dist
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
|
||||
# Find and rename artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
|
||||
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
|
||||
# Disabled Mac builds
|
||||
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
|
||||
# find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
# find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
|
||||
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
|
||||
else
|
||||
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
@@ -273,7 +280,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd ./frontend/src-tauri/target
|
||||
|
||||
|
||||
# Check for expected artifacts based on platform
|
||||
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
|
||||
echo "Checking for Windows artifacts..."
|
||||
@@ -282,14 +289,13 @@ jobs:
|
||||
echo "❌ No Windows executable found"
|
||||
exit 1
|
||||
fi
|
||||
# Disabled Mac builds
|
||||
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
|
||||
# echo "Checking for macOS artifacts..."
|
||||
# find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
# if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
# echo "❌ No macOS artifacts found"
|
||||
# exit 1
|
||||
# fi
|
||||
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
|
||||
echo "Checking for macOS artifacts..."
|
||||
find . -name "*.dmg" -o -name "*.app" | head -5
|
||||
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
|
||||
echo "❌ No macOS artifacts found"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Checking for Linux artifacts..."
|
||||
find . -name "*.deb" -o -name "*.AppImage" | head -5
|
||||
@@ -298,7 +304,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo "✅ Build artifacts found for ${{ matrix.name }}"
|
||||
|
||||
- name: Test artifact sizes
|
||||
@@ -331,4 +337,4 @@ jobs:
|
||||
echo "❌ Some Tauri builds failed."
|
||||
echo "Please check the logs and fix any issues."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -505,10 +505,19 @@ public class ApplicationProperties {
|
||||
public static class Ui {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
}
|
||||
|
||||
public String getLogoStyle() {
|
||||
// Validate and return either "modern" or "classic"
|
||||
if ("modern".equalsIgnoreCase(logoStyle)) {
|
||||
return "modern";
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -8,4 +8,6 @@ public interface UserServiceInterface {
|
||||
long getTotalUsersCount();
|
||||
|
||||
boolean isCurrentUserAdmin();
|
||||
|
||||
boolean isCurrentUserFirstLogin();
|
||||
}
|
||||
|
||||
@@ -75,6 +75,9 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|
||||
@@ -28,6 +28,8 @@ public class InitialSetup {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private static boolean isNewServer = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() throws IOException {
|
||||
initUUIDKey();
|
||||
@@ -88,6 +90,13 @@ public class InitialSetup {
|
||||
}
|
||||
|
||||
public void initSetAppVersion() throws IOException {
|
||||
// Check if this is a new server before setting the version
|
||||
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
|
||||
isNewServer =
|
||||
existingVersion == null
|
||||
|| existingVersion.isEmpty()
|
||||
|| existingVersion.equals("0.0.0");
|
||||
|
||||
String appVersion = "0.0.0";
|
||||
Resource resource = new ClassPathResource("version.properties");
|
||||
Properties props = new Properties();
|
||||
@@ -99,4 +108,8 @@ public class InitialSetup {
|
||||
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
|
||||
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
|
||||
}
|
||||
|
||||
public static boolean isNewServer() {
|
||||
return isNewServer;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -10,7 +10,10 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -19,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@@ -58,9 +62,15 @@ public class ConfigController {
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
|
||||
boolean enableLogin =
|
||||
applicationProperties.getSecurity().getEnableLogin() && userService != null;
|
||||
configData.put("enableLogin", enableLogin);
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
@@ -78,6 +88,22 @@ public class ConfigController {
|
||||
}
|
||||
configData.put("isAdmin", isAdmin);
|
||||
|
||||
// Check if this is a new server (version was 0.0.0 before initialization)
|
||||
configData.put("isNewServer", InitialSetup.isNewServer());
|
||||
|
||||
// Check if the current user is a first-time user
|
||||
boolean isNewUser =
|
||||
false; // Default to false when security is disabled or user not found
|
||||
if (userService != null) {
|
||||
try {
|
||||
isNewUser = userService.isCurrentUserFirstLogin();
|
||||
} catch (Exception e) {
|
||||
// If there's an error, assume not new user for safety
|
||||
isNewUser = false;
|
||||
}
|
||||
}
|
||||
configData.put("isNewUser", isNewUser);
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
|
||||
@@ -176,6 +176,7 @@ system:
|
||||
|
||||
ui:
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
|
||||
endpoints:
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
|
||||
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
|
||||
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
|
||||
/* Clear button disabled text color (default/light) */
|
||||
--spdf-clear-disabled-text: var(--md-sys-color-primary);
|
||||
/* Icon fill */
|
||||
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
|
||||
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
|
||||
@@ -25,6 +27,12 @@
|
||||
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
.dark-theme {
|
||||
/* In dark mode, use a neutral grey for disabled Clear button text */
|
||||
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
|
||||
}
|
||||
|
||||
.fill {
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
+5
@@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
|
||||
LoginData data = new LoginData();
|
||||
Map<String, String> providerList = new HashMap<>();
|
||||
Security securityProps = applicationProperties.getSecurity();
|
||||
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.getEnableLogin());
|
||||
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
@@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@Data
|
||||
public static class LoginData {
|
||||
private Boolean enableLogin;
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
|
||||
+27
@@ -742,4 +742,31 @@ public class UserController {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/complete-initial-setup")
|
||||
public ResponseEntity<?> completeInitialSetup() {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body("User not authenticated");
|
||||
}
|
||||
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
user.setHasCompletedInitialSetup(true);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("User {} completed initial setup", username);
|
||||
return ResponseEntity.ok().body(Map.of("success", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Error completing initial setup", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to complete initial setup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout");
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
|
||||
}
|
||||
|
||||
private enum UserLoginType {
|
||||
|
||||
@@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "isFirstLogin")
|
||||
private Boolean isFirstLogin = false;
|
||||
|
||||
@Column(name = "hasCompletedInitialSetup")
|
||||
private Boolean hasCompletedInitialSetup = false;
|
||||
|
||||
@Column(name = "roleName")
|
||||
private String roleName;
|
||||
|
||||
@@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable {
|
||||
this.isFirstLogin = isFirstLogin;
|
||||
}
|
||||
|
||||
public boolean hasCompletedInitialSetup() {
|
||||
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
|
||||
}
|
||||
|
||||
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
|
||||
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
|
||||
}
|
||||
|
||||
public void setAuthenticationType(AuthenticationType authenticationType) {
|
||||
this.authenticationType = authenticationType.toString().toLowerCase();
|
||||
}
|
||||
|
||||
+15
@@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isCurrentUserFirstLogin() {
|
||||
try {
|
||||
String username = getCurrentUsername();
|
||||
if (username != null) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
return !userOpt.get().hasCompletedInitialSetup();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking first login status", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncCustomApiUser(String customApiKey) {
|
||||
if (customApiKey == null || customApiKey.trim().isBlank()) {
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '1.4.0'
|
||||
version = '2.0.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Run nginx as non-root user
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
Generated
+58
@@ -10,6 +10,7 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
@@ -508,6 +509,63 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -35,9 +35,16 @@
|
||||
"discardChanges": "Discard & Leave",
|
||||
"applyAndContinue": "Save & Leave",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"zipWarning": {
|
||||
"title": "Large ZIP File",
|
||||
"message": "This ZIP contains {{count}} files. Extract anyway?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Extract"
|
||||
},
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"addPageNumbers": {
|
||||
"fontSize": "Font Size",
|
||||
"fontName": "Font Name",
|
||||
@@ -98,6 +105,8 @@
|
||||
"unpin": "Unpin File (replace after tool run)",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"back": "Back",
|
||||
"nothingToUndo": "Nothing to undo",
|
||||
"moreOptions": "More Options",
|
||||
"editYourNewFiles": "Edit your new file(s)",
|
||||
"close": "Close",
|
||||
@@ -1002,7 +1011,8 @@
|
||||
"title": "Choose Your Split Method"
|
||||
}
|
||||
},
|
||||
"selectMethod": "Select a split method"
|
||||
"selectMethod": "Select a split method",
|
||||
"resultsTitle": "Split Results"
|
||||
},
|
||||
"rotate": {
|
||||
"title": "Rotate PDF",
|
||||
@@ -1096,7 +1106,11 @@
|
||||
"markdown": "Markdown",
|
||||
"textRtf": "Text/RTF",
|
||||
"grayscale": "Greyscale",
|
||||
"errorConversion": "An error occurred while converting the file."
|
||||
"errorConversion": "An error occurred while converting the file.",
|
||||
"cbzOptions": "CBZ to PDF Options",
|
||||
"optimizeForEbook": "Optimize PDF for ebook readers (uses Ghostscript)",
|
||||
"cbzOutputOptions": "PDF to CBZ Options",
|
||||
"cbzDpi": "DPI for image rendering"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"tags": "conversion,img,jpg,picture,photo"
|
||||
@@ -2157,15 +2171,99 @@
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
"title": "Compare",
|
||||
"header": "Compare PDFs",
|
||||
"highlightColor": {
|
||||
"1": "Highlight Colour 1:",
|
||||
"2": "Highlight Colour 2:"
|
||||
"clearSelected": "Clear selected",
|
||||
"clear": {
|
||||
"confirmTitle": "Clear selected PDFs?",
|
||||
"confirmBody": "This will close the current comparison and take you back to Active Files.",
|
||||
"confirm": "Clear and return"
|
||||
},
|
||||
"document": {
|
||||
"1": "Document 1",
|
||||
"2": "Document 2"
|
||||
"review": {
|
||||
"title": "Comparison Result",
|
||||
"actionsHint": "Review the comparison, switch document roles, or export the summary.",
|
||||
"switchOrder": "Switch order",
|
||||
"exportSummary": "Export summary"
|
||||
},
|
||||
"submit": "Compare",
|
||||
"base": {
|
||||
"label": "Original document",
|
||||
"placeholder": "Select the original PDF"
|
||||
},
|
||||
"comparison": {
|
||||
"label": "Edited document",
|
||||
"placeholder": "Select the edited PDF"
|
||||
},
|
||||
"addFilesHint": "Add PDFs in the Files step to enable selection.",
|
||||
"noFiles": "No PDFs available yet",
|
||||
"pages": "Pages",
|
||||
"selection": {
|
||||
"originalEditedTitle": "Select Original and Edited PDFs"
|
||||
},
|
||||
"original": { "label": "Original PDF" },
|
||||
"edited": { "label": "Edited PDF" },
|
||||
"swap": {
|
||||
"confirmTitle": "Re-run comparison?",
|
||||
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
||||
"confirm": "Swap and Re-run"
|
||||
},
|
||||
"cta": "Compare",
|
||||
"loading": "Comparing...",
|
||||
|
||||
"summary": {
|
||||
"baseHeading": "Original document",
|
||||
"comparisonHeading": "Edited document",
|
||||
"pageLabel": "Page"
|
||||
},
|
||||
"rendering": {
|
||||
"pageNotReadyTitle": "Page not rendered yet",
|
||||
"pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.",
|
||||
"rendering": "rendering",
|
||||
"inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete",
|
||||
"pagesRendered": "pages rendered",
|
||||
"complete": "Page rendering complete"
|
||||
},
|
||||
"dropdown": {
|
||||
"deletionsLabel": "Deletions",
|
||||
"additionsLabel": "Additions",
|
||||
"deletions": "Deletions ({{count}})",
|
||||
"additions": "Additions ({{count}})",
|
||||
"searchPlaceholder": "Search changes...",
|
||||
"noResults": "No changes found"
|
||||
},
|
||||
"actions": {
|
||||
"stackVertically": "Stack vertically",
|
||||
"placeSideBySide": "Place side by side",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"resetView": "Reset view",
|
||||
"unlinkScrollPan": "Unlink scroll and pan",
|
||||
"linkScrollPan": "Link scroll and pan",
|
||||
"unlinkScroll": "Unlink scroll",
|
||||
"linkScroll": "Link scroll"
|
||||
},
|
||||
"toasts": {
|
||||
"unlinkedTitle": "Independent scroll & pan enabled",
|
||||
"unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
|
||||
},
|
||||
"error": {
|
||||
"selectRequired": "Select a original and edited document.",
|
||||
"filesMissing": "Unable to locate the selected files. Please re-select them.",
|
||||
"generic": "Unable to compare these files."
|
||||
},
|
||||
"status": {
|
||||
"extracting": "Extracting text...",
|
||||
"processing": "Analysing differences...",
|
||||
"complete": "Comparison ready"
|
||||
},
|
||||
"longJob": {
|
||||
"title": "Large comparison in progress",
|
||||
"body": "These PDFs together exceed 2,000 pages. Processing can take several minutes."
|
||||
},
|
||||
"slowOperation": {
|
||||
"title": "Still working…",
|
||||
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
|
||||
"cancel": "Cancel comparison"
|
||||
},
|
||||
|
||||
"newLine": "new-line",
|
||||
"complex": {
|
||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||
},
|
||||
@@ -2178,6 +2276,16 @@
|
||||
"text": {
|
||||
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
|
||||
}
|
||||
},
|
||||
"too": {
|
||||
"dissimilar": {
|
||||
"message": "These documents appear highly dissimilar. Comparison was stopped to save time."
|
||||
}
|
||||
},
|
||||
"earlyDissimilarity": {
|
||||
"title": "These PDFs look highly different",
|
||||
"body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
|
||||
"stopButton": "Stop comparison"
|
||||
}
|
||||
},
|
||||
"certSign": {
|
||||
@@ -2658,7 +2766,14 @@
|
||||
"title": "Show Javascript",
|
||||
"header": "Show Javascript",
|
||||
"downloadJS": "Download Javascript",
|
||||
"submit": "Show"
|
||||
"submit": "Show",
|
||||
"results": "Result",
|
||||
"processing": "Extracting JavaScript...",
|
||||
"done": "JavaScript extracted",
|
||||
"singleFileWarning": "This tool only supports one file at a time. Please select a single file.",
|
||||
"view": {
|
||||
"title": "Extracted JavaScript"
|
||||
}
|
||||
},
|
||||
"redact": {
|
||||
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
|
||||
@@ -3062,6 +3177,7 @@
|
||||
"rememberme": "Remember me",
|
||||
"invalid": "Invalid username or password.",
|
||||
"locked": "Your account has been locked.",
|
||||
"sessionExpired": "Your session has expired. Please sign in again.",
|
||||
"signinTitle": "Please sign in",
|
||||
"ssoSignIn": "Login via Single Sign-on",
|
||||
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
|
||||
@@ -3491,7 +3607,8 @@
|
||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||
"annotationMode": "Toggle Annotation Mode",
|
||||
"draw": "Draw",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
@@ -3538,8 +3655,10 @@
|
||||
"help": "Help",
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"settings": "Settings",
|
||||
"adminSettings": "Admin Settings",
|
||||
"allTools": "All Tools",
|
||||
"allTools": "Tools",
|
||||
"reader": "Reader",
|
||||
"helpMenu": {
|
||||
"toolsTour": "Tools Tour",
|
||||
"toolsTourDesc": "Learn what the tools can do",
|
||||
@@ -3565,6 +3684,12 @@
|
||||
"saveSuccess": "Settings saved successfully",
|
||||
"save": "Save Changes",
|
||||
"restartRequired": "Restart Required",
|
||||
"loginRequired": "Login mode must be enabled to modify admin settings",
|
||||
"loginDisabled": {
|
||||
"title": "Login Mode Required",
|
||||
"message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
|
||||
"readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
|
||||
},
|
||||
"restart": {
|
||||
"title": "Restart Required",
|
||||
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
|
||||
@@ -3635,6 +3760,12 @@
|
||||
"description": "Default producer for PDF metadata"
|
||||
}
|
||||
},
|
||||
"logoStyle": {
|
||||
"label": "Logo Style",
|
||||
"description": "Choose between the modern minimalist logo or the classic S icon",
|
||||
"classic": "Classic",
|
||||
"modern": "Modern"
|
||||
},
|
||||
"customPaths": {
|
||||
"label": "Custom Paths",
|
||||
"description": "Configure custom file system paths for pipeline processing and external tools",
|
||||
@@ -4492,6 +4623,12 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"collapsed": "collapsed",
|
||||
"lines": "lines",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"refresh": "Refresh",
|
||||
@@ -4528,6 +4665,12 @@
|
||||
}
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Use your API key to programmatically access Stirling PDF's processing capabilities.",
|
||||
"docsTitle": "API Documentation",
|
||||
"docsDescription": "Learn more about integrating with Stirling PDF:",
|
||||
"docsLink": "API Documentation",
|
||||
"schemaLink": "API Schema Reference",
|
||||
"usage": "Include this key in the X-API-KEY header with all API requests.",
|
||||
"description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
|
||||
"publicKeyAriaLabel": "Public API key",
|
||||
"copyKeyAriaLabel": "Copy API key",
|
||||
@@ -4656,7 +4799,7 @@
|
||||
"maybeLater": "Maybe Later",
|
||||
"dontShowAgain": "Don't Show Again"
|
||||
},
|
||||
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
|
||||
@@ -4716,7 +4859,8 @@
|
||||
"admin": "Admin",
|
||||
"roleDescriptions": {
|
||||
"admin": "Can manage settings and invite members, with full administrative access.",
|
||||
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
|
||||
"member": "Can view and edit shared files, but cannot manage workspace settings or members.",
|
||||
"user": "User"
|
||||
},
|
||||
"editRole": "Edit Role",
|
||||
"enable": "Enable",
|
||||
@@ -4797,6 +4941,7 @@
|
||||
"copied": "Link copied to clipboard",
|
||||
"success": "Invite link generated successfully",
|
||||
"successWithEmail": "Invite link generated and sent via email",
|
||||
"emailSent": "Invite link generated and sent via email",
|
||||
"emailFailed": "Invite link generated, but email failed",
|
||||
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
|
||||
"error": "Failed to generate invite link",
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
"unpin": "Unpin File (replace after tool run)",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"back": "Back",
|
||||
"nothingToUndo": "Nothing to undo",
|
||||
"moreOptions": "More Options",
|
||||
"editYourNewFiles": "Edit your new file(s)",
|
||||
"close": "Close",
|
||||
@@ -2443,15 +2445,99 @@
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
"title": "Compare",
|
||||
"header": "Compare PDFs",
|
||||
"highlightColor": {
|
||||
"1": "Highlight Color 1:",
|
||||
"2": "Highlight Color 2:"
|
||||
"clearSelected": "Clear selected",
|
||||
"clear": {
|
||||
"confirmTitle": "Clear selected PDFs?",
|
||||
"confirmBody": "This will close the current comparison and take you back to Active Files.",
|
||||
"confirm": "Clear and return"
|
||||
},
|
||||
"document": {
|
||||
"1": "Document 1",
|
||||
"2": "Document 2"
|
||||
"review": {
|
||||
"title": "Comparison Result",
|
||||
"actionsHint": "Review the comparison, switch document roles, or export the summary.",
|
||||
"switchOrder": "Switch order",
|
||||
"exportSummary": "Export summary"
|
||||
},
|
||||
"submit": "Compare",
|
||||
"base": {
|
||||
"label": "Original document",
|
||||
"placeholder": "Select the original PDF"
|
||||
},
|
||||
"comparison": {
|
||||
"label": "Edited document",
|
||||
"placeholder": "Select the edited PDF"
|
||||
},
|
||||
"addFilesHint": "Add PDFs in the Files step to enable selection.",
|
||||
"noFiles": "No PDFs available yet",
|
||||
"pages": "Pages",
|
||||
"selection": {
|
||||
"originalEditedTitle": "Select Original and Edited PDFs"
|
||||
},
|
||||
"original": { "label": "Original PDF" },
|
||||
"edited": { "label": "Edited PDF" },
|
||||
"swap": {
|
||||
"confirmTitle": "Re-run comparison?",
|
||||
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
||||
"confirm": "Swap and Re-run"
|
||||
},
|
||||
"cta": "Compare",
|
||||
"loading": "Comparing...",
|
||||
|
||||
"summary": {
|
||||
"baseHeading": "Original document",
|
||||
"comparisonHeading": "Edited document",
|
||||
"pageLabel": "Page"
|
||||
},
|
||||
"rendering": {
|
||||
"pageNotReadyTitle": "Page not rendered yet",
|
||||
"pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.",
|
||||
"rendering": "rendering",
|
||||
"inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete",
|
||||
"pagesRendered": "pages rendered",
|
||||
"complete": "Page rendering complete"
|
||||
},
|
||||
"dropdown": {
|
||||
"deletionsLabel": "Deletions",
|
||||
"additionsLabel": "Additions",
|
||||
"deletions": "Deletions ({{count}})",
|
||||
"additions": "Additions ({{count}})",
|
||||
"searchPlaceholder": "Search changes...",
|
||||
"noResults": "No changes found"
|
||||
},
|
||||
"actions": {
|
||||
"stackVertically": "Stack vertically",
|
||||
"placeSideBySide": "Place side by side",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"resetView": "Reset view",
|
||||
"unlinkScrollPan": "Unlink scroll and pan",
|
||||
"linkScrollPan": "Link scroll and pan",
|
||||
"unlinkScroll": "Unlink scroll",
|
||||
"linkScroll": "Link scroll"
|
||||
},
|
||||
"toasts": {
|
||||
"unlinkedTitle": "Independent scroll & pan enabled",
|
||||
"unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
|
||||
},
|
||||
"error": {
|
||||
"selectRequired": "Select a original and edited document.",
|
||||
"filesMissing": "Unable to locate the selected files. Please re-select them.",
|
||||
"generic": "Unable to compare these files."
|
||||
},
|
||||
"status": {
|
||||
"extracting": "Extracting text...",
|
||||
"processing": "Analysing differences...",
|
||||
"complete": "Comparison ready"
|
||||
},
|
||||
"longJob": {
|
||||
"title": "Large comparison in progress",
|
||||
"body": "These PDFs together exceed 2,000 pages. Processing can take several minutes."
|
||||
},
|
||||
"slowOperation": {
|
||||
"title": "Still working…",
|
||||
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
|
||||
"cancel": "Cancel comparison"
|
||||
},
|
||||
|
||||
"newLine": "new-line",
|
||||
"complex": {
|
||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||
},
|
||||
@@ -2464,6 +2550,16 @@
|
||||
"text": {
|
||||
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
|
||||
}
|
||||
},
|
||||
"too": {
|
||||
"dissimilar": {
|
||||
"message": "These documents appear highly dissimilar. Comparison was stopped to save time."
|
||||
}
|
||||
},
|
||||
"earlyDissimilarity": {
|
||||
"title": "These PDFs look highly different",
|
||||
"body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
|
||||
"stopButton": "Stop comparison"
|
||||
}
|
||||
},
|
||||
"certSign": {
|
||||
@@ -2956,7 +3052,14 @@
|
||||
"title": "Show Javascript",
|
||||
"header": "Show Javascript",
|
||||
"downloadJS": "Download Javascript",
|
||||
"submit": "Show"
|
||||
"submit": "Show",
|
||||
"results": "Result",
|
||||
"processing": "Extracting JavaScript...",
|
||||
"done": "JavaScript extracted",
|
||||
"singleFileWarning": "This tool only supports one file at a time. Please select a single file.",
|
||||
"view": {
|
||||
"title": "Extracted JavaScript"
|
||||
}
|
||||
},
|
||||
"redact": {
|
||||
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
|
||||
@@ -3115,7 +3218,7 @@
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"baseFile": {
|
||||
"label": "Select Base PDF File"
|
||||
"label": "Select Original PDF File"
|
||||
},
|
||||
"overlayFiles": {
|
||||
"label": "Select Overlay PDF Files",
|
||||
@@ -3864,7 +3967,7 @@
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"adminSettings": "Admin Settings",
|
||||
"allTools": "All Tools"
|
||||
"allTools": "Tools"
|
||||
},
|
||||
"admin": {
|
||||
"error": "Error",
|
||||
@@ -4815,6 +4918,12 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"collapsed": "collapsed",
|
||||
"lines": "lines",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -2986,7 +2986,7 @@
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"baseFile": {
|
||||
"label": "Select Base PDF File"
|
||||
"label": "Select Original PDF File"
|
||||
},
|
||||
"overlayFiles": {
|
||||
"label": "Select Overlay PDF Files",
|
||||
|
||||
Generated
+420
@@ -81,6 +81,137 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -182,6 +313,19 @@ dependencies = [
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.5.7"
|
||||
@@ -424,6 +568,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -774,6 +927,33 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
@@ -811,6 +991,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -966,6 +1167,19 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
@@ -1352,6 +1566,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -2079,6 +2299,19 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -2463,6 +2696,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
@@ -2498,6 +2741,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2679,6 +2928,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -2711,6 +2971,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.3"
|
||||
@@ -3657,6 +3931,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "stirling-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -3670,6 +3950,7 @@ dependencies = [
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -4056,6 +4337,21 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.9.1"
|
||||
@@ -4463,9 +4759,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.34"
|
||||
@@ -4515,6 +4823,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -5530,6 +5849,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.13",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"winnow 0.7.13",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.27"
|
||||
@@ -5603,3 +5983,43 @@ dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.13",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.108",
|
||||
"winnow 0.7.13",
|
||||
]
|
||||
|
||||
@@ -28,5 +28,6 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
||||
tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.1.0"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tokio = { version = "1.0", features = ["time"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
@@ -234,6 +234,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
match event {
|
||||
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
|
||||
let output_str = String::from_utf8_lossy(&output);
|
||||
// Strip exactly one trailing newline to avoid double newlines
|
||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||
add_log(format!("📤 Backend: {}", output_str));
|
||||
|
||||
// Look for startup indicators
|
||||
@@ -250,6 +252,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
|
||||
let output_str = String::from_utf8_lossy(&output);
|
||||
// Strip exactly one trailing newline to avoid double newlines
|
||||
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
|
||||
add_log(format!("📥 Backend Error: {}", output_str));
|
||||
|
||||
// Look for error indicators
|
||||
|
||||
@@ -1,49 +1,47 @@
|
||||
use crate::utils::add_log;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Store the opened file path globally
|
||||
static OPENED_FILE: Mutex<Option<String>> = Mutex::new(None);
|
||||
// Store the opened file paths globally (supports multiple files)
|
||||
static OPENED_FILES: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
// Set the opened file path (called by macOS file open events)
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set_opened_file(file_path: String) {
|
||||
let mut opened_file = OPENED_FILE.lock().unwrap();
|
||||
*opened_file = Some(file_path.clone());
|
||||
add_log(format!("📂 File opened via file open event: {}", file_path));
|
||||
// Add an opened file path
|
||||
pub fn add_opened_file(file_path: String) {
|
||||
let mut opened_files = OPENED_FILES.lock().unwrap();
|
||||
opened_files.push(file_path.clone());
|
||||
add_log(format!("📂 File stored for later retrieval: {}", file_path));
|
||||
}
|
||||
|
||||
// Command to get opened file path (if app was launched with a file)
|
||||
// Command to get opened file paths (if app was launched with files)
|
||||
#[tauri::command]
|
||||
pub async fn get_opened_file() -> Result<Option<String>, String> {
|
||||
// First check if we have a file from macOS file open events
|
||||
{
|
||||
let opened_file = OPENED_FILE.lock().unwrap();
|
||||
if let Some(ref file_path) = *opened_file {
|
||||
add_log(format!("📂 Returning stored opened file: {}", file_path));
|
||||
return Ok(Some(file_path.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to command line arguments (Windows/Linux)
|
||||
pub async fn get_opened_files() -> Result<Vec<String>, String> {
|
||||
let mut all_files: Vec<String> = Vec::new();
|
||||
|
||||
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Look for a PDF file argument (skip the first arg which is the executable)
|
||||
for arg in args.iter().skip(1) {
|
||||
if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 PDF file opened via command line: {}", arg));
|
||||
return Ok(Some(arg.clone()));
|
||||
}
|
||||
let pdf_files: Vec<String> = args.iter()
|
||||
.skip(1)
|
||||
.filter(|arg| std::path::Path::new(arg).exists())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
all_files.extend(pdf_files);
|
||||
|
||||
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
|
||||
{
|
||||
let opened_files = OPENED_FILES.lock().unwrap();
|
||||
all_files.extend(opened_files.clone());
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
|
||||
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
|
||||
Ok(all_files)
|
||||
}
|
||||
|
||||
// Command to clear the opened file (after processing)
|
||||
// Command to clear the opened files (after processing)
|
||||
#[tauri::command]
|
||||
pub async fn clear_opened_file() -> Result<(), String> {
|
||||
let mut opened_file = OPENED_FILE.lock().unwrap();
|
||||
*opened_file = None;
|
||||
add_log("📂 Cleared opened file".to_string());
|
||||
pub async fn clear_opened_files() -> Result<(), String> {
|
||||
let mut opened_files = OPENED_FILES.lock().unwrap();
|
||||
opened_files.clear();
|
||||
add_log("📂 Cleared opened files".to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,4 @@ pub mod files;
|
||||
|
||||
pub use backend::{start_backend, cleanup_backend};
|
||||
pub use health::check_backend_health;
|
||||
pub use files::{get_opened_file, clear_opened_file};
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use files::set_opened_file;
|
||||
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
use tauri::{RunEvent, WindowEvent};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::Emitter;
|
||||
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
|
||||
|
||||
mod utils;
|
||||
mod commands;
|
||||
|
||||
use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend};
|
||||
#[cfg(target_os = "macos")]
|
||||
use commands::set_opened_file;
|
||||
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -15,12 +11,35 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
// This callback runs when a second instance tries to start
|
||||
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
||||
|
||||
// Scan args for PDF files (skip first arg which is the executable)
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
|
||||
|
||||
// Store file for later retrieval (in case frontend isn't ready yet)
|
||||
add_opened_file(arg.clone());
|
||||
|
||||
// Also emit event for immediate handling if frontend is ready
|
||||
let _ = app.emit("file-opened", arg.clone());
|
||||
|
||||
// Bring the existing window to front
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.setup(|_app| {
|
||||
add_log("🚀 Tauri app setup started".to_string());
|
||||
add_log("🔍 DEBUG: Setup completed".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs])
|
||||
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
@@ -45,8 +64,9 @@ pub fn run() {
|
||||
let file_path = url_str.strip_prefix("file://").unwrap_or(url_str);
|
||||
if file_path.ends_with(".pdf") {
|
||||
add_log(format!("📂 Processing opened PDF: {}", file_path));
|
||||
set_opened_file(file_path.to_string());
|
||||
let _ = app_handle.emit("macos://open-file", file_path.to_string());
|
||||
add_opened_file(file_path.to_string());
|
||||
// Use unified event name for consistency across platforms
|
||||
let _ = app_handle.emit("file-opened", file_path.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,4 +78,4 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "dmg", "msi"],
|
||||
"targets": ["deb", "rpm", "dmg", "app", "msi"],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
@@ -50,6 +50,12 @@
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
@@ -64,15 +65,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { downloadBlob } from '@app/utils/downloadUtils';
|
||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -65,6 +66,15 @@ const FileEditor = ({
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Current tool (for enforcing maxFiles limits)
|
||||
const { selectedTool } = useToolWorkflow();
|
||||
|
||||
// Compute effective max allowed files based on the active tool and mode
|
||||
const maxAllowed = useMemo<number>(() => {
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
|
||||
}, [selectedTool?.maxFiles, toolMode]);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
useEffect(() => {
|
||||
if (toolMode) {
|
||||
@@ -83,7 +93,10 @@ const FileEditor = ({
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
setSelectedFiles(state.files.ids);
|
||||
// Respect maxAllowed: if limited, select the last N files
|
||||
const allIds = state.files.ids;
|
||||
const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds;
|
||||
setSelectedFiles(idsToSelect);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
@@ -91,7 +104,7 @@ const FileEditor = ({
|
||||
console.warn('Failed to clear file errors on select all:', error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
|
||||
|
||||
const handleDeselectAllFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
@@ -131,6 +144,13 @@ const FileEditor = ({
|
||||
// - HTML ZIPs stay intact
|
||||
// - Non-ZIP files pass through unchanged
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
// After auto-selection, enforce maxAllowed if needed
|
||||
if (Number.isFinite(maxAllowed)) {
|
||||
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
|
||||
if (nowSelectedIds.length > maxAllowed) {
|
||||
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
|
||||
}
|
||||
}
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -138,7 +158,7 @@ const FileEditor = ({
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
}
|
||||
}, [addFiles, showStatus, showError]);
|
||||
}, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
@@ -156,24 +176,33 @@ const FileEditor = ({
|
||||
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
|
||||
} else {
|
||||
// Add file to selection
|
||||
// In tool mode, typically allow multiple files unless specified otherwise
|
||||
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
|
||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
// Only one file allowed -> replace selection with the new file
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||
return;
|
||||
// If at capacity, drop the oldest selected and append the new one
|
||||
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
|
||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||
} else {
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]);
|
||||
|
||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
|
||||
setSelectedFiles(selectedFileIds.slice(-maxAllowed));
|
||||
}
|
||||
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
@@ -64,7 +64,7 @@ const FileEditorThumbnail = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
@@ -93,6 +93,13 @@ const FileEditorThumbnail = ({
|
||||
return (m?.[1] || '').toUpperCase();
|
||||
}, [file.name]);
|
||||
|
||||
const extLower = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
||||
return (m?.[1] || '').toLowerCase();
|
||||
}, [file.name]);
|
||||
|
||||
const isCBZ = extLower === 'cbz';
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
pageCount > 0
|
||||
@@ -206,7 +213,7 @@ const FileEditorThumbnail = ({
|
||||
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile,
|
||||
hidden: !isZipFile || !onUnzipFile || isCBZ,
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
.workbench-scrollable {
|
||||
.workbenchScrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar {
|
||||
.workbenchScrollable::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-track {
|
||||
.workbenchScrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb {
|
||||
.workbenchScrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
@@ -81,6 +80,7 @@ export default function Workbench() {
|
||||
|
||||
switch (currentView) {
|
||||
case "fileEditor":
|
||||
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
@@ -98,6 +98,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
case "viewer":
|
||||
|
||||
return (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
@@ -110,6 +111,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
case "pageEditor":
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageEditor
|
||||
@@ -143,6 +145,8 @@ export default function Workbench() {
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
|
||||
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
@@ -154,7 +158,7 @@ export default function Workbench() {
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
className="flex-1 h-full min-w-0 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
@@ -185,14 +189,13 @@ export default function Workbench() {
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer
|
||||
analyticsEnabled={config?.enableAnalytics ?? undefined}
|
||||
analyticsEnabled={config?.enableAnalytics === true}
|
||||
termsAndConditions={config?.termsAndConditions}
|
||||
privacyPolicy={config?.privacyPolicy}
|
||||
cookiePolicy={config?.cookiePolicy}
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function OnboardingTour() {
|
||||
const stepsConfig: Record<TourStep, StepType> = {
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.gridContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.virtualRows {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.virtualRow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rowContent {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selectionBox {
|
||||
position: absolute;
|
||||
border: 2px dashed #3b82f6;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropIndicator {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
background-color: rgba(96, 165, 250, 0.8);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dragOverlay {
|
||||
position: relative;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dragOverlayBadge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
background-color: #3b82f6;
|
||||
color: #ffffff;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.dragOverlayPreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -1,33 +1,369 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
|
||||
import styles from '@app/components/pageEditor/DragDropGrid.module.css';
|
||||
import {
|
||||
Z_INDEX_SELECTION_BOX,
|
||||
Z_INDEX_DROP_INDICATOR,
|
||||
Z_INDEX_DRAG_BADGE,
|
||||
} from '@app/styles/zIndex';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitAfter?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
originalFileId?: string;
|
||||
pageNumber?: number;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
zoomLevel?: number;
|
||||
selectedFileIds?: string[];
|
||||
}
|
||||
|
||||
type DropSide = 'left' | 'right' | null;
|
||||
|
||||
type ItemRect = { id: string; rect: DOMRect };
|
||||
|
||||
interface DropHint {
|
||||
hoveredId: string | null;
|
||||
dropSide: DropSide;
|
||||
}
|
||||
|
||||
function resolveDropHint(
|
||||
activeId: string | null,
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>,
|
||||
cursorX: number,
|
||||
cursorY: number,
|
||||
): DropHint {
|
||||
if (!activeId) {
|
||||
return { hoveredId: null, dropSide: null };
|
||||
}
|
||||
|
||||
const items: ItemRect[] = Array.from(itemRefs.current.entries())
|
||||
.filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId)
|
||||
.map(([itemId, element]) => ({
|
||||
id: itemId,
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
return { hoveredId: null, dropSide: null };
|
||||
}
|
||||
|
||||
items.sort((a, b) => a.rect.top - b.rect.top);
|
||||
|
||||
const rows: ItemRect[][] = [];
|
||||
const rowTolerance = items[0].rect.height / 2;
|
||||
|
||||
items.forEach((item) => {
|
||||
const currentRow = rows[rows.length - 1];
|
||||
if (!currentRow) {
|
||||
rows.push([item]);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
|
||||
if (isSameRow) {
|
||||
currentRow.push(item);
|
||||
} else {
|
||||
rows.push([item]);
|
||||
}
|
||||
});
|
||||
|
||||
let targetRow: ItemRect[] | undefined;
|
||||
let smallestRowDistance = Infinity;
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.length === 0) {
|
||||
return;
|
||||
}
|
||||
const top = row[0].rect.top;
|
||||
const bottom = row[0].rect.bottom;
|
||||
const centerY = top + (bottom - top) / 2;
|
||||
const distance = Math.abs(cursorY - centerY);
|
||||
if (distance < smallestRowDistance) {
|
||||
smallestRowDistance = distance;
|
||||
targetRow = row;
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetRow || targetRow.length === 0) {
|
||||
return { hoveredId: null, dropSide: null };
|
||||
}
|
||||
|
||||
let hoveredItem = targetRow[0];
|
||||
let smallestHorizontalDistance = Infinity;
|
||||
|
||||
targetRow.forEach((item) => {
|
||||
const midpoint = item.rect.left + item.rect.width / 2;
|
||||
const distance = Math.abs(cursorX - midpoint);
|
||||
if (distance < smallestHorizontalDistance) {
|
||||
smallestHorizontalDistance = distance;
|
||||
hoveredItem = item;
|
||||
}
|
||||
});
|
||||
|
||||
const firstItem = targetRow[0];
|
||||
const lastItem = targetRow[targetRow.length - 1];
|
||||
|
||||
let dropSide: DropSide;
|
||||
if (cursorX < firstItem.rect.left) {
|
||||
hoveredItem = firstItem;
|
||||
dropSide = 'left';
|
||||
} else if (cursorX > lastItem.rect.right) {
|
||||
hoveredItem = lastItem;
|
||||
dropSide = 'right';
|
||||
} else {
|
||||
const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2;
|
||||
dropSide = cursorX >= midpoint ? 'right' : 'left';
|
||||
}
|
||||
|
||||
return { hoveredId: hoveredItem.id, dropSide };
|
||||
}
|
||||
|
||||
function resolveTargetIndex<T extends DragDropItem>(
|
||||
hoveredId: string | null,
|
||||
dropSide: DropSide,
|
||||
filteredItems: T[],
|
||||
filteredToOriginalIndex: number[],
|
||||
originalItemsLength: number,
|
||||
fallbackIndex: number | null,
|
||||
): number | null {
|
||||
const convertFilteredIndexToOriginal = (filteredIndex: number): number => {
|
||||
if (filteredToOriginalIndex.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (filteredIndex <= 0) {
|
||||
return filteredToOriginalIndex[0];
|
||||
}
|
||||
|
||||
if (filteredIndex >= filteredToOriginalIndex.length) {
|
||||
return originalItemsLength;
|
||||
}
|
||||
|
||||
return filteredToOriginalIndex[filteredIndex];
|
||||
};
|
||||
|
||||
if (hoveredId) {
|
||||
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
|
||||
if (filteredIndex !== -1) {
|
||||
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
|
||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackIndex !== null && fallbackIndex !== undefined) {
|
||||
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
|
||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lightweight wrapper that handles dnd-kit hooks for each visible item
|
||||
interface DraggableItemProps<T extends DragDropItem> {
|
||||
item: T;
|
||||
index: number;
|
||||
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
boxSelectedPageIds: string[];
|
||||
clearBoxSelection: () => void;
|
||||
getBoxSelection: () => string[];
|
||||
activeId: string | null;
|
||||
activeDragIds: string[];
|
||||
justMoved: boolean;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
onUpdateDropTarget: (itemId: string | null) => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
||||
zoomLevel: number;
|
||||
}
|
||||
|
||||
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
|
||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||
const pageNumber = (item as any).pageNumber ?? index + 1;
|
||||
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
|
||||
id: item.id,
|
||||
disabled: isPlaceholder,
|
||||
data: {
|
||||
index,
|
||||
pageNumber,
|
||||
getThumbnail: () => {
|
||||
if (getThumbnailData) {
|
||||
const data = getThumbnailData(item.id);
|
||||
if (data?.src) return data;
|
||||
}
|
||||
|
||||
const element = itemRefs.current.get(item.id);
|
||||
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
|
||||
if (imgElement?.src) {
|
||||
return {
|
||||
src: imgElement.src,
|
||||
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||
id: item.id,
|
||||
data: { index, pageNumber: index + 1 }
|
||||
});
|
||||
|
||||
// Notify parent when hover state changes
|
||||
useEffect(() => {
|
||||
if (isOver) {
|
||||
onUpdateDropTarget(item.id);
|
||||
} else {
|
||||
onUpdateDropTarget(null);
|
||||
}
|
||||
}, [isOver, item.id, onUpdateDropTarget]);
|
||||
|
||||
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
|
||||
setDraggableRef(element);
|
||||
setDroppableRef(element);
|
||||
}, [setDraggableRef, setDroppableRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
renderItem,
|
||||
onReorderPages,
|
||||
getThumbnailData,
|
||||
zoomLevel = 1.0,
|
||||
selectedFileIds,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getScrollElement = useCallback(() => {
|
||||
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
|
||||
}, []);
|
||||
|
||||
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
|
||||
const filtered: T[] = [];
|
||||
const indexMap: number[] = [];
|
||||
const selectedIds =
|
||||
selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||
if (isPlaceholder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const belongsToVisibleFile =
|
||||
!selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
|
||||
|
||||
if (!belongsToVisibleFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.push(item);
|
||||
indexMap.push(index);
|
||||
});
|
||||
|
||||
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
|
||||
}, [items, selectedFileIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const visibleIdSet = new Set(visibleItems.map(item => item.id));
|
||||
itemRefs.current.forEach((_, pageId) => {
|
||||
if (!visibleIdSet.has(pageId)) {
|
||||
itemRefs.current.delete(pageId);
|
||||
}
|
||||
});
|
||||
}, [visibleItems]);
|
||||
|
||||
// Box selection state
|
||||
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
||||
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
||||
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
|
||||
const highlightTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
// Drag state
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
|
||||
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
|
||||
const [dropSide, setDropSide] = useState<DropSide>(null);
|
||||
|
||||
// Configure sensors for dnd-kit with activation constraint
|
||||
// Require 10px movement before drag starts to allow clicks for selection
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Throttled pointer move handler for drop indicator
|
||||
// Calculate drop position based on cursor location relative to ALL items, not just hovered item
|
||||
useEffect(() => {
|
||||
if (!activeId) {
|
||||
setDropSide(null);
|
||||
setHoveredItemId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let rafId: number | null = null;
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
// Use the actual cursor position (pointer coordinates)
|
||||
const cursorX = e.clientX;
|
||||
const cursorY = e.clientY;
|
||||
|
||||
if (rafId === null) {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY);
|
||||
setHoveredItemId(hint.hoveredId);
|
||||
setDropSide(hint.dropSide);
|
||||
rafId = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}, [activeId]);
|
||||
|
||||
// Responsive grid configuration
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const calculateItemsPerRow = useCallback(() => {
|
||||
@@ -38,8 +374,8 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
|
||||
// Convert rem to pixels for calculation
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||
|
||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||
@@ -47,9 +383,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||
|
||||
return Math.max(1, calculated); // At least 1 item per row
|
||||
}, []);
|
||||
}, [zoomLevel]);
|
||||
|
||||
// Update items per row when container resizes
|
||||
// Update items per row when container resizes or zoom changes
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
const newItemsPerRow = calculateItemsPerRow();
|
||||
@@ -72,86 +408,410 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateItemsPerRow]);
|
||||
}, [calculateItemsPerRow, zoomLevel]);
|
||||
|
||||
// Virtualization with react-virtual library
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: Math.ceil(items.length / itemsPerRow),
|
||||
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||
count: Math.ceil(visibleItems.length / itemsPerRow),
|
||||
getScrollElement,
|
||||
estimateSize: () => {
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
||||
},
|
||||
overscan: OVERSCAN,
|
||||
});
|
||||
|
||||
// Re-measure virtualizer when zoom or items per row changes
|
||||
useEffect(() => {
|
||||
rowVirtualizer.measure();
|
||||
}, [zoomLevel, itemsPerRow, visibleItems.length]);
|
||||
|
||||
// Cleanup highlight timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimeoutRef.current) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Box selection handlers
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return; // Only respond to primary button
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const clickTarget = e.target as Node;
|
||||
let clickedPageId: string | null = null;
|
||||
|
||||
itemRefs.current.forEach((element, pageId) => {
|
||||
if (element.contains(clickTarget)) {
|
||||
clickedPageId = pageId;
|
||||
}
|
||||
});
|
||||
|
||||
if (clickedPageId) {
|
||||
// Clicking directly on a page shouldn't initiate box selection
|
||||
// but clear previous box selection if clicking outside current group
|
||||
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
|
||||
setBoxSelectedPageIds([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
setIsBoxSelecting(true);
|
||||
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
setBoxSelectedPageIds([]);
|
||||
}, [boxSelectedPageIds]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isBoxSelecting || !boxSelectStart) return;
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
|
||||
// Calculate which pages intersect with selection box
|
||||
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
|
||||
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
|
||||
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
|
||||
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
|
||||
|
||||
const selectedIds: string[] = [];
|
||||
itemRefs.current.forEach((pageEl, pageId) => {
|
||||
const pageRect = pageEl.getBoundingClientRect();
|
||||
const pageLeft = pageRect.left - rect.left;
|
||||
const pageRight = pageRect.right - rect.left;
|
||||
const pageTop = pageRect.top - rect.top;
|
||||
const pageBottom = pageRect.bottom - rect.top;
|
||||
|
||||
// Check if page intersects with selection box
|
||||
const intersects = !(
|
||||
pageRight < boxLeft ||
|
||||
pageLeft > boxRight ||
|
||||
pageBottom < boxTop ||
|
||||
pageTop > boxBottom
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
selectedIds.push(pageId);
|
||||
}
|
||||
});
|
||||
|
||||
setBoxSelectedPageIds(selectedIds);
|
||||
}, [isBoxSelecting, boxSelectStart]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isBoxSelecting) {
|
||||
// Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet)
|
||||
// They will remain highlighted until next interaction
|
||||
setIsBoxSelecting(false);
|
||||
setBoxSelectStart(null);
|
||||
setBoxSelectEnd(null);
|
||||
}
|
||||
}, [isBoxSelecting]);
|
||||
|
||||
// Function to clear box selection (exposed to child components)
|
||||
const clearBoxSelection = useCallback(() => {
|
||||
setBoxSelectedPageIds([]);
|
||||
}, []);
|
||||
|
||||
// Function to get current box selection (exposed to child components)
|
||||
const getBoxSelection = useCallback(() => {
|
||||
return boxSelectedPageIds;
|
||||
}, [boxSelectedPageIds]);
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const activeId = event.active.id as string;
|
||||
setActiveId(activeId);
|
||||
|
||||
// Call the getter function to get fresh thumbnail data
|
||||
const getThumbnail = event.active.data.current?.getThumbnail;
|
||||
if (getThumbnail) {
|
||||
const thumbnailData = getThumbnail();
|
||||
if (thumbnailData?.src) {
|
||||
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDragPreview(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// Handle drag cancel
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setDragPreview(null);
|
||||
setHoveredItemId(null);
|
||||
setDropSide(null);
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const finalDropSide = dropSide;
|
||||
setActiveId(null);
|
||||
setDragPreview(null);
|
||||
setHoveredItemId(null);
|
||||
setDropSide(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeData = active.data.current;
|
||||
if (!activeData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePageNumber = activeData.pageNumber;
|
||||
|
||||
const overData = over?.data.current;
|
||||
let targetIndex = resolveTargetIndex(
|
||||
hoveredItemId,
|
||||
finalDropSide,
|
||||
visibleItems,
|
||||
filteredToOriginalIndex,
|
||||
items.length,
|
||||
overData ? overData.index : null
|
||||
);
|
||||
|
||||
if (targetIndex === null) {
|
||||
return;
|
||||
}
|
||||
if (targetIndex < 0) targetIndex = 0;
|
||||
if (targetIndex > items.length) targetIndex = items.length;
|
||||
|
||||
// Check if this page is box-selected
|
||||
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
||||
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
||||
|
||||
// Call reorder with page number and target index
|
||||
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
||||
|
||||
// Highlight moved pages briefly
|
||||
const movedIds = pagesToDrag ?? [active.id as string];
|
||||
setJustMovedIds(movedIds);
|
||||
if (highlightTimeoutRef.current) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setJustMovedIds([]);
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 1200);
|
||||
|
||||
// Clear box selection after drag
|
||||
if (pagesToDrag) {
|
||||
clearBoxSelection();
|
||||
}
|
||||
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
|
||||
|
||||
// Calculate optimal width for centering
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// Basic container styles
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
margin: '0 auto',
|
||||
maxWidth: `${gridWidth}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||
const rowItems = items.slice(startIndex, endIndex);
|
||||
// Calculate selection box dimensions
|
||||
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
|
||||
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
||||
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
||||
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
||||
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
||||
zIndex: Z_INDEX_SELECTION_BOX,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
// Calculate drop indicator position
|
||||
const dropIndicatorStyle = useMemo(() => {
|
||||
if (!hoveredItemId || !dropSide || !activeId) return null;
|
||||
|
||||
const element = itemRefs.current.get(hoveredItemId);
|
||||
const container = containerRef.current;
|
||||
if (!element || !container) return null;
|
||||
|
||||
const itemRect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
const top = itemRect.top - containerRect.top;
|
||||
const height = itemRect.height;
|
||||
const left = dropSide === 'left'
|
||||
? itemRect.left - containerRect.left - itemGap / 2
|
||||
: itemRect.right - containerRect.left + itemGap / 2;
|
||||
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
height: `${height}px`,
|
||||
zIndex: Z_INDEX_DROP_INDICATOR,
|
||||
};
|
||||
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
|
||||
|
||||
const activeDragIds = useMemo(() => {
|
||||
if (!activeId) return [];
|
||||
if (boxSelectedPageIds.includes(activeId)) {
|
||||
return boxSelectedPageIds;
|
||||
}
|
||||
return [activeId];
|
||||
}, [activeId, boxSelectedPageIds]);
|
||||
|
||||
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!activeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollElement = getScrollElement();
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollElement.scrollBy({
|
||||
top: event.deltaY,
|
||||
left: event.deltaX,
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
}, [activeId, getScrollElement]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
className={styles.gridContainer}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheelWhileDragging}
|
||||
>
|
||||
{selectionBoxStyle && (
|
||||
<div
|
||||
className={styles.selectionBox}
|
||||
style={selectionBoxStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dropIndicatorStyle && (
|
||||
<div
|
||||
className={styles.dropIndicator}
|
||||
style={dropIndicatorStyle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles.virtualRows}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
maxWidth: `${gridWidth}px`,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
|
||||
const rowItems = visibleItems.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
className={styles.virtualRow}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: GRID_CONSTANTS.ITEM_GAP,
|
||||
justifyContent: 'flex-start',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Item */}
|
||||
{renderItem(item, actualIndex, itemRefs)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className={styles.rowContent}
|
||||
style={{
|
||||
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
|
||||
}}
|
||||
>
|
||||
{rowItems.map((item, itemIndex) => {
|
||||
const actualIndex = startIndex + itemIndex;
|
||||
return (
|
||||
<DraggableItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={actualIndex}
|
||||
itemRefs={itemRefs}
|
||||
boxSelectedPageIds={boxSelectedPageIds}
|
||||
clearBoxSelection={clearBoxSelection}
|
||||
getBoxSelection={getBoxSelection}
|
||||
activeId={activeId}
|
||||
activeDragIds={activeDragIds}
|
||||
justMoved={justMovedIds.includes(item.id)}
|
||||
getThumbnailData={getThumbnailData}
|
||||
onUpdateDropTarget={setHoveredItemId}
|
||||
renderItem={renderItem}
|
||||
zoomLevel={zoomLevel}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<div className={styles.dragOverlay}>
|
||||
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
||||
<div
|
||||
className={styles.dragOverlayBadge}
|
||||
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
|
||||
>
|
||||
{boxSelectedPageIds.length}
|
||||
</div>
|
||||
)}
|
||||
{dragPreview ? (
|
||||
<img
|
||||
src={dragPreview.src}
|
||||
alt="Dragging"
|
||||
style={{
|
||||
width: `calc(20rem * ${zoomLevel})`,
|
||||
height: `calc(20rem * ${zoomLevel})`,
|
||||
objectFit: 'contain',
|
||||
transform: `rotate(${dragPreview.rotation}deg)`,
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles.dragOverlayPreview}
|
||||
style={{
|
||||
width: `calc(20rem * ${zoomLevel})`,
|
||||
height: `calc(20rem * ${zoomLevel})`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="description" width="3rem" height="3rem" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,26 @@
|
||||
transform: scale(1.02) translateZ(0);
|
||||
}
|
||||
|
||||
.pageSurface {
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
|
||||
.pageJustMoved {
|
||||
animation: pageMovedHighlight 1.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pageMovedHighlight {
|
||||
0% {
|
||||
background-color: rgba(59, 130, 246, 0.32);
|
||||
}
|
||||
60% {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
100% {
|
||||
background-color: rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageNumber {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -30,28 +50,6 @@
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.pageMoving {
|
||||
z-index: 10;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Multi-page drag indicator */
|
||||
.multiDragIndicator {
|
||||
position: fixed;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,12 +138,12 @@ const PageEditorControls = ({
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
@@ -156,7 +156,7 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -168,7 +168,7 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -180,7 +180,7 @@ const PageEditorControls = ({
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -192,7 +192,7 @@ const PageEditorControls = ({
|
||||
onClick={onSplit}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -204,7 +204,7 @@ const PageEditorControls = ({
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@@ -8,12 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
|
||||
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||
import styles from '@app/components/pageEditor/PageEditor.module.css';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
|
||||
@@ -22,11 +23,17 @@ interface PageThumbnailProps {
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File;
|
||||
fileColorIndex: number;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
isBoxSelected?: boolean;
|
||||
clearBoxSelection?: () => void;
|
||||
activeDragIds: string[];
|
||||
justMoved?: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
dragHandleProps?: any;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
@@ -40,19 +47,25 @@ interface PageThumbnailProps {
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
splitPositions: Set<number>;
|
||||
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
|
||||
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
|
||||
zoomLevel?: number;
|
||||
}
|
||||
|
||||
const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
page,
|
||||
index,
|
||||
index: _index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
fileColorIndex,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
isBoxSelected = false,
|
||||
clearBoxSelection,
|
||||
activeDragIds,
|
||||
pageRefs,
|
||||
dragHandleProps,
|
||||
onReorderPages,
|
||||
onTogglePage,
|
||||
onExecuteCommand,
|
||||
@@ -64,17 +77,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
pdfDocument,
|
||||
splitPositions,
|
||||
onInsertFiles,
|
||||
zoomLevel = 1.0,
|
||||
justMoved = false,
|
||||
}: PageThumbnailProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const pageIndex = page.pageNumber - 1;
|
||||
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const lastClickTimeRef = useRef<number>(0);
|
||||
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
// Check if this page is currently being dragged
|
||||
const isDragging = activeDragIds.includes(page.id);
|
||||
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||
@@ -131,63 +152,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
};
|
||||
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
|
||||
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
// Merge refs - combine our ref tracking with dnd-kit's ref
|
||||
const mergedRef = useCallback((element: HTMLDivElement | null) => {
|
||||
// Track in our refs map
|
||||
elementRef.current = element;
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
pageNumber: page.pageNumber,
|
||||
pageId: page.id,
|
||||
selectedPageIds: [page.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: ({ location }) => {
|
||||
setIsDragging(false);
|
||||
|
||||
if (location.current.dropTargets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dropTarget = location.current.dropTargets[0];
|
||||
const targetData = dropTarget.data;
|
||||
|
||||
if (targetData.type === 'page') {
|
||||
const targetPageNumber = targetData.pageNumber as number;
|
||||
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||
if (targetIndex !== -1) {
|
||||
onReorderPages(page.pageNumber, targetIndex, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
element.style.cursor = 'grab';
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'page',
|
||||
pageNumber: page.pageNumber
|
||||
}),
|
||||
onDrop: (_) => {}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||
(dragElementRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
|
||||
|
||||
// Call dnd-kit's ref if provided
|
||||
if (dragHandleProps?.ref) {
|
||||
dragHandleProps.ref(element);
|
||||
}
|
||||
}, [page.id, pageRefs, dragHandleProps]);
|
||||
|
||||
|
||||
// DOM command handlers
|
||||
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
|
||||
@@ -216,13 +196,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
e.stopPropagation();
|
||||
|
||||
// Create a command to toggle split at this position
|
||||
const command = createSplitCommand(index);
|
||||
const command = createSplitCommand(pageIndex);
|
||||
onExecuteCommand(command);
|
||||
|
||||
const hasSplit = splitPositions.has(index);
|
||||
const hasSplit = splitPositions.has(pageIndex);
|
||||
const action = hasSplit ? 'removed' : 'added';
|
||||
onSetStatus(`Split marker ${action} after position ${index + 1}`);
|
||||
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
||||
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
|
||||
|
||||
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -231,9 +211,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
// Open file manager modal with custom handler for page insertion
|
||||
openFilesModal({
|
||||
insertAfterPage: page.pageNumber,
|
||||
customHandler: (files: File[], insertAfterPage?: number) => {
|
||||
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||
if (insertAfterPage !== undefined) {
|
||||
onInsertFiles(files, insertAfterPage);
|
||||
onInsertFiles(files, insertAfterPage, isFromStorage);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -263,14 +243,28 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
// If mouse moved less than 5 pixels, consider it a click (not a drag)
|
||||
if (distance < 5 && !isDragging) {
|
||||
onTogglePage(page.id);
|
||||
// If mouse moved less than 2 pixels, consider it a click (not a drag)
|
||||
if (distance < 2 && !isDragging) {
|
||||
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
|
||||
const now = Date.now();
|
||||
if (now - lastClickTimeRef.current > 100) {
|
||||
lastClickTimeRef.current = now;
|
||||
|
||||
// Clear box selection when clicking on a non-selected page
|
||||
if (!isBoxSelected && clearBoxSelection) {
|
||||
clearBoxSelection();
|
||||
}
|
||||
|
||||
// Don't toggle page selection if it's box-selected (just keep the box selection)
|
||||
if (!isBoxSelected) {
|
||||
onTogglePage(page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
|
||||
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
@@ -278,6 +272,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||
|
||||
// Spread dragHandleProps but use our merged ref
|
||||
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
@@ -286,14 +285,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
label: 'Move Left',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
if (pageIndex > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
onReorderPages(page.pageNumber, pageIndex - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
},
|
||||
disabled: index === 0
|
||||
disabled: pageIndex === 0
|
||||
},
|
||||
{
|
||||
id: 'move-right',
|
||||
@@ -301,14 +300,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
label: 'Move Right',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
// ReorderPagesCommand expects target index relative to the original array.
|
||||
// When moving toward the right (higher index), provide desiredIndex + 1
|
||||
// so the command's internal adjustment (targetIndex - 1) lands correctly.
|
||||
onReorderPages(page.pageNumber, pageIndex + 2);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
},
|
||||
disabled: index === totalPages - 1
|
||||
disabled: pageIndex === totalPages - 1
|
||||
},
|
||||
{
|
||||
id: 'rotate-left',
|
||||
@@ -334,7 +336,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Split After',
|
||||
onClick: handleSplit,
|
||||
hidden: index >= totalPages - 1,
|
||||
hidden: pageIndex >= totalPages - 1,
|
||||
},
|
||||
{
|
||||
id: 'insert',
|
||||
@@ -342,11 +344,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
label: 'Insert File After',
|
||||
onClick: handleInsertFileAfter,
|
||||
}
|
||||
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
||||
], [pageIndex, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
ref={mergedRef}
|
||||
{...restDragProps}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
@@ -354,24 +357,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
!rounded-lg
|
||||
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
flex items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
|
||||
`}
|
||||
style={{
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
width: `calc(20rem * ${zoomLevel})`,
|
||||
height: `calc(20rem * ${zoomLevel})`,
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||
zIndex: isHovered ? 50 : 1,
|
||||
...(isBoxSelected && {
|
||||
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
|
||||
}),
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
@@ -414,12 +418,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||
<div
|
||||
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -440,11 +445,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: 2
|
||||
}}></div>
|
||||
}} />
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
@@ -476,7 +482,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
|
||||
background: 'rgba(162, 201, 255, 0.8)',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FileId } from '@app/types/file';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
import { PDFDocument, PDFPage, PageBreakSettings } from '@app/types/pageEditor';
|
||||
|
||||
// V1-style DOM-first command system (replaces the old React state commands)
|
||||
export abstract class DOMCommand {
|
||||
@@ -67,7 +67,7 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
private pagesToDelete: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private setSelectedPageIds: (pageIds: string[]) => void,
|
||||
private getSplitPositions: () => Set<number>,
|
||||
private setSplitPositions: (positions: Set<number>) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
@@ -99,6 +99,13 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
this.hasExecuted = true;
|
||||
}
|
||||
|
||||
const selectedPageNumbersBefore = this.getSelectedPages();
|
||||
const selectedIdSet = new Set(
|
||||
selectedPageNumbersBefore
|
||||
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
);
|
||||
|
||||
// Filter out deleted pages by ID (stable across undo/redo)
|
||||
const remainingPages = currentDoc.pages.filter(page =>
|
||||
!this.pageIdsToDelete.includes(page.id)
|
||||
@@ -106,7 +113,7 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
|
||||
if (remainingPages.length === 0) {
|
||||
// If all pages would be deleted, clear selection/splits and close PDF
|
||||
this.setSelectedPages([]);
|
||||
this.setSelectedPageIds([]);
|
||||
this.setSplitPositions(new Set());
|
||||
this.onAllPagesDeleted?.();
|
||||
return;
|
||||
@@ -135,7 +142,12 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
|
||||
// Apply changes
|
||||
this.setDocument(updatedDocument);
|
||||
this.setSelectedPages([]);
|
||||
|
||||
const remainingSelectedPageIds = remainingPages
|
||||
.filter((page) => selectedIdSet.has(page.id))
|
||||
.map((page) => page.id);
|
||||
this.setSelectedPageIds(remainingSelectedPageIds);
|
||||
|
||||
this.setSplitPositions(newPositions);
|
||||
}
|
||||
|
||||
@@ -145,7 +157,12 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
// Simply restore the complete original document state
|
||||
this.setDocument(this.originalDocument);
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
this.setSelectedPages(this.originalSelectedPages);
|
||||
const restoredIds = this.originalSelectedPages
|
||||
.map((pageNum) =>
|
||||
this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || ""
|
||||
)
|
||||
.filter((id) => id !== "");
|
||||
this.setSelectedPageIds(restoredIds);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
@@ -161,7 +178,8 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
private targetIndex: number,
|
||||
private selectedPages: number[] | undefined,
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private onReorderComplete?: (newPages: PDFPage[]) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -196,7 +214,13 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
} else {
|
||||
// Single page reorder
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
newPages.splice(this.targetIndex, 0, movedPage);
|
||||
|
||||
// Adjust target index if moving forward (after removal, indices shift)
|
||||
const adjustedTargetIndex = sourceIndex < this.targetIndex
|
||||
? this.targetIndex - 1
|
||||
: this.targetIndex;
|
||||
|
||||
newPages.splice(adjustedTargetIndex, 0, movedPage);
|
||||
|
||||
newPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
@@ -210,6 +234,11 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
};
|
||||
|
||||
this.setDocument(reorderedDocument);
|
||||
|
||||
// Notify that reordering is complete
|
||||
if (this.onReorderComplete) {
|
||||
this.onReorderComplete(newPages);
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
@@ -408,6 +437,8 @@ export class SplitAllCommand extends DOMCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// PageBreakSettings, PageSize, and PageOrientation are now imported from pageEditor.ts
|
||||
|
||||
export class PageBreakCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
@@ -415,7 +446,8 @@ export class PageBreakCommand extends DOMCommand {
|
||||
constructor(
|
||||
private selectedPageNumbers: number[],
|
||||
private getCurrentDocument: () => PDFDocument | null,
|
||||
private setDocument: (doc: PDFDocument) => void
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private settings?: PageBreakSettings
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -450,7 +482,8 @@ export class PageBreakCommand extends DOMCommand {
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isBlankPage: true // Custom flag for blank pages
|
||||
isBlankPage: true, // Custom flag for blank pages
|
||||
pageBreakSettings: this.settings // Store settings for export
|
||||
};
|
||||
newPages.push(blankPage);
|
||||
this.insertedPages.push(blankPage);
|
||||
@@ -883,6 +916,10 @@ export class UndoManager {
|
||||
return this.redoStack.length > 0;
|
||||
}
|
||||
|
||||
hasHistory(): boolean {
|
||||
return this.undoStack.length > 0;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* File color palette for page editor
|
||||
* Each file gets a distinct color for visual organization
|
||||
* Colors are applied at 0.3 opacity for subtle highlighting
|
||||
* Maximum 20 files supported in page editor
|
||||
*/
|
||||
|
||||
export const FILE_COLORS = [
|
||||
// Subtle colors (1-6) - fit well with UI theme
|
||||
'rgb(59, 130, 246)', // Blue
|
||||
'rgb(16, 185, 129)', // Green
|
||||
'rgb(139, 92, 246)', // Purple
|
||||
'rgb(6, 182, 212)', // Cyan
|
||||
'rgb(20, 184, 166)', // Teal
|
||||
'rgb(99, 102, 241)', // Indigo
|
||||
|
||||
// Mid-range colors (7-12) - more distinct
|
||||
'rgb(244, 114, 182)', // Pink
|
||||
'rgb(251, 146, 60)', // Orange
|
||||
'rgb(234, 179, 8)', // Yellow
|
||||
'rgb(132, 204, 22)', // Lime
|
||||
'rgb(248, 113, 113)', // Red
|
||||
'rgb(168, 85, 247)', // Violet
|
||||
|
||||
// Vibrant colors (13-20) - maximum distinction
|
||||
'rgb(236, 72, 153)', // Fuchsia
|
||||
'rgb(245, 158, 11)', // Amber
|
||||
'rgb(34, 197, 94)', // Emerald
|
||||
'rgb(14, 165, 233)', // Sky
|
||||
'rgb(239, 68, 68)', // Rose
|
||||
'rgb(168, 162, 158)', // Stone
|
||||
'rgb(251, 191, 36)', // Gold
|
||||
'rgb(192, 132, 252)', // Light Purple
|
||||
] as const;
|
||||
|
||||
export const MAX_PAGE_EDITOR_FILES = 20;
|
||||
|
||||
/**
|
||||
* Get color for a file by its index
|
||||
* @param index - Zero-based file index
|
||||
* @returns RGB color string
|
||||
*/
|
||||
export function getFileColor(index: number): string {
|
||||
if (index < 0 || index >= FILE_COLORS.length) {
|
||||
console.warn(`File index ${index} out of range, using default color`);
|
||||
return FILE_COLORS[0];
|
||||
}
|
||||
return FILE_COLORS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color with specified opacity
|
||||
* @param index - Zero-based file index
|
||||
* @param opacity - Opacity value (0-1), defaults to 0.3
|
||||
* @returns RGBA color string
|
||||
*/
|
||||
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
|
||||
const rgb = getFileColor(index);
|
||||
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
||||
return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { FileId } from "@app/types/file";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
|
||||
interface UseEditedDocumentStateParams {
|
||||
initialDocument: PDFDocument | null;
|
||||
mergedPdfDocument: PDFDocument | null;
|
||||
reorderedPages: PDFPage[] | null;
|
||||
clearReorderedPages: () => void;
|
||||
fileOrder: FileId[];
|
||||
updateCurrentPages: (pages: PDFPage[] | null) => void;
|
||||
}
|
||||
|
||||
export const useEditedDocumentState = ({
|
||||
initialDocument,
|
||||
mergedPdfDocument,
|
||||
reorderedPages,
|
||||
clearReorderedPages,
|
||||
fileOrder,
|
||||
updateCurrentPages,
|
||||
}: UseEditedDocumentStateParams) => {
|
||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
||||
const editedDocumentRef = useRef<PDFDocument | null>(null);
|
||||
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
|
||||
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
|
||||
const lastSyncedSignatureRef = useRef<string | null>(null);
|
||||
|
||||
// Clone the initial document once so we can safely mutate working state
|
||||
useEffect(() => {
|
||||
if (!initialDocument || editedDocument) return;
|
||||
|
||||
setEditedDocument({
|
||||
...initialDocument,
|
||||
pages: initialDocument.pages.map((page) => ({ ...page })),
|
||||
});
|
||||
}, [initialDocument, editedDocument]);
|
||||
|
||||
// Apply reorders triggered elsewhere in the editor
|
||||
useEffect(() => {
|
||||
if (!reorderedPages || !editedDocument) return;
|
||||
|
||||
setEditedDocument({
|
||||
...editedDocument,
|
||||
pages: reorderedPages,
|
||||
});
|
||||
clearReorderedPages();
|
||||
}, [reorderedPages, editedDocument, clearReorderedPages]);
|
||||
|
||||
// Keep ref synced so effects can read latest without re-running
|
||||
useEffect(() => {
|
||||
editedDocumentRef.current = editedDocument;
|
||||
}, [editedDocument]);
|
||||
|
||||
// Cache page positions to help future insertions preserve intent
|
||||
useEffect(() => {
|
||||
if (!editedDocument) return;
|
||||
|
||||
const positionCache = pagePositionCacheRef.current;
|
||||
const neighborCache = pageNeighborCacheRef.current;
|
||||
const pages = editedDocument.pages;
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
positionCache.set(page.id, index);
|
||||
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
|
||||
});
|
||||
}, [editedDocument]);
|
||||
|
||||
const fileOrderKey = useMemo(() => fileOrder.join(","), [fileOrder]);
|
||||
const mergedDocSignature = useMemo(() => {
|
||||
if (!mergedPdfDocument?.pages) return "";
|
||||
return mergedPdfDocument.pages.map((page) => page.id).join(",");
|
||||
}, [mergedPdfDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mergedPdfDocument) {
|
||||
lastSyncedSignatureRef.current = null;
|
||||
}
|
||||
}, [mergedPdfDocument]);
|
||||
|
||||
// Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing)
|
||||
useEffect(() => {
|
||||
const currentEditedDocument = editedDocumentRef.current;
|
||||
if (!mergedPdfDocument || !currentEditedDocument) return;
|
||||
|
||||
const signatureChanged =
|
||||
mergedDocSignature !== lastSyncedSignatureRef.current;
|
||||
const metadataChanged =
|
||||
currentEditedDocument.id !== mergedPdfDocument.id ||
|
||||
currentEditedDocument.file !== mergedPdfDocument.file ||
|
||||
currentEditedDocument.name !== mergedPdfDocument.name;
|
||||
|
||||
if (!signatureChanged && !metadataChanged) return;
|
||||
|
||||
setEditedDocument((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
let pages = prev.pages;
|
||||
|
||||
if (signatureChanged) {
|
||||
const sourcePages = mergedPdfDocument.pages;
|
||||
const sourceIds = new Set(sourcePages.map((p) => p.id));
|
||||
const prevIds = new Set(prev.pages.map((p) => p.id));
|
||||
|
||||
const newPages: PDFPage[] = [];
|
||||
for (const page of sourcePages) {
|
||||
if (!prevIds.has(page.id)) {
|
||||
newPages.push(page);
|
||||
}
|
||||
}
|
||||
|
||||
const hasAdditions = newPages.length > 0;
|
||||
const isEphemeralPage = (page: PDFPage) =>
|
||||
Boolean(page.isBlankPage || page.isPlaceholder);
|
||||
|
||||
let hasRemovals = false;
|
||||
for (const page of prev.pages) {
|
||||
if (!sourceIds.has(page.id) && !isEphemeralPage(page)) {
|
||||
hasRemovals = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasAdditions || hasRemovals) {
|
||||
pages = [...prev.pages];
|
||||
|
||||
const placeholderPositions = new Map<FileId, number>();
|
||||
pages.forEach((page, index) => {
|
||||
if (page.isPlaceholder && page.originalFileId) {
|
||||
placeholderPositions.set(page.originalFileId, index);
|
||||
}
|
||||
});
|
||||
|
||||
const nextInsertIndexByFile = new Map(placeholderPositions);
|
||||
|
||||
if (hasRemovals) {
|
||||
pages = pages.filter(
|
||||
(page) => sourceIds.has(page.id) || isEphemeralPage(page)
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAdditions) {
|
||||
const mergedIndexMap = new Map<string, number>();
|
||||
sourcePages.forEach((page, index) =>
|
||||
mergedIndexMap.set(page.id, index)
|
||||
);
|
||||
|
||||
const additions = newPages
|
||||
.map((page) => ({
|
||||
page,
|
||||
cachedIndex: pagePositionCacheRef.current.get(page.id),
|
||||
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
|
||||
neighborId: pageNeighborCacheRef.current.get(page.id),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.cachedIndex ?? a.mergedIndex;
|
||||
const bIndex = b.cachedIndex ?? b.mergedIndex;
|
||||
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||
return a.mergedIndex - b.mergedIndex;
|
||||
});
|
||||
|
||||
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
|
||||
if (pages.some((existing) => existing.id === page.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let insertIndex: number;
|
||||
const originalFileId = page.originalFileId;
|
||||
const placeholderIndex =
|
||||
originalFileId !== undefined
|
||||
? nextInsertIndexByFile.get(originalFileId)
|
||||
: undefined;
|
||||
|
||||
if (originalFileId && placeholderIndex !== undefined) {
|
||||
insertIndex = Math.min(placeholderIndex, pages.length);
|
||||
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
|
||||
} else if (neighborId === null) {
|
||||
insertIndex = 0;
|
||||
} else if (neighborId) {
|
||||
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
|
||||
if (neighborIndex !== -1) {
|
||||
insertIndex = neighborIndex + 1;
|
||||
} else {
|
||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||
}
|
||||
} else {
|
||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||
}
|
||||
|
||||
const clonedPage = { ...page };
|
||||
pages.splice(insertIndex, 0, clonedPage);
|
||||
});
|
||||
}
|
||||
|
||||
pages = pages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const shouldReplaceBase = metadataChanged || signatureChanged;
|
||||
const baseDocument = shouldReplaceBase
|
||||
? {
|
||||
...mergedPdfDocument,
|
||||
destroy: prev.destroy,
|
||||
}
|
||||
: prev;
|
||||
|
||||
if (baseDocument === prev && pages === prev.pages) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseDocument,
|
||||
pages,
|
||||
totalPages: pages.length,
|
||||
};
|
||||
});
|
||||
|
||||
if (signatureChanged) {
|
||||
lastSyncedSignatureRef.current = mergedDocSignature;
|
||||
}
|
||||
}, [mergedPdfDocument, fileOrderKey, mergedDocSignature]);
|
||||
|
||||
const displayDocument = editedDocument || initialDocument;
|
||||
|
||||
const getEditedDocument = useCallback(
|
||||
() => editedDocumentRef.current,
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateCurrentPages(displayDocument?.pages ?? null);
|
||||
}, [displayDocument, updateCurrentPages]);
|
||||
|
||||
return {
|
||||
editedDocument,
|
||||
setEditedDocument,
|
||||
displayDocument,
|
||||
getEditedDocument,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseEditedDocumentStateReturn = ReturnType<
|
||||
typeof useEditedDocumentState
|
||||
>;
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
BulkRotateCommand,
|
||||
DeletePagesCommand,
|
||||
PageBreakCommand,
|
||||
ReorderPagesCommand,
|
||||
SplitCommand,
|
||||
} from "@app/components/pageEditor/commands/pageCommands";
|
||||
import type {
|
||||
useFileActions,
|
||||
useFileState,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
type FileActions = ReturnType<typeof useFileActions>["actions"];
|
||||
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
|
||||
|
||||
interface UsePageEditorCommandsParams {
|
||||
displayDocument: PDFDocument | null;
|
||||
getEditedDocument: () => PDFDocument | null;
|
||||
setEditedDocument: React.Dispatch<React.SetStateAction<PDFDocument | null>>;
|
||||
splitPositions: Set<number>;
|
||||
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
selectedPageIds: string[];
|
||||
setSelectedPageIds: (ids: string[]) => void;
|
||||
getPageNumbersFromIds: (pageIds: string[]) => number[];
|
||||
executeCommandWithTracking: (command: any) => void;
|
||||
updateFileOrderFromPages: (pages: PDFPage[]) => void;
|
||||
actions: FileActions;
|
||||
selectors: FileSelectors;
|
||||
setSelectionMode: (enabled: boolean) => void;
|
||||
clearUndoHistory: () => void;
|
||||
}
|
||||
|
||||
export const usePageEditorCommands = ({
|
||||
displayDocument,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
splitPositions,
|
||||
setSplitPositions,
|
||||
selectedPageIds,
|
||||
setSelectedPageIds,
|
||||
getPageNumbersFromIds,
|
||||
executeCommandWithTracking,
|
||||
updateFileOrderFromPages,
|
||||
actions,
|
||||
selectors,
|
||||
setSelectionMode,
|
||||
clearUndoHistory,
|
||||
}: UsePageEditorCommandsParams) => {
|
||||
const closePdf = useCallback(() => {
|
||||
actions.clearAllFiles();
|
||||
clearUndoHistory();
|
||||
setSelectedPageIds([]);
|
||||
setSelectionMode(false);
|
||||
}, [actions, clearUndoHistory, setSelectedPageIds, setSelectionMode]);
|
||||
|
||||
const handleRotatePages = useCallback(
|
||||
(pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
},
|
||||
[executeCommandWithTracking]
|
||||
);
|
||||
|
||||
const createRotateCommand = useCallback(
|
||||
(pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
},
|
||||
}),
|
||||
[executeCommandWithTracking]
|
||||
);
|
||||
|
||||
const createDeleteCommand = useCallback(
|
||||
(pageIds: string[]) => ({
|
||||
execute: () => {
|
||||
const currentDocument = getEditedDocument();
|
||||
if (!currentDocument) return;
|
||||
|
||||
const pagesToDelete = pageIds
|
||||
.map((pageId) => {
|
||||
const page = currentDocument.pages.find((p) => p.id === pageId);
|
||||
return page?.pageNumber || 0;
|
||||
})
|
||||
.filter((num) => num > 0);
|
||||
|
||||
if (pagesToDelete.length > 0) {
|
||||
const deleteCommand = new DeletePagesCommand(
|
||||
pagesToDelete,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
setSelectedPageIds,
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
() => closePdf()
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[
|
||||
closePdf,
|
||||
executeCommandWithTracking,
|
||||
getEditedDocument,
|
||||
getPageNumbersFromIds,
|
||||
selectedPageIds,
|
||||
setEditedDocument,
|
||||
setSelectedPageIds,
|
||||
setSplitPositions,
|
||||
splitPositions,
|
||||
]
|
||||
);
|
||||
|
||||
const createSplitCommand = useCallback(
|
||||
(position: number) => ({
|
||||
execute: () => {
|
||||
const splitCommand = new SplitCommand(
|
||||
position,
|
||||
() => splitPositions,
|
||||
setSplitPositions
|
||||
);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
},
|
||||
}),
|
||||
[splitPositions, executeCommandWithTracking, setSplitPositions]
|
||||
);
|
||||
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
if (command && typeof command.execute === "function") {
|
||||
command.execute();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRotate = useCallback(
|
||||
(direction: "left" | "right") => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
const rotation = direction === "left" ? -90 : 90;
|
||||
|
||||
handleRotatePages(selectedPageIds, rotation);
|
||||
},
|
||||
[displayDocument, selectedPageIds, handleRotatePages]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const deleteCommand = new DeletePagesCommand(
|
||||
selectedPageNumbers,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
setSelectedPageIds,
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => selectedPageNumbers,
|
||||
() => closePdf()
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [
|
||||
closePdf,
|
||||
displayDocument,
|
||||
executeCommandWithTracking,
|
||||
getEditedDocument,
|
||||
getPageNumbersFromIds,
|
||||
selectedPageIds,
|
||||
setEditedDocument,
|
||||
setSelectedPageIds,
|
||||
setSplitPositions,
|
||||
splitPositions,
|
||||
]);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
(pageNumber: number) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
const deleteCommand = new DeletePagesCommand(
|
||||
[pageNumber],
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
setSelectedPageIds,
|
||||
() => splitPositions,
|
||||
setSplitPositions,
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
() => closePdf()
|
||||
);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
},
|
||||
[
|
||||
closePdf,
|
||||
getEditedDocument,
|
||||
executeCommandWithTracking,
|
||||
getPageNumbersFromIds,
|
||||
selectedPageIds,
|
||||
setEditedDocument,
|
||||
setSelectedPageIds,
|
||||
setSplitPositions,
|
||||
splitPositions,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
const selectedPositions: number[] = [];
|
||||
selectedPageNumbers.forEach((pageNum) => {
|
||||
const pageIndex = displayDocument.pages.findIndex(
|
||||
(p) => p.pageNumber === pageNum
|
||||
);
|
||||
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
|
||||
selectedPositions.push(pageIndex);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedPositions.length === 0) return;
|
||||
|
||||
const existingSplitsCount = selectedPositions.filter((pos) =>
|
||||
splitPositions.has(pos)
|
||||
).length;
|
||||
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
const newSplitPositions = new Set(splitPositions);
|
||||
|
||||
if (shouldRemoveSplits) {
|
||||
selectedPositions.forEach((pos) => newSplitPositions.delete(pos));
|
||||
} else {
|
||||
selectedPositions.forEach((pos) => newSplitPositions.add(pos));
|
||||
}
|
||||
|
||||
const smartSplitCommand = {
|
||||
execute: () => setSplitPositions(newSplitPositions),
|
||||
undo: () => setSplitPositions(splitPositions),
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`,
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
setSplitPositions,
|
||||
getPageNumbersFromIds,
|
||||
executeCommandWithTracking,
|
||||
]);
|
||||
|
||||
const handleSplitAll = handleSplit;
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const pageBreakCommand = new PageBreakCommand(
|
||||
selectedPageNumbers,
|
||||
getEditedDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [
|
||||
displayDocument,
|
||||
executeCommandWithTracking,
|
||||
getEditedDocument,
|
||||
getPageNumbersFromIds,
|
||||
selectedPageIds,
|
||||
setEditedDocument,
|
||||
]);
|
||||
|
||||
const handlePageBreakAll = handlePageBreak;
|
||||
|
||||
const handleInsertFiles = useCallback(
|
||||
async (
|
||||
files: File[] | StirlingFileStub[],
|
||||
insertAfterPage: number,
|
||||
isFromStorage?: boolean
|
||||
) => {
|
||||
const workingDocument = getEditedDocument();
|
||||
if (!workingDocument || files.length === 0) return;
|
||||
|
||||
try {
|
||||
const targetPage = workingDocument.pages.find(
|
||||
(p) => p.pageNumber === insertAfterPage
|
||||
);
|
||||
if (!targetPage) return;
|
||||
|
||||
const insertAfterPageId = targetPage.id;
|
||||
let addedFileIds: FileId[] = [];
|
||||
if (isFromStorage) {
|
||||
const stubs = files as StirlingFileStub[];
|
||||
const result = await actions.addStirlingFileStubs(stubs, {
|
||||
selectFiles: true,
|
||||
insertAfterPageId,
|
||||
});
|
||||
addedFileIds = result.map((file) => file.fileId);
|
||||
} else {
|
||||
const result = await actions.addFiles(files as File[], {
|
||||
selectFiles: true,
|
||||
insertAfterPageId,
|
||||
});
|
||||
addedFileIds = result.map((file) => file.fileId);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const newPages: PDFPage[] = [];
|
||||
for (const fileId of addedFileIds) {
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
if (stub?.processedFile?.pages) {
|
||||
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
|
||||
...page,
|
||||
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
|
||||
pageNumber: page.pageNumber ?? idx + 1,
|
||||
originalFileId: fileId,
|
||||
originalPageNumber:
|
||||
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
||||
rotation: page.rotation ?? 0,
|
||||
thumbnail: page.thumbnail ?? null,
|
||||
selected: false,
|
||||
splitAfter: page.splitAfter ?? false,
|
||||
}));
|
||||
newPages.push(...clonedPages);
|
||||
}
|
||||
}
|
||||
|
||||
if (newPages.length > 0) {
|
||||
const targetIndex = workingDocument.pages.findIndex(
|
||||
(p) => p.id === targetPage.id
|
||||
);
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
const updatedPages = [...workingDocument.pages];
|
||||
updatedPages.splice(targetIndex + 1, 0, ...newPages);
|
||||
|
||||
updatedPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
setEditedDocument({
|
||||
...workingDocument,
|
||||
pages: updatedPages,
|
||||
});
|
||||
|
||||
updateFileOrderFromPages(updatedPages);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to insert files:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
getEditedDocument,
|
||||
actions,
|
||||
selectors,
|
||||
updateFileOrderFromPages,
|
||||
setEditedDocument,
|
||||
]
|
||||
);
|
||||
|
||||
const handleReorderPages = useCallback(
|
||||
(
|
||||
sourcePageNumber: number,
|
||||
targetIndex: number,
|
||||
draggedPageIds?: string[]
|
||||
) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
const selectedPages = draggedPageIds
|
||||
? getPageNumbersFromIds(draggedPageIds)
|
||||
: undefined;
|
||||
|
||||
const reorderCommand = new ReorderPagesCommand(
|
||||
sourcePageNumber,
|
||||
targetIndex,
|
||||
selectedPages,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
(newPages) => updateFileOrderFromPages(newPages)
|
||||
);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
},
|
||||
[
|
||||
displayDocument,
|
||||
getEditedDocument,
|
||||
executeCommandWithTracking,
|
||||
getPageNumbersFromIds,
|
||||
setEditedDocument,
|
||||
updateFileOrderFromPages,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
createRotateCommand,
|
||||
createDeleteCommand,
|
||||
createSplitCommand,
|
||||
executeCommand,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleDeletePage,
|
||||
handleSplit,
|
||||
handleSplitAll,
|
||||
handlePageBreak,
|
||||
handlePageBreakAll,
|
||||
handleInsertFiles,
|
||||
handleReorderPages,
|
||||
closePdf,
|
||||
};
|
||||
};
|
||||
|
||||
export type UsePageEditorCommandsReturn = ReturnType<
|
||||
typeof usePageEditorCommands
|
||||
>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
/**
|
||||
* Maintains stable color assignments for a collection of file IDs.
|
||||
* Colors are assigned by insertion order and preserved across reorders.
|
||||
*/
|
||||
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
|
||||
const assignmentsRef = useRef(new Map<FileId, number>());
|
||||
|
||||
const serializedIds = useMemo(() => fileIds.join(','), [fileIds]);
|
||||
|
||||
return useMemo(() => {
|
||||
const assignments = assignmentsRef.current;
|
||||
const activeIds = new Set(fileIds);
|
||||
|
||||
// Remove colors for files that no longer exist
|
||||
for (const id of Array.from(assignments.keys())) {
|
||||
if (!activeIds.has(id)) {
|
||||
assignments.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign colors to any new files
|
||||
fileIds.forEach((id) => {
|
||||
if (!assignments.has(id)) {
|
||||
assignments.set(id, assignments.size);
|
||||
}
|
||||
});
|
||||
|
||||
return assignments;
|
||||
}, [serializedIds, fileIds]);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
|
||||
import { PDFDocument } from '@app/types/pageEditor';
|
||||
|
||||
/**
|
||||
* Hook that calls usePageDocument but only returns the FIRST non-null result
|
||||
* After initialization, it ignores all subsequent updates
|
||||
*/
|
||||
export function useInitialPageDocument(): PDFDocument | null {
|
||||
const { document: liveDocument } = usePageDocument();
|
||||
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Only set once when we get the first non-null document
|
||||
if (liveDocument && !initialDocument) {
|
||||
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
|
||||
setInitialDocument(liveDocument);
|
||||
}
|
||||
}, [liveDocument, initialDocument]);
|
||||
|
||||
return initialDocument;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
@@ -15,14 +16,40 @@ export interface PageDocumentHook {
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
const { fileOrder, currentPages } = usePageEditor();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||
// This ensures the page editor respects its own workspace ordering
|
||||
const allFileIds = fileOrder;
|
||||
|
||||
// Stable signature for effects (prevents loops)
|
||||
// Derive selected file IDs directly from FileContext (single source of truth)
|
||||
// Filter to only include PDF files (PageEditor only supports PDFs)
|
||||
// Use stable string keys to prevent infinite loops
|
||||
const allFileIdsKey = allFileIds.join(',');
|
||||
const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(',');
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Get ALL PDF files (selected or not) for document building with placeholders
|
||||
const activeFileIds = useMemo(() => {
|
||||
return allFileIds.filter(id => {
|
||||
const stub = selectors.getStirlingFileStub(id);
|
||||
return stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
|
||||
});
|
||||
}, [allFileIdsKey, activeFilesSignature, selectors]);
|
||||
|
||||
const selectedActiveFileIds = useMemo(() => {
|
||||
if (activeFileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const selectedSet = new Set(state.ui.selectedFileIds);
|
||||
if (selectedSet.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return activeFileIds.filter((id) => selectedSet.has(id));
|
||||
}, [activeFileIds, selectedFileIdsKey]);
|
||||
|
||||
const primaryFileId = selectedActiveFileIds[0] ?? activeFileIds[0] ?? null;
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
@@ -32,6 +59,10 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
// Compute merged document with stable signature (prevents infinite loops)
|
||||
const currentPagesSignature = useMemo(() => {
|
||||
return currentPages ? currentPages.map(page => page.id).join(',') : '';
|
||||
}, [currentPages]);
|
||||
|
||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||
if (activeFileIds.length === 0) return null;
|
||||
|
||||
@@ -43,10 +74,14 @@ export function usePageDocument(): PageDocumentHook {
|
||||
return null;
|
||||
}
|
||||
|
||||
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||
|
||||
const name =
|
||||
activeFileIds.length === 1
|
||||
? (primaryStirlingFileStub.name ?? 'document.pdf')
|
||||
: activeFileIds
|
||||
namingFileIds.length <= 1
|
||||
? (namingFileIds[0]
|
||||
? selectors.getStirlingFileStub(namingFileIds[0])?.name ?? 'document.pdf'
|
||||
: 'document.pdf')
|
||||
: namingFileIds
|
||||
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
|
||||
.join(' + ');
|
||||
|
||||
@@ -69,13 +104,28 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[] = [];
|
||||
|
||||
// Helper function to create pages from a file
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
|
||||
// Helper function to create pages from a file (or placeholder if deselected)
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If file is deselected, create a single placeholder page
|
||||
if (!isSelected) {
|
||||
return [{
|
||||
id: `${fileId}-placeholder`,
|
||||
pageNumber: startPageNumber,
|
||||
originalPageNumber: 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: true,
|
||||
}];
|
||||
}
|
||||
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
let filePages: PDFPage[] = [];
|
||||
|
||||
@@ -90,6 +140,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
splitAfter: page.splitAfter || false,
|
||||
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
} else if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
@@ -102,16 +153,30 @@ export function usePageDocument(): PageDocumentHook {
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return filePages;
|
||||
};
|
||||
|
||||
// Collect all pages from original files (without renumbering yet)
|
||||
// Collect all pages from original files, respecting their previous positions
|
||||
const selectedFileIdsSet = new Set(state.ui.selectedFileIds);
|
||||
|
||||
// Sort original files by their position in fileOrder (so placeholders stay in correct spot)
|
||||
// Use fileOrder as source of truth since it persists across page editor sessions
|
||||
const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index]));
|
||||
|
||||
const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => {
|
||||
const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER;
|
||||
const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER;
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const originalFilePages: PDFPage[] = [];
|
||||
originalFileIds.forEach(fileId => {
|
||||
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
|
||||
sortedOriginalFileIds.forEach(fileId => {
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
@@ -130,7 +195,8 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Collect all pages to insert
|
||||
const allNewPages: PDFPage[] = [];
|
||||
fileIds.forEach(fileId => {
|
||||
const insertedPages = createPagesFromFile(fileId, 1);
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
|
||||
allNewPages.push(...insertedPages);
|
||||
});
|
||||
|
||||
@@ -147,6 +213,29 @@ export function usePageDocument(): PageDocumentHook {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pages are already in the correct order from the sorted assembly above
|
||||
// Just ensure page numbers are sequential
|
||||
pages = pages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
|
||||
const currentPagesSet = currentPages ? new Set(currentPages.map(page => page.id)) : null;
|
||||
if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) {
|
||||
const sameIds = pages.every(page => currentPagesSet.has(page.id));
|
||||
if (sameIds) {
|
||||
const mergedById = new Map(pages.map(page => [page.id, page]));
|
||||
pages = currentPages.map((currentPage, index) => {
|
||||
const source = mergedById.get(currentPage.id);
|
||||
const mergedPage = source ? { ...source, ...currentPage } : currentPage;
|
||||
return {
|
||||
...mergedPage,
|
||||
pageNumber: index + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mergedDoc: PDFDocument = {
|
||||
id: activeFileIds.join('-'),
|
||||
name,
|
||||
@@ -156,7 +245,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
|
||||
}, [activeFileIds, selectedActiveFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds, currentPagesSignature, currentPages]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePageEditor } from '@app/contexts/PageEditorContext';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { useFileColorMap } from '@app/components/pageEditor/hooks/useFileColorMap';
|
||||
|
||||
export interface PageEditorDropdownFile {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
versionNumber?: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface PageEditorDropdownState {
|
||||
files: PageEditorDropdownFile[];
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
fileColorMap: Map<FileId, number>;
|
||||
}
|
||||
|
||||
const isPdf = (name?: string | null) =>
|
||||
typeof name === 'string' && name.toLowerCase().endsWith('.pdf');
|
||||
|
||||
export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||
const { state, selectors } = useFileState();
|
||||
const {
|
||||
toggleFileSelection,
|
||||
reorderFiles,
|
||||
fileOrder,
|
||||
} = usePageEditor();
|
||||
|
||||
const pageEditorFiles = useMemo(() => {
|
||||
return fileOrder
|
||||
.map<PageEditorDropdownFile | null>((fileId) => {
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
if (!isPdf(stub?.name)) return null;
|
||||
|
||||
return {
|
||||
fileId,
|
||||
name: stub?.name || '',
|
||||
versionNumber: stub?.versionNumber,
|
||||
isSelected: state.ui.selectedFileIds.includes(fileId),
|
||||
};
|
||||
})
|
||||
.filter((file): file is PageEditorDropdownFile => file !== null);
|
||||
}, [fileOrder, selectors, state.ui.selectedFileIds]);
|
||||
|
||||
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => pageEditorFiles.filter((file) => file.isSelected).length,
|
||||
[pageEditorFiles]
|
||||
);
|
||||
|
||||
return useMemo<PageEditorDropdownState>(() => ({
|
||||
files: pageEditorFiles,
|
||||
selectedCount,
|
||||
totalCount: pageEditorFiles.length,
|
||||
onToggleSelection: toggleFileSelection,
|
||||
onReorder: reorderFiles,
|
||||
fileColorMap,
|
||||
}), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Dispatch, SetStateAction, useCallback } from "react";
|
||||
|
||||
import type {
|
||||
useFileActions,
|
||||
useFileState,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { documentManipulationService } from "@app/services/documentManipulationService";
|
||||
import { pdfExportService } from "@app/services/pdfExportService";
|
||||
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { PDFDocument } from "@app/types/pageEditor";
|
||||
|
||||
type FileActions = ReturnType<typeof useFileActions>["actions"];
|
||||
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
|
||||
|
||||
interface UsePageEditorExportParams {
|
||||
displayDocument: PDFDocument | null;
|
||||
selectedPageIds: string[];
|
||||
splitPositions: Set<number>;
|
||||
selectedFileIds: FileId[];
|
||||
selectors: FileSelectors;
|
||||
actions: FileActions;
|
||||
setHasUnsavedChanges: (dirty: boolean) => void;
|
||||
exportLoading: boolean;
|
||||
setExportLoading: (loading: boolean) => void;
|
||||
setSplitPositions: Dispatch<SetStateAction<Set<number>>>;
|
||||
}
|
||||
|
||||
const removePlaceholderPages = (document: PDFDocument): PDFDocument => {
|
||||
const filteredPages = document.pages.filter((page) => !page.isPlaceholder);
|
||||
if (filteredPages.length === document.pages.length) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const normalizedPages = filteredPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
|
||||
return {
|
||||
...document,
|
||||
pages: normalizedPages,
|
||||
totalPages: normalizedPages.length,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeProcessedDocuments = (
|
||||
processed: PDFDocument | PDFDocument[]
|
||||
): PDFDocument | PDFDocument[] => {
|
||||
if (Array.isArray(processed)) {
|
||||
const normalized = processed
|
||||
.map(removePlaceholderPages)
|
||||
.filter((doc) => doc.pages.length > 0);
|
||||
return normalized;
|
||||
}
|
||||
return removePlaceholderPages(processed);
|
||||
};
|
||||
|
||||
export const usePageEditorExport = ({
|
||||
displayDocument,
|
||||
selectedPageIds,
|
||||
splitPositions,
|
||||
selectedFileIds,
|
||||
selectors,
|
||||
actions,
|
||||
setHasUnsavedChanges,
|
||||
exportLoading,
|
||||
setExportLoading,
|
||||
setSplitPositions,
|
||||
}: UsePageEditorExportParams) => {
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
const sourceFiles = new Map<FileId, File>();
|
||||
|
||||
selectedFileIds.forEach((fileId) => {
|
||||
const file = selectors.getFile(fileId);
|
||||
if (file) {
|
||||
sourceFiles.set(fileId, file);
|
||||
}
|
||||
});
|
||||
|
||||
const hasInsertedFiles = false;
|
||||
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
|
||||
|
||||
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sourceFiles.size > 0 ? sourceFiles : null;
|
||||
}, [selectedFileIds, selectors]);
|
||||
|
||||
const getExportFilename = useCallback((): string => {
|
||||
if (selectedFileIds.length <= 1) {
|
||||
return displayDocument?.name || "document.pdf";
|
||||
}
|
||||
|
||||
const firstFile = selectors.getFile(selectedFileIds[0]);
|
||||
if (firstFile) {
|
||||
const baseName = firstFile.name.replace(/\.pdf$/i, "");
|
||||
return `${baseName} (merged).pdf`;
|
||||
}
|
||||
|
||||
return "merged-document.pdf";
|
||||
}, [selectedFileIds, selectors, displayDocument]);
|
||||
|
||||
const onExportSelected = useCallback(async () => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const processedDocuments =
|
||||
documentManipulationService.applyDOMChangesToDocument(
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||
const documentWithDOMState = Array.isArray(normalizedDocuments)
|
||||
? normalizedDocuments[0]
|
||||
: normalizedDocuments;
|
||||
|
||||
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) {
|
||||
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
||||
setExportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const validSelectedPageIds = selectedPageIds.filter((pageId) =>
|
||||
documentWithDOMState.pages.some((page) => page.id === pageId)
|
||||
);
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
documentWithDOMState,
|
||||
sourceFiles,
|
||||
validSelectedPageIds,
|
||||
{ selectedOnly: true, filename: exportFilename }
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
documentWithDOMState,
|
||||
validSelectedPageIds,
|
||||
{ selectedOnly: true, filename: exportFilename }
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false);
|
||||
setSplitPositions(new Set());
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [
|
||||
displayDocument,
|
||||
selectedPageIds,
|
||||
splitPositions,
|
||||
getSourceFiles,
|
||||
getExportFilename,
|
||||
setHasUnsavedChanges,
|
||||
setExportLoading,
|
||||
]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const processedDocuments =
|
||||
documentManipulationService.applyDOMChangesToDocument(
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||
|
||||
if (
|
||||
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
||||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
||||
) {
|
||||
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
||||
setExportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(
|
||||
normalizedDocuments,
|
||||
sourceFiles,
|
||||
exportFilename
|
||||
);
|
||||
|
||||
if (files.length > 1) {
|
||||
const JSZip = await import("jszip");
|
||||
const zip = new JSZip.default();
|
||||
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: "blob" });
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, ".zip");
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
} else {
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setSplitPositions(new Set());
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
getSourceFiles,
|
||||
getExportFilename,
|
||||
setHasUnsavedChanges,
|
||||
setExportLoading,
|
||||
]);
|
||||
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const processedDocuments =
|
||||
documentManipulationService.applyDOMChangesToDocument(
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||
|
||||
if (
|
||||
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
||||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
||||
) {
|
||||
console.warn("Apply changes skipped: no concrete pages available after filtering placeholders.");
|
||||
setExportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(
|
||||
normalizedDocuments,
|
||||
sourceFiles,
|
||||
exportFilename
|
||||
);
|
||||
|
||||
// Add "_multitool" suffix to filenames
|
||||
const renamedFiles = files.map(file => {
|
||||
const nameParts = file.name.match(/^(.+?)(\.pdf)$/i);
|
||||
if (nameParts) {
|
||||
const baseName = nameParts[1];
|
||||
const extension = nameParts[2];
|
||||
const newName = `${baseName}_multitool${extension}`;
|
||||
return new File([file], newName, { type: file.type });
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
// Store source file IDs before adding new files
|
||||
const sourceFileIds = [...selectedFileIds];
|
||||
|
||||
const newStirlingFiles = await actions.addFiles(renamedFiles, {
|
||||
selectFiles: true,
|
||||
});
|
||||
if (newStirlingFiles.length > 0) {
|
||||
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
|
||||
}
|
||||
|
||||
// Remove source files from context
|
||||
if (sourceFileIds.length > 0) {
|
||||
await actions.removeFiles(sourceFileIds, true);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setSplitPositions(new Set());
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Apply changes failed:", error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
getSourceFiles,
|
||||
getExportFilename,
|
||||
actions,
|
||||
selectedFileIds,
|
||||
setHasUnsavedChanges,
|
||||
setExportLoading,
|
||||
]);
|
||||
|
||||
return {
|
||||
exportLoading,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
applyChanges,
|
||||
};
|
||||
};
|
||||
|
||||
export type UsePageEditorExportReturn = ReturnType<typeof usePageEditorExport>;
|
||||
@@ -1,26 +1,27 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
export interface PageEditorState {
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPageIds: string[];
|
||||
|
||||
// Animation state
|
||||
|
||||
// Animation state
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
|
||||
|
||||
// Split state
|
||||
splitPositions: Set<number>;
|
||||
|
||||
|
||||
// Export state
|
||||
exportLoading: boolean;
|
||||
|
||||
|
||||
// Actions
|
||||
setSelectionMode: (mode: boolean) => void;
|
||||
setSelectedPageIds: (pages: string[]) => void;
|
||||
setMovingPage: (pageNumber: number | null) => void;
|
||||
setIsAnimating: (animating: boolean) => void;
|
||||
setSplitPositions: (positions: Set<number>) => void;
|
||||
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
|
||||
setExportLoading: (loading: boolean) => void;
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { PDFDocument } from "@app/types/pageEditor";
|
||||
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
|
||||
|
||||
interface UsePageSelectionManagerParams {
|
||||
displayDocument: PDFDocument | null;
|
||||
selectedPageIds: string[];
|
||||
setSelectedPageIds: (ids: string[]) => void;
|
||||
setSelectionMode: (enabled: boolean) => void;
|
||||
toggleSelectAll: (ids: string[]) => void;
|
||||
activeFilesSignature: string;
|
||||
}
|
||||
|
||||
export const usePageSelectionManager = ({
|
||||
displayDocument,
|
||||
selectedPageIds,
|
||||
setSelectedPageIds,
|
||||
setSelectionMode,
|
||||
toggleSelectAll,
|
||||
activeFilesSignature,
|
||||
}: UsePageSelectionManagerParams) => {
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
const hasInitializedSelection = useRef(false);
|
||||
const previousPageIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const totalPages = displayDocument?.pages.length ?? 0;
|
||||
|
||||
const getPageNumbersFromIds = useCallback(
|
||||
(pageIds: string[]) => {
|
||||
if (!displayDocument) return [];
|
||||
return pageIds
|
||||
.map((id) => {
|
||||
const page = displayDocument.pages.find((p) => p.id === id);
|
||||
return page?.pageNumber || 0;
|
||||
})
|
||||
.filter((num) => num > 0);
|
||||
},
|
||||
[displayDocument]
|
||||
);
|
||||
|
||||
const getPageIdsFromNumbers = useCallback(
|
||||
(pageNumbers: number[]) => {
|
||||
if (!displayDocument) return [];
|
||||
return pageNumbers
|
||||
.map((num) => {
|
||||
const page = displayDocument.pages.find((p) => p.pageNumber === num);
|
||||
return page?.id || "";
|
||||
})
|
||||
.filter((id) => id !== "");
|
||||
},
|
||||
[displayDocument]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
displayDocument &&
|
||||
displayDocument.pages.length > 0 &&
|
||||
!hasInitializedSelection.current
|
||||
) {
|
||||
const allPageIds = displayDocument.pages.map((page) => page.id);
|
||||
setSelectedPageIds(allPageIds);
|
||||
setSelectionMode(true);
|
||||
hasInitializedSelection.current = true;
|
||||
}
|
||||
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!displayDocument || displayDocument.pages.length === 0) {
|
||||
previousPageIdsRef.current = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(displayDocument.pages.map((page) => page.id));
|
||||
const newlyAddedPageIds: string[] = [];
|
||||
currentIds.forEach((id) => {
|
||||
if (!previousPageIdsRef.current.has(id)) {
|
||||
newlyAddedPageIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (newlyAddedPageIds.length > 0) {
|
||||
const next = new Set(selectedPageIds);
|
||||
newlyAddedPageIds.forEach((id) => next.add(id));
|
||||
setSelectedPageIds(Array.from(next));
|
||||
}
|
||||
|
||||
previousPageIdsRef.current = currentIds;
|
||||
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setCsvInput("");
|
||||
}, [activeFilesSignature]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (!displayDocument) return;
|
||||
const allPageIds = displayDocument.pages.map((page) => page.id);
|
||||
toggleSelectAll(allPageIds);
|
||||
}, [displayDocument, toggleSelectAll]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
setSelectedPageIds([]);
|
||||
}, [setSelectedPageIds]);
|
||||
|
||||
const handleSetSelectedPages = useCallback(
|
||||
(pageNumbers: number[]) => {
|
||||
const pageIds = getPageIdsFromNumbers(pageNumbers);
|
||||
setSelectedPageIds(pageIds);
|
||||
},
|
||||
[getPageIdsFromNumbers, setSelectedPageIds]
|
||||
);
|
||||
|
||||
const updatePagesFromCSV = useCallback(
|
||||
(override?: string) => {
|
||||
if (totalPages === 0) return;
|
||||
const normalized = parseSelection(override ?? csvInput, totalPages);
|
||||
handleSetSelectedPages(normalized);
|
||||
},
|
||||
[csvInput, totalPages, handleSetSelectedPages]
|
||||
);
|
||||
|
||||
return {
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
totalPages,
|
||||
getPageNumbersFromIds,
|
||||
getPageIdsFromNumbers,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleSetSelectedPages,
|
||||
updatePagesFromCSV,
|
||||
};
|
||||
};
|
||||
|
||||
export type UsePageSelectionManagerReturn = ReturnType<
|
||||
typeof usePageSelectionManager
|
||||
>;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { UndoManager } from "@app/components/pageEditor/commands/pageCommands";
|
||||
|
||||
interface UseUndoManagerStateParams {
|
||||
setHasUnsavedChanges: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUndoManagerState = ({
|
||||
setHasUnsavedChanges,
|
||||
}: UseUndoManagerStateParams) => {
|
||||
const undoManagerRef = useRef(new UndoManager());
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
const updateUndoRedoState = useCallback(() => {
|
||||
const undoManager = undoManagerRef.current;
|
||||
setCanUndo(undoManager.canUndo());
|
||||
setCanRedo(undoManager.canRedo());
|
||||
|
||||
if (!undoManager.hasHistory()) {
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
|
||||
updateUndoRedoState();
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
const executeCommandWithTracking = useCallback(
|
||||
(command: any) => {
|
||||
undoManagerRef.current.executeCommand(command);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[setHasUnsavedChanges]
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
undoManagerRef.current.undo();
|
||||
}, []);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
undoManagerRef.current.redo();
|
||||
}, []);
|
||||
|
||||
const clearUndoHistory = useCallback(() => {
|
||||
undoManagerRef.current.clear();
|
||||
updateUndoRedoState();
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
executeCommandWithTracking,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
clearUndoHistory,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseUndoManagerStateReturn = ReturnType<typeof useUndoManagerState>;
|
||||
@@ -16,6 +16,7 @@ interface PageEditorRightRailButtonsParams {
|
||||
handleDeselectAll: () => void;
|
||||
handleDelete: () => void;
|
||||
onExportSelected: () => void;
|
||||
onSaveChanges: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
@@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
@@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
|
||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
@@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
visible: totalPages > 0,
|
||||
onClick: onExportSelected,
|
||||
},
|
||||
{
|
||||
id: 'page-save-changes',
|
||||
icon: <LocalIcon icon="save" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: saveChangesLabel,
|
||||
ariaLabel: saveChangesLabel,
|
||||
section: 'top' as const,
|
||||
order: 55,
|
||||
disabled: totalPages === 0 || exportLoading,
|
||||
visible: totalPages > 0,
|
||||
onClick: onSaveChanges,
|
||||
},
|
||||
{
|
||||
id: 'page-close-pdf',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
selectByNumberLabel,
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
saveChangesLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
@@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
onSaveChanges,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
|
||||
@@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
|
||||
const iconNode = (
|
||||
<span className="iconContainer">
|
||||
<AppsIcon sx={{ fontSize: '2rem' }} />
|
||||
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={handleNavClick}
|
||||
size={'lg'}
|
||||
size={isActive ? 'lg' : 'md'}
|
||||
variant="subtle"
|
||||
aria-label={t("quickAccess.allTools", "All Tools")}
|
||||
aria-label={t("quickAccess.allTools", "Tools")}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||
@@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
{iconNode}
|
||||
</ActionIcon>
|
||||
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
|
||||
{t("quickAccess.allTools", "All Tools")}
|
||||
{t("quickAccess.allTools", "Tools")}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
@@ -15,10 +15,10 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
@@ -64,19 +64,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Get isAdmin and runningEE from app config
|
||||
// Get isAdmin, runningEE, and loginEnabled from app config
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
|
||||
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE
|
||||
runningEE,
|
||||
loginEnabled
|
||||
),
|
||||
[isAdmin, runningEE]
|
||||
[isAdmin, runningEE, loginEnabled]
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
@@ -143,16 +143,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}
|
||||
// Allow navigation even when disabled - the content inside will be disabled
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
data-tour={`admin-${item.key}-nav`}
|
||||
>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await accountService.changePassword(currentPassword, newPassword);
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import styles from '@app/components/shared/HoverActionMenu.module.css';
|
||||
import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex';
|
||||
|
||||
export interface HoverAction {
|
||||
id: string;
|
||||
@@ -34,7 +35,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0 }}
|
||||
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -44,10 +45,10 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
style={{ color: action.color || 'var(--right-rail-icon)' }}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -14,6 +16,9 @@ const LandingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
const logoPath = useLogoPath();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addFiles(files);
|
||||
@@ -36,6 +41,22 @@ const LandingPage = () => {
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// Determine if the user has any recent files (same source as File Manager)
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const files = await loadRecentFiles();
|
||||
if (isMounted) {
|
||||
setHasRecents((files?.length || 0) > 0);
|
||||
}
|
||||
} catch (_err) {
|
||||
if (isMounted) setHasRecents(false);
|
||||
}
|
||||
})();
|
||||
return () => { isMounted = false; };
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
return (
|
||||
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
||||
{/* White PDF Page Background */}
|
||||
@@ -72,7 +93,7 @@ const LandingPage = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
|
||||
src={logoPath}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
height: 'auto',
|
||||
@@ -117,59 +138,89 @@ const LandingPage = () => {
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
{/* Show both buttons only when recents exist; otherwise show a single Upload button */}
|
||||
{hasRecents && (
|
||||
<>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!hasRecents && (
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
minWidth: '58px',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for native file picker */}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React from 'react';
|
||||
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop';
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
// Local interface for PageEditor file display
|
||||
interface PageEditorFile {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
versionNumber?: number;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
interface FileMenuItemProps {
|
||||
file: PageEditorFile;
|
||||
index: number;
|
||||
colorIndex: number;
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
const FileMenuItem: React.FC<FileMenuItemProps> = ({
|
||||
file,
|
||||
index,
|
||||
colorIndex,
|
||||
onToggleSelection,
|
||||
onReorder,
|
||||
}) => {
|
||||
const {
|
||||
itemRef,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
dropPosition,
|
||||
movedRef,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
} = useFileItemDragDrop({
|
||||
fileId: file.fileId,
|
||||
index,
|
||||
onReorder,
|
||||
});
|
||||
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
|
||||
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Drop indicator line */}
|
||||
{isDragOver && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
backgroundColor: 'rgb(59, 130, 246)',
|
||||
borderRadius: '2px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={itemRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (movedRef.current) return; // ignore click after drag
|
||||
onToggleSelection(file.fileId);
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
borderLeft: `6px solid ${fileColorBorder}`,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={file.isSelected}
|
||||
onChange={() => onToggleSelection(file.fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<PrivateContent>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
</PrivateContent>
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageEditorFileDropdownProps {
|
||||
files: PageEditorFile[];
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
fileColorMap: Map<string, number>;
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
files,
|
||||
onToggleSelection,
|
||||
onReorder,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
fileColorMap,
|
||||
selectedCount,
|
||||
totalCount,
|
||||
}) => {
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="40rem">
|
||||
<Menu.Target>
|
||||
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
|
||||
)}
|
||||
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown className="ph-no-capture" style={{
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{files.map((file, index) => {
|
||||
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
|
||||
|
||||
return (
|
||||
<FileMenuItem
|
||||
key={file.fileId}
|
||||
file={file}
|
||||
index={index}
|
||||
colorIndex={colorIndex}
|
||||
onToggleSelection={onToggleSelection}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add File Button */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
marginTop: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'transparent',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%' }}>
|
||||
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
|
||||
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
|
||||
Add File
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -88,7 +89,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
onClick: () => handleClick(),
|
||||
'aria-label': config.name
|
||||
})}
|
||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
||||
size={isActive ? 'lg' : 'md'}
|
||||
variant="subtle"
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
@@ -108,9 +109,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const mainButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
size: 'lg',
|
||||
name: t("quickAccess.reader", "Reader"),
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
@@ -118,23 +119,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
handleReaderToggle();
|
||||
}
|
||||
},
|
||||
// {
|
||||
// id: 'sign',
|
||||
// name: t("quickAccess.sign", "Sign"),
|
||||
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// size: 'lg',
|
||||
// isRound: false,
|
||||
// type: 'navigation',
|
||||
// onClick: () => {
|
||||
// setActiveButton('sign');
|
||||
// handleToolSelect('sign');
|
||||
// }
|
||||
// },
|
||||
{
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
|
||||
size: 'lg',
|
||||
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
@@ -147,37 +136,36 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
|
||||
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
size: 'md',
|
||||
type: 'modal',
|
||||
onClick: handleFilesButtonClick
|
||||
},
|
||||
//TODO: Activity
|
||||
//{
|
||||
// id: 'activity',
|
||||
// name: t("quickAccess.activity", "Activity"),
|
||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// isRound: true,
|
||||
// size: 'lg',
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [];
|
||||
//TODO: Activity
|
||||
//{
|
||||
// id: 'activity',
|
||||
// name: t("quickAccess.activity", "Activity"),
|
||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// isRound: true,
|
||||
// size: 'lg',
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
size: 'md',
|
||||
type: 'action',
|
||||
onClick: () => {
|
||||
// This will be overridden by the wrapper logic
|
||||
@@ -185,9 +173,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
name: t("quickAccess.settings", "Settings"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
navigate('/settings/overview');
|
||||
@@ -200,7 +188,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="quick-access"
|
||||
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
style={{
|
||||
borderRight: '1px solid var(--border-default)'
|
||||
}}
|
||||
@@ -239,20 +227,30 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Divider after main buttons */}
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
{/* Divider after main buttons (creates gap) */}
|
||||
{middleButtons.length === 0 && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Middle section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
{middleButtons.length > 0 && (
|
||||
<>
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer to push bottom buttons to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
@@ -14,6 +14,8 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||
@@ -39,7 +41,7 @@ export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
@@ -195,7 +197,11 @@ export default function RightRail() {
|
||||
className="right-rail-icon"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
{themeMode === 'dark' ? (
|
||||
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||
) : (
|
||||
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||
)}
|
||||
</ActionIcon>,
|
||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||
)}
|
||||
|
||||
@@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const clickPendingRef = useRef(false);
|
||||
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
|
||||
const isDomNode = (value: unknown): value is Node =>
|
||||
typeof Node !== 'undefined' && value instanceof Node;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
@@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: MouseEvent) => {
|
||||
const tEl = tooltipRef.current;
|
||||
const trg = triggerRef.current;
|
||||
const target = e.target as Node | null;
|
||||
const insideTooltip = tEl && target && tEl.contains(target);
|
||||
const insideTrigger = trg && target && trg.contains(target);
|
||||
const target = e.target as unknown;
|
||||
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
|
||||
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
|
||||
|
||||
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
||||
if (isPinned) {
|
||||
@@ -172,7 +176,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const related = e.relatedTarget as Node | null;
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
@@ -236,7 +241,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
return;
|
||||
}
|
||||
@@ -258,7 +263,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleTooltipPointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
|
||||
@@ -1,47 +1,49 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import GridViewIcon from "@mui/icons-material/GridView";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
|
||||
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
paddingTop: '0.3rem',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'center',
|
||||
padding: '2px 1rem',
|
||||
};
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
// Helper function to create view options for SegmentedControl
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
pageEditorState?: PageEditorDropdownState,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
// Viewer dropdown logic
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
const showViewerDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
label: showViewerDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
displayName={viewerDisplayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
@@ -51,29 +53,38 @@ const createViewOptions = (
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
<InsertDriveFileIcon fontSize="medium" />
|
||||
)}
|
||||
<PrivateContent>{displayName}</PrivateContent>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
};
|
||||
|
||||
// Page Editor dropdown logic
|
||||
const isInPageEditor = currentView === 'pageEditor';
|
||||
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
|
||||
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
|
||||
|
||||
const pageEditorOption = {
|
||||
label: (
|
||||
label: showPageEditorDropdown ? (
|
||||
<PageEditorFileDropdown
|
||||
files={pageEditorState!.files}
|
||||
onToggleSelection={pageEditorState!.onToggleSelection}
|
||||
onReorder={pageEditorState!.onReorder}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
fileColorMap={pageEditorState!.fileColorMap}
|
||||
selectedCount={pageEditorState!.selectedCount}
|
||||
totalCount={pageEditorState!.totalCount}
|
||||
/>
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
<span>Page Editor</span>
|
||||
</>
|
||||
<GridViewIcon fontSize="medium" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
@@ -83,17 +94,7 @@ const createViewOptions = (
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
<span>Active Files</span>
|
||||
</>
|
||||
)}
|
||||
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
|
||||
</div>
|
||||
),
|
||||
value: "fileEditor",
|
||||
@@ -111,9 +112,9 @@ const createViewOptions = (
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
view.icon || <PictureAsPdfIcon fontSize="medium" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
@@ -144,6 +145,8 @@ const TopControls = ({
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const pageEditorState = usePageEditorDropdownState();
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
@@ -166,12 +169,25 @@ const TopControls = ({
|
||||
});
|
||||
}, [setCurrentView]);
|
||||
|
||||
// Memoize view options to prevent SegmentedControl re-renders
|
||||
const viewOptions = useMemo(() => createViewOptions(
|
||||
currentView,
|
||||
switchingTo,
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
pageEditorState,
|
||||
customViews
|
||||
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<div className="flex justify-center">
|
||||
|
||||
<SegmentedControl
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
data={viewOptions}
|
||||
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
@@ -184,18 +200,32 @@ const TopControls = ({
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2.6rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '1.8rem',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderTop: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
outline: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
outlineOffset: '-1px',
|
||||
padding: '0 0',
|
||||
gap: '0',
|
||||
},
|
||||
control: {
|
||||
borderRadius: 9999,
|
||||
borderRadius: '0 0 16px 16px',
|
||||
padding: '0',
|
||||
border: 'none',
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
maxHeight: '2rem',
|
||||
borderRadius: '0 0 16px 16px',
|
||||
height: '100%',
|
||||
top: '0rem',
|
||||
margin: '0',
|
||||
border: 'none',
|
||||
},
|
||||
label: {
|
||||
paddingTop: '0rem',
|
||||
paddingTop: '0',
|
||||
paddingBottom: '0',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
interface ZipWarningModalProps {
|
||||
opened: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
fileCount: number;
|
||||
zipFileName: string;
|
||||
}
|
||||
|
||||
const WARNING_ICON_STYLE: CSSProperties = {
|
||||
fontSize: 36,
|
||||
display: 'block',
|
||||
margin: '0 auto 8px',
|
||||
color: 'var(--mantine-color-blue-6)'
|
||||
};
|
||||
|
||||
const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("zipWarning.title", "Large ZIP File")}
|
||||
centered
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack ta="center" p="md" gap="sm">
|
||||
<WarningAmberIcon style={WARNING_ICON_STYLE} />
|
||||
<Text size="md" fw={300}>
|
||||
{zipFileName}
|
||||
</Text>
|
||||
<Text size="lg" fw={500}>
|
||||
{t("zipWarning.message", {
|
||||
count: fileCount,
|
||||
defaultValue: "This ZIP contains {{count}} files. Extract anyway?"
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Desktop layout: centered buttons */}
|
||||
<Group justify="center" gap="sm" visibleFrom="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={onCancel}
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="var(--mantine-color-blue-9)"
|
||||
onClick={onConfirm}
|
||||
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.confirm", "Extract")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Mobile layout: vertical stack */}
|
||||
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={onCancel}
|
||||
leftSection={<CancelIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="var(--mantine-color-blue-9)"
|
||||
onClick={onConfirm}
|
||||
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
|
||||
w="10rem"
|
||||
>
|
||||
{t("zipWarning.confirm", "Extract")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZipWarningModal;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface LoginRequiredBannerProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner component that displays when login mode is required but not enabled
|
||||
* Shows prominent warning that settings are read-only
|
||||
*/
|
||||
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
|
||||
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderLeft: '4px solid var(--mantine-color-blue-6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} mt="xs" c="dimmed">
|
||||
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,6 @@ import React from 'react';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
|
||||
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
|
||||
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
|
||||
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
|
||||
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
|
||||
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
|
||||
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
|
||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
|
||||
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -40,8 +28,9 @@ export interface ConfigColors {
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false
|
||||
_isAdmin: boolean = false,
|
||||
_runningEE: boolean = false,
|
||||
_loginEnabled: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
@@ -63,112 +52,5 @@ export const createConfigNavSections = (
|
||||
},
|
||||
];
|
||||
|
||||
// Add Admin sections if user is admin
|
||||
if (isAdmin) {
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{
|
||||
key: 'adminGeneral',
|
||||
label: 'System Settings',
|
||||
icon: 'settings-rounded',
|
||||
component: <AdminGeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'adminFeatures',
|
||||
label: 'Features',
|
||||
icon: 'extension-rounded',
|
||||
component: <AdminFeaturesSection />
|
||||
},
|
||||
{
|
||||
key: 'adminEndpoints',
|
||||
label: 'Endpoints',
|
||||
icon: 'api-rounded',
|
||||
component: <AdminEndpointsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAdvanced',
|
||||
label: 'Advanced',
|
||||
icon: 'tune-rounded',
|
||||
component: <AdminAdvancedSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: 'Security & Authentication',
|
||||
items: [
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: 'Licensing & Analytics',
|
||||
items: [
|
||||
{
|
||||
key: 'adminPlan',
|
||||
label: 'Plan',
|
||||
icon: 'receipt-long-rounded',
|
||||
component: <AdminPlanSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAudit',
|
||||
label: 'Audit',
|
||||
icon: 'fact-check-rounded',
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
{
|
||||
key: 'adminUsage',
|
||||
label: 'Usage Analytics',
|
||||
icon: 'analytics-rounded',
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: 'Policies & Privacy',
|
||||
items: [
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ProviderCardProps {
|
||||
settings?: Record<string, any>;
|
||||
onSave?: (settings: Record<string, any>) => void;
|
||||
onDisconnect?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ProviderCard({
|
||||
@@ -18,6 +19,7 @@ export default function ProviderCard({
|
||||
settings = {},
|
||||
onSave,
|
||||
onDisconnect,
|
||||
disabled = false,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -39,6 +41,7 @@ export default function ProviderCard({
|
||||
};
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
if (disabled) return; // Block changes when disabled
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
@@ -63,6 +66,7 @@ export default function ProviderCard({
|
||||
<Switch
|
||||
checked={value || false}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -76,6 +80,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -88,6 +93,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -100,6 +106,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -174,11 +181,12 @@ export default function ProviderCard({
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, Image } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
@@ -53,12 +53,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||
<PrivateContent>
|
||||
<PictureAsPdfIcon
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
{getFileTypeIcon(file)}
|
||||
</PrivateContent>
|
||||
</Center>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import JavascriptIcon from "@mui/icons-material/Javascript";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
|
||||
type FileLike = File | StirlingFileStub;
|
||||
|
||||
/**
|
||||
* Returns an appropriate file type icon for the provided file.
|
||||
* - Uses the real file type and extension to decide the icon.
|
||||
* - No any-casts; accepts File or StirlingFileStub.
|
||||
*/
|
||||
export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement {
|
||||
const name = (file?.name ?? "").toLowerCase();
|
||||
const mime = (file?.type ?? "").toLowerCase();
|
||||
const ext = detectFileExtension(name);
|
||||
|
||||
// JavaScript
|
||||
if (ext === "js" || mime.includes("javascript")) {
|
||||
return <JavascriptIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (ext === "pdf" || mime === "application/pdf") {
|
||||
return <PictureAsPdfIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
|
||||
}
|
||||
|
||||
// Fallback generic
|
||||
return <InsertDriveFileIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
interface UseFileItemDragDropParams {
|
||||
fileId: FileId;
|
||||
index: number;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
interface UseFileItemDragDropReturn {
|
||||
itemRef: React.RefObject<HTMLDivElement | null>;
|
||||
isDragging: boolean;
|
||||
isDragOver: boolean;
|
||||
dropPosition: 'above' | 'below';
|
||||
movedRef: React.MutableRefObject<boolean>;
|
||||
startRef: React.MutableRefObject<{ x: number; y: number } | null>;
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle drag and drop functionality for file items in a list.
|
||||
* Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop.
|
||||
*/
|
||||
export const useFileItemDragDrop = ({
|
||||
fileId,
|
||||
index,
|
||||
onReorder,
|
||||
}: UseFileItemDragDropParams): UseFileItemDragDropReturn => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep latest values without re-registering DnD
|
||||
const indexRef = useRef(index);
|
||||
const fileIdRef = useRef(fileId);
|
||||
const dropPositionRef = useRef<'above' | 'below'>('below');
|
||||
const onReorderRef = useRef(onReorder);
|
||||
|
||||
useEffect(() => { indexRef.current = index; }, [index]);
|
||||
useEffect(() => { fileIdRef.current = fileId; }, [fileId]);
|
||||
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
|
||||
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
|
||||
|
||||
// Gesture guard for row click vs drag
|
||||
const movedRef = useRef(false);
|
||||
const startRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
movedRef.current = false;
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const dx = e.clientX - startRef.current.x;
|
||||
const dy = e.clientY - startRef.current.y;
|
||||
if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
startRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: fileIdRef.current,
|
||||
fromIndex: indexRef.current,
|
||||
}),
|
||||
onDragStart: () => setIsDragging((p) => (p ? p : true)),
|
||||
onDrop: () => setIsDragging((p) => (p ? false : p)),
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: fileIdRef.current,
|
||||
toIndex: indexRef.current,
|
||||
}),
|
||||
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
|
||||
onDragLeave: () => {
|
||||
setIsDragOver((p) => (p ? false : p));
|
||||
setDropPosition('below');
|
||||
},
|
||||
onDrag: ({ source }) => {
|
||||
// Determine drop position based on cursor location
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
|
||||
setDropPosition(clientY < midpoint ? 'below' : 'above');
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragOver(false);
|
||||
const dropPos = dropPositionRef.current;
|
||||
setDropPosition('below');
|
||||
const sourceData = source.data as any;
|
||||
if (sourceData?.type === 'file-item') {
|
||||
const fromIndex = sourceData.fromIndex as number;
|
||||
let toIndex = indexRef.current;
|
||||
|
||||
// Adjust toIndex based on drop position
|
||||
if (dropPos === 'below' && fromIndex < toIndex) {
|
||||
// Dragging down, drop after target - no adjustment needed
|
||||
} else if (dropPos === 'above' && fromIndex > toIndex) {
|
||||
// Dragging up, drop before target - no adjustment needed
|
||||
} else if (dropPos === 'below' && fromIndex > toIndex) {
|
||||
// Dragging up but want below target
|
||||
toIndex = toIndex + 1;
|
||||
} else if (dropPos === 'above' && fromIndex < toIndex) {
|
||||
// Dragging down but want above target
|
||||
toIndex = toIndex - 1;
|
||||
}
|
||||
|
||||
if (fromIndex !== toIndex) {
|
||||
onReorderRef.current(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
try { dragCleanup(); } catch { /* cleanup */ }
|
||||
try { dropCleanup(); } catch { /* cleanup */ }
|
||||
};
|
||||
}, []); // Stable - no dependencies
|
||||
|
||||
return {
|
||||
itemRef,
|
||||
isDragging,
|
||||
isDragOver,
|
||||
dropPosition,
|
||||
movedRef,
|
||||
startRef,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
};
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||
*
|
||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||
* When a user selects a tool from the Tools list, this component displays the tool's
|
||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||
* tool is currently active and offers a back button to return to the All Tools list.
|
||||
* tool is currently active and offers a back button to return to the Tools list.
|
||||
*
|
||||
* Features:
|
||||
* - Shows tool icon and name when a tool is selected
|
||||
* - Hover to reveal back arrow for returning to All Tools
|
||||
* - Hover to reveal back arrow for returning to Tools
|
||||
* - Smooth slide-down/slide-up animations
|
||||
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
||||
*/
|
||||
@@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
handleBackToTools();
|
||||
});
|
||||
}}
|
||||
size={'xl'}
|
||||
size={'lg'}
|
||||
variant="subtle"
|
||||
onMouseEnter={() => setIsBackHover(true)}
|
||||
onMouseLeave={() => setIsBackHover(false)}
|
||||
@@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{isBackHover ? (
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
|
||||
) : (
|
||||
indicatorTool.icon
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
.activeIconScale {
|
||||
transform: scale(1.3);
|
||||
transform: scale(1);
|
||||
transition: transform 0.2s;
|
||||
z-index: 1;
|
||||
width: calc(1.75rem + 10px) !important;
|
||||
height: calc(1.75rem + 10px) !important;
|
||||
}
|
||||
|
||||
.activeIconScale .iconContainer {
|
||||
width: calc(1.5rem + 10px);
|
||||
height: calc(1.5rem + 10px);
|
||||
}
|
||||
|
||||
.activeIconScale .iconContainer svg,
|
||||
.activeIconScale .iconContainer img {
|
||||
width: calc(1.25rem + 10px) !important;
|
||||
height: calc(1.25rem + 10px) !important;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
transition: width 0.2s, height 0.2s;
|
||||
}
|
||||
|
||||
/* Action icon styles */
|
||||
@@ -24,9 +38,9 @@
|
||||
/* Main container styles */
|
||||
.quick-access-bar-main {
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
width: 4rem;
|
||||
min-width: 4rem;
|
||||
max-width: 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -34,9 +48,9 @@
|
||||
/* Rainbow mode container */
|
||||
.quick-access-bar-main.rainbow-mode {
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
width: 4rem;
|
||||
min-width: 4rem;
|
||||
max-width: 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -57,7 +71,7 @@
|
||||
|
||||
/* Nav header divider */
|
||||
.nav-header-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -85,7 +99,7 @@
|
||||
|
||||
/* Overflow divider */
|
||||
.overflow-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
@@ -143,7 +157,7 @@
|
||||
|
||||
/* Content divider */
|
||||
.content-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@@ -241,7 +255,7 @@
|
||||
|
||||
/* Divider that animates growing from top */
|
||||
.current-tool-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0.5rem auto 0.5rem auto;
|
||||
transform-origin: top;
|
||||
|
||||
@@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
// Detect completion but do not auto-flip to success.
|
||||
// Callers (e.g., compare workbench) explicitly set alertType when done.
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
next.justCompleted = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-center {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
|
||||
@@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
'bottom-center': 'toast-container--bottom-center',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
@@ -44,7 +45,7 @@ export default function ToastRenderer() {
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ToastOptions } from '@app/components/toast/types';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types';
|
||||
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
|
||||
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
||||
|
||||
@@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer };
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
type ToastContextApi = ToastApi & { toasts: ToastInstance[] };
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
const subscribers: Array<(fn: ToastContextApi) => void> = [];
|
||||
let api: ToastContextApi | null = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
provide(instance: ToastContextApi) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
subscribers.splice(0).forEach(cb => cb(instance));
|
||||
},
|
||||
get(): ToastContextApi | null {
|
||||
return api;
|
||||
},
|
||||
onReady(cb: (readyApi: ToastContextApi) => void) {
|
||||
if (api) {
|
||||
cb(api);
|
||||
} else {
|
||||
subscribers.push(cb);
|
||||
}
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,4 +66,3 @@ export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-center';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { useFocusTrap } from '@app/hooks/useFocusTrap';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import '@app/components/tools/ToolPanel.css';
|
||||
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
|
||||
@@ -51,9 +52,7 @@ const FullscreenToolSurface = ({
|
||||
useFocusTrap(surfaceRef, !isExiting);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
|
||||
colorScheme === "dark" ? "Dark" : "Light"
|
||||
}.svg`;
|
||||
const brandIconSrc = useLogoPath();
|
||||
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
|
||||
colorScheme === "dark" ? "White" : "Black"
|
||||
}Text.svg`;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import { ActionIcon, ScrollArea } from '@mantine/core';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
|
||||
@@ -26,7 +26,7 @@ export default function ToolPanel() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
@@ -8,11 +8,10 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
|
||||
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
|
||||
import Badge from "@app/components/shared/Badge";
|
||||
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
|
||||
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@@ -23,49 +22,8 @@ interface ToolPickerProps {
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
||||
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const quickHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const allHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep header heights in sync with any dynamic size changes
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (quickHeaderRef.current) {
|
||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
|
||||
}
|
||||
if (allHeaderRef.current) {
|
||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
// Update on window resize
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
// Update on element resize (e.g., font load, badge count change, zoom)
|
||||
const observers: ResizeObserver[] = [];
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observe = (el: HTMLDivElement | null, cb: () => void) => {
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => cb());
|
||||
ro.observe(el);
|
||||
observers.push(ro);
|
||||
};
|
||||
observe(quickHeaderRef.current, update);
|
||||
observe(allHeaderRef.current, update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
observers.forEach(o => o.disconnect());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
const { favoriteTools, toolRegistry } = useToolWorkflow();
|
||||
@@ -84,31 +42,25 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
return items;
|
||||
}, [quickSection]);
|
||||
|
||||
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
|
||||
const allSection = useMemo(
|
||||
() => visibleSections.find(s => s.key === 'all'),
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
const container = scrollableRef.current;
|
||||
const target = ref.current;
|
||||
if (container && target) {
|
||||
const stackedOffset = ref === allToolsRef
|
||||
? (quickHeaderHeight + allHeaderHeight)
|
||||
: quickHeaderHeight;
|
||||
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
|
||||
container.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build flat list by subcategory for search mode
|
||||
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
|
||||
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
|
||||
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
||||
const headerTextStyle: React.CSSProperties = {
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.5rem 0 0.25rem 0.5rem",
|
||||
textTransform: "none",
|
||||
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
|
||||
opacity: 0.7
|
||||
};
|
||||
const toTitleCase = (s: string) =>
|
||||
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -141,110 +93,55 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{quickSection && (
|
||||
<>
|
||||
<div
|
||||
ref={quickHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
onClick={() => scrollTo(quickAccessRef)}
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
||||
<Badge>
|
||||
{recommendedCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Box ref={quickAccessRef} w="100%" my="sm">
|
||||
<Stack p="sm" gap="xs">
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
|
||||
<div>
|
||||
{favoriteToolItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`fav-${id}`}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{recommendedItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
|
||||
<div>
|
||||
{recommendedItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`rec-${id}`}
|
||||
id={id as ToolId}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Flat list: favorites and recommended first, then all subcategories */}
|
||||
<Stack p="sm" gap="xs">
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
{t('toolPanel.fullscreen.favorites', 'Favourites')}
|
||||
</div>
|
||||
<div>
|
||||
{favoriteToolItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`fav-${id}`}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allSection && (
|
||||
<>
|
||||
<div
|
||||
ref={allHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: quickSection ? quickHeaderHeight -1 : 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
onClick={() => scrollTo(allToolsRef)}
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
||||
<Badge>
|
||||
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Box ref={allToolsRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
|
||||
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{recommendedItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
{t('toolPanel.fullscreen.recommended', 'Recommended')}
|
||||
</div>
|
||||
<div>
|
||||
{recommendedItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`rec-${id}`}
|
||||
id={id as ToolId}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
|
||||
<Box key={sc.subcategoryId} w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
|
||||
</div>
|
||||
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{!quickSection && !allSection && <NoToolsFound />}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ToolSelector({
|
||||
// If no sections, create a simple group from filtered tools
|
||||
if (baseFilteredTools.length > 0) {
|
||||
return [{
|
||||
name: 'All Tools',
|
||||
name: 'Tools',
|
||||
subcategoryId: 'all' as any,
|
||||
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
|
||||
}];
|
||||
|
||||
+72
-75
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core";
|
||||
import { Stack, Text, Divider, Collapse, Button, NumberInput, Checkbox } from "@mantine/core";
|
||||
import { BookletImpositionParameters } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
|
||||
import ButtonSelector from "@app/components/shared/ButtonSelector";
|
||||
|
||||
@@ -21,28 +21,27 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
|
||||
{/* Double Sided */}
|
||||
<Stack gap="sm">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.doubleSided}
|
||||
onChange={(e) => {
|
||||
const isDoubleSided = e.target.checked;
|
||||
onParameterChange('doubleSided', isDoubleSided);
|
||||
// Reset to BOTH when turning double-sided back on
|
||||
if (isDoubleSided) {
|
||||
onParameterChange('duplexPass', 'BOTH');
|
||||
} else {
|
||||
// Default to FIRST pass when going to manual duplex
|
||||
onParameterChange('duplexPass', 'FIRST');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.doubleSided.label', 'Double-sided printing')}</Text>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={parameters.doubleSided}
|
||||
onChange={(event) => {
|
||||
const isDoubleSided = event.currentTarget.checked;
|
||||
onParameterChange('doubleSided', isDoubleSided);
|
||||
// Reset to BOTH when turning double-sided back on
|
||||
if (isDoubleSided) {
|
||||
onParameterChange('duplexPass', 'BOTH');
|
||||
} else {
|
||||
// Default to FIRST pass when going to manual duplex
|
||||
onParameterChange('duplexPass', 'FIRST');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<div>
|
||||
<Text size="sm">{t('bookletImposition.doubleSided.label', 'Double-sided printing')}</Text>
|
||||
<Text size="xs" c="dimmed">{t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Manual Duplex Pass Selection - only show when double-sided is OFF */}
|
||||
{!parameters.doubleSided && (
|
||||
@@ -90,47 +89,44 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
<Collapse in={advancedOpen}>
|
||||
<Stack gap="md" mt="md">
|
||||
{/* Right-to-Left Binding */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.spineLocation === 'RIGHT'}
|
||||
onChange={(e) => onParameterChange('spineLocation', e.target.checked ? 'RIGHT' : 'LEFT')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.rtlBinding.label', 'Right-to-left binding')}</Text>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={parameters.spineLocation === 'RIGHT'}
|
||||
onChange={(event) => onParameterChange('spineLocation', event.currentTarget.checked ? 'RIGHT' : 'LEFT')}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<div>
|
||||
<Text size="sm">{t('bookletImposition.rtlBinding.label', 'Right-to-left binding')}</Text>
|
||||
<Text size="xs" c="dimmed">{t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Add Border Option */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.addBorder}
|
||||
onChange={(e) => onParameterChange('addBorder', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={parameters.addBorder}
|
||||
onChange={(event) => onParameterChange('addBorder', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<div>
|
||||
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
|
||||
<Text size="xs" c="dimmed">{t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Gutter Margin */}
|
||||
<Stack gap="xs">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.addGutter}
|
||||
onChange={(e) => onParameterChange('addGutter', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t('bookletImposition.addGutter.label', 'Add gutter margin')}</Text>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={parameters.addGutter}
|
||||
onChange={(event) => onParameterChange('addGutter', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
label={
|
||||
<div>
|
||||
<Text size="sm">{t('bookletImposition.addGutter.label', 'Add gutter margin')}</Text>
|
||||
<Text size="xs" c="dimmed">{t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{parameters.addGutter && (
|
||||
<NumberInput
|
||||
@@ -147,23 +143,24 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
</Stack>
|
||||
|
||||
{/* Flip on Short Edge */}
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={!parameters.doubleSided
|
||||
? t('bookletImposition.flipOnShortEdge.manualNote', 'Not needed in manual mode - you flip the stack yourself')
|
||||
: t('bookletImposition.flipOnShortEdge.tooltip', 'Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)')
|
||||
<Checkbox
|
||||
checked={parameters.flipOnShortEdge}
|
||||
onChange={(event) => onParameterChange('flipOnShortEdge', event.currentTarget.checked)}
|
||||
disabled={disabled || !parameters.doubleSided}
|
||||
label={
|
||||
<div>
|
||||
<Text size="sm" c={!parameters.doubleSided ? "dimmed" : undefined}>
|
||||
{t('bookletImposition.flipOnShortEdge.label', 'Flip on short edge')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{!parameters.doubleSided
|
||||
? t('bookletImposition.flipOnShortEdge.manualNote', 'Not needed in manual mode - you flip the stack yourself')
|
||||
: t('bookletImposition.flipOnShortEdge.tooltip', 'Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)')
|
||||
}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.flipOnShortEdge}
|
||||
onChange={(e) => onParameterChange('flipOnShortEdge', e.target.checked)}
|
||||
disabled={disabled || !parameters.doubleSided}
|
||||
/>
|
||||
<Text size="sm" c={!parameters.doubleSided ? "dimmed" : undefined}>
|
||||
{t('bookletImposition.flipOnShortEdge.label', 'Flip on short edge')}
|
||||
</Text>
|
||||
</label>
|
||||
/>
|
||||
|
||||
{/* Paper Size Note */}
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
|
||||
import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare';
|
||||
import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
// utilities moved to compare.ts
|
||||
|
||||
const CompareDocumentPane = ({
|
||||
pane,
|
||||
layout,
|
||||
scrollRef,
|
||||
peerScrollRef,
|
||||
handleScrollSync,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPanMode,
|
||||
zoom,
|
||||
title,
|
||||
dropdownPlaceholder,
|
||||
changes,
|
||||
onNavigateChange,
|
||||
isLoading,
|
||||
processingMessage,
|
||||
pages,
|
||||
pairedPages,
|
||||
wordHighlightMap,
|
||||
metaIndexToGroupId,
|
||||
documentLabel,
|
||||
pageLabel,
|
||||
altLabel,
|
||||
onVisiblePageChange,
|
||||
}: CompareDocumentPaneProps) => {
|
||||
const isMobileViewport = useIsMobile();
|
||||
const pairedPageMap = useMemo(() => {
|
||||
const map = new Map<number, PagePreview>();
|
||||
pairedPages.forEach((item) => {
|
||||
map.set(item.pageNumber, item);
|
||||
});
|
||||
return map;
|
||||
}, [pairedPages]);
|
||||
|
||||
const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)';
|
||||
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
|
||||
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
|
||||
const pagePanRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||
const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 });
|
||||
|
||||
// Track which page images have finished loading to avoid flashing between states
|
||||
const imageLoadedRef = useRef<Map<number, boolean>>(new Map());
|
||||
// Force a re-render when an image load state changes (refs don't trigger renders)
|
||||
const [, setImageLoadedTick] = useState(0);
|
||||
const visiblePageRafRef = useRef<number | null>(null);
|
||||
const lastReportedVisiblePageRef = useRef<number | null>(null);
|
||||
const pageNodesRef = useRef<HTMLElement[] | null>(null);
|
||||
const groupedRectsByPage = useMemo(() => {
|
||||
const out = new Map<number, Map<string, TokenBoundingBox[]>>();
|
||||
for (const p of pages) {
|
||||
const rects = wordHighlightMap.get(p.pageNumber) ?? [];
|
||||
out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane));
|
||||
}
|
||||
return out;
|
||||
}, [pages, wordHighlightMap, metaIndexToGroupId, pane]);
|
||||
|
||||
// When zoom returns to 1 (reset), clear per-page pan state so content is centered again
|
||||
useEffect(() => {
|
||||
if (zoom <= 1) {
|
||||
pagePanRef.current.clear();
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||
<CompareNavigationDropdown
|
||||
changes={changes}
|
||||
placeholder={dropdownPlaceholder ?? null}
|
||||
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
|
||||
onNavigate={onNavigateChange}
|
||||
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => {
|
||||
handleScrollSync(event.currentTarget, peerScrollRef.current);
|
||||
// Notify parent about the currently visible page (throttled via rAF)
|
||||
if (visiblePageRafRef.current != null) return;
|
||||
if (!onVisiblePageChange || pages.length === 0) return;
|
||||
visiblePageRafRef.current = requestAnimationFrame(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const mid = container.scrollTop + container.clientHeight * 0.5;
|
||||
let bestPage = pages[0]?.pageNumber ?? 1;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
let nodes = pageNodesRef.current;
|
||||
if (!nodes || nodes.length !== pages.length) {
|
||||
nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
|
||||
pageNodesRef.current = nodes;
|
||||
}
|
||||
for (const el of nodes) {
|
||||
const top = el.offsetTop;
|
||||
const height = el.clientHeight || 1;
|
||||
const center = top + height / 2;
|
||||
const dist = Math.abs(center - mid);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
const attr = el.getAttribute('data-page-number');
|
||||
const pn = attr ? parseInt(attr, 10) : NaN;
|
||||
if (!Number.isNaN(pn)) bestPage = pn;
|
||||
}
|
||||
}
|
||||
if (typeof onVisiblePageChange === 'function' && bestPage !== lastReportedVisiblePageRef.current) {
|
||||
lastReportedVisiblePageRef.current = bestPage;
|
||||
onVisiblePageChange(pane, bestPage);
|
||||
}
|
||||
visiblePageRafRef.current = null;
|
||||
});
|
||||
}}
|
||||
onMouseDown={undefined}
|
||||
onMouseMove={undefined}
|
||||
onMouseUp={undefined}
|
||||
onMouseLeave={undefined}
|
||||
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
|
||||
onTouchStart={(event) => onTouchStart(pane, event)}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
className="compare-pane__scroll"
|
||||
style={{ cursor: cursorStyle }}
|
||||
>
|
||||
<Stack gap={zoom <= 0.6 ? 2 : zoom <= 0.85 ? 'xs' : 'sm'} className="compare-pane__content">
|
||||
{isLoading && (
|
||||
<Group justify="center" gap="xs" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm">{processingMessage}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pages.map((page) => {
|
||||
const peerPage = pairedPageMap.get(page.pageNumber);
|
||||
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
||||
const metrics = computePageLayoutMetrics({
|
||||
page,
|
||||
peerPage: peerPage ?? null,
|
||||
layout,
|
||||
isMobileViewport,
|
||||
scrollRefWidth: scrollRef.current?.clientWidth ?? null,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
offsetPixels: OFFSET_PIXELS,
|
||||
});
|
||||
|
||||
const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics;
|
||||
|
||||
// Compute clamped pan for current zoom so content always touches edges when in bounds
|
||||
const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||
const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||
const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||
const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x));
|
||||
const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y));
|
||||
|
||||
const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="compare-diff-page"
|
||||
data-page-number={page.pageNumber}
|
||||
style={{ minHeight: `${containerHeight}px` }}
|
||||
>
|
||||
<div
|
||||
className="compare-page-title"
|
||||
style={{ width: `${containerWidth}px`, marginLeft: 'auto', marginRight: 'auto' }}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed" ta="center">
|
||||
{documentLabel} · {pageLabel} {page.pageNumber}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
|
||||
style={{ width: `${containerWidth}px`, height: `${containerHeight}px`, marginLeft: 'auto', marginRight: 'auto', overflow: 'hidden' }}
|
||||
onMouseDown={(e) => {
|
||||
if (!isPanMode || zoom <= 1) return;
|
||||
dragRef.current.active = true;
|
||||
dragRef.current.page = page.pageNumber;
|
||||
dragRef.current.startX = e.clientX;
|
||||
dragRef.current.startY = e.clientY;
|
||||
const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||
dragRef.current.startPanX = curr.x;
|
||||
dragRef.current.startPanY = curr.y;
|
||||
(e.currentTarget as HTMLElement).style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
// Clamp panning based on the actual rendered content size.
|
||||
// The inner layer is width/height of the container, then scaled by innerScale.
|
||||
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||
const maxX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||
const maxY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||
const candX = dragRef.current.startPanX - dx;
|
||||
const candY = dragRef.current.startPanY - dy;
|
||||
const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) };
|
||||
pagePanRef.current.set(page.pageNumber, next);
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (dragRef.current.active) {
|
||||
dragRef.current.active = false;
|
||||
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (dragRef.current.active) {
|
||||
dragRef.current.active = false;
|
||||
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
|
||||
style={{
|
||||
transform: `scale(${innerScale}) translate3d(${-((clampedPanX) / innerScale)}px, ${-((clampedPanY) / innerScale)}px, 0)`,
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{/* Image layer */}
|
||||
<img
|
||||
src={page.url ?? ''}
|
||||
alt={altLabel}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="compare-diff-page__image"
|
||||
onLoad={() => {
|
||||
if (!imageLoadedRef.current.get(page.pageNumber)) {
|
||||
imageLoadedRef.current.set(page.pageNumber, true);
|
||||
setImageLoadedTick((v) => v + 1); // refs don't trigger renders
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Overlay loader until the page image is loaded */}
|
||||
{!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && (
|
||||
<div className="compare-page-loader-overlay">
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{[...groupedRects.entries()].flatMap(([id, rects]) =>
|
||||
mergeConnectedRects(rects).map((rect, index) => {
|
||||
const rotation = normalizeRotation(page.rotation);
|
||||
const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset;
|
||||
return (
|
||||
<span
|
||||
key={`${pane}-highlight-${page.pageNumber}-${id}-${index}`}
|
||||
data-change-id={id}
|
||||
className="compare-diff-highlight"
|
||||
style={{
|
||||
left: `${rect.left * 100}%`,
|
||||
top: `${(rect.top + verticalOffset) * 100}%`,
|
||||
width: `${rect.width * 100}%`,
|
||||
height: `${rect.height * 100}%`,
|
||||
backgroundColor: HIGHLIGHT_BG_VAR,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareDocumentPane;
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NavigationDropdownProps } from '@app/types/compare';
|
||||
|
||||
const CompareNavigationDropdown = ({
|
||||
changes,
|
||||
placeholder,
|
||||
className,
|
||||
onNavigate,
|
||||
renderedPageNumbers,
|
||||
}: NavigationDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const newLineLabel = t('compare.newLine', 'new-line');
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
// Cache scrollTop so we can restore on next open
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) scrollTopRef.current = viewport.scrollTop;
|
||||
setIsOpen(false);
|
||||
},
|
||||
onDropdownOpen: () => {
|
||||
setIsOpen(true);
|
||||
// Restore scrollTop after mount and rebuild offsets
|
||||
requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) viewport.scrollTop = scrollTopRef.current;
|
||||
const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[];
|
||||
groupOffsetsRef.current = headers.map((el) => {
|
||||
const text = el.textContent || '';
|
||||
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
return { top: el.offsetTop, page };
|
||||
});
|
||||
// Update sticky label based on current scroll position
|
||||
handleScrollPos({ x: 0, y: scrollTopRef.current });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sanitize = (s: string) => {
|
||||
// Normalize and remove control/separator characters without regex ranges
|
||||
return s
|
||||
.normalize('NFKC')
|
||||
.split('')
|
||||
.map(char => {
|
||||
const code = char.charCodeAt(0);
|
||||
// Replace control chars (0-31, 127) and special separators with space
|
||||
if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) {
|
||||
return ' ';
|
||||
}
|
||||
return char;
|
||||
})
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const isMeaningful = (s: string) => {
|
||||
const t = sanitize(s);
|
||||
|
||||
// Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class.
|
||||
const rx =
|
||||
(() => {
|
||||
try {
|
||||
// Construct at runtime so old engines don’t fail parse-time
|
||||
return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u');
|
||||
} catch {
|
||||
// Fallback (no Unicode props): letters, digits, and common punctuation/symbols
|
||||
return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!rx.test(t)) return false;
|
||||
return t.length > 0;
|
||||
};
|
||||
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [stickyPage, setStickyPage] = useState<number | null>(null);
|
||||
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
|
||||
const scrollTopRef = useRef(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const normalizedChanges = useMemo(() => {
|
||||
// Helper to strip localized new-line marker occurrences from labels
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const stripNewLine = (s: string) =>
|
||||
s
|
||||
.replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const cleaned = changes
|
||||
.map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber }))
|
||||
.filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase());
|
||||
const q = sanitize(query).toLowerCase();
|
||||
if (!q) return cleaned;
|
||||
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}, [changes, query, newLineLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
// Build offsets for group headers whenever list changes while open
|
||||
if (!isOpen) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
|
||||
groupOffsetsRef.current = headers.map((el) => {
|
||||
const text = el.textContent || '';
|
||||
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
return { top: el.offsetTop, page };
|
||||
});
|
||||
// Update sticky based on current scroll position
|
||||
handleScrollPos({ x: 0, y: viewport.scrollTop });
|
||||
}, [normalizedChanges, isOpen]);
|
||||
|
||||
const handleScrollPos = ({ y }: { x: number; y: number }) => {
|
||||
const offsets = groupOffsetsRef.current;
|
||||
if (offsets.length === 0) return;
|
||||
// Find the last header whose top is <= scroll, so the next header replaces it
|
||||
let low = 0;
|
||||
let high = offsets.length - 1;
|
||||
let idx = 0;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
|
||||
idx = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
const page = offsets[idx]?.page ?? offsets[0].page;
|
||||
if (page !== stickyPage) setStickyPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => {
|
||||
const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber;
|
||||
onNavigate(value, pn);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
|
||||
>
|
||||
<Combobox.Target>
|
||||
<div
|
||||
className={['compare-changes-select', className].filter(Boolean).join(' ')}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
>
|
||||
<span className="compare-changes-select__placeholder">{placeholder}</span>
|
||||
<Combobox.Chevron />
|
||||
</div>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown className="compare-changes-dropdown">
|
||||
{/* Header sits outside scroll so it stays fixed at top */}
|
||||
<div>
|
||||
<Combobox.Search
|
||||
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Lazy render the scrollable content only when open */}
|
||||
{isOpen && (
|
||||
<div className="compare-dropdown-scrollwrap">
|
||||
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
|
||||
{stickyPage != null && (
|
||||
<div className="compare-dropdown-sticky" style={{ top: 0 }}>
|
||||
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
|
||||
{renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && (
|
||||
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Combobox.Options className="compare-dropdown-options">
|
||||
{normalizedChanges.length > 0 ? (
|
||||
(() => {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastPage: number | null = null;
|
||||
for (const item of normalizedChanges) {
|
||||
if (item.pageNumber && item.pageNumber !== lastPage) {
|
||||
lastPage = item.pageNumber;
|
||||
nodes.push(
|
||||
<div
|
||||
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
|
||||
key={`group-${lastPage}`}
|
||||
>
|
||||
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
|
||||
{renderedPageNumbers && !renderedPageNumbers.has(lastPage) && (
|
||||
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
<Combobox.Option
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onNavigate(item.value, item.pageNumber);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<div className="compare-dropdown-option">
|
||||
<span className="compare-dropdown-option__text">{item.label}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
return nodes;
|
||||
})()
|
||||
) : (
|
||||
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</ScrollArea.Autosize>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareNavigationDropdown;
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { Loader, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import {
|
||||
mapChangesForDropdown,
|
||||
getFileFromSelection,
|
||||
getStubFromSelection,
|
||||
computeShowProgressBanner,
|
||||
computeProgressPct,
|
||||
computeCountsText,
|
||||
computeMaxSharedPages,
|
||||
} from '@app/components/tools/compare/compare';
|
||||
import {
|
||||
CompareResultData,
|
||||
CompareWorkbenchData,
|
||||
} from '@app/types/compare';
|
||||
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||
import { useRightRailButtons } from '@app/hooks/useRightRailButtons';
|
||||
import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane';
|
||||
import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews';
|
||||
import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom';
|
||||
import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights';
|
||||
import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation';
|
||||
import '@app/components/tools/compare/compareView.css';
|
||||
import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons';
|
||||
import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
|
||||
interface CompareWorkbenchViewProps {
|
||||
data: CompareWorkbenchData | null;
|
||||
}
|
||||
|
||||
// helpers moved to compare.ts
|
||||
|
||||
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const prefersStacked = useIsMobile();
|
||||
const { selectors } = useFileContext();
|
||||
|
||||
const result: CompareResultData | null = data?.result ?? null;
|
||||
const baseFileId = data?.baseFileId ?? null;
|
||||
const comparisonFileId = data?.comparisonFileId ?? null;
|
||||
const isOperationLoading = data?.isLoading ?? false;
|
||||
|
||||
const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors);
|
||||
const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors);
|
||||
const baseStub = getStubFromSelection(baseFileId, selectors);
|
||||
const comparisonStub = getStubFromSelection(comparisonFileId, selectors);
|
||||
|
||||
const processedAt = result?.totals.processedAt ?? null;
|
||||
|
||||
const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({
|
||||
file: baseFile,
|
||||
enabled: Boolean(result && baseFile),
|
||||
cacheKey: processedAt,
|
||||
});
|
||||
|
||||
const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({
|
||||
file: comparisonFile,
|
||||
enabled: Boolean(result && comparisonFile),
|
||||
cacheKey: processedAt,
|
||||
});
|
||||
|
||||
const {
|
||||
layout,
|
||||
toggleLayout,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
handleScrollSync,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
setBaseZoom,
|
||||
comparisonZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
setIsScrollLinked,
|
||||
isScrollLinked,
|
||||
zoomLimits,
|
||||
} = useComparePanZoom({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
});
|
||||
|
||||
const {
|
||||
baseWordChanges,
|
||||
comparisonWordChanges,
|
||||
metaIndexToGroupId,
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
} = useCompareHighlights(result, basePages, comparisonPages);
|
||||
|
||||
const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => {
|
||||
const wasLinked = isScrollLinked;
|
||||
if (wasLinked) setIsScrollLinked(false);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
if (wasLinked) {
|
||||
// recapture anchors to keep panes aligned when relinking
|
||||
captureScrollLinkDelta();
|
||||
setIsScrollLinked(true);
|
||||
}
|
||||
}, Math.max(200, durationMs));
|
||||
}
|
||||
}, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]);
|
||||
|
||||
const handleChangeNavigation = useCompareChangeNavigation(
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
{ temporarilySuppressScrollLink }
|
||||
);
|
||||
|
||||
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
|
||||
const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document');
|
||||
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document');
|
||||
const pageLabel = t('compare.summary.pageLabel', 'Page');
|
||||
|
||||
// Always show the selected file names from the sidebar; they are known before diff results
|
||||
const baseTitle = baseStub?.name || result?.base?.fileName || '';
|
||||
const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || '';
|
||||
|
||||
// During diff processing, show compact spinners in the dropdown badges
|
||||
const baseDropdownPlaceholder = (isOperationLoading || !result)
|
||||
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
|
||||
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
|
||||
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
|
||||
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
|
||||
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
|
||||
|
||||
const rightRailButtons = useCompareRightRailButtons({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
});
|
||||
|
||||
useRightRailButtons(rightRailButtons);
|
||||
|
||||
// Rendering progress toast for very large PDFs
|
||||
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||
const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0;
|
||||
const showProgressBanner = useMemo(() => (
|
||||
computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD)
|
||||
), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
|
||||
|
||||
const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered);
|
||||
|
||||
const progressToastIdRef = useRef<string | null>(null);
|
||||
const completionTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allDone = useMemo(() => {
|
||||
const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length);
|
||||
const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length);
|
||||
return baseDone && compDone;
|
||||
}, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||
|
||||
// Drive toast lifecycle and progress updates
|
||||
useEffect(() => {
|
||||
// No toast needed
|
||||
if (!showProgressBanner) {
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const countsText = computeCountsText(
|
||||
baseRendered,
|
||||
baseTotal,
|
||||
basePages.length,
|
||||
compRendered,
|
||||
compTotal,
|
||||
comparisonPages.length,
|
||||
);
|
||||
if (!allDone) {
|
||||
// Create toast if missing
|
||||
if (!progressToastIdRef.current) {
|
||||
const id = alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
durationMs: 0,
|
||||
expandable: false,
|
||||
progressBarPercentage: progressPct,
|
||||
});
|
||||
progressToastIdRef.current = id;
|
||||
} else {
|
||||
updateToast(progressToastIdRef.current, {
|
||||
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
alertType: 'neutral', // ensure it stays neutral until completion
|
||||
});
|
||||
updateToastProgress(progressToastIdRef.current, progressPct);
|
||||
}
|
||||
} else {
|
||||
// Completed: update then auto-dismiss after 3s
|
||||
if (progressToastIdRef.current) {
|
||||
updateToast(progressToastIdRef.current, {
|
||||
title: t('compare.rendering.complete', 'Page rendering complete'),
|
||||
body: undefined,
|
||||
isPersistentPopup: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
updateToastProgress(progressToastIdRef.current, 100);
|
||||
if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = window.setTimeout(() => {
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
|
||||
|
||||
// Shared page navigation state/input
|
||||
const maxSharedPages = useMemo(() => (
|
||||
computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length)
|
||||
), [baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||
|
||||
const [pageInputValue, setPageInputValue] = useState<string>('1');
|
||||
const typingTimerRef = useRef<number | null>(null);
|
||||
const isTypingRef = useRef(false);
|
||||
|
||||
// Clamp the displayed input if max changes smaller than current
|
||||
useEffect(() => {
|
||||
if (!pageInputValue) return;
|
||||
const n = Math.max(1, parseInt(pageInputValue, 10) || 1);
|
||||
if (maxSharedPages > 0 && n > maxSharedPages) {
|
||||
setPageInputValue(String(maxSharedPages));
|
||||
}
|
||||
}, [maxSharedPages]);
|
||||
|
||||
const scrollBothToPage = useCallback((pageNum: number) => {
|
||||
const scrollOne = (container: HTMLDivElement | null) => {
|
||||
if (!container) return false;
|
||||
const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null;
|
||||
if (!pageEl) return false;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2)));
|
||||
container.scrollTop = desired;
|
||||
return true;
|
||||
};
|
||||
|
||||
const hitBase = scrollOne(baseScrollRef.current);
|
||||
const hitComp = scrollOne(comparisonScrollRef.current);
|
||||
|
||||
// Warn if one or both pages are not yet rendered
|
||||
const baseHas = basePages.some(p => p.pageNumber === pageNum);
|
||||
const compHas = comparisonPages.some(p => p.pageNumber === pageNum);
|
||||
if (!baseHas || !compHas) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'),
|
||||
body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'),
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
return hitBase || hitComp;
|
||||
}, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]);
|
||||
|
||||
const handleTypingChange = useCallback((next: string) => {
|
||||
// Only digits; allow empty while editing
|
||||
const digits = next.replace(/[^0-9]/g, '');
|
||||
if (digits.length === 0) {
|
||||
setPageInputValue('');
|
||||
if (typingTimerRef.current != null) {
|
||||
window.clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Math.max(1, parseInt(digits, 10));
|
||||
const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed;
|
||||
const display = String(capped);
|
||||
setPageInputValue(display);
|
||||
|
||||
isTypingRef.current = true;
|
||||
if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = window.setTimeout(() => {
|
||||
isTypingRef.current = false;
|
||||
scrollBothToPage(capped);
|
||||
}, 300);
|
||||
}, [maxSharedPages, scrollBothToPage]);
|
||||
|
||||
return (
|
||||
<Stack className="compare-workbench">
|
||||
|
||||
<Stack gap="lg" className="compare-workbench__content">
|
||||
<div
|
||||
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
|
||||
>
|
||||
<CompareDocumentPane
|
||||
pane="base"
|
||||
layout={layout}
|
||||
scrollRef={baseScrollRef}
|
||||
peerScrollRef={comparisonScrollRef}
|
||||
handleScrollSync={handleScrollSync}
|
||||
handleWheelZoom={handleWheelZoom}
|
||||
handleWheelOverscroll={handleWheelOverscroll}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
isPanMode={isPanMode}
|
||||
zoom={baseZoom}
|
||||
title={baseTitle}
|
||||
dropdownPlaceholder={baseDropdownPlaceholder}
|
||||
changes={mapChangesForDropdown(baseWordChanges)}
|
||||
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)}
|
||||
isLoading={isOperationLoading || baseLoading}
|
||||
processingMessage={processingMessage}
|
||||
pages={basePages}
|
||||
pairedPages={comparisonPages}
|
||||
getRowHeightPx={getRowHeightPx}
|
||||
wordHighlightMap={wordHighlightMaps.base}
|
||||
metaIndexToGroupId={metaIndexToGroupId.base}
|
||||
documentLabel={baseDocumentLabel}
|
||||
pageLabel={pageLabel}
|
||||
altLabel={baseDocumentLabel}
|
||||
pageInputValue={pageInputValue}
|
||||
onPageInputChange={handleTypingChange}
|
||||
maxSharedPages={maxSharedPages}
|
||||
/>
|
||||
<CompareDocumentPane
|
||||
pane="comparison"
|
||||
layout={layout}
|
||||
scrollRef={comparisonScrollRef}
|
||||
peerScrollRef={baseScrollRef}
|
||||
handleScrollSync={handleScrollSync}
|
||||
handleWheelZoom={handleWheelZoom}
|
||||
handleWheelOverscroll={handleWheelOverscroll}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
isPanMode={isPanMode}
|
||||
zoom={comparisonZoom}
|
||||
title={comparisonTitle}
|
||||
dropdownPlaceholder={comparisonDropdownPlaceholder}
|
||||
changes={mapChangesForDropdown(comparisonWordChanges)}
|
||||
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)}
|
||||
isLoading={isOperationLoading || comparisonLoading}
|
||||
processingMessage={processingMessage}
|
||||
pages={comparisonPages}
|
||||
pairedPages={basePages}
|
||||
getRowHeightPx={getRowHeightPx}
|
||||
wordHighlightMap={wordHighlightMaps.comparison}
|
||||
metaIndexToGroupId={metaIndexToGroupId.comparison}
|
||||
documentLabel={comparisonDocumentLabel}
|
||||
pageLabel={pageLabel}
|
||||
altLabel={comparisonDocumentLabel}
|
||||
pageInputValue={pageInputValue}
|
||||
onPageInputChange={handleTypingChange}
|
||||
maxSharedPages={maxSharedPages}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareWorkbenchView;
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare';
|
||||
import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
|
||||
/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */
|
||||
export const toRgba = (hexColor: string, alpha: number): string => {
|
||||
const hex = hexColor.replace('#', '');
|
||||
if (hex.length !== 6) return hexColor;
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
/** Normalize rotation to [0, 360). */
|
||||
export const normalizeRotation = (deg: number | undefined | null): number => {
|
||||
const n = ((deg ?? 0) % 360 + 360) % 360;
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge overlapping or touching rectangles into larger non-overlapping blocks.
|
||||
* Robust across rotations (vertical groups) and prevents dark spots from overlaps.
|
||||
*/
|
||||
export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
|
||||
if (rects.length === 0) return rects;
|
||||
const EPS = 0.004; // small tolerance in normalized page coords
|
||||
const sorted = rects
|
||||
.slice()
|
||||
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
|
||||
const merged: TokenBoundingBox[] = [];
|
||||
|
||||
const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => {
|
||||
const aR = a.left + a.width;
|
||||
const aB = a.top + a.height;
|
||||
const bR = b.left + b.width;
|
||||
const bB = b.top + b.height;
|
||||
return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS);
|
||||
};
|
||||
|
||||
for (const r of sorted) {
|
||||
let mergedIntoExisting = false;
|
||||
for (let i = 0; i < merged.length; i += 1) {
|
||||
const m = merged[i];
|
||||
if (overlapsOrTouches(m, r)) {
|
||||
const left = Math.min(m.left, r.left);
|
||||
const top = Math.min(m.top, r.top);
|
||||
const right = Math.max(m.left + m.width, r.left + r.width);
|
||||
const bottom = Math.max(m.top + m.height, r.top + r.height);
|
||||
merged[i] = {
|
||||
left,
|
||||
top,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
mergedIntoExisting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mergedIntoExisting) merged.push({ ...r });
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
/** Group word rectangles by change id using metaIndexToGroupId. */
|
||||
export const groupWordRects = (
|
||||
wordRects: WordHighlightEntry[],
|
||||
metaIndexToGroupId: Map<number, string>,
|
||||
pane: 'base' | 'comparison'
|
||||
): Map<string, TokenBoundingBox[]> => {
|
||||
const groupedRects = new Map<string, TokenBoundingBox[]>();
|
||||
for (const { rect, metaIndex } of wordRects) {
|
||||
const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`;
|
||||
const current = groupedRects.get(id) ?? [];
|
||||
current.push(rect);
|
||||
groupedRects.set(id, current);
|
||||
}
|
||||
return groupedRects;
|
||||
};
|
||||
|
||||
/** Compute derived layout metrics for a page render, given environment and zoom. */
|
||||
export const computePageLayoutMetrics = (args: {
|
||||
page: PagePreview;
|
||||
peerPage?: PagePreview | null;
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
isMobileViewport: boolean;
|
||||
scrollRefWidth: number | null;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
offsetPixels: number; // highlight offset in px relative to original page height
|
||||
}) => {
|
||||
const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args;
|
||||
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const highlightOffset = offsetPixels / page.height;
|
||||
const rotationNorm = normalizeRotation(page.rotation);
|
||||
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
|
||||
const isStackedPortrait = layout === 'stacked' && isPortrait;
|
||||
|
||||
const containerW = scrollRefWidth ?? viewportWidth;
|
||||
const stackedWidth = isMobileViewport
|
||||
? Math.max(320, Math.round(containerW))
|
||||
: Math.max(320, Math.round(viewportWidth * 0.5));
|
||||
const stackedHeight = Math.round(stackedWidth * 1.4142);
|
||||
|
||||
const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit);
|
||||
const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight);
|
||||
const containerMaxW = scrollRefWidth ?? viewportWidth;
|
||||
|
||||
// Container-first zooming with a stable baseline:
|
||||
// Treat zoom=1 as "fit to available width" for the page's base size so
|
||||
// the initial render is fully visible and centered (no cropping), regardless
|
||||
// of rotation or pane/container width. When zoom < 1, shrink the container;
|
||||
// when zoom > 1, keep the container at fit width and scale inner content.
|
||||
const MIN_CONTAINER_WIDTH = 120;
|
||||
const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth);
|
||||
const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth);
|
||||
// Effective baseline scale used at zoom=1 (ensures at least the min width)
|
||||
const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer);
|
||||
// Lower bound the zoom so interactions remain stable
|
||||
const desiredZoom = Math.max(0.1, zoom);
|
||||
|
||||
let containerScale: number;
|
||||
let innerScale: number;
|
||||
if (desiredZoom >= 1) {
|
||||
// At or above baseline: keep container at fit width and scale inner content
|
||||
containerScale = baselineContainerScale;
|
||||
innerScale = +Math.max(0.1, desiredZoom).toFixed(4);
|
||||
} else {
|
||||
// Below baseline: shrink container proportionally, do not upscale inner
|
||||
const scaled = baselineContainerScale * desiredZoom;
|
||||
// Never smaller than minimum readable width
|
||||
containerScale = Math.max(minScaleByWidth, scaled);
|
||||
innerScale = 1;
|
||||
}
|
||||
|
||||
const containerWidth = Math.max(
|
||||
MIN_CONTAINER_WIDTH,
|
||||
Math.min(containerMaxW, Math.round(baseWidth * containerScale))
|
||||
);
|
||||
const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth)));
|
||||
|
||||
return {
|
||||
targetHeight,
|
||||
fit,
|
||||
highlightOffset,
|
||||
rotationNorm,
|
||||
isPortrait,
|
||||
isStackedPortrait,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
containerMaxW,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
innerScale,
|
||||
};
|
||||
};
|
||||
|
||||
/** Map changes to dropdown options tuple. */
|
||||
export const mapChangesForDropdown = (
|
||||
changes: Array<{ value: string; label: string; pageNumber: number }>
|
||||
) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber }));
|
||||
|
||||
/** File selection helpers */
|
||||
export const getFileFromSelection = (
|
||||
explicit: StirlingFile | null | undefined,
|
||||
fileId: FileId | null,
|
||||
selectors: { getFile: (id: FileId) => StirlingFile | undefined | null }
|
||||
): StirlingFile | null => {
|
||||
if (explicit) return explicit;
|
||||
if (!fileId) return null;
|
||||
return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null;
|
||||
};
|
||||
|
||||
export const getStubFromSelection = (
|
||||
fileId: FileId | null,
|
||||
selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined }
|
||||
): StirlingFileStub | null => {
|
||||
if (!fileId) return null;
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
return stub ?? null;
|
||||
};
|
||||
|
||||
/** Progress banner computations */
|
||||
export const computeShowProgressBanner = (
|
||||
totalsKnown: boolean,
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseLoading: boolean,
|
||||
compLoading: boolean,
|
||||
threshold: number = 400
|
||||
): boolean => {
|
||||
if (!totalsKnown) return false;
|
||||
const totals = [baseTotal ?? 0, compTotal ?? 0];
|
||||
return Math.max(...totals) >= threshold && (baseLoading || compLoading);
|
||||
};
|
||||
|
||||
export const computeProgressPct = (
|
||||
totalsKnown: boolean,
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseRendered: number,
|
||||
compRendered: number
|
||||
): number => {
|
||||
const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0;
|
||||
const renderedCombined = baseRendered + compRendered;
|
||||
return totalsKnown && totalCombined > 0
|
||||
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
|
||||
: 0;
|
||||
};
|
||||
|
||||
export const computeCountsText = (
|
||||
baseRendered: number,
|
||||
baseTotal: number | null | undefined,
|
||||
baseLength: number,
|
||||
compRendered: number,
|
||||
compTotal: number | null | undefined,
|
||||
compLength: number
|
||||
): string => {
|
||||
const baseTotalShown = baseTotal || baseLength;
|
||||
const compTotalShown = compTotal || compLength;
|
||||
return `${baseRendered}/${baseTotalShown} • ${compRendered}/${compTotalShown}`;
|
||||
};
|
||||
|
||||
export const computeMaxSharedPages = (
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseLen: number,
|
||||
compLen: number
|
||||
): number => {
|
||||
const baseMax = baseTotal || baseLen || 0;
|
||||
const compMax = compTotal || compLen || 0;
|
||||
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
|
||||
if (!Number.isFinite(minKnown)) return 0;
|
||||
return Math.max(0, minKnown);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
.compare-dropdown-scrollwrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-dropdown-sticky {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.compare-workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
padding-top: 3rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-workbench__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compare-pane__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.compare-pane__content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-workbench__mode {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.compare-workbench__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked mode: two rows with synchronized scroll panes */
|
||||
.compare-workbench__columns--stacked {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.compare-workbench__columns > div {
|
||||
/* Critical for responsive flex children inside non-wrapping layouts */
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-legend {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sticky header styling overrides */
|
||||
.compare-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-toolbar);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
|
||||
}
|
||||
|
||||
/* Dropdown badge-like style - only style the dropdowns, not titles */
|
||||
.compare-changes-select {
|
||||
background: var(--spdf-compare-removed-badge-bg) !important;
|
||||
color: var(--spdf-compare-removed-badge-fg) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.compare-changes-select__placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.compare-changes-select__placeholder .mantine-Loader-root {
|
||||
display: inline-flex;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison {
|
||||
background: var(--spdf-compare-added-badge-bg) !important;
|
||||
color: var(--spdf-compare-added-badge-fg) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Wider dropdown menu for long block text */
|
||||
.compare-changes-dropdown {
|
||||
min-width: 520px !important;
|
||||
max-width: 70vw !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden; /* prevent inner elements from overflowing rounded edges */
|
||||
border-radius: 8px !important; /* match dropdown container radius */
|
||||
}
|
||||
|
||||
/* Ensure options text uses full width inside wider dropdown */
|
||||
.compare-dropdown-option__text {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Remove default padding inside Mantine options list so sticky header touches edges */
|
||||
.compare-changes-dropdown .mantine-Combobox-options {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.compare-dropdown-scrollwrap { margin: 0; }
|
||||
|
||||
/* Ensure search field sits flush and does not overflow */
|
||||
.compare-changes-dropdown .mantine-Combobox-search {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-top-left-radius: 8px !important;
|
||||
border-top-right-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown container */
|
||||
.compare-changes-select .mantine-Combobox-dropdown {
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
background-color: var(--bg-surface) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-dropdown {
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
background-color: var(--bg-surface) !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for ScrollArea */
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-muted) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-muted) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown options */
|
||||
.compare-changes-select .mantine-Combobox-option {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-option {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-Combobox-option:hover {
|
||||
background-color: var(--spdf-compare-removed-badge-bg) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-option:hover {
|
||||
background-color: var(--spdf-compare-added-badge-bg) !important;
|
||||
}
|
||||
|
||||
/* Style the search input */
|
||||
.compare-changes-select .mantine-Combobox-search {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-search {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-Combobox-search::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Style the chevron - ensure proper coloring */
|
||||
.compare-changes-select .mantine-Combobox-chevron,
|
||||
.compare-changes-select--comparison .mantine-Combobox-chevron {
|
||||
color: inherit !important;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Flash/pulse highlight for navigated change */
|
||||
@keyframes compare-flash {
|
||||
0% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||
background-color: rgba(255, 235, 59, 0.2) !important;
|
||||
}
|
||||
25% {
|
||||
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
|
||||
background-color: rgba(255, 235, 59, 0.4) !important;
|
||||
}
|
||||
50% {
|
||||
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
|
||||
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||
}
|
||||
75% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.8);
|
||||
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
|
||||
background-color: rgba(255, 235, 59, 0.3) !important;
|
||||
}
|
||||
100% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||
background-color: rgba(255, 235, 59, 0.0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.compare-diff-highlight--flash {
|
||||
animation: compare-flash 1.5s ease-in-out 1;
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
/* Bonus: temporarily override red/green to yellow during flash for clarity */
|
||||
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Union overlay for group flash */
|
||||
.compare-diff-flash-overlay {
|
||||
animation: compare-flash 1.5s ease-in-out 1;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 235, 59, 0.4);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.compare-legend__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.compare-legend__swatch {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.compare-summary__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compare-summary__stat-card {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.compare-summary__segment {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
.compare-diff-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.compare-diff-page__canvas {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-surface);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Center canvas in stacked portrait mode (width/height set inline) */
|
||||
.compare-diff-page__canvas[style*="margin-left: auto"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.compare-diff-page__canvas--zoom {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.compare-diff-page__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
background-color: #fff; /* ensure stable white backing during load */
|
||||
border: 1px solid var(--border-subtle);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.compare-diff-page__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Centered per-page title wrapper */
|
||||
.compare-page-title {
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.compare-page-title .mantine-Text-root {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
}
|
||||
|
||||
/* Overlay loader to avoid flash when image not yet loaded */
|
||||
.compare-page-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.compare-diff-highlight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Compare dropdown option formatting (page + clamped text) */
|
||||
.compare-dropdown-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.compare-dropdown-option__page {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.compare-dropdown-option__text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
/* Non-sticky in-flow group headers; sticky handled by floating header */
|
||||
.compare-dropdown-group {
|
||||
position: static;
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.compare-dropdown-group.compare-dropdown-group--hidden {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Light grey rendering flag next to page labels in the dropdown */
|
||||
.compare-dropdown-rendering-flag {
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Inline paragraph highlights in summary */
|
||||
.compare-inline {
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.05rem 0.15rem;
|
||||
}
|
||||
.compare-inline--removed {
|
||||
background-color: var(--spdf-compare-inline-removed-bg);
|
||||
}
|
||||
.compare-inline--added {
|
||||
background-color: var(--spdf-compare-inline-added-bg);
|
||||
}
|
||||
|
||||
.compare-pane-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg-background);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */
|
||||
.compare-tool__thumbnail {
|
||||
width: 4rem;
|
||||
height: 5.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.compare-tool__details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mobile: remove side margins and let canvases take full width inside column */
|
||||
@media (max-width: 768px) {
|
||||
.compare-workbench__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.compare-diff-page__inner {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
type SuppressOptions = {
|
||||
temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void;
|
||||
};
|
||||
|
||||
export const useCompareChangeNavigation = (
|
||||
baseScrollRef: RefObject<HTMLDivElement | null>,
|
||||
comparisonScrollRef: RefObject<HTMLDivElement | null>,
|
||||
options?: SuppressOptions,
|
||||
) => {
|
||||
return useCallback(
|
||||
(changeValue: string, pane: Pane, pageNumber?: number) => {
|
||||
const suppress = <T extends void>(fn: () => T) => {
|
||||
if (options?.temporarilySuppressScrollLink) {
|
||||
options.temporarilySuppressScrollLink(fn, 700);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
|
||||
const container = targetRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const findNodes = (): HTMLElement[] => {
|
||||
return Array.from(
|
||||
container.querySelectorAll(`[data-change-id="${changeValue}"]`)
|
||||
) as HTMLElement[];
|
||||
};
|
||||
|
||||
const scrollToPageIfNeeded = () => {
|
||||
if (!pageNumber) return false;
|
||||
const pageEl = container.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!pageEl) return false;
|
||||
const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2);
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollPeerPageIfPossible = () => {
|
||||
if (!pageNumber) return;
|
||||
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||
const peer = peerRef.current;
|
||||
if (!peer) return;
|
||||
const peerPageEl = peer.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!peerPageEl) return;
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
peerMaxTop,
|
||||
peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)
|
||||
)
|
||||
);
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top, behavior: 'auto' });
|
||||
});
|
||||
};
|
||||
|
||||
const proceedWithNodes = (nodes: HTMLElement[]) => {
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
// Prefer a percent-in-page based vertical scroll, which is resilient to transforms.
|
||||
const anchor = nodes[0];
|
||||
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||
const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||
if (pageEl && inner && !Number.isNaN(topPercent)) {
|
||||
const innerRect = inner.getBoundingClientRect();
|
||||
const innerHeight = Math.max(1, innerRect.height);
|
||||
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desiredTop = Math.max(
|
||||
0,
|
||||
Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2)
|
||||
);
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: desiredTop, behavior: 'auto' });
|
||||
});
|
||||
} else {
|
||||
// Fallback to bounding-rect based centering if percent approach is unavailable.
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
let minTop = Number.POSITIVE_INFINITY;
|
||||
let minLeft = Number.POSITIVE_INFINITY;
|
||||
let maxBottom = Number.NEGATIVE_INFINITY;
|
||||
let maxRight = Number.NEGATIVE_INFINITY;
|
||||
|
||||
nodes.forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
minTop = Math.min(minTop, rect.top);
|
||||
minLeft = Math.min(minLeft, rect.left);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
});
|
||||
|
||||
const boxHeight = Math.max(1, maxBottom - minTop);
|
||||
const boxWidth = Math.max(1, maxRight - minLeft);
|
||||
const absoluteTop = minTop - containerRect.top + container.scrollTop;
|
||||
const absoluteLeft = minLeft - containerRect.left + container.scrollLeft;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2));
|
||||
const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2);
|
||||
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
// Also scroll the peer container to the corresponding location in the
|
||||
// other PDF (same page and approximate vertical position within page),
|
||||
// not just the same list/scroll position.
|
||||
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||
const peer = peerRef.current;
|
||||
if (peer) {
|
||||
// Use the first node as the anchor
|
||||
const anchor = nodes[0];
|
||||
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||
const pageNumAttr = pageEl?.getAttribute('data-page-number');
|
||||
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||
if (pageNumAttr) {
|
||||
const peerPageEl = peer.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumAttr}"]`
|
||||
) as HTMLElement | null;
|
||||
const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null;
|
||||
if (peerPageEl && peerInner) {
|
||||
const innerRect = peerInner.getBoundingClientRect();
|
||||
const innerHeight = Math.max(1, innerRect.height);
|
||||
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const peerDesiredTop = Math.max(
|
||||
0,
|
||||
Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2)
|
||||
);
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' });
|
||||
});
|
||||
} else if (peerPageEl) {
|
||||
// Fallback: Scroll to page top (clamped)
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)));
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
|
||||
nodes.forEach((element) => {
|
||||
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||
if (!inner) return;
|
||||
const list = groupsByInner.get(inner) ?? [];
|
||||
list.push(element);
|
||||
groupsByInner.set(inner, list);
|
||||
});
|
||||
|
||||
groupsByInner.forEach((elements, inner) => {
|
||||
let minL = 100;
|
||||
let minT = 100;
|
||||
let maxR = 0;
|
||||
let maxB = 0;
|
||||
elements.forEach((element) => {
|
||||
const leftPercent = parseFloat(element.style.left) || 0;
|
||||
const topPercent = parseFloat(element.style.top) || 0;
|
||||
const widthPercent = parseFloat(element.style.width) || 0;
|
||||
const heightPercent = parseFloat(element.style.height) || 0;
|
||||
minL = Math.min(minL, leftPercent);
|
||||
minT = Math.min(minT, topPercent);
|
||||
maxR = Math.max(maxR, leftPercent + widthPercent);
|
||||
maxB = Math.max(maxB, topPercent + heightPercent);
|
||||
});
|
||||
const overlay = document.createElement('span');
|
||||
overlay.className = 'compare-diff-flash-overlay';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.left = `${minL}%`;
|
||||
overlay.style.top = `${minT}%`;
|
||||
overlay.style.width = `${Math.max(0.1, maxR - minL)}%`;
|
||||
overlay.style.height = `${Math.max(0.1, maxB - minT)}%`;
|
||||
inner.appendChild(overlay);
|
||||
window.setTimeout(() => overlay.remove(), 1600);
|
||||
});
|
||||
|
||||
nodes.forEach((element) => {
|
||||
element.classList.remove('compare-diff-highlight--flash');
|
||||
});
|
||||
void container.clientWidth; // Force reflow
|
||||
nodes.forEach((element) => {
|
||||
element.classList.add('compare-diff-highlight--flash');
|
||||
window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600);
|
||||
});
|
||||
};
|
||||
|
||||
const nodes = findNodes();
|
||||
if (nodes.length > 0) {
|
||||
proceedWithNodes(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Page-level fallback immediately so the user sees something happen
|
||||
const scrolledPage = scrollToPageIfNeeded();
|
||||
if (scrolledPage) {
|
||||
scrollPeerPageIfPossible();
|
||||
} else {
|
||||
// Even if the page element is not present yet, try to nudge peer pane
|
||||
scrollPeerPageIfPossible();
|
||||
}
|
||||
|
||||
// Wait for highlights to mount (pages/images render progressively)
|
||||
let settled = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (settled) return;
|
||||
const n = findNodes();
|
||||
if (n.length > 0) {
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
proceedWithNodes(n);
|
||||
}
|
||||
});
|
||||
try {
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
// Safety timeout to stop waiting after a while
|
||||
window.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
// We already scrolled to the page above; nothing else to do.
|
||||
}, 5000);
|
||||
},
|
||||
[baseScrollRef, comparisonScrollRef]
|
||||
);
|
||||
};
|
||||
|
||||
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
CompareFilteredTokenInfo,
|
||||
WordHighlightEntry,
|
||||
CompareResultData,
|
||||
CompareChangeOption,
|
||||
PagePreview,
|
||||
} from '@app/types/compare';
|
||||
|
||||
interface MetaGroupMap {
|
||||
base: Map<number, string>;
|
||||
comparison: Map<number, string>;
|
||||
}
|
||||
|
||||
interface WordHighlightMaps {
|
||||
base: Map<number, WordHighlightEntry[]>;
|
||||
comparison: Map<number, WordHighlightEntry[]>;
|
||||
}
|
||||
|
||||
export interface UseCompareHighlightsResult {
|
||||
baseWordChanges: CompareChangeOption[];
|
||||
comparisonWordChanges: CompareChangeOption[];
|
||||
metaIndexToGroupId: MetaGroupMap;
|
||||
wordHighlightMaps: WordHighlightMaps;
|
||||
getRowHeightPx: (pageNumber: number) => number;
|
||||
}
|
||||
|
||||
const buildWordChanges = (
|
||||
tokens: CompareFilteredTokenInfo[],
|
||||
metaIndexToGroupId: Map<number, string>,
|
||||
groupPrefix: string
|
||||
): CompareChangeOption[] => {
|
||||
metaIndexToGroupId.clear();
|
||||
if (!tokens.length) return [];
|
||||
|
||||
const items: CompareChangeOption[] = [];
|
||||
let currentRun: CompareFilteredTokenInfo[] = [];
|
||||
|
||||
const flushRun = () => {
|
||||
if (currentRun.length === 0) return;
|
||||
const label = currentRun.map((token) => token.token).join(' ').trim();
|
||||
if (label.length === 0) {
|
||||
currentRun = [];
|
||||
return;
|
||||
}
|
||||
const first = currentRun[0];
|
||||
const last = currentRun[currentRun.length - 1];
|
||||
const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`;
|
||||
currentRun.forEach((token) => {
|
||||
metaIndexToGroupId.set(token.metaIndex, groupId);
|
||||
});
|
||||
const pageNumber = first.page ?? last.page ?? 1;
|
||||
items.push({ value: groupId, label, pageNumber });
|
||||
currentRun = [];
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.hasHighlight && token.bbox) {
|
||||
currentRun.push(token);
|
||||
} else {
|
||||
flushRun();
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const buildHighlightMap = (
|
||||
tokens: CompareFilteredTokenInfo[]
|
||||
): Map<number, WordHighlightEntry[]> => {
|
||||
const map = new Map<number, WordHighlightEntry[]>();
|
||||
for (const token of tokens) {
|
||||
if (!token.hasHighlight || !token.bbox || token.page == null) continue;
|
||||
const list = map.get(token.page) ?? [];
|
||||
list.push({ rect: token.bbox, metaIndex: token.metaIndex });
|
||||
map.set(token.page, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const useCompareHighlights = (
|
||||
result: CompareResultData | null,
|
||||
basePages: PagePreview[],
|
||||
comparisonPages: PagePreview[],
|
||||
): UseCompareHighlightsResult => {
|
||||
const baseMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
const comparisonMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
|
||||
const baseWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.filteredTokenData.base,
|
||||
baseMetaIndexToGroupId,
|
||||
'base-group'
|
||||
);
|
||||
}, [baseMetaIndexToGroupId, result]);
|
||||
|
||||
const comparisonWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.filteredTokenData.comparison,
|
||||
comparisonMetaIndexToGroupId,
|
||||
'comparison-group'
|
||||
);
|
||||
}, [comparisonMetaIndexToGroupId, result]);
|
||||
|
||||
const wordHighlightMaps = useMemo(() => {
|
||||
if (!result) {
|
||||
return {
|
||||
base: new Map<number, WordHighlightEntry[]>(),
|
||||
comparison: new Map<number, WordHighlightEntry[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
base: buildHighlightMap(result.filteredTokenData.base),
|
||||
comparison: buildHighlightMap(result.filteredTokenData.comparison),
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
const getRowHeightPx = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const basePage = basePages.find((page) => page.pageNumber === pageNumber);
|
||||
const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber);
|
||||
const baseHeight = basePage ? basePage.height : 0;
|
||||
const comparisonHeight = comparisonPage ? comparisonPage.height : 0;
|
||||
const rowHeight = Math.max(baseHeight, comparisonHeight);
|
||||
return Math.round(rowHeight);
|
||||
},
|
||||
[basePages, comparisonPages]
|
||||
);
|
||||
|
||||
return {
|
||||
baseWordChanges,
|
||||
comparisonWordChanges,
|
||||
metaIndexToGroupId: {
|
||||
base: baseMetaIndexToGroupId,
|
||||
comparison: comparisonMetaIndexToGroupId,
|
||||
},
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
|
||||
const DISPLAY_SCALE = 1;
|
||||
|
||||
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||
|
||||
// Observable preview cache so rendering progress can resume across remounts and view switches
|
||||
type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> };
|
||||
const previewCache: Map<string, CacheEntry> = new Map();
|
||||
const latestVersionMap: Map<string, symbol> = new Map();
|
||||
|
||||
const getOrCreateEntry = (key: string): CacheEntry => {
|
||||
let entry = previewCache.get(key);
|
||||
if (!entry) {
|
||||
entry = { pages: [], total: 0, subscribers: new Set() };
|
||||
previewCache.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const notify = (entry: CacheEntry) => {
|
||||
entry.subscribers.forEach((fn) => {
|
||||
try { fn(); } catch { /* no-op */ }
|
||||
});
|
||||
};
|
||||
|
||||
const subscribe = (key: string, fn: () => void): (() => void) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
entry.subscribers.add(fn);
|
||||
return () => entry.subscribers.delete(fn);
|
||||
};
|
||||
|
||||
const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
const next = entry.pages.slice();
|
||||
for (const p of batch) {
|
||||
const idx = next.findIndex((x) => x.pageNumber > p.pageNumber);
|
||||
if (idx === -1) next.push(p); else next.splice(idx, 0, p);
|
||||
}
|
||||
entry.pages = next;
|
||||
if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal;
|
||||
notify(entry);
|
||||
};
|
||||
|
||||
const setTotalInCache = (key: string, total: number) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
entry.total = total;
|
||||
notify(entry);
|
||||
};
|
||||
|
||||
const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
entry.pages = pages.slice();
|
||||
if (typeof total === 'number') entry.total = total;
|
||||
notify(entry);
|
||||
};
|
||||
|
||||
|
||||
const renderPdfDocumentToImages = async (
|
||||
file: File,
|
||||
onBatch?: (previews: PagePreview[]) => void,
|
||||
batchSize: number = 12,
|
||||
onInitTotal?: (totalPages: number) => void,
|
||||
startAtPage: number = 1,
|
||||
shouldAbort?: () => boolean,
|
||||
): Promise<PagePreview[]> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const previews: PagePreview[] = [];
|
||||
const dpr = getDevicePixelRatio();
|
||||
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||
onInitTotal?.(pdf.numPages);
|
||||
|
||||
let batch: PagePreview[] = [];
|
||||
const shouldStop = () => Boolean(shouldAbort?.());
|
||||
|
||||
for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
if (shouldStop()) break;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||
const renderViewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = Math.round(renderViewport.width);
|
||||
canvas.height = Math.round(renderViewport.height);
|
||||
|
||||
if (!context) {
|
||||
page.cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||
if (shouldStop()) break;
|
||||
|
||||
const preview: PagePreview = {
|
||||
pageNumber,
|
||||
width: Math.round(displayViewport.width),
|
||||
height: Math.round(displayViewport.height),
|
||||
rotation: (page.rotate || 0) % 360,
|
||||
url: canvas.toDataURL(),
|
||||
};
|
||||
previews.push(preview);
|
||||
if (onBatch) {
|
||||
batch.push(preview);
|
||||
if (batch.length >= batchSize) {
|
||||
onBatch(batch);
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
page.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
if (shouldStop()) break;
|
||||
}
|
||||
|
||||
if (onBatch && batch.length > 0) onBatch(batch);
|
||||
return previews;
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
}
|
||||
};
|
||||
|
||||
interface UseComparePagePreviewsOptions {
|
||||
file: File | null;
|
||||
enabled: boolean;
|
||||
cacheKey: number | null;
|
||||
}
|
||||
|
||||
export const useComparePagePreviews = ({
|
||||
file,
|
||||
enabled,
|
||||
cacheKey,
|
||||
}: UseComparePagePreviewsOptions) => {
|
||||
const [pages, setPages] = useState<PagePreview[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const inFlightRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!file || !enabled) {
|
||||
setPages([]);
|
||||
setLoading(false);
|
||||
setTotalPages(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`;
|
||||
const refreshVersion = Symbol(key);
|
||||
latestVersionMap.set(key, refreshVersion);
|
||||
const entry = getOrCreateEntry(key);
|
||||
const cachedTotal = entry.total ?? (entry.pages.length ?? 0);
|
||||
let lastKnownTotal = cachedTotal;
|
||||
const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
|
||||
|
||||
if (entry.pages.length > 0) {
|
||||
const nextPages = entry.pages.slice();
|
||||
setPages(nextPages);
|
||||
setTotalPages(cachedTotal);
|
||||
} else {
|
||||
setTotalPages(0);
|
||||
}
|
||||
|
||||
setLoading(!isFullyCached);
|
||||
|
||||
const unsubscribe = subscribe(key, () => {
|
||||
const e = getOrCreateEntry(key);
|
||||
setPages(e.pages.slice());
|
||||
setTotalPages(e.total);
|
||||
const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total;
|
||||
setLoading(!done);
|
||||
});
|
||||
|
||||
if (isFullyCached) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const render = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
inFlightRef.current += 1;
|
||||
const current = inFlightRef.current;
|
||||
const startAt = (entry?.pages?.length ?? 0) + 1;
|
||||
const previews = await renderPdfDocumentToImages(
|
||||
file,
|
||||
(batch) => {
|
||||
if (cancelled || current !== inFlightRef.current) return;
|
||||
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
|
||||
},
|
||||
16,
|
||||
(total) => {
|
||||
if (!cancelled && current === inFlightRef.current) {
|
||||
lastKnownTotal = total;
|
||||
setTotalInCache(key, total);
|
||||
}
|
||||
},
|
||||
startAt,
|
||||
() => cancelled || current !== inFlightRef.current
|
||||
);
|
||||
if (!cancelled && current === inFlightRef.current) {
|
||||
const stillLatest = latestVersionMap.get(key) === refreshVersion;
|
||||
if (!stillLatest) {
|
||||
return;
|
||||
}
|
||||
const cacheEntry = getOrCreateEntry(key);
|
||||
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
|
||||
lastKnownTotal = finalTotal;
|
||||
const cachePages = cacheEntry.pages ?? [];
|
||||
const preferPreviews = previews.length > cachePages.length;
|
||||
const finalPages = preferPreviews ? previews.slice() : cachePages.slice();
|
||||
replacePagesInCache(key, finalPages, finalTotal);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[compare] failed to render document preview', error);
|
||||
if (!cancelled) {
|
||||
setPages([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [file, enabled, cacheKey]);
|
||||
|
||||
return { pages, loading, totalPages, renderedPages: pages.length };
|
||||
};
|
||||
|
||||
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
|
||||
@@ -0,0 +1,895 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
import type {
|
||||
PagePreview,
|
||||
ComparePane as Pane,
|
||||
PanState,
|
||||
ScrollLinkDelta,
|
||||
ScrollLinkAnchors,
|
||||
PanDragState,
|
||||
PinchState,
|
||||
UseComparePanZoomOptions,
|
||||
UseComparePanZoomReturn,
|
||||
} from '@app/types/compare';
|
||||
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 100000;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
// Default structural adjustments applied to each rendered page row. These are
|
||||
// refined at runtime via DOM measurements once the panes have mounted.
|
||||
const DEFAULT_ROW_STRUCTURAL_EXTRA = 32;
|
||||
const DEFAULT_ROW_GAP = 8;
|
||||
|
||||
// (Interfaces moved to @app/types/compare)
|
||||
|
||||
export const useComparePanZoom = ({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
|
||||
const baseScrollRef = useRef<HTMLDivElement>(null);
|
||||
const comparisonScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isSyncingRef = useRef(false);
|
||||
const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false });
|
||||
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
|
||||
const scrollLinkAnchorsRef = useRef<ScrollLinkAnchors>({
|
||||
deltaPixelsBaseToComp: 0,
|
||||
deltaPixelsCompToBase: 0,
|
||||
});
|
||||
const [isScrollLinked, setIsScrollLinked] = useState(true);
|
||||
const [isPanMode, setIsPanMode] = useState(false);
|
||||
const panDragRef = useRef<PanDragState>({
|
||||
active: false,
|
||||
source: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startPanX: 0,
|
||||
startPanY: 0,
|
||||
targetStartPanX: 0,
|
||||
targetStartPanY: 0,
|
||||
});
|
||||
const lastActivePaneRef = useRef<Pane>('base');
|
||||
const [baseZoom, setBaseZoom] = useState(1);
|
||||
const [comparisonZoom, setComparisonZoom] = useState(1);
|
||||
const [basePan, setBasePan] = useState<PanState>({ x: 0, y: 0 });
|
||||
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
|
||||
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
|
||||
const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||
const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA);
|
||||
const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP);
|
||||
|
||||
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
|
||||
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
|
||||
setLayoutState(next);
|
||||
}, []);
|
||||
const toggleLayout = useCallback(() => {
|
||||
setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev));
|
||||
}, [prefersStacked]);
|
||||
|
||||
const getPagesForPane = useCallback(
|
||||
(pane: Pane) => (pane === 'base' ? basePages : comparisonPages),
|
||||
[basePages, comparisonPages]
|
||||
);
|
||||
|
||||
// rAF-coalesced follower scroll writes
|
||||
const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||
const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||
|
||||
const canonicalLayout = useMemo(() => {
|
||||
const baseMap = new Map<number, PagePreview>();
|
||||
const compMap = new Map<number, PagePreview>();
|
||||
for (const page of basePages) baseMap.set(page.pageNumber, page);
|
||||
for (const page of comparisonPages) compMap.set(page.pageNumber, page);
|
||||
|
||||
const allPageNumbers = Array.from(
|
||||
new Set([
|
||||
...basePages.map(p => p.pageNumber),
|
||||
...comparisonPages.map(p => p.pageNumber),
|
||||
])
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
const rows = allPageNumbers.map(pageNumber => {
|
||||
const basePage = baseMap.get(pageNumber) ?? null;
|
||||
const compPage = compMap.get(pageNumber) ?? null;
|
||||
const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0);
|
||||
return {
|
||||
pageNumber,
|
||||
canonicalHeight,
|
||||
hasBase: Boolean(basePage),
|
||||
hasComparison: Boolean(compPage),
|
||||
};
|
||||
});
|
||||
|
||||
const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0);
|
||||
return { rows, totalCanonicalHeight };
|
||||
}, [basePages, comparisonPages]);
|
||||
|
||||
// Measure structural padding (labels, internal gaps) and the inter-row gap
|
||||
// so the scroll mapper can account for real DOM layout instead of relying on
|
||||
// bare page image heights.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (canonicalLayout.rows.length === 0) return;
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const sourceContent =
|
||||
baseScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content') ??
|
||||
comparisonScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content');
|
||||
if (!sourceContent) return;
|
||||
|
||||
const style = window.getComputedStyle(sourceContent);
|
||||
const gapStr = style.rowGap || style.gap;
|
||||
const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN;
|
||||
const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap));
|
||||
if (measuredGap !== rowGapPx) {
|
||||
setRowGapPx(measuredGap);
|
||||
}
|
||||
|
||||
const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap;
|
||||
const contentHeight = Math.round(sourceContent.scrollHeight);
|
||||
const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight;
|
||||
const candidate = canonicalLayout.rows.length > 0
|
||||
? Math.max(0, Math.round(available / canonicalLayout.rows.length))
|
||||
: 0;
|
||||
|
||||
if (Math.abs(candidate - rowStructuralExtraPx) >= 1) {
|
||||
setRowStructuralExtraPx(candidate);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]);
|
||||
|
||||
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
|
||||
const rowHeights = useMemo(() => {
|
||||
const totalRows = canonicalLayout.rows.length;
|
||||
const base: number[] = [];
|
||||
const comp: number[] = [];
|
||||
for (let index = 0; index < totalRows; index += 1) {
|
||||
const row = canonicalLayout.rows[index];
|
||||
const canonicalHeight = Math.round(row.canonicalHeight);
|
||||
const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx));
|
||||
const includeGap = index < totalRows - 1 ? rowGapPx : 0;
|
||||
const totalHeight = structuralHeight + includeGap;
|
||||
if (row.hasBase) base.push(totalHeight);
|
||||
else if (row.hasComparison) base.push(totalHeight);
|
||||
if (row.hasComparison) comp.push(totalHeight);
|
||||
else if (row.hasBase) comp.push(totalHeight);
|
||||
}
|
||||
|
||||
const prefix = (arr: number[]) => {
|
||||
const out: number[] = new Array(arr.length + 1);
|
||||
out[0] = 0;
|
||||
for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i];
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
base,
|
||||
comp,
|
||||
basePrefix: prefix(base),
|
||||
compPrefix: prefix(comp),
|
||||
};
|
||||
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
|
||||
|
||||
const mapScrollTopBetweenPanes = useCallback(
|
||||
(sourceTop: number, sourceIsBase: boolean): number => {
|
||||
const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp;
|
||||
const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base;
|
||||
const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix;
|
||||
const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix;
|
||||
|
||||
if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop;
|
||||
|
||||
// Clamp to valid range
|
||||
const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1);
|
||||
const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop)));
|
||||
|
||||
// Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1]
|
||||
let lo = 0;
|
||||
let hi = srcHeights.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2);
|
||||
if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
const i = lo;
|
||||
const within = top - srcPrefix[i];
|
||||
const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0;
|
||||
|
||||
const j = Math.min(i, dstHeights.length - 1);
|
||||
const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1);
|
||||
return dstTop;
|
||||
},
|
||||
[rowHeights]
|
||||
);
|
||||
|
||||
const getMaxCanvasSize = useCallback(
|
||||
(pane: Pane) => {
|
||||
const pages = getPagesForPane(pane);
|
||||
const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base');
|
||||
let maxW = 0;
|
||||
let maxH = 0;
|
||||
for (const page of pages) {
|
||||
const peer = peers.find(p => p.pageNumber === page.pageNumber);
|
||||
const targetHeight = peer ? Math.max(page.height, peer.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const width = Math.round(page.width * fit);
|
||||
const height = Math.round(targetHeight);
|
||||
if (width > maxW) maxW = width;
|
||||
if (height > maxH) maxH = height;
|
||||
}
|
||||
return { maxW, maxH };
|
||||
},
|
||||
[getPagesForPane]
|
||||
);
|
||||
|
||||
const getPanBounds = useCallback(
|
||||
(pane: Pane, zoomOverride?: number) => {
|
||||
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null;
|
||||
let canvasW: number | null = null;
|
||||
let canvasH: number | null = null;
|
||||
if (canvasEl) {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
canvasW = Math.max(0, Math.round(rect.width));
|
||||
canvasH = Math.max(0, Math.round(rect.height));
|
||||
}
|
||||
|
||||
const fallback = getMaxCanvasSize(pane);
|
||||
const W = canvasW ?? fallback.maxW;
|
||||
const H = canvasH ?? fallback.maxH;
|
||||
const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom;
|
||||
const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1));
|
||||
const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1));
|
||||
return { maxX: extraX, maxY: extraY };
|
||||
},
|
||||
[baseZoom, comparisonZoom, getMaxCanvasSize]
|
||||
);
|
||||
|
||||
const getPaneRotation = useCallback(
|
||||
(pane: Pane) => {
|
||||
const pages = getPagesForPane(pane);
|
||||
const rotation = pages[0]?.rotation ?? 0;
|
||||
const normalized = ((rotation % 360) + 360) % 360;
|
||||
return normalized as 0 | 90 | 180 | 270 | number;
|
||||
},
|
||||
[getPagesForPane]
|
||||
);
|
||||
|
||||
const mapPanBetweenOrientations = useCallback(
|
||||
(source: Pane, target: Pane, sourcePan: PanState) => {
|
||||
const sourceRotation = getPaneRotation(source);
|
||||
const targetRotation = getPaneRotation(target);
|
||||
const sourceBounds = getPanBounds(source);
|
||||
const targetBounds = getPanBounds(target);
|
||||
|
||||
const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1;
|
||||
const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1;
|
||||
|
||||
const applyRotation = (nx: number, ny: number, rotation: number) => {
|
||||
const r = ((rotation % 360) + 360) % 360;
|
||||
if (r === 0) return { nx, ny };
|
||||
if (r === 90) return { nx: ny, ny: -nx };
|
||||
if (r === 180) return { nx: -nx, ny: -ny };
|
||||
if (r === 270) return { nx: -ny, ny: nx };
|
||||
return { nx, ny };
|
||||
};
|
||||
|
||||
const logical = applyRotation(sx, sy, sourceRotation);
|
||||
const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation);
|
||||
|
||||
const targetNormX = (targetCentered.nx + 1) / 2;
|
||||
const targetNormY = (targetCentered.ny + 1) / 2;
|
||||
|
||||
const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX));
|
||||
const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY));
|
||||
return { x: targetX, y: targetY };
|
||||
},
|
||||
[getPaneRotation, getPanBounds]
|
||||
);
|
||||
|
||||
const centerPanForZoom = useCallback(
|
||||
(pane: Pane, zoomValue: number) => {
|
||||
const bounds = getPanBounds(pane, zoomValue);
|
||||
const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) };
|
||||
if (pane === 'base') {
|
||||
setBasePan(center);
|
||||
} else {
|
||||
setComparisonPan(center);
|
||||
}
|
||||
},
|
||||
[getPanBounds]
|
||||
);
|
||||
|
||||
const setPanToTopLeft = useCallback((pane: Pane) => {
|
||||
if (pane === 'base') {
|
||||
setBasePan({ x: 0, y: 0 });
|
||||
} else {
|
||||
setComparisonPan({ x: 0, y: 0 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clampPanForZoom = useCallback(
|
||||
(pane: Pane, zoomValue: number) => {
|
||||
const bounds = getPanBounds(pane, zoomValue);
|
||||
const current = pane === 'base' ? basePan : comparisonPan;
|
||||
const clamped = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, current.x)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, current.y)),
|
||||
};
|
||||
if (pane === 'base') {
|
||||
setBasePan(clamped);
|
||||
} else {
|
||||
setComparisonPan(clamped);
|
||||
}
|
||||
},
|
||||
[basePan, comparisonPan, getPanBounds]
|
||||
);
|
||||
|
||||
const handleScrollSync = useCallback(
|
||||
(source: HTMLDivElement | null, target: HTMLDivElement | null) => {
|
||||
if (panDragRef.current.active) return;
|
||||
if (!source || !target || isSyncingRef.current || !isScrollLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceIsBase = source === baseScrollRef.current;
|
||||
const sourceKey = sourceIsBase ? 'base' : 'comparison';
|
||||
|
||||
// Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard),
|
||||
// not by our own programmatic scrolls.
|
||||
if (!userScrollRef.current[sourceKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison';
|
||||
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
|
||||
// Use pixel anchors captured at link time to preserve offset
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
|
||||
const rawDesired = mappedTop + deltaPx;
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired));
|
||||
|
||||
// If the mapping requests a position beyond target bounds and the target is already
|
||||
// at that bound, skip writing to avoid any subtle feedback that could impede
|
||||
// continued scrolling in the source pane.
|
||||
const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0;
|
||||
const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange;
|
||||
if (atTopBound || atBottomBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIsBase = target === baseScrollRef.current;
|
||||
const key = targetIsBase ? 'base' : 'comparison';
|
||||
|
||||
desiredTopRef.current[key] = desiredTop;
|
||||
if (syncRafRef.current[key] == null) {
|
||||
syncRafRef.current[key] = requestAnimationFrame(() => {
|
||||
const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const top = desiredTopRef.current[key] ?? 0;
|
||||
if (el) {
|
||||
isSyncingRef.current = true;
|
||||
el.scrollTop = top;
|
||||
}
|
||||
syncRafRef.current[key] = null;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
// Track user-initiated scroll state per pane
|
||||
useEffect(() => {
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) return;
|
||||
|
||||
const onUserScrollStartBase = () => { userScrollRef.current.base = true; };
|
||||
const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; };
|
||||
const onUserScrollEndBase = () => { userScrollRef.current.base = false; };
|
||||
const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; };
|
||||
|
||||
const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => {
|
||||
el.addEventListener('wheel', onStart, { passive: true });
|
||||
el.addEventListener('mousedown', onStart, { passive: true });
|
||||
el.addEventListener('touchstart', onStart, { passive: true });
|
||||
// Heuristic: clear the flag shortly after scroll events settle
|
||||
let timeout: number | null = null;
|
||||
const onScroll = () => {
|
||||
// Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work
|
||||
if (isSyncingRef.current) return;
|
||||
onStart();
|
||||
if (timeout != null) window.clearTimeout(timeout);
|
||||
timeout = window.setTimeout(onEnd, 120);
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onStart as any);
|
||||
el.removeEventListener('mousedown', onStart as any);
|
||||
el.removeEventListener('touchstart', onStart as any);
|
||||
el.removeEventListener('scroll', onScroll as any);
|
||||
if (timeout != null) window.clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase);
|
||||
const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp);
|
||||
return () => { cleanupBase(); cleanupComp(); };
|
||||
}, []);
|
||||
|
||||
// Helpers for clearer pan-edge overscroll behavior
|
||||
const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => {
|
||||
if (rawY < 0) return rawY; // negative -> scroll up
|
||||
if (rawY > maxY) return rawY - maxY; // positive -> scroll down
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const normalizeApplyCandidate = useCallback((overflowY: number): number => {
|
||||
const DEADZONE = 32; // pixels
|
||||
if (overflowY < -DEADZONE) return overflowY + DEADZONE;
|
||||
if (overflowY > DEADZONE) return overflowY - DEADZONE;
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => {
|
||||
const STEP = 48; // pixels per incremental scroll
|
||||
const key = isBase ? 'base' : 'comparison';
|
||||
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
|
||||
const magnitude = Math.abs(deltaSinceLast);
|
||||
if (magnitude < STEP) return;
|
||||
|
||||
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
|
||||
edgeOverscrollRef.current[key] += stepDelta;
|
||||
|
||||
const prevTop = container.scrollTop;
|
||||
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
|
||||
if (nextTop === prevTop) return;
|
||||
|
||||
container.scrollTop = nextTop;
|
||||
if (isScrollLinked) {
|
||||
const sourceIsBase = isBase;
|
||||
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (target) {
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||
target.scrollTop = desiredTop;
|
||||
}
|
||||
}
|
||||
}, [isScrollLinked, mapScrollTopBetweenPanes]);
|
||||
|
||||
const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => {
|
||||
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (!container) return;
|
||||
const overflowY = getVerticalOverflow(rawY, boundsMaxY);
|
||||
const applyCandidate = normalizeApplyCandidate(overflowY);
|
||||
if (applyCandidate !== 0) {
|
||||
applyIncrementalScroll(container, isBase, applyCandidate);
|
||||
} else {
|
||||
// Reset accumulator when back within deadzone
|
||||
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
|
||||
}
|
||||
}, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]);
|
||||
|
||||
const beginPan = useCallback(
|
||||
(pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isPanMode) return;
|
||||
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
if (zoom <= 1) return;
|
||||
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const targetEl = event.target as HTMLElement | null;
|
||||
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||
if (!isOnImage) return;
|
||||
|
||||
event.preventDefault();
|
||||
panDragRef.current = {
|
||||
active: true,
|
||||
source: pane,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||
};
|
||||
edgeOverscrollRef.current[pane] = 0;
|
||||
lastActivePaneRef.current = pane;
|
||||
(container as HTMLDivElement).style.cursor = 'grabbing';
|
||||
},
|
||||
[isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan]
|
||||
);
|
||||
|
||||
const continuePan = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isPanMode) return;
|
||||
const drag = panDragRef.current;
|
||||
if (!drag.active || !drag.source) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
|
||||
const isBase = drag.source === 'base';
|
||||
const bounds = getPanBounds(drag.source);
|
||||
const rawX = drag.startPanX - dx;
|
||||
const rawY = drag.startPanY - dy;
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||
};
|
||||
|
||||
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
|
||||
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
},
|
||||
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
|
||||
);
|
||||
|
||||
const endPan = useCallback(() => {
|
||||
const drag = panDragRef.current;
|
||||
if (!drag.active) return;
|
||||
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (sourceEl) {
|
||||
const zoom = drag.source === 'base' ? baseZoom : comparisonZoom;
|
||||
(sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : '';
|
||||
}
|
||||
panDragRef.current.active = false;
|
||||
panDragRef.current.source = null;
|
||||
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||
|
||||
const handleWheelZoom = useCallback(
|
||||
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (!event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
const key = pane === 'base' ? 'base' : 'comparison';
|
||||
const accum = wheelZoomAccumRef.current;
|
||||
const threshold = 180;
|
||||
accum[key] += event.deltaY;
|
||||
const steps = Math.trunc(Math.abs(accum[key]) / threshold);
|
||||
if (steps <= 0) return;
|
||||
const direction = accum[key] > 0 ? -1 : 1;
|
||||
accum[key] = accum[key] % threshold;
|
||||
const applySteps = (zoom: number) => {
|
||||
let next = zoom;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
next = direction > 0
|
||||
? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2))
|
||||
: Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2));
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (pane === 'base') {
|
||||
const prev = baseZoom;
|
||||
const next = applySteps(prev);
|
||||
setBaseZoom(next);
|
||||
if (next < prev) {
|
||||
centerPanForZoom('base', next);
|
||||
} else {
|
||||
clampPanForZoom('base', next);
|
||||
}
|
||||
} else {
|
||||
const prev = comparisonZoom;
|
||||
const next = applySteps(prev);
|
||||
setComparisonZoom(next);
|
||||
if (next < prev) {
|
||||
centerPanForZoom('comparison', next);
|
||||
} else {
|
||||
clampPanForZoom('comparison', next);
|
||||
}
|
||||
}
|
||||
},
|
||||
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom]
|
||||
);
|
||||
|
||||
// When the source pane hits its scroll limit but the peer still has room,
|
||||
// propagate the wheel delta to the peer so it continues following.
|
||||
const handleWheelOverscroll = useCallback(
|
||||
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (event.ctrlKey) return; // handled by zoom handler
|
||||
if (!isScrollLinked) return;
|
||||
|
||||
const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (!source || !target) return;
|
||||
|
||||
const deltaY = event.deltaY;
|
||||
if (deltaY === 0) return;
|
||||
|
||||
const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight);
|
||||
const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY));
|
||||
|
||||
// If the source can scroll, let the normal scroll event drive syncing
|
||||
if (nextSource !== source.scrollTop) return;
|
||||
|
||||
// Source is at a bound; push mapped delta into the target
|
||||
const sourceIsBase = pane === 'base';
|
||||
|
||||
// Map the desired new source position (scrollTop + deltaY) into target space
|
||||
const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase);
|
||||
const mappedDelta = mappedAfter - mappedBefore;
|
||||
|
||||
// Include the pixel anchor captured when linking
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
|
||||
const targetMax = Math.max(0, target.scrollHeight - target.clientHeight);
|
||||
const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY)));
|
||||
|
||||
if (desired !== target.scrollTop) {
|
||||
isSyncingRef.current = true;
|
||||
// Adjust relative to mapped space to keep the anchor consistent
|
||||
const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY)));
|
||||
target.scrollTop = anchored;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length === 2) {
|
||||
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
pinchRef.current = {
|
||||
active: true,
|
||||
pane,
|
||||
startDistance: Math.hypot(dx, dy),
|
||||
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
|
||||
};
|
||||
event.preventDefault();
|
||||
} else if (event.touches.length === 1) {
|
||||
if (!isPanMode) return;
|
||||
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
if (zoom <= 1) return;
|
||||
const targetEl = event.target as HTMLElement | null;
|
||||
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||
if (!isOnImage) return;
|
||||
const touch = event.touches[0];
|
||||
panDragRef.current = {
|
||||
active: true,
|
||||
source: pane,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||
};
|
||||
edgeOverscrollRef.current[pane] = 0;
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode]
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (pinchRef.current.active && event.touches.length === 2) {
|
||||
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const scale = distance / Math.max(1, pinchRef.current.startDistance);
|
||||
const dampened = 1 + (scale - 1) * 0.6;
|
||||
const pane = pinchRef.current.pane!;
|
||||
const startZoom = pinchRef.current.startZoom;
|
||||
const previousZoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
|
||||
if (pane === 'base') {
|
||||
setBaseZoom(nextZoom);
|
||||
if (nextZoom < previousZoom) {
|
||||
centerPanForZoom('base', nextZoom);
|
||||
} else if (nextZoom > previousZoom) {
|
||||
clampPanForZoom('base', nextZoom);
|
||||
}
|
||||
} else {
|
||||
setComparisonZoom(nextZoom);
|
||||
if (nextZoom < previousZoom) {
|
||||
centerPanForZoom('comparison', nextZoom);
|
||||
} else if (nextZoom > previousZoom) {
|
||||
clampPanForZoom('comparison', nextZoom);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (panDragRef.current.active && event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const dx = touch.clientX - panDragRef.current.startX;
|
||||
const dy = touch.clientY - panDragRef.current.startY;
|
||||
const isBase = panDragRef.current.source === 'base';
|
||||
const bounds = getPanBounds(panDragRef.current.source!);
|
||||
const rawX = panDragRef.current.startPanX - dx;
|
||||
const rawY = panDragRef.current.startPanY - dy;
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||
};
|
||||
|
||||
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations]
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
pinchRef.current.active = false;
|
||||
pinchRef.current.pane = null;
|
||||
panDragRef.current.active = false;
|
||||
}, []);
|
||||
|
||||
// Auto-toggle Pan Mode based on zoom level
|
||||
useEffect(() => {
|
||||
const shouldPan = baseZoom > 1 || comparisonZoom > 1;
|
||||
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
|
||||
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||
|
||||
// When new pages render and extend scrollHeight, re-apply the mapping so
|
||||
// the follower continues tracking instead of getting stuck at its prior max.
|
||||
useEffect(() => {
|
||||
if (!isScrollLinked) return;
|
||||
const sourceIsBase = lastActivePaneRef.current === 'base';
|
||||
const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (!source || !target) return;
|
||||
|
||||
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||
|
||||
if (Math.abs(target.scrollTop - desiredTop) > 1) {
|
||||
isSyncingRef.current = true;
|
||||
target.scrollTop = desiredTop;
|
||||
requestAnimationFrame(() => { isSyncingRef.current = false; });
|
||||
}
|
||||
}, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isScrollLinked) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = (target?.tagName || '').toLowerCase();
|
||||
const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true');
|
||||
if (isEditable) return;
|
||||
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) return;
|
||||
|
||||
const STEP = 80;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? STEP : -STEP;
|
||||
isSyncingRef.current = true;
|
||||
baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight));
|
||||
compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight));
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isScrollLinked]);
|
||||
|
||||
const captureScrollLinkDelta = useCallback(() => {
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
|
||||
return;
|
||||
}
|
||||
const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight);
|
||||
const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight);
|
||||
const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth);
|
||||
const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth);
|
||||
|
||||
const baseV = baseEl.scrollTop / baseVMax;
|
||||
const compV = compEl.scrollTop / compVMax;
|
||||
const baseH = baseEl.scrollLeft / baseHMax;
|
||||
const compH = compEl.scrollLeft / compHMax;
|
||||
|
||||
scrollLinkDeltaRef.current = {
|
||||
vertical: compV - baseV,
|
||||
horizontal: compH - baseH,
|
||||
};
|
||||
|
||||
// Capture pixel anchors in mapped space
|
||||
const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true);
|
||||
const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false);
|
||||
scrollLinkAnchorsRef.current = {
|
||||
deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp,
|
||||
deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase,
|
||||
};
|
||||
}, [mapScrollTopBetweenPanes]);
|
||||
|
||||
const clearScrollLinkDelta = useCallback(() => {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
|
||||
}, []);
|
||||
|
||||
const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
setLayout,
|
||||
toggleLayout,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
captureScrollLinkDelta,
|
||||
clearScrollLinkDelta,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
setBaseZoom,
|
||||
comparisonZoom,
|
||||
setComparisonZoom,
|
||||
basePan,
|
||||
comparisonPan,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
handleScrollSync,
|
||||
beginPan,
|
||||
continuePan,
|
||||
endPan,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
zoomLimits,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
export interface UseCompareRightRailButtonsOptions {
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
toggleLayout: () => void;
|
||||
isPanMode: boolean;
|
||||
setIsPanMode: (value: boolean) => void;
|
||||
baseZoom: number;
|
||||
comparisonZoom: number;
|
||||
setBaseZoom: (value: number) => void;
|
||||
setComparisonZoom: (value: number) => void;
|
||||
setPanToTopLeft: (pane: Pane) => void;
|
||||
centerPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clampPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clearScrollLinkDelta: () => void;
|
||||
captureScrollLinkDelta: () => void;
|
||||
isScrollLinked: boolean;
|
||||
setIsScrollLinked: (value: boolean) => void;
|
||||
zoomLimits: { min: number; max: number; step: number };
|
||||
baseScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const useCompareRightRailButtons = ({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'compare-toggle-layout',
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon={layout === 'side-by-side' ? 'vertical-split-rounded' : 'horizontal-split-rounded'}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: layout === 'side-by-side'
|
||||
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||
ariaLabel: layout === 'side-by-side'
|
||||
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||
section: 'top',
|
||||
order: 10,
|
||||
onClick: toggleLayout,
|
||||
},
|
||||
{
|
||||
id: 'compare-zoom-out',
|
||||
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.zoomOut', 'Zoom out'),
|
||||
ariaLabel: t('compare.actions.zoomOut', 'Zoom out'),
|
||||
section: 'top',
|
||||
order: 13,
|
||||
onClick: () => {
|
||||
const { min, step } = zoomLimits;
|
||||
const nextBase = Math.max(min, +(baseZoom - step).toFixed(2));
|
||||
const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2));
|
||||
setBaseZoom(nextBase);
|
||||
setComparisonZoom(nextComparison);
|
||||
centerPanForZoom('base', nextBase);
|
||||
centerPanForZoom('comparison', nextComparison);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-zoom-in',
|
||||
icon: <LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.zoomIn', 'Zoom in'),
|
||||
ariaLabel: t('compare.actions.zoomIn', 'Zoom in'),
|
||||
section: 'top',
|
||||
order: 14,
|
||||
onClick: () => {
|
||||
const { max, step } = zoomLimits;
|
||||
const nextBase = Math.min(max, +(baseZoom + step).toFixed(2));
|
||||
const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2));
|
||||
setBaseZoom(nextBase);
|
||||
setComparisonZoom(nextComparison);
|
||||
clampPanForZoom('base', nextBase);
|
||||
clampPanForZoom('comparison', nextComparison);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-reset-view',
|
||||
icon: <LocalIcon icon="refresh-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||
section: 'top',
|
||||
order: 14.5,
|
||||
disabled: baseZoom === 1 && comparisonZoom === 1,
|
||||
onClick: () => {
|
||||
setBaseZoom(1);
|
||||
setComparisonZoom(1);
|
||||
setPanToTopLeft('base');
|
||||
setPanToTopLeft('comparison');
|
||||
clearScrollLinkDelta();
|
||||
// Reset scrollTop for both panes to realign view
|
||||
if (baseScrollRef?.current) {
|
||||
baseScrollRef.current.scrollTop = 0;
|
||||
}
|
||||
if (comparisonScrollRef?.current) {
|
||||
comparisonScrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-toggle-scroll-link',
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon={isScrollLinked ? 'link-rounded' : 'link-off-rounded'}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: isScrollLinked
|
||||
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||
ariaLabel: isScrollLinked
|
||||
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||
section: 'top',
|
||||
order: 15,
|
||||
onClick: () => {
|
||||
const next = !isScrollLinked;
|
||||
if (next) {
|
||||
captureScrollLinkDelta();
|
||||
} else {
|
||||
if (!isMobile) {
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'),
|
||||
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'),
|
||||
durationMs: 5000,
|
||||
location: 'bottom-center' as ToastLocation,
|
||||
expandable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsScrollLinked(next);
|
||||
},
|
||||
},
|
||||
], [
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
setPanToTopLeft,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
t,
|
||||
isMobile,
|
||||
]);
|
||||
};
|
||||
|
||||
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
|
||||
import ButtonSelector from "@app/components/shared/ButtonSelector";
|
||||
@@ -123,18 +123,12 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
|
||||
{/* Compression Options */}
|
||||
<Stack gap="sm">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={parameters.grayscale}
|
||||
onChange={(e) => onParameterChange('grayscale', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">{t("compress.grayscale.label", "Apply Grayscale for compression")}</Text>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={parameters.grayscale}
|
||||
onChange={(event) => onParameterChange('grayscale', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Stack, Text, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
|
||||
|
||||
interface ConvertFromCbzSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertFromCbzSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ConvertFromCbzSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" data-testid="cbz-settings">
|
||||
<Text size="sm" fw={500}>{t('convert.cbzOptions', 'CBZ to PDF Options')}:</Text>
|
||||
|
||||
<Checkbox
|
||||
label={t('convert.optimizeForEbook', 'Optimize PDF for ebook readers (uses Ghostscript)')}
|
||||
checked={parameters.cbzOptions.optimizeForEbook}
|
||||
onChange={(event) => onParameterChange('cbzOptions', {
|
||||
...parameters.cbzOptions,
|
||||
optimizeForEbook: event.currentTarget.checked
|
||||
})}
|
||||
disabled={disabled}
|
||||
data-testid="optimize-ebook-checkbox"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertFromCbzSettings;
|
||||
@@ -13,6 +13,8 @@ import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImage
|
||||
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
|
||||
import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings";
|
||||
import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings";
|
||||
import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings";
|
||||
import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings";
|
||||
import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings";
|
||||
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
|
||||
import {
|
||||
@@ -118,6 +120,12 @@ const ConvertSettings = ({
|
||||
onParameterChange('pdfaOptions', {
|
||||
outputFormat: 'pdfa-1',
|
||||
});
|
||||
onParameterChange('cbzOptions', {
|
||||
optimizeForEbook: false,
|
||||
});
|
||||
onParameterChange('cbzOutputOptions', {
|
||||
dpi: 150,
|
||||
});
|
||||
onParameterChange('isSmartDetection', false);
|
||||
onParameterChange('smartDetectionType', 'none');
|
||||
};
|
||||
@@ -180,6 +188,12 @@ const ConvertSettings = ({
|
||||
onParameterChange('pdfaOptions', {
|
||||
outputFormat: 'pdfa-1',
|
||||
});
|
||||
onParameterChange('cbzOptions', {
|
||||
optimizeForEbook: false,
|
||||
});
|
||||
onParameterChange('cbzOutputOptions', {
|
||||
dpi: 150,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -293,6 +307,30 @@ const ConvertSettings = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* CBZ to PDF options */}
|
||||
{parameters.fromExtension === 'cbz' && parameters.toExtension === 'pdf' && (
|
||||
<>
|
||||
<Divider />
|
||||
<ConvertFromCbzSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PDF to CBZ options */}
|
||||
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbz' && (
|
||||
<>
|
||||
<Divider />
|
||||
<ConvertToCbzSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PDF to PDF/A options */}
|
||||
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user