Merge branch 'V2' into add_eslint_plugins_20250928

This commit is contained in:
Ludy
2025-11-14 11:03:42 +01:00
committed by GitHub
134 changed files with 13473 additions and 1468 deletions
+80 -67
View File
@@ -47,21 +47,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 +94,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 +181,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 +287,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 +296,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 +311,7 @@ jobs:
exit 1
fi
fi
echo "✅ Build artifacts found for ${{ matrix.name }}"
- name: Test artifact sizes
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) |
| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) |
| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) |
| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) |
| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) |
| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) |
| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) |
| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) |
| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) |
| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) |
| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) |
| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) |
| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) |
| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![82%](https://geps.dev/progress/82) |
| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) |
| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) |
| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) |
| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) |
| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) |
| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) |
| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) |
| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) |
| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) |
| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) |
| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) |
| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) |
| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) |
| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) |
| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) |
| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) |
| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) |
| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) |
| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) |
| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) |
| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) |
| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) |
| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) |
| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) |
| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) |
| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) |
| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) |
| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) |
| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) |
| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) |
| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) |
| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) |
| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) |
| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) |
| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) |
| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) |
| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) |
| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) |
| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) |
| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) |
| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) |
| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) |
| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) |
| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) |
| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) |
| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) |
| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -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);
}
+58
View File
@@ -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",
@@ -660,6 +661,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",
+1
View File
@@ -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",
+139 -11
View File
@@ -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",
@@ -3491,7 +3606,8 @@
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
"save": "Save",
"saveChanges": "Save Changes"
},
"search": {
"title": "Search PDF",
@@ -4492,6 +4608,12 @@
}
},
"common": {
"previous": "Previous",
"next": "Next",
"collapse": "Collapse",
"expand": "Expand",
"collapsed": "collapsed",
"lines": "lines",
"copy": "Copy",
"copied": "Copied!",
"refresh": "Refresh",
@@ -4528,6 +4650,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",
+118 -9
View File
@@ -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",
@@ -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",
+420
View File
@@ -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",
]
+1
View File
@@ -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
+32 -34
View File
@@ -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(())
}
+1 -3
View File
@@ -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};
+30 -10
View 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() {
}
}
});
}
}
+7 -1
View File
@@ -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": {
+12 -9
View File
@@ -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}
@@ -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,
@@ -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(() => {
@@ -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>
@@ -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>
);
};
@@ -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 FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { LocalIcon } from '@app/components/shared/LocalIcon';
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" />
<VisibilityIcon 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>
</>
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
)}
</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;
@@ -41,7 +41,8 @@ export interface ConfigColors {
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false
runningEE: boolean = false,
_loginEnabled: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@@ -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,
};
};
@@ -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 (
<>
+17 -10
View File
@@ -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 -1
View File
@@ -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 {
@@ -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,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' && (
<>
@@ -0,0 +1,39 @@
import { Stack, Text, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
interface ConvertToCbzSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}
const ConvertToCbzSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertToCbzSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="cbz-output-settings">
<Text size="sm" fw={500}>{t('convert.cbzOutputOptions', 'PDF to CBZ Options')}:</Text>
<NumberInput
data-testid="cbz-dpi-input"
label={t('convert.cbzDpi', 'DPI for image rendering')}
value={parameters.cbzOutputOptions.dpi}
onChange={(val) => typeof val === 'number' && onParameterChange('cbzOutputOptions', {
...parameters.cbzOutputOptions,
dpi: val
})}
min={72}
max={600}
step={1}
disabled={disabled}
/>
</Stack>
);
};
export default ConvertToCbzSettings;
@@ -174,5 +174,3 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis
</Stack>
);
}
@@ -87,7 +87,7 @@ const FileStatusIndicator = ({
<Text size="sm" c="dimmed">
<Anchor
size="sm"
onClick={() => openFilesModal()}
onClick={() => openFilesModal({})}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
@@ -122,7 +122,7 @@ const FileStatusIndicator = ({
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}
onClick={() => openFilesModal({})}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
@@ -14,7 +14,7 @@ export interface ReviewToolStepProps<TParams = unknown> {
operation: ToolOperationHook<TParams>;
title?: string;
onFileClick?: (file: File) => void;
onUndo: () => void;
onUndo?: () => void;
isCollapsed?: boolean;
onCollapsedClick?: () => void;
}
@@ -26,14 +26,14 @@ function ReviewStepContent<TParams = unknown>({
}: {
operation: ToolOperationHook<TParams>;
onFileClick?: (file: File) => void;
onUndo: () => void;
onUndo?: () => void;
}) {
const { t } = useTranslation();
const stepRef = useRef<HTMLDivElement>(null);
const handleUndo = async () => {
try {
onUndo();
onUndo?.();
} catch (error) {
// Error is already handled by useToolOperation, just reset loading state
console.error("Undo operation failed:", error);
@@ -73,17 +73,19 @@ function ReviewStepContent<TParams = unknown>({
/>
)}
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
<Button
leftSection={<UndoIcon />}
variant="outline"
color="var(--mantine-color-gray-6)"
onClick={handleUndo}
fullWidth
>
{t("undo", "Undo")}
</Button>
</Tooltip>
{onUndo && (
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
<Button
leftSection={<UndoIcon />}
variant="outline"
color="var(--mantine-color-gray-6)"
onClick={handleUndo}
fullWidth
>
{t("undo", "Undo")}
</Button>
</Tooltip>
)}
{operation.downloadUrl && (
<Button
component="a"
@@ -104,7 +106,17 @@ function ReviewStepContent<TParams = unknown>({
}
export function createReviewToolStep<TParams = unknown>(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
createStep: (
title: string,
props: {
isVisible?: boolean;
isCollapsed?: boolean;
onCollapsedClick?: () => void;
_excludeFromCount?: boolean;
_noPadding?: boolean;
},
children?: React.ReactNode
) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation();
@@ -5,6 +5,7 @@ import OperationButton from '@app/components/tools/shared/OperationButton';
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
import { StirlingFile } from '@app/types/fileContext';
import type { TooltipTip } from '@app/types/tips';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
@@ -22,7 +23,7 @@ export interface MiddleStepConfig {
content: React.ReactNode;
tooltip?: {
content?: React.ReactNode;
tips?: any[];
tips?: TooltipTip[];
header?: {
title: string;
logo?: React.ReactNode;
@@ -39,25 +40,25 @@ export interface ExecuteButtonConfig {
testId?: string;
}
export interface ReviewStepConfig {
export interface ReviewStepConfig<TParams = unknown> {
isVisible: boolean;
operation: ToolOperationHook<any>;
operation: ToolOperationHook<TParams>;
title: string;
onFileClick?: (file: File) => void;
onUndo: () => void;
onUndo?: () => void;
testId?: string;
}
export interface TitleConfig extends ToolWorkflowTitleProps {}
export interface ToolFlowConfig {
export interface ToolFlowConfig<TParams = unknown> {
title?: TitleConfig;
files: FilesStepConfig;
steps: MiddleStepConfig[];
// Optional preview content rendered between steps and the execute button
preview?: React.ReactNode;
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
review: ReviewStepConfig<TParams>;
forceStepNumbers?: boolean;
}
@@ -65,7 +66,7 @@ export interface ToolFlowConfig {
* Creates a flexible tool flow with configurable steps and state management left to the tool.
* Reduces boilerplate while allowing tools to manage their own collapse/expansion logic.
*/
export function createToolFlow(config: ToolFlowConfig) {
export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams>) {
const steps = createToolSteps();
return (
@@ -86,7 +87,6 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.steps.map((stepConfig) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
onCollapsedClick: stepConfig.onCollapsedClick,
tooltip: stepConfig.tooltip
}, stepConfig.content)
@@ -0,0 +1,131 @@
.showjs-code {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre;
tab-size: 2;
margin: 0;
color: var(--text-primary);
}
.tok-kw {
color: var(--code-kw-color);
font-weight: 600;
}
.tok-str {
color: var(--code-str-color);
}
.tok-num {
color: var(--code-num-color);
}
.tok-com {
color: var(--code-com-color);
font-style: italic;
}
.code-line {
display: flex;
align-items: flex-start;
gap: 8px;
}
.code-gutter {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 64px;
color: var(--text-muted);
user-select: none;
}
.line-number {
min-width: 28px;
text-align: right;
}
.fold-toggle {
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0 2px;
}
.fold-collapsed {
transform: translateY(-1px);
}
.fold-placeholder {
display: inline-block;
width: 10px;
}
.code-content {
white-space: pre-wrap;
word-break: break-word;
flex: 1 1 auto;
}
.collapsed-indicator {
color: var(--text-muted);
font-style: italic;
cursor: pointer;
padding-left: 8px;
}
.collapsed-inline {
color: var(--text-muted);
margin-left: 6px;
}
.search-hit {
background: rgba(255, 235, 59, 0.4); /* yellow highlight */
border-radius: 2px;
}
.search-hit-active {
background: rgba(33, 150, 243, 0.4); /* active blue */
}
.showjs-root {
height: 100%;
margin-left: 1rem;
margin-right: 1rem;
}
.showjs-container {
position: relative;
height: 100%;
min-height: 360px;
border: 1px solid var(--mantine-color-gray-4);
border-radius: 8px;
overflow: hidden;
background: var(--right-rail-bg);
}
.showjs-toolbar {
position: sticky;
top: 8px;
right: 8px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 0;
padding: 8px;
background: transparent;
margin-bottom: 10px;
margin-left: 6px;
pointer-events: none;
}
.showjs-toolbar-controls {
pointer-events: auto;
}
.showjs-search-input {
width: 220px;
}
.showjs-outline-button {
background: transparent;
border: 1px solid currentColor;
color: var(--mantine-color-blue-5);
}
.showjs-scrollarea {
height: calc(100vh - 220px);
}
.showjs-inner {
padding: 40px 24px 24px 24px;
}
@@ -0,0 +1,305 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Box, Button, Group, Stack, Text, ScrollArea, TextInput } from "@mantine/core";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
import "@app/components/tools/showJS/ShowJSView.css";
import { useTranslation } from "react-i18next";
import {
tokenizeToLines,
computeBlocks,
computeSearchMatches,
copyTextToClipboard,
triggerDownload,
type ShowJsToken,
} from "@app/components/tools/showJS/utils";
interface ScriptData {
scriptText: string;
downloadUrl?: string | null;
downloadFilename?: string | null;
}
interface ShowJSViewProps {
data: string | ScriptData;
}
const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
const { t } = useTranslation();
const text = useMemo(() => {
if (typeof data === "string") return data;
return data?.scriptText ?? "";
}, [data]);
const downloadUrl = useMemo(() => {
if (typeof data === "string") return null;
return data?.downloadUrl ?? null;
}, [data]);
const downloadFilename = useMemo(() => {
if (typeof data === "string") return null;
return data?.downloadFilename ?? null;
}, [data]);
const [copied, setCopied] = useState(false);
const codeRef = useRef<HTMLDivElement | null>(null);
const scrollAreaInnerRef = useRef<HTMLDivElement | null>(null);
const handleCopy = useCallback(async () => {
const ok = await copyTextToClipboard(text || "", codeRef.current);
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}, [text]);
const handleDownload = useCallback(() => {
if (!downloadUrl) return;
triggerDownload(downloadUrl, downloadFilename || "extracted.js");
}, [downloadUrl, downloadFilename]);
const [lines, setLines] = useState<ShowJsToken[][]>([]);
const [blocks, setBlocks] = useState<Array<{ start: number; end: number }>>([]);
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
useEffect(() => {
const src = text || "";
setLines(tokenizeToLines(src));
setBlocks(computeBlocks(src));
setCollapsed(new Set());
}, [text]);
const startToEnd = useMemo(() => {
const m = new Map<number, number>();
for (const b of blocks) if (!m.has(b.start)) m.set(b.start, b.end);
return m;
}, [blocks]);
const isHidden = useCallback(
(ln: number) => {
for (const s of collapsed) {
const e = startToEnd.get(s);
if (e != null && ln > s && ln <= e) return true;
}
return false;
},
[collapsed, startToEnd],
);
const toggleFold = (ln: number) => {
if (!startToEnd.has(ln)) return;
setCollapsed((prev) => {
const n = new Set(prev);
if (n.has(ln)) n.delete(ln);
else n.add(ln);
return n;
});
};
// Search
const [query, setQuery] = useState("");
const [matches, setMatches] = useState<Array<{ line: number; start: number; end: number }>>([]);
const [active, setActive] = useState(0);
useEffect(() => {
if (!query) {
setMatches([]);
setActive(0);
return;
}
const list = computeSearchMatches(lines, query);
setMatches(list);
setActive(list.length ? 0 : 0);
}, [query, lines]);
useEffect(() => {
const m = matches[active];
if (!m) return;
for (const [s, e] of startToEnd.entries()) {
if (m.line > s && m.line <= e && collapsed.has(s)) {
setCollapsed((prev) => {
const n = new Set(prev);
n.delete(s);
return n;
});
}
}
if (scrollAreaInnerRef.current) {
const el = scrollAreaInnerRef.current.querySelector(`[data-code-line="${m.line}"]`) as HTMLElement | null;
if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [active, matches, startToEnd, collapsed]);
return (
<Stack gap="sm" p="sm" className="showjs-root">
<Box className="showjs-container">
<div className="showjs-toolbar">
<Group gap="xs" align="center" className="showjs-toolbar-controls">
<TextInput
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
size="xs"
placeholder={t("search.placeholder", "Enter search term...")}
className="showjs-search-input"
/>
<Text size="xs" c="dimmed">
{matches.length ? `${active + 1}/${matches.length}` : "0/0"}
</Text>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => {
if (matches.length) setActive((p) => (p - 1 + matches.length) % matches.length);
}}
aria-label={t("common.previous", "Previous")}
>
<ArrowUpwardRoundedIcon fontSize="small" />
</ActionIcon>
<ActionIcon
size="sm"
variant="subtle"
onClick={() => {
if (matches.length) setActive((p) => (p + 1) % matches.length);
}}
aria-label={t("common.next", "Next")}
>
<ArrowDownwardRoundedIcon fontSize="small" />
</ActionIcon>
</Group>
<Group gap="xs" align="center" className="showjs-toolbar-controls">
<Button
size="xs"
variant="subtle"
className="showjs-outline-button"
onClick={handleDownload}
disabled={!downloadUrl}
leftSection={<DownloadRoundedIcon fontSize="small" />}
>
{t("download", "Download")}
</Button>
<Button
size="xs"
variant="subtle"
className="showjs-outline-button"
onClick={handleCopy}
leftSection={<ContentCopyRoundedIcon fontSize="small" />}
>
{copied ? t("common.copied", "Copied!") : t("common.copy", "Copy")}
</Button>
</Group>
</div>
<ScrollArea className="showjs-scrollarea" offsetScrollbars>
<div ref={scrollAreaInnerRef} className="showjs-inner">
<div ref={codeRef} className="showjs-code">
{lines.map((tokens, ln) => {
if (isHidden(ln)) return null;
const end = startToEnd.get(ln);
const folded = end != null && collapsed.has(ln);
let pos = 0;
const lineMatches = matches.map((m, idx) => ({ ...m, idx })).filter((m) => m.line === ln);
const content: React.ReactNode[] = [];
tokens.forEach((tok, ti) => {
const textSeg = tok.text;
const tokenStart = pos;
const tokenEnd = pos + textSeg.length;
if (!query || lineMatches.length === 0) {
const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`;
content.push(
<span key={`t-${ln}-${ti}`} className={cls}>
{textSeg}
</span>,
);
pos = tokenEnd;
return;
}
// Collect matches that intersect this token
const matchesInToken = lineMatches
.filter((m) => m.start < tokenEnd && m.end > tokenStart)
.sort((a, b) => a.start - b.start);
if (matchesInToken.length === 0) {
const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`;
content.push(
<span key={`t-${ln}-${ti}`} className={cls}>
{textSeg}
</span>,
);
pos = tokenEnd;
return;
}
let cursor = 0;
const tokenCls = tok.type === "plain" ? "" : `tok-${tok.type}`;
matchesInToken.forEach((m, mi) => {
const localStart = Math.max(0, m.start - tokenStart);
const localEnd = Math.min(textSeg.length, m.end - tokenStart);
// before match
if (localStart > cursor) {
const beforeText = textSeg.slice(cursor, localStart);
const cls = tokenCls || undefined;
content.push(
<span key={`t-${ln}-${ti}-b-${cursor}`} className={cls}>
{beforeText}
</span>,
);
}
// matched piece
const hitText = textSeg.slice(localStart, localEnd);
const hitCls =
["search-hit", m.idx === active ? "search-hit-active" : "", tokenCls].filter(Boolean).join(" ") ||
undefined;
content.push(
<span key={`t-${ln}-${ti}-h-${localStart}-${mi}`} className={hitCls}>
{hitText}
</span>,
);
cursor = localEnd;
});
// tail after last match
if (cursor < textSeg.length) {
const tailText = textSeg.slice(cursor);
const cls = tokenCls || undefined;
content.push(
<span key={`t-${ln}-${ti}-a-${cursor}`} className={cls}>
{tailText}
</span>,
);
}
pos = tokenEnd;
});
return (
<div key={`l-${ln}`} className="code-line" data-code-line={ln}>
<div className="code-gutter">
{end != null ? (
<button
className={`fold-toggle ${folded ? "fold-collapsed" : ""}`}
onClick={() => toggleFold(ln)}
aria-label={folded ? t("common.expand", "Expand") : t("common.collapse", "Collapse")}
>
{folded ? "▸" : "▾"}
</button>
) : (
<span className="fold-placeholder" />
)}
<span className="line-number">{ln + 1}</span>
</div>
<div className="code-content">
{content}
{folded && <span className="collapsed-inline">{"{...}"}</span>}
</div>
</div>
);
})}
</div>
</div>
</ScrollArea>
</Box>
</Stack>
);
};
export default ShowJSView;
@@ -0,0 +1,382 @@
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
export type ShowJsToken = { type: ShowJsTokenType; text: string };
const JS_KEYWORDS = new Set([
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"export",
"extends",
"finally",
"for",
"function",
"if",
"import",
"in",
"instanceof",
"let",
"new",
"return",
"super",
"switch",
"this",
"throw",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
"await",
"of",
]);
export function tokenizeToLines(src: string, keywords: Set<string> = JS_KEYWORDS): ShowJsToken[][] {
const lines: ShowJsToken[][] = [];
let current: ShowJsToken[] = [];
let i = 0;
let inBlockCom = false;
let inLineCom = false;
let inString: '"' | "'" | "`" | null = null;
let escaped = false;
const push = (type: ShowJsTokenType, s: string) => {
if (s) {
current.push({ type, text: s });
}
};
// Named actions for readability
const advance = (n: number = 1) => {
i += n;
};
const handleNewline = () => {
lines.push(current);
current = [];
inLineCom = false;
advance();
};
const handleInLineCommentChar = (ch: string) => {
push("com", ch);
advance();
};
const handleBlockCommentEnd = () => {
push("com", "*/");
inBlockCom = false;
advance(2);
};
const handleInBlockCommentChar = (ch: string) => {
push("com", ch);
advance();
};
const handleInStringChar = (ch: string) => {
push("str", ch);
if (!escaped) {
const isEscape = ch === "\\";
const isStringClose = ch === inString;
if (isEscape) {
escaped = true;
} else if (isStringClose) {
inString = null;
}
} else {
escaped = false;
}
advance();
};
const startLineComment = () => {
push("com", "//");
inLineCom = true;
advance(2);
};
const startBlockComment = () => {
push("com", "/*");
inBlockCom = true;
advance(2);
};
const startString = (ch: '"' | "'" | "`") => {
inString = ch;
push("str", ch);
advance();
};
const pushNumberToken = () => {
let j = i + 1;
const isNumberContinuation = (c: string) => /[0-9._xobA-Fa-f]/.test(c);
while (j < src.length && isNumberContinuation(src[j])) {
j++;
}
push("num", src.slice(i, j));
i = j;
};
const pushIdentifierToken = () => {
let j = i + 1;
const isIdentContinuation = (c: string) => /[A-Za-z0-9_$]/.test(c);
while (j < src.length && isIdentContinuation(src[j])) {
j++;
}
const id = src.slice(i, j);
const isKeyword = keywords.has(id);
push(isKeyword ? "kw" : "plain", id);
i = j;
};
const pushPlainChar = (ch: string) => {
push("plain", ch);
advance();
};
while (i < src.length) {
const ch = src[i];
const next = src[i + 1];
// Named conditions
const isNewline = ch === "\n";
const isLineCommentStart = ch === "/" && next === "/";
const isBlockCommentStart = ch === "/" && next === "*";
const isStringDelimiter = ch === "'" || ch === '"' || ch === "`";
const isDigit = /[0-9]/.test(ch);
const isIdentifierStart = /[A-Za-z_$]/.test(ch);
if (isNewline) {
handleNewline();
continue;
}
if (inLineCom) {
handleInLineCommentChar(ch);
continue;
}
if (inBlockCom) {
const isBlockCommentEnd = ch === "*" && next === "/";
if (isBlockCommentEnd) {
handleBlockCommentEnd();
continue;
}
handleInBlockCommentChar(ch);
continue;
}
if (inString) {
handleInStringChar(ch);
continue;
}
if (isLineCommentStart) {
startLineComment();
continue;
}
if (isBlockCommentStart) {
startBlockComment();
continue;
}
if (isStringDelimiter) {
startString(ch as '"' | "'" | "`");
continue;
}
if (isDigit) {
pushNumberToken();
continue;
}
if (isIdentifierStart) {
pushIdentifierToken();
continue;
}
pushPlainChar(ch);
}
lines.push(current);
return lines;
}
export function computeBlocks(src: string): Array<{ start: number; end: number }> {
const res: Array<{ start: number; end: number }> = [];
let i = 0;
let line = 0;
let inBlock = false;
let inLine = false;
let str: '"' | "'" | "`" | null = null;
let esc = false;
const stack: number[] = [];
// Actions
const advance = (n: number = 1) => {
i += n;
};
const handleNewline = () => {
line++;
inLine = false;
advance();
};
const startLineComment = () => {
inLine = true;
advance(2);
};
const startBlockComment = () => {
inBlock = true;
advance(2);
};
const endBlockComment = () => {
inBlock = false;
advance(2);
};
const startString = (delim: '"' | "'" | "`") => {
str = delim;
advance();
};
const handleStringChar = (ch: string) => {
if (!esc) {
const isEscape = ch === "\\";
const isClose = ch === str;
if (isEscape) {
esc = true;
} else if (isClose) {
str = null;
}
} else {
esc = false;
}
advance();
};
const pushOpenBrace = () => {
stack.push(line);
advance();
};
const handleCloseBrace = () => {
const s = stack.pop();
if (s != null && line > s) {
res.push({ start: s, end: line });
}
advance();
};
while (i < src.length) {
const ch = src[i];
const nx = src[i + 1];
// Conditions
const isNewline = ch === "\n";
const isLineCommentStart = ch === "/" && nx === "/";
const isBlockCommentStart = ch === "/" && nx === "*";
const isBlockCommentEnd = ch === "*" && nx === "/";
const isStringDelimiter = ch === "'" || ch === '"' || ch === "`";
const isOpenBrace = ch === "{";
const isCloseBrace = ch === "}";
if (isNewline) {
handleNewline();
continue;
}
if (inLine) {
advance();
continue;
}
if (inBlock) {
if (isBlockCommentEnd) {
endBlockComment();
} else {
advance();
}
continue;
}
if (str) {
handleStringChar(ch);
continue;
}
if (isLineCommentStart) {
startLineComment();
continue;
}
if (isBlockCommentStart) {
startBlockComment();
continue;
}
if (isStringDelimiter) {
startString(ch as '"' | "'" | "`");
continue;
}
if (isOpenBrace) {
pushOpenBrace();
continue;
}
if (isCloseBrace) {
handleCloseBrace();
continue;
}
advance();
}
return res;
}
export function computeSearchMatches(
lines: ShowJsToken[][],
query: string,
): Array<{ line: number; start: number; end: number }> {
if (!query) {
return [];
}
const q = query.toLowerCase();
const list: Array<{ line: number; start: number; end: number }> = [];
lines.forEach((toks, ln) => {
const raw = toks.map((t) => t.text).join("");
let idx = 0;
while (true) {
const pos = raw.toLowerCase().indexOf(q, idx);
if (pos === -1) {
break;
}
list.push({
line: ln,
start: pos,
end: pos + q.length,
});
idx = pos + Math.max(1, q.length);
}
});
return list;
}
export async function copyTextToClipboard(text: string, fallbackElement?: HTMLElement | null): Promise<boolean> {
try {
if (typeof navigator !== "undefined" && navigator.clipboard) {
await navigator.clipboard.writeText(text || "");
return true;
}
} catch {
// fall through to fallback
}
if (typeof document === "undefined" || !fallbackElement) return false;
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(fallbackElement);
selection?.removeAllRanges();
selection?.addRange(range);
try {
document.execCommand("copy");
return true;
} finally {
selection?.removeAllRanges();
}
}
export function triggerDownload(url: string, filename: string): void {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
@@ -13,14 +13,14 @@ export const useOverlayPdfsTips = (): TooltipContent => {
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
description: t(
'overlay-pdfs.tooltip.description.text',
'Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
'Combine a original PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
)
},
{
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
description: t(
'overlay-pdfs.tooltip.mode.text',
'Choose how to distribute overlay pages across the base PDF pages.'
'Choose how to distribute overlay pages across the original PDF pages.'
),
bullets: [
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
@@ -39,7 +39,7 @@ export const useOverlayPdfsTips = (): TooltipContent => {
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
description: t(
'overlay-pdfs.tooltip.overlayFiles.text',
'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
'Select one or more PDFs to overlay on the original. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
)
},
{
@@ -131,4 +131,4 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent |
};
return tooltipMap[method];
};
};
@@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@@ -122,39 +123,11 @@ const EmbedPdfViewerContent = ({
}
}, [previewFile, fileWithUrl]);
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
useEffect(() => {
let accumulator = 0;
const handleWheel = (event: WheelEvent) => {
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
event.stopPropagation();
accumulator += event.deltaY;
const threshold = 10;
if (accumulator <= -threshold) {
// Accumulated scroll up - zoom in
zoomActions.zoomIn();
accumulator = 0;
} else if (accumulator >= threshold) {
// Accumulated scroll down - zoom out
zoomActions.zoomOut();
accumulator = 0;
}
}
};
const viewerElement = viewerRef.current;
if (viewerElement) {
viewerElement.addEventListener('wheel', handleWheel, { passive: false });
return () => {
viewerElement.removeEventListener('wheel', handleWheel);
};
}
}, [zoomActions]);
useWheelZoom({
ref: viewerRef,
onZoomIn: zoomActions.zoomIn,
onZoomOut: zoomActions.zoomOut,
});
// Handle keyboard zoom shortcuts
useEffect(() => {
@@ -62,8 +62,8 @@ export function useViewerRightRailButtons() {
render: ({ disabled }) => (
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? 'filled' : 'subtle'}
color={isPanning ? 'blue' : undefined}
variant={isPanning ? 'default' : 'subtle'}
color={undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
@@ -71,6 +71,7 @@ export function useViewerRightRailButtons() {
setIsPanning(prev => !prev);
}}
disabled={disabled}
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -21,6 +21,8 @@ export const CONVERSION_ENDPOINTS = {
'office-pdf': '/api/v1/convert/file/pdf',
'pdf-image': '/api/v1/convert/pdf/img',
'image-pdf': '/api/v1/convert/img/pdf',
'cbz-pdf': '/api/v1/convert/cbz/pdf',
'pdf-cbz': '/api/v1/convert/pdf/cbz',
'pdf-office-word': '/api/v1/convert/pdf/word',
'pdf-office-presentation': '/api/v1/convert/pdf/presentation',
'pdf-office-text': '/api/v1/convert/pdf/text',
@@ -38,6 +40,8 @@ export const ENDPOINT_NAMES = {
'office-pdf': 'file-to-pdf',
'pdf-image': 'pdf-to-img',
'image-pdf': 'img-to-pdf',
'cbz-pdf': 'cbz-to-pdf',
'pdf-cbz': 'pdf-to-cbz',
'pdf-office-word': 'pdf-to-word',
'pdf-office-presentation': 'pdf-to-presentation',
'pdf-office-text': 'pdf-to-text',
@@ -57,6 +61,7 @@ export const FROM_FORMAT_OPTIONS = [
{ value: 'any', label: 'Any', group: 'Multiple Files' },
{ value: 'image', label: 'Images', group: 'Multiple Files' },
{ value: 'pdf', label: 'PDF', group: 'Document' },
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
{ value: 'docx', label: 'DOCX', group: 'Document' },
{ value: 'doc', label: 'DOC', group: 'Document' },
{ value: 'odt', label: 'ODT', group: 'Document' },
@@ -87,6 +92,7 @@ export const TO_FORMAT_OPTIONS = [
{ value: 'pdfa', label: 'PDF/A', group: 'Document' },
{ value: 'docx', label: 'DOCX', group: 'Document' },
{ value: 'odt', label: 'ODT', group: 'Document' },
{ value: 'cbz', label: 'CBZ', group: 'Archive' },
{ value: 'csv', label: 'CSV', group: 'Spreadsheet' },
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
{ value: 'odp', label: 'ODP', group: 'Presentation' },
@@ -107,7 +113,8 @@ export const TO_FORMAT_OPTIONS = [
export const CONVERSION_MATRIX: Record<string, string[]> = {
'any': ['pdf'], // Mixed files always convert to PDF
'image': ['pdf'], // Multiple images always convert to PDF
'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa'],
'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'cbz'],
'cbz': ['pdf'],
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'],
'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'],
@@ -130,8 +137,10 @@ export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
'csv': 'pdf-to-csv',
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown',
'html': 'pdf-to-html', 'xml': 'pdf-to-xml',
'pdfa': 'pdf-to-pdfa'
'pdfa': 'pdf-to-pdfa',
'cbz': 'pdf-to-cbz'
},
'cbz': { 'pdf': 'cbz-to-pdf' },
'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' },
'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' },
'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' },
@@ -13,7 +13,7 @@ export const CONVERT_SUPPORTED_FORMATS = [
// Email formats
'eml',
// Archive formats
'zip',
'zip', 'cbz',
// Other
'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf',
];
+16 -2
View File
@@ -31,6 +31,8 @@ import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createF
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
import { useZipConfirmation } from '@app/hooks/useZipConfirmation';
import ZipWarningModal from '@app/components/shared/ZipWarningModal';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -52,6 +54,9 @@ function FileContextInner({
const stateRef = useRef(state);
stateRef.current = state;
// ZIP confirmation dialog
const { confirmationState, requestConfirmation, handleConfirm, handleCancel } = useZipConfirmation();
// Create lifecycle manager
const lifecycleManagerRef = useRef<FileLifecycleManager | null>(null);
if (!lifecycleManagerRef.current) {
@@ -86,7 +91,9 @@ function FileContextInner({
...options,
// For direct file uploads: ALWAYS unzip (except HTML ZIPs)
// skipAutoUnzip bypasses preference checks - HTML detection still applies
skipAutoUnzip: true
skipAutoUnzip: true,
// Provide confirmation callback for large ZIP files
confirmLargeExtraction: requestConfirmation
},
stateRef,
filesRef,
@@ -101,7 +108,7 @@ function FileContextInner({
}
return stirlingFiles;
}, [enablePersistence]);
}, [enablePersistence, requestConfirmation]);
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
@@ -237,6 +244,13 @@ function FileContextInner({
<FileStateContext.Provider value={stateValue}>
<FileActionsContext.Provider value={actionsValue}>
{children}
<ZipWarningModal
opened={confirmationState.opened}
onConfirm={handleConfirm}
onCancel={handleCancel}
fileCount={confirmationState.fileCount}
zipFileName={confirmationState.fileName}
/>
</FileActionsContext.Provider>
</FileStateContext.Provider>
);
@@ -1,7 +1,9 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileActions } from '@app/contexts/FileContext';
import { useFileContext } from '@app/contexts/file/fileHooks';
import { StirlingFileStub } from '@app/types/fileContext';
import type { FileId } from '@app/types/file';
import { fileStorage } from '@app/services/fileStorage';
interface FilesModalContextType {
@@ -19,6 +21,7 @@ const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addFiles } = useFileHandler();
const { actions } = useFileActions();
const fileCtx = useFileContext();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
@@ -37,16 +40,25 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
onModalClose?.();
}, [onModalClose]);
const handleFileUpload = useCallback((files: File[]) => {
const handleFileUpload = useCallback(async (files: File[]) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addFiles(files);
// 1) Add via standard flow (auto-selects new files)
await addFiles(files);
// 2) Merge all requested file IDs (covers already-present files too)
const ids = files
.map((f) => fileCtx.findFileId(f) as FileId | undefined)
.filter((id): id is FileId => Boolean(id));
if (ids.length > 0) {
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
const nextSelection = Array.from(new Set([...currentSelected, ...ids]));
actions.setSelectedFiles(nextSelection);
}
}
closeFilesModal();
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
}, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]);
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
if (customHandler) {
@@ -67,15 +79,22 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
console.error('Failed to load files for custom handler:', error);
}
} else {
// Normal case - use addStirlingFileStubs to preserve metadata
// Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new)
if (actions.addStirlingFileStubs) {
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
await actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
// Merge all requested IDs into selection (covers files that already existed)
const requestedIds = stirlingFileStubs.map((s) => s.id);
if (requestedIds.length > 0) {
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
const nextSelection = Array.from(new Set([...currentSelected, ...requestedIds]));
actions.setSelectedFiles(nextSelection);
}
} else {
console.error('addStirlingFileStubs action not available');
}
}
closeFilesModal();
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);
@@ -0,0 +1,359 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo, useRef, useEffect } from 'react';
import { FileId } from '@app/types/file';
import { useFileActions, useFileState } from '@app/contexts/FileContext';
import { PDFPage } from '@app/types/pageEditor';
import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors';
// PageEditorFile is now defined locally in consuming components
// Components should derive file list directly from FileContext
/**
* Computes file order based on the position of each file's first page
* @param pages - Current page order
* @returns Array of FileIds in order based on first page positions
*/
function computeFileOrderFromPages(pages: PDFPage[]): FileId[] {
// Find the first page for each file
const fileFirstPagePositions = new Map<FileId, number>();
pages.forEach((page, index) => {
const fileId = page.originalFileId;
if (!fileId) return;
if (!fileFirstPagePositions.has(fileId)) {
fileFirstPagePositions.set(fileId, index);
}
});
// Sort files by their first page position
const fileOrder = Array.from(fileFirstPagePositions.entries())
.sort((a, b) => a[1] - b[1])
.map(entry => entry[0]);
return fileOrder;
}
/**
* Reorders pages based on file reordering while preserving interlacing and manual page order
* @param currentPages - Current page order (may include manual reordering and interlacing)
* @param fromIndex - Source file index in the file order
* @param toIndex - Target file index in the file order
* @param orderedFileIds - File IDs in their current order
* @returns Reordered pages with updated page numbers
*/
function reorderPagesForFileMove(
currentPages: PDFPage[],
fromIndex: number,
toIndex: number,
orderedFileIds: FileId[]
): PDFPage[] {
// Get the file ID being moved
const movedFileId = orderedFileIds[fromIndex];
const targetFileId = orderedFileIds[toIndex];
// Extract pages belonging to the moved file (maintaining their relative order)
const movedFilePages: PDFPage[] = [];
const remainingPages: PDFPage[] = [];
currentPages.forEach(page => {
if (page.originalFileId === movedFileId) {
movedFilePages.push(page);
} else {
remainingPages.push(page);
}
});
// Find the insertion point based on the target file
let insertionIndex = 0;
if (fromIndex < toIndex) {
// Moving down: insert AFTER the last page of ANY file that should come before us
// We need to find the last page belonging to any file at index <= toIndex in orderedFileIds
const filesBeforeUs = new Set(orderedFileIds.slice(0, toIndex + 1));
for (let i = remainingPages.length - 1; i >= 0; i--) {
const pageFileId = remainingPages[i].originalFileId;
if (pageFileId && filesBeforeUs.has(pageFileId)) {
insertionIndex = i + 1;
break;
}
}
} else {
// Moving up: insert BEFORE the first page of target file
for (let i = 0; i < remainingPages.length; i++) {
if (remainingPages[i].originalFileId === targetFileId) {
insertionIndex = i;
break;
}
}
}
// Insert moved pages at the calculated position
const reorderedPages = [
...remainingPages.slice(0, insertionIndex),
...movedFilePages,
...remainingPages.slice(insertionIndex)
];
// Renumber all pages sequentially (clone to avoid mutation)
return reorderedPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
}
interface PageEditorContextValue {
// Current page order (updated by PageEditor, used for file reordering)
currentPages: PDFPage[] | null;
updateCurrentPages: (pages: PDFPage[] | null) => void;
// Reordered pages (when file reordering happens)
reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void;
// Page editor's own file order (independent of FileContext global order)
fileOrder: FileId[];
setFileOrder: (order: FileId[]) => void;
// Set file selection (calls FileContext actions)
setFileSelection: (fileId: FileId, selected: boolean) => void;
// Toggle file selection (calls FileContext actions)
toggleFileSelection: (fileId: FileId) => void;
// Select/deselect all files (calls FileContext actions)
selectAll: () => void;
deselectAll: () => void;
// Reorder files (only affects page editor's local order)
reorderFiles: (fromIndex: number, toIndex: number) => void;
// Update file order based on page positions (when pages are manually reordered)
updateFileOrderFromPages: (pages: PDFPage[]) => void;
}
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
interface PageEditorProviderProps {
children: ReactNode;
}
export function PageEditorProvider({ children }: PageEditorProviderProps) {
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
// Page editor's own file order (independent of FileContext)
const [fileOrder, setFileOrder] = useState<FileId[]>([]);
// Read from FileContext (for file metadata only, not order)
const { actions: fileActions } = useFileActions();
const { state } = useFileState();
// Keep a ref to always read latest state in stable callbacks
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
// Track the previous FileContext order to detect actual changes
const prevFileContextIdsRef = useRef<FileId[]>([]);
// Initialize fileOrder from FileContext when files change (add/remove only)
useEffect(() => {
const currentFileIds = state.files.ids;
const prevFileIds = prevFileContextIdsRef.current;
// Only react to FileContext changes, not our own fileOrder changes
const fileContextChanged =
currentFileIds.length !== prevFileIds.length ||
!currentFileIds.every((id, idx) => id === prevFileIds[idx]);
if (!fileContextChanged) {
return;
}
prevFileContextIdsRef.current = currentFileIds;
// Collect new file IDs outside the setState callback so we can clear them after
let newFileIdsToProcess: FileId[] = [];
// Use functional setState to read latest fileOrder without depending on it
setFileOrder(currentOrder => {
// Identify new files
const newFileIds = currentFileIds.filter(id => !currentOrder.includes(id));
newFileIdsToProcess = newFileIds; // Store for cleanup
// Remove deleted files
const validFileOrder = currentOrder.filter(id => currentFileIds.includes(id));
if (newFileIds.length === 0 && validFileOrder.length === currentOrder.length) {
return currentOrder; // No changes needed
}
// Always append new files to end
// If files have insertAfterPageId, page-level insertion is handled by usePageDocument
return [...validFileOrder, ...newFileIds];
});
// Clear insertAfterPageId after a delay to allow usePageDocument to consume it first
setTimeout(() => {
newFileIdsToProcess.forEach(fileId => {
const stub = state.files.byId[fileId];
if (stub?.insertAfterPageId) {
fileActions.updateStirlingFileStub(fileId, { insertAfterPageId: undefined });
}
});
}, 100);
}, [state.files.ids, state.files.byId, fileActions]);
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
setCurrentPages(pages);
}, []);
const clearReorderedPages = useCallback(() => {
setReorderedPages(null);
}, []);
const setFileSelection = useCallback((fileId: FileId, selected: boolean) => {
const currentSelection = stateRef.current.ui.selectedFileIds;
const isAlreadySelected = currentSelection.includes(fileId);
// Check if we're trying to select when at limit
if (selected && !isAlreadySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
return;
}
// Update FileContext selection
const newSelectedIds = selected
? [...currentSelection, fileId]
: currentSelection.filter(id => id !== fileId);
fileActions.setSelectedFiles(newSelectedIds);
}, [fileActions]);
const toggleFileSelection = useCallback((fileId: FileId) => {
const currentSelection = stateRef.current.ui.selectedFileIds;
const isCurrentlySelected = currentSelection.includes(fileId);
// If toggling on and at limit, don't allow
if (!isCurrentlySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
return;
}
// Update FileContext selection
const newSelectedIds = isCurrentlySelected
? currentSelection.filter(id => id !== fileId)
: [...currentSelection, fileId];
fileActions.setSelectedFiles(newSelectedIds);
}, [fileActions]);
const selectAll = useCallback(() => {
const allFileIds = stateRef.current.files.ids;
if (allFileIds.length > MAX_PAGE_EDITOR_FILES) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`);
fileActions.setSelectedFiles(allFileIds.slice(0, MAX_PAGE_EDITOR_FILES));
} else {
fileActions.setSelectedFiles(allFileIds);
}
}, [fileActions]);
const deselectAll = useCallback(() => {
fileActions.setSelectedFiles([]);
}, [fileActions]);
const reorderFiles = useCallback((fromIndex: number, toIndex: number) => {
// Reorder local fileOrder array (page editor workspace only)
const newOrder = [...fileOrder];
const [movedFileId] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedFileId);
setFileOrder(newOrder);
// If current pages available, reorder them based on file move
if (currentPages && currentPages.length > 0 && fromIndex !== toIndex) {
// Get the current file order from pages (files that have pages loaded)
const currentFileOrder: FileId[] = [];
const filesSeen = new Set<FileId>();
currentPages.forEach(page => {
const fileId = page.originalFileId;
if (fileId && !filesSeen.has(fileId)) {
filesSeen.add(fileId);
currentFileOrder.push(fileId);
}
});
// Get the target file ID from the NEW order (after the move)
// When moving down: we want to position after the file at toIndex-1 (file just before insertion)
// When moving up: we want to position before the file at toIndex+1 (file just after insertion)
const targetFileId = fromIndex < toIndex
? newOrder[toIndex - 1] // Moving down: target is the file just before where we inserted
: newOrder[toIndex + 1]; // Moving up: target is the file just after where we inserted
// Find their positions in the current page order (not the full file list)
const pageOrderFromIndex = currentFileOrder.findIndex(id => id === movedFileId);
const pageOrderToIndex = currentFileOrder.findIndex(id => id === targetFileId);
// Only reorder pages if both files have pages loaded
if (pageOrderFromIndex >= 0 && pageOrderToIndex >= 0) {
const reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder);
setReorderedPages(reorderedPagesResult);
}
}
}, [fileOrder, currentPages]);
const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => {
if (!pages || pages.length === 0) return;
// Compute the new file order based on page positions
const newFileOrder = computeFileOrderFromPages(pages);
if (newFileOrder.length > 0) {
// Update local page editor file order (not FileContext)
setFileOrder(newFileOrder);
}
}, []);
const value: PageEditorContextValue = useMemo(() => ({
currentPages,
updateCurrentPages,
reorderedPages,
clearReorderedPages,
fileOrder,
setFileOrder,
setFileSelection,
toggleFileSelection,
selectAll,
deselectAll,
reorderFiles,
updateFileOrderFromPages,
}), [
currentPages,
updateCurrentPages,
reorderedPages,
clearReorderedPages,
fileOrder,
setFileSelection,
toggleFileSelection,
selectAll,
deselectAll,
reorderFiles,
updateFileOrderFromPages,
]);
return (
<PageEditorContext.Provider value={value}>
{children}
</PageEditorContext.Provider>
);
}
export function usePageEditor() {
const context = useContext(PageEditorContext);
if (!context) {
throw new Error('usePageEditor must be used within PageEditorProvider');
}
return context;
}
@@ -421,4 +421,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
return context;
}
}
@@ -149,8 +149,10 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
// Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
// Reorder selected files by passed order
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
return {
...state,
files: {
@@ -186,6 +186,7 @@ interface AddFileOptions {
autoUnzip?: boolean;
autoUnzipFileLimit?: number;
skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs.
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>; // Optional callback to confirm extraction of large ZIP files
}
/**
@@ -219,6 +220,7 @@ export async function addFiles(
const autoUnzip = options.autoUnzip ?? true; // Default to true
const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit
const skipAutoUnzip = options.skipAutoUnzip ?? false;
const confirmLargeExtraction = options.confirmLargeExtraction;
for (const file of files) {
// Check if file is a ZIP
@@ -238,7 +240,8 @@ export async function addFiles(
const extractedFiles = await zipFileService.extractWithPreferences(file, {
autoUnzip,
autoUnzipFileLimit,
skipAutoUnzip
skipAutoUnzip,
confirmLargeExtraction
});
if (extractedFiles.length === 1 && extractedFiles[0] === file) {
@@ -121,9 +121,13 @@ import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlank
import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
import ValidateSignature from "@app/tools/ValidateSignature";
import ShowJS from "@app/tools/ShowJS";
import Automate from "@app/tools/Automate";
import Compare from "@app/tools/Compare";
import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats";
export interface TranslatedToolCatalog {
allTools: ToolRegistry;
regularTools: RegularToolRegistry;
@@ -711,10 +715,12 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
showJS: {
icon: <LocalIcon icon="javascript-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.showJS.title", "Show JavaScript"),
component: null,
component: ShowJS,
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
maxFiles: 1,
endpoints: ["show-javascript"],
synonyms: getSynonyms(t, "showJS"),
supportsAutomate: false,
automationSettings: null
@@ -772,13 +778,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
compare: {
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.compare.title", "Compare"),
component: null,
component: Compare,
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: 2,
operationConfig: undefined,
automationSettings: null,
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
automationSettings: null
supportsAutomate: false
},
compress: {
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
@@ -0,0 +1,566 @@
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { appendWord as sharedAppendWord } from '@app/utils/textDiff';
import { PARAGRAPH_SENTINEL } from '@app/types/compare';
import type { StirlingFile } from '@app/types/fileContext';
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
import type {
CompareChange,
CompareDiffToken,
CompareResultData,
TokenBoundingBox,
CompareParagraph,
} from '@app/types/compare';
export interface TokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
export interface ExtractedContent {
tokens: string[];
metadata: TokenMetadata[];
pageSizes: { width: number; height: number }[];
paragraphs: CompareParagraph[];
}
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
let lastMeasurementFont = '';
const DEFAULT_CHAR_WIDTH = 1;
const DEFAULT_SPACE_WIDTH = 0.33;
export const measureTextWidth = (fontSpec: string, text: string): number => {
if (!measurementContext) {
if (!text) return 0;
if (text === ' ') return DEFAULT_SPACE_WIDTH;
return text.length * DEFAULT_CHAR_WIDTH;
}
if (lastMeasurementFont !== fontSpec) {
measurementContext.font = fontSpec;
lastMeasurementFont = fontSpec;
}
const key = `${fontSpec}|${text}`;
const cached = textMeasurementCache?.get(key);
if (cached !== undefined) {
return cached;
}
const width = measurementContext.measureText(text).width || 0;
textMeasurementCache?.set(key, width);
return width;
};
export const appendWord = (existing: string, word: string) => {
if (!existing) {
return sharedAppendWord('', word);
}
return sharedAppendWord(existing, word);
};
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
return tokens.reduce(
(totals, token) => {
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
return totals;
}
switch (token.type) {
case 'added':
totals.added += 1;
break;
case 'removed':
totals.removed += 1;
break;
default:
totals.unchanged += 1;
}
return totals;
},
{ added: 0, removed: 0, unchanged: 0 }
);
};
export const buildChanges = (
tokens: CompareDiffToken[],
baseMetadata: TokenMetadata[],
comparisonMetadata: TokenMetadata[]
): CompareChange[] => {
const changes: CompareChange[] = [];
let baseIndex = 0;
let comparisonIndex = 0;
let current: CompareChange | null = null;
let currentBaseParagraph: number | null = null;
let currentComparisonParagraph: number | null = null;
const ensureCurrent = (): CompareChange => {
if (!current) {
current = {
id: `change-${changes.length}`,
base: null,
comparison: null,
};
}
return current;
};
const flush = () => {
if (current) {
if (current.base) {
current.base.text = current.base.text.trim();
}
if (current.comparison) {
current.comparison.text = current.comparison.text.trim();
}
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
changes.push(current);
}
}
current = null;
currentBaseParagraph = null;
currentComparisonParagraph = null;
};
for (const token of tokens) {
if (token.type === 'removed') {
const meta = baseMetadata[baseIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.base) {
active.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentBaseParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentBaseParagraph !== null &&
paragraph !== currentBaseParagraph &&
active.base.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.base.text = appendWord(active.base.text, token.text);
}
if (meta && active.base.page === null) {
active.base.page = meta.page;
}
if (meta && active.base.paragraph === null) {
active.base.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentBaseParagraph = paragraph;
}
}
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
continue;
}
if (token.type === 'added') {
const meta = comparisonMetadata[comparisonIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.comparison) {
active.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentComparisonParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentComparisonParagraph !== null &&
paragraph !== currentComparisonParagraph &&
active.comparison.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.comparison.text = appendWord(active.comparison.text, token.text);
}
if (meta && active.comparison.page === null) {
active.comparison.page = meta.page;
}
if (meta && active.comparison.paragraph === null) {
active.comparison.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentComparisonParagraph = paragraph;
}
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
continue;
}
// unchanged token
flush();
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
}
flush();
return changes;
};
export const createSummaryFile = (result: CompareResultData): File => {
const exportPayload = {
generatedAt: new Date(result.totals.processedAt).toISOString(),
base: {
name: result.base.fileName,
totalWords: result.base.wordCount,
},
comparison: {
name: result.comparison.fileName,
totalWords: result.comparison.wordCount,
},
totals: {
added: result.totals.added,
removed: result.totals.removed,
unchanged: result.totals.unchanged,
durationMs: result.totals.durationMs,
},
changes: result.changes.map((change) => ({
base: change.base,
comparison: change.comparison,
})),
warnings: result.warnings,
};
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
};
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => {
if (typeof value === 'object' && value !== null && 'code' in value) {
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code;
return potentialCode;
}
return undefined;
};
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
// returning a mapping to original indices for potential future use.
export const filterTokensForDiff = (
tokens: string[],
metadata: TokenMetadata[],
): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => {
const outTokens: string[] = [];
const outMeta: TokenMetadata[] = [];
const map: number[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const t = tokens[i];
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
if (!isPara) {
outTokens.push(t);
if (metadata[i]) outMeta.push(metadata[i]);
map.push(i);
}
}
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
};
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const tokens: string[] = [];
const metadata: TokenMetadata[] = [];
const pageSizes: { width: number; height: number }[] = [];
const paragraphs: CompareParagraph[] = [];
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
const viewport = page.getViewport({ scale: 1 });
const content: TextContent = await page.getTextContent({
disableCombineTextItems: true,
} as Parameters<PDFPageProxy['getTextContent']>[0]);
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
let paragraphIndex = 1;
let paragraphBuffer = '';
let prevItem: TextItem | null = null;
pageSizes.push({ width: viewport.width, height: viewport.height });
const normalizeToken = (s: string) =>
s
.normalize('NFKC')
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[–—]/g, '-')
.replace(/\u00A0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
if (hasHardBreak) return true;
if (!prev) return false;
const prevY = prev.transform[5];
const currY = curr.transform[5];
const dy = Math.abs(currY - prevY);
const currX = curr.transform[4];
const prevX = prev.transform[4];
const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9);
const looksLikeParagraph = dy > approxLine * 1.8;
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
return looksLikeParagraph && !likelySoftWrap;
};
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
if (width <= 0 || height <= 0) {
return null;
}
const MIN_WIDTH = 0.004;
const MIN_HORIZONTAL_PAD = 0.0012;
const HORIZONTAL_PAD_RATIO = 0.12;
const MIN_VERTICAL_PAD = 0.0008;
const VERTICAL_PAD_RATIO = 0.18;
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
let expandedLeft = left - horizontalPad;
let expandedRight = left + width + horizontalPad;
let expandedTop = top - verticalPad;
let expandedBottom = top + height + verticalPad;
if (expandedRight - expandedLeft < MIN_WIDTH) {
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
expandedLeft -= deficit / 2;
expandedRight += deficit / 2;
}
expandedLeft = clamp(expandedLeft);
expandedRight = clamp(expandedRight);
expandedTop = clamp(expandedTop);
expandedBottom = clamp(expandedBottom);
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
return null;
}
return {
left: expandedLeft,
top: expandedTop,
width: expandedRight - expandedLeft,
height: expandedBottom - expandedTop,
};
};
for (const item of content.items as TextItem[]) {
if (!item?.str) {
prevItem = null;
continue;
}
const rawText = item.str;
const totalLen = Math.max(rawText.length, 1);
const textStyle = item.fontName ? styles[item.fontName] : undefined;
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
const fontSpec = `${fontScale}px ${fontFamily}`;
const weights: number[] = new Array(totalLen);
let runningText = '';
let previousAdvance = 0;
for (let i = 0; i < totalLen; i += 1) {
runningText += rawText[i];
const advance = measureTextWidth(fontSpec, runningText);
let width = advance - previousAdvance;
if (!Number.isFinite(width) || width <= 0) {
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
weights[i] = width;
previousAdvance = advance;
}
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
for (let i = 0; i < totalLen; i += 1) {
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
}
const prefix: number[] = new Array(totalLen + 1);
prefix[0] = 0;
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
const totalWeight = prefix[totalLen] || 1;
const rawX = item.transform[4];
const rawY = item.transform[5];
const transformed = [
viewport.convertToViewportPoint(rawX, rawY),
viewport.convertToViewportPoint(rawX + item.width, rawY),
viewport.convertToViewportPoint(rawX, rawY + item.height),
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
];
const xs = transformed.map(([px]) => px);
const ys = transformed.map(([, py]) => py);
const left = Math.min(...xs);
const right = Math.max(...xs);
const top = Math.min(...ys);
const bottom = Math.max(...ys);
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
prevItem = item;
continue;
}
const [baselineStart, baselineEnd, verticalEnd] = transformed;
const baselineVector: [number, number] = [
baselineEnd[0] - baselineStart[0],
baselineEnd[1] - baselineStart[1],
];
const verticalVector: [number, number] = [
verticalEnd[0] - baselineStart[0],
verticalEnd[1] - baselineStart[1],
];
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
const font = item.fontName ? styles[item.fontName] : undefined;
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(rawText)) !== null) {
const wordRaw = match[0];
const normalizedWord = normalizeToken(wordRaw);
if (!normalizedWord) {
continue;
}
const startIndex = match.index;
const endIndex = startIndex + wordRaw.length;
const relStart = prefix[startIndex] / totalWeight;
const relEnd = prefix[endIndex] / totalWeight;
let wordLeftAbs: number;
let wordRightAbs: number;
let wordTopAbs: number;
let wordBottomAbs: number;
if (hasOrientationVectors) {
const segStart: [number, number] = [
baselineStart[0] + baselineVector[0] * relStart,
baselineStart[1] + baselineVector[1] * relStart,
];
const segEnd: [number, number] = [
baselineStart[0] + baselineVector[0] * relEnd,
baselineStart[1] + baselineVector[1] * relEnd,
];
const cornerPoints: Array<[number, number]> = [
segStart,
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
segEnd,
];
const cornerXs = cornerPoints.map(([px]) => px);
const cornerYs = cornerPoints.map(([, py]) => py);
wordLeftAbs = Math.min(...cornerXs);
wordRightAbs = Math.max(...cornerXs);
wordTopAbs = Math.min(...cornerYs);
wordBottomAbs = Math.max(...cornerYs);
} else {
const segLeftAbs = left + (right - left) * relStart;
const segRightAbs = left + (right - left) * relEnd;
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
wordTopAbs = top;
wordBottomAbs = bottom;
}
const wordLeft = clamp(wordLeftAbs / viewport.width);
const wordRight = clamp(wordRightAbs / viewport.width);
const wordTop = clamp(wordTopAbs / viewport.height);
const wordBottom = clamp(wordBottomAbs / viewport.height);
const wordWidth = Math.max(0, wordRight - wordLeft);
let wordHeight = Math.max(0, wordBottom - wordTop);
if (wordHeight > 0 && verticalScale < 1) {
const midY = (wordTop + wordBottom) / 2;
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
const half = shrunkHeight / 2;
const newTop = clamp(midY - half);
const newBottom = clamp(midY + half);
wordHeight = Math.max(0, newBottom - newTop);
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
tokens.push(normalizedWord);
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
continue;
}
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
tokens.push(normalizedWord);
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox,
});
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
}
if (isParagraphBreak(item as TextItem, prevItem)) {
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
}
tokens.push('\uE000PARA');
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
paragraphIndex += 1;
}
prevItem = item as TextItem;
}
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
tokens.push('\uE000PARA');
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
}
}
return { tokens, metadata, pageSizes, paragraphs };
} finally {
pdfWorkerManager.destroyDocument(pdfDoc);
}
};
@@ -0,0 +1,559 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ADDITION_HIGHLIGHT,
CompareDiffToken,
CompareFilteredTokenInfo,
CompareResultData,
CompareWorkerRequest,
CompareWorkerResponse,
CompareWorkerWarnings,
REMOVAL_HIGHLIGHT,
} from '@app/types/compare';
import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters';
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import type { StirlingFile } from '@app/types/fileContext';
import { useFileContext } from '@app/contexts/file/fileHooks';
import {
aggregateTotals,
buildChanges,
createSummaryFile,
extractContentFromPdf,
getWorkerErrorCode,
filterTokensForDiff,
} from '@app/hooks/tools/compare/operationUtils';
import { alert, dismissToast } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
import CompareWorkerCtor from '@app/workers/compareWorker?worker';
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
result: CompareResultData | null;
warnings: string[];
}
// extractContentFromPdf moved to utils
export const useCompareOperation = (): CompareOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const workerRef = useRef<Worker | null>(null);
const previousUrl = useRef<string | null>(null);
const activeRunIdRef = useRef(0);
const cancelledRef = useRef(false);
type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error';
const [isLoading, setIsLoading] = useState(false);
const [statusState, setStatusState] = useState<OperationStatus>('idle');
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [result, setResult] = useState<CompareResultData | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const longRunningToastIdRef = useRef<string | null>(null);
const dissimilarityToastIdRef = useRef<string | null>(null);
const dissimilarityToastShownRef = useRef<boolean>(false);
const ensureWorker = useCallback(() => {
if (!workerRef.current) {
workerRef.current = new CompareWorkerCtor();
}
return workerRef.current;
}, []);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
setResult(null);
setWarnings([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatusState('idle');
setStatusDetailMs(null);
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const runCompareWorker = useCallback(
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => {
const worker = ensureWorker();
return await new Promise<{
tokens: CompareDiffToken[];
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
warnings: string[];
}>((resolve, reject) => {
const collectedWarnings: string[] = [];
const collectedTokens: CompareDiffToken[] = [];
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
if (cancelledRef.current) {
cleanup();
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
return;
}
const message = event.data;
if (!message) {
return;
}
switch (message.type) {
case 'chunk': {
if (message.tokens.length > 0) {
collectedTokens.push(...message.tokens);
onChunk?.(message.tokens);
}
break;
}
case 'success':
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
resolve({
tokens: collectedTokens,
stats: message.stats,
warnings: collectedWarnings,
});
break;
case 'warning':
collectedWarnings.push(message.message);
break;
case 'error': {
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message);
error.code = message.code;
reject(error);
break;
}
default:
break;
}
};
const handleError = (event: ErrorEvent) => {
cleanup();
if (cancelledRef.current) {
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
} else {
reject(event.error ?? new Error(event.message));
}
};
const cleanup = () => {
worker.removeEventListener('message', handleMessage as EventListener);
worker.removeEventListener('error', handleError as EventListener);
};
worker.addEventListener('message', handleMessage as EventListener);
worker.addEventListener('error', handleError as EventListener);
const request: CompareWorkerRequest = {
type: 'compare',
payload: {
baseTokens,
comparisonTokens,
warnings: warningMessages,
// Static worker settings to support large documents
settings: {
batchSize: 5000,
complexThreshold: 120000,
maxWordThreshold: 200000,
},
},
};
worker.postMessage(request);
});
},
[ensureWorker]
);
const executeOperation = useCallback(
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
// start new run
const runId = ++activeRunIdRef.current;
cancelledRef.current = false;
if (!params.baseFileId || !params.comparisonFileId) {
setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.'));
return;
}
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
?? selectors.getFile(params.baseFileId);
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
?? selectors.getFile(params.comparisonFileId);
if (!baseFile || !comparisonFile) {
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
return;
}
setIsLoading(true);
setStatusState('extracting');
setStatusDetailMs(null);
setErrorMessage(null);
setWarnings([]);
setResult(null);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
const warningMessages: CompareWorkerWarnings = {
// No accuracy warning any more
tooLargeMessage: t(
'compare.large.file.message',
'These documents are very large; comparison may take several minutes. Please keep this tab open.'
),
emptyTextMessage: t(
'compare.no.text.message',
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
),
tooDissimilarMessage: t(
'compare.too.dissimilar.message',
'These documents appear highly dissimilar. Comparison was stopped to save time.'
),
};
const operationStart = performance.now();
try {
const [baseContent, comparisonContent] = await Promise.all([
extractContentFromPdf(baseFile),
extractContentFromPdf(comparisonFile),
]);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
}
setStatusState('processing');
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata);
const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata);
const combinedPageCount =
(baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0);
if (
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
!longRunningToastIdRef.current
) {
const toastId = alert({
alertType: 'neutral',
title: t('compare.longJob.title', 'Large comparison in progress'),
body: t(
'compare.longJob.body',
'These PDFs together exceed 2,000 pages. Processing can take several minutes.'
),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
expandable: false,
});
longRunningToastIdRef.current = toastId || null;
}
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
let observedAddedRemoved = 0;
let observedUnchanged = 0;
const handleEarlyDissimilarity = () => {
if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return;
const toastId = alert({
alertType: 'warning',
title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'),
body: t(
'compare.earlyDissimilarity.body',
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents."
),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
expandable: false,
buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'),
buttonCallback: () => {
try { cancelOperation(); } catch {
console.error('Failed to cancel operation');
}
try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {
console.error('Failed to dispatch clear selected event');
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
},
});
dissimilarityToastIdRef.current = toastId || null;
dissimilarityToastShownRef.current = true;
};
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
baseFiltered.tokens,
comparisonFiltered.tokens,
warningMessages,
(chunk) => {
// Incremental ratio tracking for early warning
for (const tok of chunk) {
if (tok.type === 'unchanged') observedUnchanged += 1;
else observedAddedRemoved += 1;
}
const seen = observedAddedRemoved + observedUnchanged;
if (
!dissimilarityToastShownRef.current &&
seen >= EARLY_TOAST_MIN_TOKENS &&
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
) {
handleEarlyDissimilarity();
}
}
);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
const baseHasHighlight = new Array<boolean>(baseFiltered.tokens.length).fill(false);
const comparisonHasHighlight = new Array<boolean>(comparisonFiltered.tokens.length).fill(false);
let baseTokenPointer = 0;
let comparisonTokenPointer = 0;
for (const diffToken of tokens) {
if (diffToken.type === 'removed') {
if (baseTokenPointer < baseHasHighlight.length) {
baseHasHighlight[baseTokenPointer] = true;
}
baseTokenPointer += 1;
} else if (diffToken.type === 'added') {
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonHasHighlight[comparisonTokenPointer] = true;
}
comparisonTokenPointer += 1;
} else {
if (baseTokenPointer < baseHasHighlight.length) {
baseTokenPointer += 1;
}
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonTokenPointer += 1;
}
}
}
const buildFilteredTokenData = (
tokensList: typeof baseFiltered.tokens,
metadataList: typeof baseFiltered.metadata,
highlightFlags: boolean[]
): CompareFilteredTokenInfo[] =>
tokensList.map((token, index) => {
const meta = metadataList[index];
return {
token,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
bbox: meta?.bbox ?? null,
hasHighlight: highlightFlags[index] ?? false,
metaIndex: index,
};
});
const totals = aggregateTotals(tokens);
const processedAt = Date.now();
const baseMetadata = baseFiltered.metadata;
const comparisonMetadata = comparisonFiltered.metadata;
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
const comparisonResult: CompareResultData = {
base: {
fileId: baseFile.fileId,
fileName: baseFile.name,
highlightColor: REMOVAL_HIGHLIGHT,
wordCount: stats.baseWordCount,
pageSizes: baseContent.pageSizes,
},
comparison: {
fileId: comparisonFile.fileId,
fileName: comparisonFile.name,
highlightColor: ADDITION_HIGHLIGHT,
wordCount: stats.comparisonWordCount,
pageSizes: comparisonContent.pageSizes,
},
totals: {
...totals,
durationMs: stats.durationMs,
processedAt,
},
tokens,
tokenMetadata: {
base: baseMetadata,
comparison: comparisonMetadata,
},
filteredTokenData: {
base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight),
comparison: buildFilteredTokenData(
comparisonFiltered.tokens,
comparisonFiltered.metadata,
comparisonHasHighlight
),
},
sourceTokens: {
base: baseContent.tokens,
comparison: comparisonContent.tokens,
},
changes,
warnings: workerWarnings,
baseParagraphs: baseContent.paragraphs,
comparisonParagraphs: comparisonContent.paragraphs,
};
setResult(comparisonResult);
setWarnings(workerWarnings);
const summaryFile = createSummaryFile(comparisonResult);
setFiles([summaryFile]);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(summaryFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
setDownloadFilename(summaryFile.name);
setStatusState('complete');
} catch (error: unknown) {
console.error('[compare] operation failed', error);
const errorCode = getWorkerErrorCode(error);
if (errorCode === 'EMPTY_TEXT') {
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
} else {
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
if (error instanceof Error && error.message) {
setErrorMessage(error.message);
} else if (typeof error === 'string' && error.trim().length > 0) {
setErrorMessage(error);
} else {
setErrorMessage(fallbackMessage);
}
}
} finally {
const duration = performance.now() - operationStart;
setStatusDetailMs(Math.round(duration));
setIsLoading(false);
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
dissimilarityToastShownRef.current = false;
}
},
[cleanupDownloadUrl, runCompareWorker, selectors, t]
);
const cancelOperation = useCallback(() => {
if (!isLoading) return;
cancelledRef.current = true;
setIsLoading(false);
setStatusState('cancelled');
if (workerRef.current) {
try {
workerRef.current.terminate();
// eslint-disable-next-line no-empty
} catch {}
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
}, [isLoading]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
};
}, [cleanupDownloadUrl]);
const status = useMemo(() => {
const label =
statusState === 'idle' ? ''
: statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...')
: statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...')
: statusState === 'complete' ? t('compare.status.complete', 'Comparison ready')
: statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled')
: '';
if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`;
return label;
}, [statusState, statusDetailMs, t]);
return useMemo<CompareOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
result,
warnings,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
result,
status,
undoOperation,
warnings,
]
);
};
@@ -0,0 +1,23 @@
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
import type { FileId } from '@app/types/file';
export interface CompareParameters {
baseFileId: FileId | null;
comparisonFileId: FileId | null;
}
export const defaultParameters: CompareParameters = {
baseFileId: null,
comparisonFileId: null,
};
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
export const useCompareParameters = (): CompareParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'compare',
validateFn: (params) =>
Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId),
});
};
@@ -39,7 +39,7 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("fileInput", file);
});
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters;
const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbzOptions, cbzOutputOptions } = parameters;
if (isImageFormat(toExtension)) {
formData.append("imageFormat", toExtension);
@@ -67,6 +67,10 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile
formData.append("outputFormat", pdfaOptions.outputFormat);
} else if (fromExtension === 'pdf' && toExtension === 'csv') {
formData.append("pageNumbers", "all");
} else if (fromExtension === 'cbz' && toExtension === 'pdf') {
formData.append("optimizeForEbook", (cbzOptions?.optimizeForEbook ?? false).toString());
} else if (fromExtension === 'pdf' && toExtension === 'cbz') {
formData.append("dpi", (cbzOutputOptions?.dpi ?? 150).toString());
}
return formData;
@@ -36,6 +36,12 @@ export interface ConvertParameters extends BaseParameters {
pdfaOptions: {
outputFormat: string;
};
cbzOptions: {
optimizeForEbook: boolean;
};
cbzOutputOptions: {
dpi: number;
};
isSmartDetection: boolean;
smartDetectionType: 'mixed' | 'images' | 'web' | 'none';
}
@@ -69,6 +75,12 @@ export const defaultParameters: ConvertParameters = {
pdfaOptions: {
outputFormat: 'pdfa-1',
},
cbzOptions: {
optimizeForEbook: false,
},
cbzOutputOptions: {
dpi: 150,
},
isSmartDetection: false,
smartDetectionType: 'none',
};
@@ -83,12 +83,17 @@ export const useToolResources = () => {
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
const extractZipFiles = useCallback(async (
zipBlob: Blob,
skipAutoUnzip = false,
confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise<boolean>
): Promise<File[]> => {
try {
return await zipFileService.extractWithPreferences(zipBlob, {
autoUnzip: preferences.autoUnzip,
autoUnzipFileLimit: preferences.autoUnzipFileLimit,
skipAutoUnzip
skipAutoUnzip,
confirmLargeExtraction
});
} catch (error) {
console.error('useToolResources.extractZipFiles - Error:', error);
@@ -0,0 +1,137 @@
import { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import type { StirlingFile } from '@app/types/fileContext';
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters';
import type { ResponseType } from 'axios';
export interface ShowJSOperationHook extends ToolOperationHook<ShowJSParameters> {
scriptText: string | null;
}
export const useShowJSOperation = (): ShowJSOperationHook => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [scriptText, setScriptText] = useState<string | null>(null);
const cancelRequested = useRef(false);
const previousUrl = useRef<string | null>(null);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
cancelRequested.current = false;
setScriptText(null);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatus('');
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const executeOperation = useCallback(
async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t('noFileSelected', 'No files selected'));
return;
}
cancelRequested.current = false;
setIsLoading(true);
setStatus(t('showJS.processing', 'Extracting JavaScript...'));
setErrorMessage(null);
setScriptText(null);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
try {
const file = selectedFiles[0];
const formData = new FormData();
formData.append('fileInput', file);
const response = await apiClient.post('/api/v1/misc/show-javascript', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'text' as ResponseType,
transformResponse: [(data) => data],
});
const text: string = typeof response.data === 'string' ? response.data : '';
setScriptText(text);
// Optional: prepare a downloadable file
const outFile = new File([text], (file.name?.replace(/\.[^.]+$/, '') || 'extracted') + '.js', {
type: 'application/javascript',
});
setFiles([outFile]);
const blobUrl = URL.createObjectURL(outFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
setDownloadFilename(outFile.name);
setStatus(t('showJS.done', 'JavaScript extracted'));
} catch (error: unknown) {
setErrorMessage(extractErrorMessage(error));
setStatus('');
} finally {
setIsLoading(false);
}
},
[t, cleanupDownloadUrl]
);
const cancelOperation = useCallback(() => {
cancelRequested.current = true;
setIsLoading(false);
setStatus(t('operationCancelled', 'Operation cancelled'));
}, [t]);
const undoOperation = useCallback(async () => {
// No-op for this tool
setStatus(t('nothingToUndo', 'Nothing to undo'));
}, [t]);
return {
// State (align with ToolOperationHook)
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
// Custom state
scriptText,
// Actions
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
};
};
@@ -0,0 +1,21 @@
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { BaseParameters } from '@app/types/parameters';
export interface ShowJSParameters extends BaseParameters {
// Extends BaseParameters - ready for future parameter additions if needed
}
export const defaultParameters: ShowJSParameters = {
// No parameters needed
};
export type ShowJSParametersHook = BaseParametersHook<ShowJSParameters>;
export const useShowJSParameters = (): ShowJSParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'show-javascript',
});
};
+4 -2
View File
@@ -1,14 +1,16 @@
import { useCallback } from 'react';
import { useFileActions } from '@app/contexts/FileContext';
import type { StirlingFile } from '@app/types/fileContext';
export const useFileHandler = () => {
const { actions } = useFileActions();
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => {
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}): Promise<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
const mergedOptions = { selectFiles: true, ...options };
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files, mergedOptions);
const result = await actions.addFiles(files, mergedOptions);
return result;
}, [actions.addFiles]);
return {

Some files were not shown because too many files have changed in this diff Show More