Merge remote-tracking branch 'origin/V2' into PaymentSelfhost

This commit is contained in:
Connor Yoh
2025-11-16 16:06:21 +00:00
203 changed files with 15828 additions and 2687 deletions
+74
View File
@@ -0,0 +1,74 @@
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.iml
*.iws
*.ipr
# Logs
*.log
logs
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
+83 -77
View File
@@ -17,18 +17,11 @@ on:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions:
contents: read
@@ -47,21 +40,19 @@ jobs:
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
# "macos")
# echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
# ;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For PR/push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build:
@@ -96,7 +87,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
@@ -183,80 +174,96 @@ jobs:
working-directory: ./frontend
run: npm install
# Disabled Mac builds - Import Apple Developer Certificate
# - name: Import Apple Developer Certificate
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# env:
# APPLE_ID: ${{ secrets.APPLE_ID }}
# APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
# APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
# run: |
# echo "Importing Apple Developer Certificate..."
# echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# security default-keychain -s build.keychain
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# security set-keychain-settings -t 3600 -u build.keychain
# security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# security find-identity -v -p codesigning build.keychain
# - name: Verify Certificate
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# run: |
# echo "Verifying Apple Developer Certificate..."
# CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
# echo "Certificate Info: $CERT_INFO"
# CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
# echo "Certificate ID: $CERT_ID"
# echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
# echo "Certificate imported."
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
# - name: Check DMG creation dependencies (macOS only)
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# run: |
# echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
# echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
# echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
# echo "Available disk space: $(df -h /tmp | tail -1)"
# echo "macOS version: $(sw_vers -productVersion)"
# echo "Available tools:"
# ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Build Tauri app
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
echo "Available disk space: $(df -h /tmp | tail -1)"
echo "macOS version: $(sw_vers -productVersion)"
echo "Available tools:"
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
SIGN: 1
CI: true
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/src-tauri/target
DMG_FILE=$(find . -name "*.dmg" | head -1)
if [ -n "$DMG_FILE" ]; then
echo "Found DMG: $DMG_FILE"
echo "Checking notarization ticket..."
spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
else
echo "⚠️ No DMG file found to verify"
fi
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
# Disabled Mac builds
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
# find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
# find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
@@ -273,7 +280,7 @@ jobs:
shell: bash
run: |
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
echo "Checking for Windows artifacts..."
@@ -282,14 +289,13 @@ jobs:
echo "❌ No Windows executable found"
exit 1
fi
# Disabled Mac builds
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
# echo "Checking for macOS artifacts..."
# find . -name "*.dmg" -o -name "*.app" | head -5
# if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
# echo "❌ No macOS artifacts found"
# exit 1
# fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found"
exit 1
fi
else
echo "Checking for Linux artifacts..."
find . -name "*.deb" -o -name "*.AppImage" | head -5
@@ -298,7 +304,7 @@ jobs:
exit 1
fi
fi
echo "✅ Build artifacts found for ${{ matrix.name }}"
- name: Test artifact sizes
@@ -331,4 +337,4 @@ jobs:
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi
fi
+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
@@ -505,10 +505,19 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
}
@Data
@@ -8,4 +8,6 @@ public interface UserServiceInterface {
long getTotalUsersCount();
boolean isCurrentUserAdmin();
boolean isCurrentUserFirstLogin();
}
@@ -75,6 +75,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
@@ -28,6 +28,8 @@ public class InitialSetup {
private final ApplicationProperties applicationProperties;
private static boolean isNewServer = false;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
@@ -88,6 +90,13 @@ public class InitialSetup {
}
public void initSetAppVersion() throws IOException {
// Check if this is a new server before setting the version
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
@@ -99,4 +108,8 @@ public class InitialSetup {
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
public static boolean isNewServer() {
return isNewServer;
}
}
@@ -10,7 +10,10 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -19,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@Slf4j
public class ConfigController {
private final ApplicationProperties applicationProperties;
@@ -58,9 +62,15 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
@@ -78,6 +88,22 @@ public class ConfigController {
}
configData.put("isAdmin", isAdmin);
// Check if this is a new server (version was 0.0.0 before initialization)
configData.put("isNewServer", InitialSetup.isNewServer());
// Check if the current user is a first-time user
boolean isNewUser =
false; // Default to false when security is disabled or user not found
if (userService != null) {
try {
isNewUser = userService.isCurrentUserFirstLogin();
} catch (Exception e) {
// If there's an error, assume not new user for safety
isNewUser = false;
}
}
configData.put("isNewUser", isNewUser);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -176,6 +176,7 @@ system:
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints:
@@ -7,6 +7,8 @@
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
@@ -25,6 +27,12 @@
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}
@@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
@Data
public static class LoginData {
private Boolean enableLogin;
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
@@ -742,4 +742,31 @@ public class UserController {
return errorMessage;
}
}
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
User user = userOpt.get();
user.setHasCompletedInitialSetup(true);
userRepository.save(user);
log.info("User {} completed initial setup", username);
return ResponseEntity.ok().body(Map.of("success", true));
} catch (Exception e) {
log.error("Error completing initial setup", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to complete initial setup");
}
}
}
@@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout");
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
@@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "roleName")
private String roleName;
@@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable {
this.isFirstLogin = isFirstLogin;
}
public boolean hasCompletedInitialSetup() {
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
}
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface {
return false;
}
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
if (username != null) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
return !userOpt.get().hasCompletedInitialSetup();
}
}
} catch (Exception e) {
log.debug("Error checking first login status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '1.4.0'
version = '2.0.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+3
View File
@@ -1,3 +1,6 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
+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",
@@ -508,6 +509,63 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@embedpdf/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
+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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+159 -14
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",
@@ -3062,6 +3177,7 @@
"rememberme": "Remember me",
"invalid": "Invalid username or password.",
"locked": "Your account has been locked.",
"sessionExpired": "Your session has expired. Please sign in again.",
"signinTitle": "Please sign in",
"ssoSignIn": "Login via Single Sign-on",
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
@@ -3491,7 +3607,8 @@
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
"save": "Save",
"saveChanges": "Save Changes"
},
"search": {
"title": "Search PDF",
@@ -3538,8 +3655,10 @@
"help": "Help",
"account": "Account",
"config": "Config",
"settings": "Settings",
"adminSettings": "Admin Settings",
"allTools": "All Tools",
"allTools": "Tools",
"reader": "Reader",
"helpMenu": {
"toolsTour": "Tools Tour",
"toolsTourDesc": "Learn what the tools can do",
@@ -3565,6 +3684,12 @@
"saveSuccess": "Settings saved successfully",
"save": "Save Changes",
"restartRequired": "Restart Required",
"loginRequired": "Login mode must be enabled to modify admin settings",
"loginDisabled": {
"title": "Login Mode Required",
"message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
"readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
},
"restart": {
"title": "Restart Required",
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
@@ -3635,6 +3760,12 @@
"description": "Default producer for PDF metadata"
}
},
"logoStyle": {
"label": "Logo Style",
"description": "Choose between the modern minimalist logo or the classic S icon",
"classic": "Classic",
"modern": "Modern"
},
"customPaths": {
"label": "Custom Paths",
"description": "Configure custom file system paths for pipeline processing and external tools",
@@ -4492,6 +4623,12 @@
}
},
"common": {
"previous": "Previous",
"next": "Next",
"collapse": "Collapse",
"expand": "Expand",
"collapsed": "collapsed",
"lines": "lines",
"copy": "Copy",
"copied": "Copied!",
"refresh": "Refresh",
@@ -4528,6 +4665,12 @@
}
},
"apiKeys": {
"intro": "Use your API key to programmatically access Stirling PDF's processing capabilities.",
"docsTitle": "API Documentation",
"docsDescription": "Learn more about integrating with Stirling PDF:",
"docsLink": "API Documentation",
"schemaLink": "API Schema Reference",
"usage": "Include this key in the X-API-KEY header with all API requests.",
"description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
"publicKeyAriaLabel": "Public API key",
"copyKeyAriaLabel": "Copy API key",
@@ -4656,7 +4799,7 @@
"maybeLater": "Maybe Later",
"dontShowAgain": "Don't Show Again"
},
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
@@ -4716,7 +4859,8 @@
"admin": "Admin",
"roleDescriptions": {
"admin": "Can manage settings and invite members, with full administrative access.",
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
"member": "Can view and edit shared files, but cannot manage workspace settings or members.",
"user": "User"
},
"editRole": "Edit Role",
"enable": "Enable",
@@ -4797,6 +4941,7 @@
"copied": "Link copied to clipboard",
"success": "Invite link generated successfully",
"successWithEmail": "Invite link generated and sent via email",
"emailSent": "Invite link generated and sent via email",
"emailFailed": "Invite link generated, but email failed",
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
"error": "Failed to generate invite link",
+119 -10
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",
@@ -3864,7 +3967,7 @@
"account": "Account",
"config": "Config",
"adminSettings": "Admin Settings",
"allTools": "All Tools"
"allTools": "Tools"
},
"admin": {
"error": "Error",
@@ -4815,6 +4918,12 @@
}
},
"common": {
"previous": "Previous",
"next": "Next",
"collapse": "Collapse",
"expand": "Expand",
"collapsed": "collapsed",
"lines": "lines",
"copy": "Copy",
"copied": "Copied!",
"refresh": "Refresh",
@@ -2986,7 +2986,7 @@
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
"label": "Select Original PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files",
+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}
@@ -123,7 +123,7 @@ export default function OnboardingTour() {
const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
@@ -0,0 +1,72 @@
.gridContainer {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.virtualRows {
width: 100%;
position: relative;
margin: 0 auto;
}
.virtualRow {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.rowContent {
display: flex;
justify-content: flex-start;
height: 100%;
align-items: center;
position: relative;
}
.selectionBox {
position: absolute;
border: 2px dashed #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
pointer-events: none;
}
.dropIndicator {
position: absolute;
width: 4px;
background-color: rgba(96, 165, 250, 0.8);
border-radius: 2px;
pointer-events: none;
}
.dragOverlay {
position: relative;
cursor: grabbing;
}
.dragOverlayBadge {
position: absolute;
top: -8px;
right: -8px;
background-color: #3b82f6;
color: #ffffff;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.dragOverlayPreview {
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
opacity: 0.5;
}
@@ -1,33 +1,369 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import styles from '@app/components/pageEditor/DragDropGrid.module.css';
import {
Z_INDEX_SELECTION_BOX,
Z_INDEX_DROP_INDICATOR,
Z_INDEX_DRAG_BADGE,
} from '@app/styles/zIndex';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverlay,
useSensor,
useSensors,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
interface DragDropItem {
id: string;
splitAfter?: boolean;
isPlaceholder?: boolean;
originalFileId?: string;
pageNumber?: number;
}
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: string[];
selectionMode: boolean;
isAnimating: boolean;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
selectedFileIds?: string[];
}
type DropSide = 'left' | 'right' | null;
type ItemRect = { id: string; rect: DOMRect };
interface DropHint {
hoveredId: string | null;
dropSide: DropSide;
}
function resolveDropHint(
activeId: string | null,
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>,
cursorX: number,
cursorY: number,
): DropHint {
if (!activeId) {
return { hoveredId: null, dropSide: null };
}
const items: ItemRect[] = Array.from(itemRefs.current.entries())
.filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId)
.map(([itemId, element]) => ({
id: itemId,
rect: element.getBoundingClientRect(),
}))
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
if (items.length === 0) {
return { hoveredId: null, dropSide: null };
}
items.sort((a, b) => a.rect.top - b.rect.top);
const rows: ItemRect[][] = [];
const rowTolerance = items[0].rect.height / 2;
items.forEach((item) => {
const currentRow = rows[rows.length - 1];
if (!currentRow) {
rows.push([item]);
return;
}
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
if (isSameRow) {
currentRow.push(item);
} else {
rows.push([item]);
}
});
let targetRow: ItemRect[] | undefined;
let smallestRowDistance = Infinity;
rows.forEach((row) => {
if (row.length === 0) {
return;
}
const top = row[0].rect.top;
const bottom = row[0].rect.bottom;
const centerY = top + (bottom - top) / 2;
const distance = Math.abs(cursorY - centerY);
if (distance < smallestRowDistance) {
smallestRowDistance = distance;
targetRow = row;
}
});
if (!targetRow || targetRow.length === 0) {
return { hoveredId: null, dropSide: null };
}
let hoveredItem = targetRow[0];
let smallestHorizontalDistance = Infinity;
targetRow.forEach((item) => {
const midpoint = item.rect.left + item.rect.width / 2;
const distance = Math.abs(cursorX - midpoint);
if (distance < smallestHorizontalDistance) {
smallestHorizontalDistance = distance;
hoveredItem = item;
}
});
const firstItem = targetRow[0];
const lastItem = targetRow[targetRow.length - 1];
let dropSide: DropSide;
if (cursorX < firstItem.rect.left) {
hoveredItem = firstItem;
dropSide = 'left';
} else if (cursorX > lastItem.rect.right) {
hoveredItem = lastItem;
dropSide = 'right';
} else {
const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2;
dropSide = cursorX >= midpoint ? 'right' : 'left';
}
return { hoveredId: hoveredItem.id, dropSide };
}
function resolveTargetIndex<T extends DragDropItem>(
hoveredId: string | null,
dropSide: DropSide,
filteredItems: T[],
filteredToOriginalIndex: number[],
originalItemsLength: number,
fallbackIndex: number | null,
): number | null {
const convertFilteredIndexToOriginal = (filteredIndex: number): number => {
if (filteredToOriginalIndex.length === 0) {
return 0;
}
if (filteredIndex <= 0) {
return filteredToOriginalIndex[0];
}
if (filteredIndex >= filteredToOriginalIndex.length) {
return originalItemsLength;
}
return filteredToOriginalIndex[filteredIndex];
};
if (hoveredId) {
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
if (filteredIndex !== -1) {
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
}
}
if (fallbackIndex !== null && fallbackIndex !== undefined) {
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
}
return null;
}
// Lightweight wrapper that handles dnd-kit hooks for each visible item
interface DraggableItemProps<T extends DragDropItem> {
item: T;
index: number;
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
boxSelectedPageIds: string[];
clearBoxSelection: () => void;
getBoxSelection: () => string[];
activeId: string | null;
activeDragIds: string[];
justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
zoomLevel: number;
}
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const isPlaceholder = Boolean(item.isPlaceholder);
const pageNumber = (item as any).pageNumber ?? index + 1;
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
id: item.id,
disabled: isPlaceholder,
data: {
index,
pageNumber,
getThumbnail: () => {
if (getThumbnailData) {
const data = getThumbnailData(item.id);
if (data?.src) return data;
}
const element = itemRefs.current.get(item.id);
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
if (imgElement?.src) {
return {
src: imgElement.src,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
};
}
return null;
}
}
});
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: item.id,
data: { index, pageNumber: index + 1 }
});
// Notify parent when hover state changes
useEffect(() => {
if (isOver) {
onUpdateDropTarget(item.id);
} else {
onUpdateDropTarget(null);
}
}, [isOver, item.id, onUpdateDropTarget]);
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
setDraggableRef(element);
setDroppableRef(element);
}, [setDraggableRef, setDroppableRef]);
return (
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
</>
);
};
const DragDropGrid = <T extends DragDropItem>({
items,
renderItem,
onReorderPages,
getThumbnailData,
zoomLevel = 1.0,
selectedFileIds,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
const getScrollElement = useCallback(() => {
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
}, []);
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
const filtered: T[] = [];
const indexMap: number[] = [];
const selectedIds =
selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
items.forEach((item, index) => {
const isPlaceholder = Boolean(item.isPlaceholder);
if (isPlaceholder) {
return;
}
const belongsToVisibleFile =
!selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
if (!belongsToVisibleFile) {
return;
}
filtered.push(item);
indexMap.push(index);
});
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
}, [items, selectedFileIds]);
useEffect(() => {
const visibleIdSet = new Set(visibleItems.map(item => item.id));
itemRefs.current.forEach((_, pageId) => {
if (!visibleIdSet.has(pageId)) {
itemRefs.current.delete(pageId);
}
});
}, [visibleItems]);
// Box selection state
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
const highlightTimeoutRef = useRef<number | null>(null);
// Drag state
const [activeId, setActiveId] = useState<string | null>(null);
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
const [dropSide, setDropSide] = useState<DropSide>(null);
// Configure sensors for dnd-kit with activation constraint
// Require 10px movement before drag starts to allow clicks for selection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
},
})
);
// Throttled pointer move handler for drop indicator
// Calculate drop position based on cursor location relative to ALL items, not just hovered item
useEffect(() => {
if (!activeId) {
setDropSide(null);
setHoveredItemId(null);
return;
}
let rafId: number | null = null;
const handlePointerMove = (e: PointerEvent) => {
// Use the actual cursor position (pointer coordinates)
const cursorX = e.clientX;
const cursorY = e.clientY;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY);
setHoveredItemId(hint.hoveredId);
setDropSide(hint.dropSide);
rafId = null;
});
}
};
window.addEventListener('pointermove', handlePointerMove, { passive: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [activeId]);
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
@@ -38,8 +374,8 @@ const DragDropGrid = <T extends DragDropItem>({
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
@@ -47,9 +383,9 @@ const DragDropGrid = <T extends DragDropItem>({
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
}, [zoomLevel]);
// Update items per row when container resizes
// Update items per row when container resizes or zoom changes
useEffect(() => {
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
@@ -72,86 +408,410 @@ const DragDropGrid = <T extends DragDropItem>({
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow]);
}, [calculateItemsPerRow, zoomLevel]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
count: Math.ceil(visibleItems.length / itemsPerRow),
getScrollElement,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
},
overscan: OVERSCAN,
});
// Re-measure virtualizer when zoom or items per row changes
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow, visibleItems.length]);
// Cleanup highlight timeout on unmount
useEffect(() => {
return () => {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
};
}, []);
// Box selection handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // Only respond to primary button
const container = containerRef.current;
if (!container) return;
const clickTarget = e.target as Node;
let clickedPageId: string | null = null;
itemRefs.current.forEach((element, pageId) => {
if (element.contains(clickTarget)) {
clickedPageId = pageId;
}
});
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
setBoxSelectedPageIds([]);
}
return;
}
e.preventDefault();
const rect = container.getBoundingClientRect();
setIsBoxSelecting(true);
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectedPageIds([]);
}, [boxSelectedPageIds]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isBoxSelecting || !boxSelectStart) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
// Calculate which pages intersect with selection box
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
const selectedIds: string[] = [];
itemRefs.current.forEach((pageEl, pageId) => {
const pageRect = pageEl.getBoundingClientRect();
const pageLeft = pageRect.left - rect.left;
const pageRight = pageRect.right - rect.left;
const pageTop = pageRect.top - rect.top;
const pageBottom = pageRect.bottom - rect.top;
// Check if page intersects with selection box
const intersects = !(
pageRight < boxLeft ||
pageLeft > boxRight ||
pageBottom < boxTop ||
pageTop > boxBottom
);
if (intersects) {
selectedIds.push(pageId);
}
});
setBoxSelectedPageIds(selectedIds);
}, [isBoxSelecting, boxSelectStart]);
const handleMouseUp = useCallback(() => {
if (isBoxSelecting) {
// Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet)
// They will remain highlighted until next interaction
setIsBoxSelecting(false);
setBoxSelectStart(null);
setBoxSelectEnd(null);
}
}, [isBoxSelecting]);
// Function to clear box selection (exposed to child components)
const clearBoxSelection = useCallback(() => {
setBoxSelectedPageIds([]);
}, []);
// Function to get current box selection (exposed to child components)
const getBoxSelection = useCallback(() => {
return boxSelectedPageIds;
}, [boxSelectedPageIds]);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
const activeId = event.active.id as string;
setActiveId(activeId);
// Call the getter function to get fresh thumbnail data
const getThumbnail = event.active.data.current?.getThumbnail;
if (getThumbnail) {
const thumbnailData = getThumbnail();
if (thumbnailData?.src) {
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
return;
}
}
setDragPreview(null);
}, []);
// Handle drag cancel
const handleDragCancel = useCallback(() => {
setActiveId(null);
setDragPreview(null);
setHoveredItemId(null);
setDropSide(null);
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
const finalDropSide = dropSide;
setActiveId(null);
setDragPreview(null);
setHoveredItemId(null);
setDropSide(null);
if (!over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (!activeData) {
return;
}
const sourcePageNumber = activeData.pageNumber;
const overData = over?.data.current;
let targetIndex = resolveTargetIndex(
hoveredItemId,
finalDropSide,
visibleItems,
filteredToOriginalIndex,
items.length,
overData ? overData.index : null
);
if (targetIndex === null) {
return;
}
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > items.length) targetIndex = items.length;
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
// Highlight moved pages briefly
const movedIds = pagesToDrag ?? [active.id as string];
setJustMovedIds(movedIds);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setJustMovedIds([]);
highlightTimeoutRef.current = null;
}, 1200);
// Clear box selection after drag
if (pagesToDrag) {
clearBoxSelection();
}
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
return (
<Box
ref={containerRef}
style={{
// Basic container styles
width: '100%',
height: '100%',
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
margin: '0 auto',
maxWidth: `${gridWidth}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex);
// Calculate selection box dimensions
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
zIndex: Z_INDEX_SELECTION_BOX,
} : null;
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
// Calculate drop indicator position
const dropIndicatorStyle = useMemo(() => {
if (!hoveredItemId || !dropSide || !activeId) return null;
const element = itemRefs.current.get(hoveredItemId);
const container = containerRef.current;
if (!element || !container) return null;
const itemRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const top = itemRect.top - containerRect.top;
const height = itemRect.height;
const left = dropSide === 'left'
? itemRect.left - containerRect.left - itemGap / 2
: itemRect.right - containerRect.left + itemGap / 2;
return {
left: `${left}px`,
top: `${top}px`,
height: `${height}px`,
zIndex: Z_INDEX_DROP_INDICATOR,
};
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
const activeDragIds = useMemo(() => {
if (!activeId) return [];
if (boxSelectedPageIds.includes(activeId)) {
return boxSelectedPageIds;
}
return [activeId];
}, [activeId, boxSelectedPageIds]);
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!activeId) {
return;
}
const scrollElement = getScrollElement();
if (!scrollElement) {
return;
}
scrollElement.scrollBy({
top: event.deltaY,
left: event.deltaX,
});
event.preventDefault();
}, [activeId, getScrollElement]);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<Box
ref={containerRef}
className={styles.gridContainer}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging}
>
{selectionBoxStyle && (
<div
className={styles.selectionBox}
style={selectionBoxStyle}
/>
)}
{dropIndicatorStyle && (
<div
className={styles.dropIndicator}
style={dropIndicatorStyle}
/>
)}
<div
className={styles.virtualRows}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
maxWidth: `${gridWidth}px`,
margin: '0 auto',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
const rowItems = visibleItems.slice(startIndex, endIndex);
return (
<div
key={virtualRow.index}
className={styles.virtualRow}
style={{
display: 'flex',
gap: GRID_CONSTANTS.ITEM_GAP,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
position: 'relative'
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
);
})}
<div
className={styles.rowContent}
style={{
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<DraggableItem
key={item.id}
item={item}
index={actualIndex}
itemRefs={itemRefs}
boxSelectedPageIds={boxSelectedPageIds}
clearBoxSelection={clearBoxSelection}
getBoxSelection={getBoxSelection}
activeId={activeId}
activeDragIds={activeDragIds}
justMoved={justMovedIds.includes(item.id)}
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}
zoomLevel={zoomLevel}
/>
);
})}
</div>
</div>
</div>
);
})}
</div>
</Box>
);
})}
</div>
</Box>
{/* Drag Overlay */}
<DragOverlay>
{activeId && (
<div className={styles.dragOverlay}>
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
<div
className={styles.dragOverlayBadge}
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
>
{boxSelectedPageIds.length}
</div>
)}
{dragPreview ? (
<img
src={dragPreview.src}
alt="Dragging"
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
objectFit: 'contain',
transform: `rotate(${dragPreview.rotation}deg)`,
pointerEvents: 'none',
opacity: 0.5,
}}
/>
) : (
<div
className={styles.dragOverlayPreview}
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
color: 'var(--mantine-color-dimmed)',
}}
>
<LocalIcon icon="description" width="3rem" height="3rem" />
</div>
)}
</div>
)}
</DragOverlay>
</DndContext>
);
};
@@ -11,6 +11,26 @@
transform: scale(1.02) translateZ(0);
}
.pageSurface {
transition: background-color 0.4s ease;
}
.pageJustMoved {
animation: pageMovedHighlight 1.2s ease-out;
}
@keyframes pageMovedHighlight {
0% {
background-color: rgba(59, 130, 246, 0.32);
}
60% {
background-color: rgba(59, 130, 246, 0.12);
}
100% {
background-color: rgba(59, 130, 246, 0);
}
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}
@@ -30,28 +50,6 @@
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.pageMoving {
z-index: 10;
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
/* Multi-page drag indicator */
.multiDragIndicator {
position: fixed;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
}
/* Animations */
@keyframes pulse {
0%, 100% {
File diff suppressed because it is too large Load Diff
@@ -138,12 +138,12 @@ const PageEditorControls = ({
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
@@ -156,7 +156,7 @@ const PageEditorControls = ({
onClick={() => onRotate('left')}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -168,7 +168,7 @@ const PageEditorControls = ({
onClick={() => onRotate('right')}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -180,7 +180,7 @@ const PageEditorControls = ({
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -192,7 +192,7 @@ const PageEditorControls = ({
onClick={onSplit}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -204,7 +204,7 @@ const PageEditorControls = ({
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
@@ -8,12 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
import styles from '@app/components/pageEditor/PageEditor.module.css';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -22,11 +23,17 @@ interface PageThumbnailProps {
index: number;
totalPages: number;
originalFile?: File;
fileColorIndex: number;
selectedPageIds: string[];
selectionMode: boolean;
movingPage: number | null;
isAnimating: boolean;
isBoxSelected?: boolean;
clearBoxSelection?: () => void;
activeDragIds: string[];
justMoved?: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: () => void;
@@ -40,19 +47,25 @@ interface PageThumbnailProps {
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
zoomLevel?: number;
}
const PageThumbnail: React.FC<PageThumbnailProps> = ({
page,
index,
index: _index,
totalPages,
originalFile,
fileColorIndex,
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
isBoxSelected = false,
clearBoxSelection,
activeDragIds,
pageRefs,
dragHandleProps,
onReorderPages,
onTogglePage,
onExecuteCommand,
@@ -64,17 +77,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pdfDocument,
splitPositions,
onInsertFiles,
zoomLevel = 1.0,
justMoved = false,
}: PageThumbnailProps) => {
const [isDragging, setIsDragging] = useState(false);
const pageIndex = page.pageNumber - 1;
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const dragElementRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobile();
const lastClickTimeRef = useRef<number>(0);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const elementRef = useRef<HTMLDivElement | null>(null);
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Check if this page is currently being dragged
const isDragging = activeDragIds.includes(page.id);
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
@@ -131,63 +152,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
};
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
// Merge refs - combine our ref tracking with dnd-kit's ref
const mergedRef = useCallback((element: HTMLDivElement | null) => {
// Track in our refs map
elementRef.current = element;
if (element) {
pageRefs.current.set(page.id, element);
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
pageNumber: page.pageNumber,
pageId: page.id,
selectedPageIds: [page.id]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ location }) => {
setIsDragging(false);
if (location.current.dropTargets.length === 0) {
return;
}
const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data;
if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
if (targetIndex !== -1) {
onReorderPages(page.pageNumber, targetIndex, undefined);
}
}
}
});
element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: (_) => {}
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
};
} else {
pageRefs.current.delete(page.id);
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
(dragElementRef.current as any).__dragCleanup();
}
}
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
// Call dnd-kit's ref if provided
if (dragHandleProps?.ref) {
dragHandleProps.ref(element);
}
}, [page.id, pageRefs, dragHandleProps]);
// DOM command handlers
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
@@ -216,13 +196,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(index);
const command = createSplitCommand(pageIndex);
onExecuteCommand(command);
const hasSplit = splitPositions.has(index);
const hasSplit = splitPositions.has(pageIndex);
const action = hasSplit ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after position ${index + 1}`);
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@@ -231,9 +211,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[], insertAfterPage?: number) => {
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage);
onInsertFiles(files, insertAfterPage, isFromStorage);
}
}
});
@@ -263,14 +243,28 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 5 pixels, consider it a click (not a drag)
if (distance < 5 && !isDragging) {
onTogglePage(page.id);
// If mouse moved less than 2 pixels, consider it a click (not a drag)
if (distance < 2 && !isDragging) {
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
const now = Date.now();
if (now - lastClickTimeRef.current > 100) {
lastClickTimeRef.current = now;
// Clear box selection when clicking on a non-selected page
if (!isBoxSelected && clearBoxSelection) {
clearBoxSelection();
}
// Don't toggle page selection if it's box-selected (just keep the box selection)
if (!isBoxSelected) {
onTogglePage(page.id);
}
}
}
setIsMouseDown(false);
setMouseStartPos(null);
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
@@ -278,6 +272,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
setIsHovered(false);
}, []);
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
// Spread dragHandleProps but use our merged ref
const { ref: _, ...restDragProps } = dragHandleProps || {};
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
@@ -286,14 +285,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
label: 'Move Left',
onClick: (e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
if (pageIndex > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, index - 1);
onReorderPages(page.pageNumber, pageIndex - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
},
disabled: index === 0
disabled: pageIndex === 0
},
{
id: 'move-right',
@@ -301,14 +300,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
label: 'Move Right',
onClick: (e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, index + 1);
// ReorderPagesCommand expects target index relative to the original array.
// When moving toward the right (higher index), provide desiredIndex + 1
// so the command's internal adjustment (targetIndex - 1) lands correctly.
onReorderPages(page.pageNumber, pageIndex + 2);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
},
disabled: index === totalPages - 1
disabled: pageIndex === totalPages - 1
},
{
id: 'rotate-left',
@@ -334,7 +336,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
label: 'Split After',
onClick: handleSplit,
hidden: index >= totalPages - 1,
hidden: pageIndex >= totalPages - 1,
},
{
id: 'insert',
@@ -342,11 +344,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
label: 'Insert File After',
onClick: handleInsertFileAfter,
}
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
], [pageIndex, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
return (
<div
ref={pageElementRef}
ref={mergedRef}
{...restDragProps}
data-page-id={page.id}
data-page-number={page.pageNumber}
className={`
@@ -354,24 +357,25 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
!rounded-lg
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
select-none
w-[20rem]
h-[20rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
`}
style={{
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
zIndex: isHovered ? 50 : 1,
...(isBoxSelected && {
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
}),
}}
draggable={false}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={() => setIsHovered(true)}
@@ -414,12 +418,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
padding: 4,
display: 'flex',
alignItems: 'center',
@@ -440,11 +445,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
backgroundColor: 'white',
border: '1px solid #e9ecef',
borderRadius: 2
}}></div>
}} />
</div>
) : thumbnailUrl ? (
<PrivateContent>
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
@@ -476,7 +482,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
position: 'absolute',
top: 5,
left: 5,
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
background: 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
@@ -1,5 +1,5 @@
import { FileId } from '@app/types/file';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { PDFDocument, PDFPage, PageBreakSettings } from '@app/types/pageEditor';
// V1-style DOM-first command system (replaces the old React state commands)
export abstract class DOMCommand {
@@ -67,7 +67,7 @@ export class DeletePagesCommand extends DOMCommand {
private pagesToDelete: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private setSelectedPageIds: (pageIds: string[]) => void,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[],
@@ -99,6 +99,13 @@ export class DeletePagesCommand extends DOMCommand {
this.hasExecuted = true;
}
const selectedPageNumbersBefore = this.getSelectedPages();
const selectedIdSet = new Set(
selectedPageNumbersBefore
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id)
.filter((id): id is string => Boolean(id))
);
// Filter out deleted pages by ID (stable across undo/redo)
const remainingPages = currentDoc.pages.filter(page =>
!this.pageIdsToDelete.includes(page.id)
@@ -106,7 +113,7 @@ export class DeletePagesCommand extends DOMCommand {
if (remainingPages.length === 0) {
// If all pages would be deleted, clear selection/splits and close PDF
this.setSelectedPages([]);
this.setSelectedPageIds([]);
this.setSplitPositions(new Set());
this.onAllPagesDeleted?.();
return;
@@ -135,7 +142,12 @@ export class DeletePagesCommand extends DOMCommand {
// Apply changes
this.setDocument(updatedDocument);
this.setSelectedPages([]);
const remainingSelectedPageIds = remainingPages
.filter((page) => selectedIdSet.has(page.id))
.map((page) => page.id);
this.setSelectedPageIds(remainingSelectedPageIds);
this.setSplitPositions(newPositions);
}
@@ -145,7 +157,12 @@ export class DeletePagesCommand extends DOMCommand {
// Simply restore the complete original document state
this.setDocument(this.originalDocument);
this.setSplitPositions(this.originalSplitPositions);
this.setSelectedPages(this.originalSelectedPages);
const restoredIds = this.originalSelectedPages
.map((pageNum) =>
this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || ""
)
.filter((id) => id !== "");
this.setSelectedPageIds(restoredIds);
}
get description(): string {
@@ -161,7 +178,8 @@ export class ReorderPagesCommand extends DOMCommand {
private targetIndex: number,
private selectedPages: number[] | undefined,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
private setDocument: (doc: PDFDocument) => void,
private onReorderComplete?: (newPages: PDFPage[]) => void
) {
super();
}
@@ -196,7 +214,13 @@ export class ReorderPagesCommand extends DOMCommand {
} else {
// Single page reorder
const [movedPage] = newPages.splice(sourceIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
// Adjust target index if moving forward (after removal, indices shift)
const adjustedTargetIndex = sourceIndex < this.targetIndex
? this.targetIndex - 1
: this.targetIndex;
newPages.splice(adjustedTargetIndex, 0, movedPage);
newPages.forEach((page, index) => {
page.pageNumber = index + 1;
@@ -210,6 +234,11 @@ export class ReorderPagesCommand extends DOMCommand {
};
this.setDocument(reorderedDocument);
// Notify that reordering is complete
if (this.onReorderComplete) {
this.onReorderComplete(newPages);
}
}
undo(): void {
@@ -408,6 +437,8 @@ export class SplitAllCommand extends DOMCommand {
}
}
// PageBreakSettings, PageSize, and PageOrientation are now imported from pageEditor.ts
export class PageBreakCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
@@ -415,7 +446,8 @@ export class PageBreakCommand extends DOMCommand {
constructor(
private selectedPageNumbers: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
private setDocument: (doc: PDFDocument) => void,
private settings?: PageBreakSettings
) {
super();
}
@@ -450,7 +482,8 @@ export class PageBreakCommand extends DOMCommand {
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: true // Custom flag for blank pages
isBlankPage: true, // Custom flag for blank pages
pageBreakSettings: this.settings // Store settings for export
};
newPages.push(blankPage);
this.insertedPages.push(blankPage);
@@ -883,6 +916,10 @@ export class UndoManager {
return this.redoStack.length > 0;
}
hasHistory(): boolean {
return this.undoStack.length > 0;
}
clear(): void {
this.undoStack = [];
this.redoStack = [];
@@ -0,0 +1,61 @@
/**
* File color palette for page editor
* Each file gets a distinct color for visual organization
* Colors are applied at 0.3 opacity for subtle highlighting
* Maximum 20 files supported in page editor
*/
export const FILE_COLORS = [
// Subtle colors (1-6) - fit well with UI theme
'rgb(59, 130, 246)', // Blue
'rgb(16, 185, 129)', // Green
'rgb(139, 92, 246)', // Purple
'rgb(6, 182, 212)', // Cyan
'rgb(20, 184, 166)', // Teal
'rgb(99, 102, 241)', // Indigo
// Mid-range colors (7-12) - more distinct
'rgb(244, 114, 182)', // Pink
'rgb(251, 146, 60)', // Orange
'rgb(234, 179, 8)', // Yellow
'rgb(132, 204, 22)', // Lime
'rgb(248, 113, 113)', // Red
'rgb(168, 85, 247)', // Violet
// Vibrant colors (13-20) - maximum distinction
'rgb(236, 72, 153)', // Fuchsia
'rgb(245, 158, 11)', // Amber
'rgb(34, 197, 94)', // Emerald
'rgb(14, 165, 233)', // Sky
'rgb(239, 68, 68)', // Rose
'rgb(168, 162, 158)', // Stone
'rgb(251, 191, 36)', // Gold
'rgb(192, 132, 252)', // Light Purple
] as const;
export const MAX_PAGE_EDITOR_FILES = 20;
/**
* Get color for a file by its index
* @param index - Zero-based file index
* @returns RGB color string
*/
export function getFileColor(index: number): string {
if (index < 0 || index >= FILE_COLORS.length) {
console.warn(`File index ${index} out of range, using default color`);
return FILE_COLORS[0];
}
return FILE_COLORS[index];
}
/**
* Get color with specified opacity
* @param index - Zero-based file index
* @param opacity - Opacity value (0-1), defaults to 0.3
* @returns RGBA color string
*/
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
const rgb = getFileColor(index);
// Convert rgb(r, g, b) to rgba(r, g, b, a)
return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`);
}
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FileId } from "@app/types/file";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
interface UseEditedDocumentStateParams {
initialDocument: PDFDocument | null;
mergedPdfDocument: PDFDocument | null;
reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void;
fileOrder: FileId[];
updateCurrentPages: (pages: PDFPage[] | null) => void;
}
export const useEditedDocumentState = ({
initialDocument,
mergedPdfDocument,
reorderedPages,
clearReorderedPages,
fileOrder,
updateCurrentPages,
}: UseEditedDocumentStateParams) => {
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const editedDocumentRef = useRef<PDFDocument | null>(null);
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
const lastSyncedSignatureRef = useRef<string | null>(null);
// Clone the initial document once so we can safely mutate working state
useEffect(() => {
if (!initialDocument || editedDocument) return;
setEditedDocument({
...initialDocument,
pages: initialDocument.pages.map((page) => ({ ...page })),
});
}, [initialDocument, editedDocument]);
// Apply reorders triggered elsewhere in the editor
useEffect(() => {
if (!reorderedPages || !editedDocument) return;
setEditedDocument({
...editedDocument,
pages: reorderedPages,
});
clearReorderedPages();
}, [reorderedPages, editedDocument, clearReorderedPages]);
// Keep ref synced so effects can read latest without re-running
useEffect(() => {
editedDocumentRef.current = editedDocument;
}, [editedDocument]);
// Cache page positions to help future insertions preserve intent
useEffect(() => {
if (!editedDocument) return;
const positionCache = pagePositionCacheRef.current;
const neighborCache = pageNeighborCacheRef.current;
const pages = editedDocument.pages;
pages.forEach((page, index) => {
positionCache.set(page.id, index);
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
});
}, [editedDocument]);
const fileOrderKey = useMemo(() => fileOrder.join(","), [fileOrder]);
const mergedDocSignature = useMemo(() => {
if (!mergedPdfDocument?.pages) return "";
return mergedPdfDocument.pages.map((page) => page.id).join(",");
}, [mergedPdfDocument]);
useEffect(() => {
if (!mergedPdfDocument) {
lastSyncedSignatureRef.current = null;
}
}, [mergedPdfDocument]);
// Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing)
useEffect(() => {
const currentEditedDocument = editedDocumentRef.current;
if (!mergedPdfDocument || !currentEditedDocument) return;
const signatureChanged =
mergedDocSignature !== lastSyncedSignatureRef.current;
const metadataChanged =
currentEditedDocument.id !== mergedPdfDocument.id ||
currentEditedDocument.file !== mergedPdfDocument.file ||
currentEditedDocument.name !== mergedPdfDocument.name;
if (!signatureChanged && !metadataChanged) return;
setEditedDocument((prev) => {
if (!prev) return prev;
let pages = prev.pages;
if (signatureChanged) {
const sourcePages = mergedPdfDocument.pages;
const sourceIds = new Set(sourcePages.map((p) => p.id));
const prevIds = new Set(prev.pages.map((p) => p.id));
const newPages: PDFPage[] = [];
for (const page of sourcePages) {
if (!prevIds.has(page.id)) {
newPages.push(page);
}
}
const hasAdditions = newPages.length > 0;
const isEphemeralPage = (page: PDFPage) =>
Boolean(page.isBlankPage || page.isPlaceholder);
let hasRemovals = false;
for (const page of prev.pages) {
if (!sourceIds.has(page.id) && !isEphemeralPage(page)) {
hasRemovals = true;
break;
}
}
if (hasAdditions || hasRemovals) {
pages = [...prev.pages];
const placeholderPositions = new Map<FileId, number>();
pages.forEach((page, index) => {
if (page.isPlaceholder && page.originalFileId) {
placeholderPositions.set(page.originalFileId, index);
}
});
const nextInsertIndexByFile = new Map(placeholderPositions);
if (hasRemovals) {
pages = pages.filter(
(page) => sourceIds.has(page.id) || isEphemeralPage(page)
);
}
if (hasAdditions) {
const mergedIndexMap = new Map<string, number>();
sourcePages.forEach((page, index) =>
mergedIndexMap.set(page.id, index)
);
const additions = newPages
.map((page) => ({
page,
cachedIndex: pagePositionCacheRef.current.get(page.id),
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
neighborId: pageNeighborCacheRef.current.get(page.id),
}))
.sort((a, b) => {
const aIndex = a.cachedIndex ?? a.mergedIndex;
const bIndex = b.cachedIndex ?? b.mergedIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
return a.mergedIndex - b.mergedIndex;
});
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
if (pages.some((existing) => existing.id === page.id)) {
return;
}
let insertIndex: number;
const originalFileId = page.originalFileId;
const placeholderIndex =
originalFileId !== undefined
? nextInsertIndexByFile.get(originalFileId)
: undefined;
if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length);
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
} else if (neighborId === null) {
insertIndex = 0;
} else if (neighborId) {
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage);
});
}
pages = pages.map((page, index) => ({
...page,
pageNumber: index + 1,
}));
}
}
const shouldReplaceBase = metadataChanged || signatureChanged;
const baseDocument = shouldReplaceBase
? {
...mergedPdfDocument,
destroy: prev.destroy,
}
: prev;
if (baseDocument === prev && pages === prev.pages) {
return prev;
}
return {
...baseDocument,
pages,
totalPages: pages.length,
};
});
if (signatureChanged) {
lastSyncedSignatureRef.current = mergedDocSignature;
}
}, [mergedPdfDocument, fileOrderKey, mergedDocSignature]);
const displayDocument = editedDocument || initialDocument;
const getEditedDocument = useCallback(
() => editedDocumentRef.current,
[]
);
useEffect(() => {
updateCurrentPages(displayDocument?.pages ?? null);
}, [displayDocument, updateCurrentPages]);
return {
editedDocument,
setEditedDocument,
displayDocument,
getEditedDocument,
};
};
export type UseEditedDocumentStateReturn = ReturnType<
typeof useEditedDocumentState
>;
@@ -0,0 +1,420 @@
import { useCallback } from "react";
import {
BulkRotateCommand,
DeletePagesCommand,
PageBreakCommand,
ReorderPagesCommand,
SplitCommand,
} from "@app/components/pageEditor/commands/pageCommands";
import type {
useFileActions,
useFileState,
} from "@app/contexts/FileContext";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
type FileActions = ReturnType<typeof useFileActions>["actions"];
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
interface UsePageEditorCommandsParams {
displayDocument: PDFDocument | null;
getEditedDocument: () => PDFDocument | null;
setEditedDocument: React.Dispatch<React.SetStateAction<PDFDocument | null>>;
splitPositions: Set<number>;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
getPageNumbersFromIds: (pageIds: string[]) => number[];
executeCommandWithTracking: (command: any) => void;
updateFileOrderFromPages: (pages: PDFPage[]) => void;
actions: FileActions;
selectors: FileSelectors;
setSelectionMode: (enabled: boolean) => void;
clearUndoHistory: () => void;
}
export const usePageEditorCommands = ({
displayDocument,
getEditedDocument,
setEditedDocument,
splitPositions,
setSplitPositions,
selectedPageIds,
setSelectedPageIds,
getPageNumbersFromIds,
executeCommandWithTracking,
updateFileOrderFromPages,
actions,
selectors,
setSelectionMode,
clearUndoHistory,
}: UsePageEditorCommandsParams) => {
const closePdf = useCallback(() => {
actions.clearAllFiles();
clearUndoHistory();
setSelectedPageIds([]);
setSelectionMode(false);
}, [actions, clearUndoHistory, setSelectedPageIds, setSelectionMode]);
const handleRotatePages = useCallback(
(pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
},
[executeCommandWithTracking]
);
const createRotateCommand = useCallback(
(pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
},
}),
[executeCommandWithTracking]
);
const createDeleteCommand = useCallback(
(pageIds: string[]) => ({
execute: () => {
const currentDocument = getEditedDocument();
if (!currentDocument) return;
const pagesToDelete = pageIds
.map((pageId) => {
const page = currentDocument.pages.find((p) => p.id === pageId);
return page?.pageNumber || 0;
})
.filter((num) => num > 0);
if (pagesToDelete.length > 0) {
const deleteCommand = new DeletePagesCommand(
pagesToDelete,
getEditedDocument,
setEditedDocument,
setSelectedPageIds,
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
() => closePdf()
);
executeCommandWithTracking(deleteCommand);
}
},
}),
[
closePdf,
executeCommandWithTracking,
getEditedDocument,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]
);
const createSplitCommand = useCallback(
(position: number) => ({
execute: () => {
const splitCommand = new SplitCommand(
position,
() => splitPositions,
setSplitPositions
);
executeCommandWithTracking(splitCommand);
},
}),
[splitPositions, executeCommandWithTracking, setSplitPositions]
);
const executeCommand = useCallback((command: any) => {
if (command && typeof command.execute === "function") {
command.execute();
}
}, []);
const handleRotate = useCallback(
(direction: "left" | "right") => {
if (!displayDocument || selectedPageIds.length === 0) return;
const rotation = direction === "left" ? -90 : 90;
handleRotatePages(selectedPageIds, rotation);
},
[displayDocument, selectedPageIds, handleRotatePages]
);
const handleDelete = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const deleteCommand = new DeletePagesCommand(
selectedPageNumbers,
getEditedDocument,
setEditedDocument,
setSelectedPageIds,
() => splitPositions,
setSplitPositions,
() => selectedPageNumbers,
() => closePdf()
);
executeCommandWithTracking(deleteCommand);
}, [
closePdf,
displayDocument,
executeCommandWithTracking,
getEditedDocument,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]);
const handleDeletePage = useCallback(
(pageNumber: number) => {
if (!displayDocument) return;
const deleteCommand = new DeletePagesCommand(
[pageNumber],
getEditedDocument,
setEditedDocument,
setSelectedPageIds,
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
() => closePdf()
);
executeCommandWithTracking(deleteCommand);
},
[
closePdf,
getEditedDocument,
executeCommandWithTracking,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]
);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const selectedPositions: number[] = [];
selectedPageNumbers.forEach((pageNum) => {
const pageIndex = displayDocument.pages.findIndex(
(p) => p.pageNumber === pageNum
);
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
selectedPositions.push(pageIndex);
}
});
if (selectedPositions.length === 0) return;
const existingSplitsCount = selectedPositions.filter((pos) =>
splitPositions.has(pos)
).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
selectedPositions.forEach((pos) => newSplitPositions.delete(pos));
} else {
selectedPositions.forEach((pos) => newSplitPositions.add(pos));
}
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`,
};
executeCommandWithTracking(smartSplitCommand);
}, [
selectedPageIds,
displayDocument,
splitPositions,
setSplitPositions,
getPageNumbersFromIds,
executeCommandWithTracking,
]);
const handleSplitAll = handleSplit;
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
getEditedDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [
displayDocument,
executeCommandWithTracking,
getEditedDocument,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
]);
const handlePageBreakAll = handlePageBreak;
const handleInsertFiles = useCallback(
async (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean
) => {
const workingDocument = getEditedDocument();
if (!workingDocument || files.length === 0) return;
try {
const targetPage = workingDocument.pages.find(
(p) => p.pageNumber === insertAfterPage
);
if (!targetPage) return;
const insertAfterPageId = targetPage.id;
let addedFileIds: FileId[] = [];
if (isFromStorage) {
const stubs = files as StirlingFileStub[];
const result = await actions.addStirlingFileStubs(stubs, {
selectFiles: true,
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
} else {
const result = await actions.addFiles(files as File[], {
selectFiles: true,
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
}
await new Promise((resolve) => setTimeout(resolve, 100));
const newPages: PDFPage[] = [];
for (const fileId of addedFileIds) {
const stub = selectors.getStirlingFileStub(fileId);
if (stub?.processedFile?.pages) {
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
...page,
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
pageNumber: page.pageNumber ?? idx + 1,
originalFileId: fileId,
originalPageNumber:
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
rotation: page.rotation ?? 0,
thumbnail: page.thumbnail ?? null,
selected: false,
splitAfter: page.splitAfter ?? false,
}));
newPages.push(...clonedPages);
}
}
if (newPages.length > 0) {
const targetIndex = workingDocument.pages.findIndex(
(p) => p.id === targetPage.id
);
if (targetIndex >= 0) {
const updatedPages = [...workingDocument.pages];
updatedPages.splice(targetIndex + 1, 0, ...newPages);
updatedPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
setEditedDocument({
...workingDocument,
pages: updatedPages,
});
updateFileOrderFromPages(updatedPages);
}
}
} catch (error) {
console.error("Failed to insert files:", error);
}
},
[
getEditedDocument,
actions,
selectors,
updateFileOrderFromPages,
setEditedDocument,
]
);
const handleReorderPages = useCallback(
(
sourcePageNumber: number,
targetIndex: number,
draggedPageIds?: string[]
) => {
if (!displayDocument) return;
const selectedPages = draggedPageIds
? getPageNumbersFromIds(draggedPageIds)
: undefined;
const reorderCommand = new ReorderPagesCommand(
sourcePageNumber,
targetIndex,
selectedPages,
getEditedDocument,
setEditedDocument,
(newPages) => updateFileOrderFromPages(newPages)
);
executeCommandWithTracking(reorderCommand);
},
[
displayDocument,
getEditedDocument,
executeCommandWithTracking,
getPageNumbersFromIds,
setEditedDocument,
updateFileOrderFromPages,
]
);
return {
createRotateCommand,
createDeleteCommand,
createSplitCommand,
executeCommand,
handleRotate,
handleDelete,
handleDeletePage,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleInsertFiles,
handleReorderPages,
closePdf,
};
};
export type UsePageEditorCommandsReturn = ReturnType<
typeof usePageEditorCommands
>;
@@ -0,0 +1,33 @@
import { useMemo, useRef } from 'react';
import { FileId } from '@app/types/file';
/**
* Maintains stable color assignments for a collection of file IDs.
* Colors are assigned by insertion order and preserved across reorders.
*/
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
const assignmentsRef = useRef(new Map<FileId, number>());
const serializedIds = useMemo(() => fileIds.join(','), [fileIds]);
return useMemo(() => {
const assignments = assignmentsRef.current;
const activeIds = new Set(fileIds);
// Remove colors for files that no longer exist
for (const id of Array.from(assignments.keys())) {
if (!activeIds.has(id)) {
assignments.delete(id);
}
}
// Assign colors to any new files
fileIds.forEach((id) => {
if (!assignments.has(id)) {
assignments.set(id, assignments.size);
}
});
return assignments;
}, [serializedIds, fileIds]);
}
@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
import { PDFDocument } from '@app/types/pageEditor';
/**
* Hook that calls usePageDocument but only returns the FIRST non-null result
* After initialization, it ignores all subsequent updates
*/
export function useInitialPageDocument(): PDFDocument | null {
const { document: liveDocument } = usePageDocument();
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
useEffect(() => {
// Only set once when we get the first non-null document
if (liveDocument && !initialDocument) {
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
setInitialDocument(liveDocument);
}
}, [liveDocument, initialDocument]);
return initialDocument;
}
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useFileState } from '@app/contexts/FileContext';
import { usePageEditor } from '@app/contexts/PageEditorContext';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { FileId } from '@app/types/file';
@@ -15,14 +16,40 @@ export interface PageDocumentHook {
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
const { fileOrder, currentPages } = usePageEditor();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
// Use PageEditorContext's fileOrder instead of FileContext's global order
// This ensures the page editor respects its own workspace ordering
const allFileIds = fileOrder;
// Stable signature for effects (prevents loops)
// Derive selected file IDs directly from FileContext (single source of truth)
// Filter to only include PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
const allFileIdsKey = allFileIds.join(',');
const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const activeFilesSignature = selectors.getFilesSignature();
// Get ALL PDF files (selected or not) for document building with placeholders
const activeFileIds = useMemo(() => {
return allFileIds.filter(id => {
const stub = selectors.getStirlingFileStub(id);
return stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
});
}, [allFileIdsKey, activeFilesSignature, selectors]);
const selectedActiveFileIds = useMemo(() => {
if (activeFileIds.length === 0) {
return [];
}
const selectedSet = new Set(state.ui.selectedFileIds);
if (selectedSet.size === 0) {
return [];
}
return activeFileIds.filter((id) => selectedSet.has(id));
}, [activeFileIds, selectedFileIdsKey]);
const primaryFileId = selectedActiveFileIds[0] ?? activeFileIds[0] ?? null;
// UI state
const globalProcessing = state.ui.isProcessing;
@@ -32,6 +59,10 @@ export function usePageDocument(): PageDocumentHook {
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops)
const currentPagesSignature = useMemo(() => {
return currentPages ? currentPages.map(page => page.id).join(',') : '';
}, [currentPages]);
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
@@ -43,10 +74,14 @@ export function usePageDocument(): PageDocumentHook {
return null;
}
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
const name =
activeFileIds.length === 1
? (primaryStirlingFileStub.name ?? 'document.pdf')
: activeFileIds
namingFileIds.length <= 1
? (namingFileIds[0]
? selectors.getStirlingFileStub(namingFileIds[0])?.name ?? 'document.pdf'
: 'document.pdf')
: namingFileIds
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
@@ -69,13 +104,28 @@ export function usePageDocument(): PageDocumentHook {
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
// Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
// Helper function to create pages from a file (or placeholder if deselected)
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!stirlingFileStub) {
return [];
}
// If file is deselected, create a single placeholder page
if (!isSelected) {
return [{
id: `${fileId}-placeholder`,
pageNumber: startPageNumber,
originalPageNumber: 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null,
selected: false,
splitAfter: false,
isPlaceholder: true,
}];
}
const processedFile = stirlingFileStub.processedFile;
let filePages: PDFPage[] = [];
@@ -90,6 +140,7 @@ export function usePageDocument(): PageDocumentHook {
splitAfter: page.splitAfter || false,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
isPlaceholder: false,
}));
} else if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
@@ -102,16 +153,30 @@ export function usePageDocument(): PageDocumentHook {
thumbnail: null,
selected: false,
splitAfter: false,
isPlaceholder: false,
}));
}
return filePages;
};
// Collect all pages from original files (without renumbering yet)
// Collect all pages from original files, respecting their previous positions
const selectedFileIdsSet = new Set(state.ui.selectedFileIds);
// Sort original files by their position in fileOrder (so placeholders stay in correct spot)
// Use fileOrder as source of truth since it persists across page editor sessions
const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index]));
const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => {
const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER;
const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER;
return posA - posB;
});
const originalFilePages: PDFPage[] = [];
originalFileIds.forEach(fileId => {
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
sortedOriginalFileIds.forEach(fileId => {
const isSelected = selectedFileIdsSet.has(fileId);
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
originalFilePages.push(...filePages);
});
@@ -130,7 +195,8 @@ export function usePageDocument(): PageDocumentHook {
// Collect all pages to insert
const allNewPages: PDFPage[] = [];
fileIds.forEach(fileId => {
const insertedPages = createPagesFromFile(fileId, 1);
const isSelected = selectedFileIdsSet.has(fileId);
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
allNewPages.push(...insertedPages);
});
@@ -147,6 +213,29 @@ export function usePageDocument(): PageDocumentHook {
return null;
}
// Pages are already in the correct order from the sorted assembly above
// Just ensure page numbers are sequential
pages = pages.map((page, index) => ({
...page,
pageNumber: index + 1,
}));
const currentPagesSet = currentPages ? new Set(currentPages.map(page => page.id)) : null;
if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) {
const sameIds = pages.every(page => currentPagesSet.has(page.id));
if (sameIds) {
const mergedById = new Map(pages.map(page => [page.id, page]));
pages = currentPages.map((currentPage, index) => {
const source = mergedById.get(currentPage.id);
const mergedPage = source ? { ...source, ...currentPage } : currentPage;
return {
...mergedPage,
pageNumber: index + 1,
};
});
}
}
const mergedDoc: PDFDocument = {
id: activeFileIds.join('-'),
name,
@@ -156,7 +245,7 @@ export function usePageDocument(): PageDocumentHook {
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
}, [activeFileIds, selectedActiveFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds, currentPagesSignature, currentPages]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {
@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { usePageEditor } from '@app/contexts/PageEditorContext';
import { useFileState } from '@app/contexts/FileContext';
import { FileId } from '@app/types/file';
import { useFileColorMap } from '@app/components/pageEditor/hooks/useFileColorMap';
export interface PageEditorDropdownFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
export interface PageEditorDropdownState {
files: PageEditorDropdownFile[];
selectedCount: number;
totalCount: number;
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
fileColorMap: Map<FileId, number>;
}
const isPdf = (name?: string | null) =>
typeof name === 'string' && name.toLowerCase().endsWith('.pdf');
export function usePageEditorDropdownState(): PageEditorDropdownState {
const { state, selectors } = useFileState();
const {
toggleFileSelection,
reorderFiles,
fileOrder,
} = usePageEditor();
const pageEditorFiles = useMemo(() => {
return fileOrder
.map<PageEditorDropdownFile | null>((fileId) => {
const stub = selectors.getStirlingFileStub(fileId);
if (!isPdf(stub?.name)) return null;
return {
fileId,
name: stub?.name || '',
versionNumber: stub?.versionNumber,
isSelected: state.ui.selectedFileIds.includes(fileId),
};
})
.filter((file): file is PageEditorDropdownFile => file !== null);
}, [fileOrder, selectors, state.ui.selectedFileIds]);
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
const selectedCount = useMemo(
() => pageEditorFiles.filter((file) => file.isSelected).length,
[pageEditorFiles]
);
return useMemo<PageEditorDropdownState>(() => ({
files: pageEditorFiles,
selectedCount,
totalCount: pageEditorFiles.length,
onToggleSelection: toggleFileSelection,
onReorder: reorderFiles,
fileColorMap,
}), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]);
}
@@ -0,0 +1,313 @@
import { Dispatch, SetStateAction, useCallback } from "react";
import type {
useFileActions,
useFileState,
} from "@app/contexts/FileContext";
import { documentManipulationService } from "@app/services/documentManipulationService";
import { pdfExportService } from "@app/services/pdfExportService";
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
import { FileId } from "@app/types/file";
import { PDFDocument } from "@app/types/pageEditor";
type FileActions = ReturnType<typeof useFileActions>["actions"];
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
interface UsePageEditorExportParams {
displayDocument: PDFDocument | null;
selectedPageIds: string[];
splitPositions: Set<number>;
selectedFileIds: FileId[];
selectors: FileSelectors;
actions: FileActions;
setHasUnsavedChanges: (dirty: boolean) => void;
exportLoading: boolean;
setExportLoading: (loading: boolean) => void;
setSplitPositions: Dispatch<SetStateAction<Set<number>>>;
}
const removePlaceholderPages = (document: PDFDocument): PDFDocument => {
const filteredPages = document.pages.filter((page) => !page.isPlaceholder);
if (filteredPages.length === document.pages.length) {
return document;
}
const normalizedPages = filteredPages.map((page, index) => ({
...page,
pageNumber: index + 1,
}));
return {
...document,
pages: normalizedPages,
totalPages: normalizedPages.length,
};
};
const normalizeProcessedDocuments = (
processed: PDFDocument | PDFDocument[]
): PDFDocument | PDFDocument[] => {
if (Array.isArray(processed)) {
const normalized = processed
.map(removePlaceholderPages)
.filter((doc) => doc.pages.length > 0);
return normalized;
}
return removePlaceholderPages(processed);
};
export const usePageEditorExport = ({
displayDocument,
selectedPageIds,
splitPositions,
selectedFileIds,
selectors,
actions,
setHasUnsavedChanges,
exportLoading,
setExportLoading,
setSplitPositions,
}: UsePageEditorExportParams) => {
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<FileId, File>();
selectedFileIds.forEach((fileId) => {
const file = selectors.getFile(fileId);
if (file) {
sourceFiles.set(fileId, file);
}
});
const hasInsertedFiles = false;
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null;
}
return sourceFiles.size > 0 ? sourceFiles : null;
}, [selectedFileIds, selectors]);
const getExportFilename = useCallback((): string => {
if (selectedFileIds.length <= 1) {
return displayDocument?.name || "document.pdf";
}
const firstFile = selectors.getFile(selectedFileIds[0]);
if (firstFile) {
const baseName = firstFile.name.replace(/\.pdf$/i, "");
return `${baseName} (merged).pdf`;
}
return "merged-document.pdf";
}, [selectedFileIds, selectors, displayDocument]);
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageIds.length === 0) return;
setExportLoading(true);
try {
const processedDocuments =
documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
const documentWithDOMState = Array.isArray(normalizedDocuments)
? normalizedDocuments[0]
: normalizedDocuments;
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) {
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
setExportLoading(false);
return;
}
const validSelectedPageIds = selectedPageIds.filter((pageId) =>
documentWithDOMState.pages.some((page) => page.id === pageId)
);
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
documentWithDOMState,
sourceFiles,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
)
: await pdfExportService.exportPDF(
documentWithDOMState,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
);
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
} catch (error) {
console.error("Export failed:", error);
setExportLoading(false);
}
}, [
displayDocument,
selectedPageIds,
splitPositions,
getSourceFiles,
getExportFilename,
setHasUnsavedChanges,
setExportLoading,
]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
const processedDocuments =
documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
if (
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
) {
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
setExportLoading(false);
return;
}
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(
normalizedDocuments,
sourceFiles,
exportFilename
);
if (files.length > 1) {
const JSZip = await import("jszip");
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
});
const zipBlob = await zip.generateAsync({ type: "blob" });
const zipFilename = exportFilename.replace(/\.pdf$/i, ".zip");
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
const file = files[0];
pdfExportService.downloadFile(file, file.name);
}
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
} catch (error) {
console.error("Export failed:", error);
setExportLoading(false);
}
}, [
displayDocument,
splitPositions,
getSourceFiles,
getExportFilename,
setHasUnsavedChanges,
setExportLoading,
]);
const applyChanges = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
const processedDocuments =
documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
if (
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
) {
console.warn("Apply changes skipped: no concrete pages available after filtering placeholders.");
setExportLoading(false);
return;
}
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(
normalizedDocuments,
sourceFiles,
exportFilename
);
// Add "_multitool" suffix to filenames
const renamedFiles = files.map(file => {
const nameParts = file.name.match(/^(.+?)(\.pdf)$/i);
if (nameParts) {
const baseName = nameParts[1];
const extension = nameParts[2];
const newName = `${baseName}_multitool${extension}`;
return new File([file], newName, { type: file.type });
}
return file;
});
// Store source file IDs before adding new files
const sourceFileIds = [...selectedFileIds];
const newStirlingFiles = await actions.addFiles(renamedFiles, {
selectFiles: true,
});
if (newStirlingFiles.length > 0) {
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
}
// Remove source files from context
if (sourceFileIds.length > 0) {
await actions.removeFiles(sourceFileIds, true);
}
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
} catch (error) {
console.error("Apply changes failed:", error);
setExportLoading(false);
}
}, [
displayDocument,
splitPositions,
getSourceFiles,
getExportFilename,
actions,
selectedFileIds,
setHasUnsavedChanges,
setExportLoading,
]);
return {
exportLoading,
onExportSelected,
onExportAll,
applyChanges,
};
};
export type UsePageEditorExportReturn = ReturnType<typeof usePageEditorExport>;
@@ -1,26 +1,27 @@
import { useState, useCallback } from 'react';
import React from 'react';
export interface PageEditorState {
// Selection state
selectionMode: boolean;
selectedPageIds: string[];
// Animation state
// Animation state
movingPage: number | null;
isAnimating: boolean;
// Split state
splitPositions: Set<number>;
// Export state
exportLoading: boolean;
// Actions
setSelectionMode: (mode: boolean) => void;
setSelectedPageIds: (pages: string[]) => void;
setMovingPage: (pageNumber: number | null) => void;
setIsAnimating: (animating: boolean) => void;
setSplitPositions: (positions: Set<number>) => void;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
setExportLoading: (loading: boolean) => void;
// Helper functions
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { PDFDocument } from "@app/types/pageEditor";
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
interface UsePageSelectionManagerParams {
displayDocument: PDFDocument | null;
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
setSelectionMode: (enabled: boolean) => void;
toggleSelectAll: (ids: string[]) => void;
activeFilesSignature: string;
}
export const usePageSelectionManager = ({
displayDocument,
selectedPageIds,
setSelectedPageIds,
setSelectionMode,
toggleSelectAll,
activeFilesSignature,
}: UsePageSelectionManagerParams) => {
const [csvInput, setCsvInput] = useState<string>("");
const hasInitializedSelection = useRef(false);
const previousPageIdsRef = useRef<Set<string>>(new Set());
const totalPages = displayDocument?.pages.length ?? 0;
const getPageNumbersFromIds = useCallback(
(pageIds: string[]) => {
if (!displayDocument) return [];
return pageIds
.map((id) => {
const page = displayDocument.pages.find((p) => p.id === id);
return page?.pageNumber || 0;
})
.filter((num) => num > 0);
},
[displayDocument]
);
const getPageIdsFromNumbers = useCallback(
(pageNumbers: number[]) => {
if (!displayDocument) return [];
return pageNumbers
.map((num) => {
const page = displayDocument.pages.find((p) => p.pageNumber === num);
return page?.id || "";
})
.filter((id) => id !== "");
},
[displayDocument]
);
useEffect(() => {
if (
displayDocument &&
displayDocument.pages.length > 0 &&
!hasInitializedSelection.current
) {
const allPageIds = displayDocument.pages.map((page) => page.id);
setSelectedPageIds(allPageIds);
setSelectionMode(true);
hasInitializedSelection.current = true;
}
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) {
previousPageIdsRef.current = new Set();
return;
}
const currentIds = new Set(displayDocument.pages.map((page) => page.id));
const newlyAddedPageIds: string[] = [];
currentIds.forEach((id) => {
if (!previousPageIdsRef.current.has(id)) {
newlyAddedPageIds.push(id);
}
});
if (newlyAddedPageIds.length > 0) {
const next = new Set(selectedPageIds);
newlyAddedPageIds.forEach((id) => next.add(id));
setSelectedPageIds(Array.from(next));
}
previousPageIdsRef.current = currentIds;
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
useEffect(() => {
setCsvInput("");
}, [activeFilesSignature]);
const handleSelectAll = useCallback(() => {
if (!displayDocument) return;
const allPageIds = displayDocument.pages.map((page) => page.id);
toggleSelectAll(allPageIds);
}, [displayDocument, toggleSelectAll]);
const handleDeselectAll = useCallback(() => {
setSelectedPageIds([]);
}, [setSelectedPageIds]);
const handleSetSelectedPages = useCallback(
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
[getPageIdsFromNumbers, setSelectedPageIds]
);
const updatePagesFromCSV = useCallback(
(override?: string) => {
if (totalPages === 0) return;
const normalized = parseSelection(override ?? csvInput, totalPages);
handleSetSelectedPages(normalized);
},
[csvInput, totalPages, handleSetSelectedPages]
);
return {
csvInput,
setCsvInput,
totalPages,
getPageNumbersFromIds,
getPageIdsFromNumbers,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
updatePagesFromCSV,
};
};
export type UsePageSelectionManagerReturn = ReturnType<
typeof usePageSelectionManager
>;
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { UndoManager } from "@app/components/pageEditor/commands/pageCommands";
interface UseUndoManagerStateParams {
setHasUnsavedChanges: (dirty: boolean) => void;
}
export const useUndoManagerState = ({
setHasUnsavedChanges,
}: UseUndoManagerStateParams) => {
const undoManagerRef = useRef(new UndoManager());
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const updateUndoRedoState = useCallback(() => {
const undoManager = undoManagerRef.current;
setCanUndo(undoManager.canUndo());
setCanRedo(undoManager.canRedo());
if (!undoManager.hasHistory()) {
setHasUnsavedChanges(false);
}
}, [setHasUnsavedChanges]);
useEffect(() => {
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
updateUndoRedoState();
}, [updateUndoRedoState]);
const executeCommandWithTracking = useCallback(
(command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
},
[setHasUnsavedChanges]
);
const handleUndo = useCallback(() => {
undoManagerRef.current.undo();
}, []);
const handleRedo = useCallback(() => {
undoManagerRef.current.redo();
}, []);
const clearUndoHistory = useCallback(() => {
undoManagerRef.current.clear();
updateUndoRedoState();
}, [updateUndoRedoState]);
return {
canUndo,
canRedo,
executeCommandWithTracking,
handleUndo,
handleRedo,
clearUndoHistory,
};
};
export type UseUndoManagerStateReturn = ReturnType<typeof useUndoManagerState>;
@@ -16,6 +16,7 @@ interface PageEditorRightRailButtonsParams {
handleDeselectAll: () => void;
handleDelete: () => void;
onExportSelected: () => void;
onSaveChanges: () => void;
exportLoading: boolean;
activeFileCount: number;
closePdf: () => void;
@@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
@@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
@@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
visible: totalPages > 0,
onClick: onExportSelected,
},
{
id: 'page-save-changes',
icon: <LocalIcon icon="save" width="1.5rem" height="1.5rem" />,
tooltip: saveChangesLabel,
ariaLabel: saveChangesLabel,
section: 'top' as const,
order: 55,
disabled: totalPages === 0 || exportLoading,
visible: totalPages > 0,
onClick: onSaveChanges,
},
{
id: 'page-close-pdf',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
@@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
selectByNumberLabel,
deleteSelectedLabel,
exportSelectedLabel,
saveChangesLabel,
closePdfLabel,
totalPages,
selectedPageCount,
@@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
@@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} />
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
</span>
);
return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={'lg'}
size={isActive ? 'lg' : 'md'}
variant="subtle"
aria-label={t("quickAccess.allTools", "All Tools")}
aria-label={t("quickAccess.allTools", "Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
@@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
{iconNode}
</ActionIcon>
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
{t("quickAccess.allTools", "All Tools")}
{t("quickAccess.allTools", "Tools")}
</span>
</div>
</Tooltip>
@@ -1,12 +1,12 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AppConfigModalProps {
@@ -15,10 +15,10 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('general');
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
const [active, setActive] = useState<NavKey>('general');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config } = useAppConfig();
// Extract section from URL path (e.g., /settings/people -> people)
@@ -64,19 +64,19 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
headerBorder: 'var(--modal-header-border)',
}), []);
// Get isAdmin and runningEE from app config
// Get isAdmin, runningEE, and loginEnabled from app config
const isAdmin = config?.isAdmin ?? false;
const runningEE = config?.runningEE ?? false;
console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config });
const loginEnabled = config?.enableLogin ?? false;
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
isAdmin,
runningEE
runningEE,
loginEnabled
),
[isAdmin, runningEE]
[isAdmin, runningEE, loginEnabled]
);
const activeLabel = useMemo(() => {
@@ -143,16 +143,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
<div
key={item.key}
onClick={() => {
if (!isDisabled) {
setActive(item.key);
navigate(`/settings/${item.key}`);
}
// Allow navigation even when disabled - the content inside will be disabled
setActive(item.key);
navigate(`/settings/${item.key}`);
}}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.6 : 1,
cursor: 'pointer',
}}
data-tour={`admin-${item.key}-nav`}
>
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
setLoading(true);
setError('');
await accountService.changePassword(currentPassword, newPassword);
await accountService.changePasswordOnLogin(currentPassword, newPassword);
alert({
alertType: 'success',
@@ -11,7 +11,6 @@
padding: 6px 12px;
border-radius: 20px;
box-shadow: var(--shadow-md);
z-index: 30;
white-space: nowrap;
pointer-events: auto;
transition: opacity 0.2s ease-in-out;
@@ -1,6 +1,7 @@
import React from 'react';
import { ActionIcon, Tooltip } from '@mantine/core';
import styles from '@app/components/shared/HoverActionMenu.module.css';
import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex';
export interface HoverAction {
id: string;
@@ -34,7 +35,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
return (
<div
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
style={{ opacity: show ? 1 : 0 }}
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -44,10 +45,10 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
<ActionIcon
size="md"
variant="subtle"
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
disabled={action.disabled}
onClick={action.onClick}
c={action.color}
style={{ color: action.color || 'var(--right-rail-icon)' }}
>
{action.icon}
</ActionIcon>
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { useFileManager } from '@app/hooks/useFileManager';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -14,6 +16,9 @@ const LandingPage = () => {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@@ -36,6 +41,22 @@ const LandingPage = () => {
event.target.value = '';
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
(async () => {
try {
const files = await loadRecentFiles();
if (isMounted) {
setHasRecents((files?.length || 0) > 0);
}
} catch (_err) {
if (isMounted) setHasRecents(false);
}
})();
return () => { isMounted = false; };
}, [loadRecentFiles]);
return (
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */}
@@ -72,7 +93,7 @@ const LandingPage = () => {
}}
>
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
src={logoPath}
alt="Stirling PDF Logo"
style={{
height: 'auto',
@@ -117,59 +138,89 @@ const LandingPage = () => {
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
{/* Show both buttons only when recents exist; otherwise show a single Upload button */}
{hasRecents && (
<>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</Button>
)}
</div>
{/* Hidden file input for native file picker */}
@@ -0,0 +1,234 @@
import React from 'react';
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import AddIcon from '@mui/icons-material/Add';
import FitText from '@app/components/shared/FitText';
import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop';
import { FileId } from '@app/types/file';
// Local interface for PageEditor file display
interface PageEditorFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
interface FileMenuItemProps {
file: PageEditorFile;
index: number;
colorIndex: number;
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
}
const FileMenuItem: React.FC<FileMenuItemProps> = ({
file,
index,
colorIndex,
onToggleSelection,
onReorder,
}) => {
const {
itemRef,
isDragging,
isDragOver,
dropPosition,
movedRef,
onPointerDown,
onPointerMove,
onPointerUp,
} = useFileItemDragDrop({
fileId: file.fileId,
index,
onReorder,
});
const itemName = file?.name || 'Untitled';
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
return (
<div
style={{
position: 'relative',
marginBottom: '0.5rem',
}}
>
{/* Drop indicator line */}
{isDragOver && (
<div
style={{
position: 'absolute',
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
left: 0,
right: 0,
height: '4px',
backgroundColor: 'rgb(59, 130, 246)',
borderRadius: '2px',
zIndex: 10,
}}
/>
)}
<div
ref={itemRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={(e) => {
e.stopPropagation();
if (movedRef.current) return; // ignore click after drag
onToggleSelection(file.fileId);
}}
style={{
padding: '0.75rem 0.75rem',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
borderLeft: `6px solid ${fileColorBorder}`,
opacity: isDragging ? 0.5 : 1,
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
}
}}
onMouseLeave={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
}
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<div
style={{
cursor: 'grab',
display: 'flex',
alignItems: 'center',
color: 'var(--mantine-color-dimmed)',
}}
>
<DragIndicatorIcon fontSize="small" />
</div>
<Checkbox
checked={file.isSelected}
onChange={() => onToggleSelection(file.fileId)}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<PrivateContent>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
</PrivateContent>
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">
v{file.versionNumber}
</Text>
)}
</Group>
</div>
</div>
);
};
interface PageEditorFileDropdownProps {
files: PageEditorFile[];
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
fileColorMap: Map<string, number>;
selectedCount: number;
totalCount: number;
}
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
files,
onToggleSelection,
onReorder,
switchingTo,
viewOptionStyle,
fileColorMap,
selectedCount,
totalCount,
}) => {
const { openFilesModal } = useFilesModalContext();
return (
<Menu trigger="click" position="bottom" width="40rem">
<Menu.Target>
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
)}
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown className="ph-no-capture" style={{
backgroundColor: 'var(--right-rail-bg)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
maxHeight: '80vh',
overflowY: 'auto'
}}>
{files.map((file, index) => {
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
return (
<FileMenuItem
key={file.fileId}
file={file}
index={index}
colorIndex={colorIndex}
onToggleSelection={onToggleSelection}
onReorder={onReorder}
/>
);
})}
{/* Add File Button */}
<div
onClick={(e) => {
e.stopPropagation();
openFilesModal();
}}
style={{
padding: '0.75rem 0.75rem',
marginTop: '0.5rem',
cursor: 'pointer',
backgroundColor: 'transparent',
borderTop: '1px solid var(--border-subtle)',
transition: 'background-color 0.15s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
Add File
</Text>
</Group>
</div>
</Menu.Dropdown>
</Menu>
);
};
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -88,7 +89,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: () => handleClick(),
'aria-label': config.name
})}
size={isActive ? (config.size || 'lg') : 'lg'}
size={isActive ? 'lg' : 'md'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
@@ -108,9 +109,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const mainButtons: ButtonConfig[] = [
{
id: 'read',
name: t("quickAccess.read", "Read"),
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
size: 'lg',
name: t("quickAccess.reader", "Reader"),
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
isRound: false,
type: 'navigation',
onClick: () => {
@@ -118,23 +119,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
handleReaderToggle();
}
},
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
size: 'lg',
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'md',
isRound: false,
type: 'navigation',
onClick: () => {
@@ -147,37 +136,36 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
}
},
];
const middleButtons: ButtonConfig[] = [
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'lg',
size: 'md',
type: 'modal',
onClick: handleFilesButtonClick
},
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
];
const middleButtons: ButtonConfig[] = [];
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
isRound: true,
size: 'lg',
size: 'md',
type: 'action',
onClick: () => {
// This will be overridden by the wrapper logic
@@ -185,9 +173,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
},
{
id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
name: t("quickAccess.settings", "Settings"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
type: 'modal',
onClick: () => {
navigate('/settings/overview');
@@ -200,7 +188,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
ref={ref}
data-sidebar="quick-access"
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
style={{
borderRight: '1px solid var(--border-default)'
}}
@@ -239,20 +227,30 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
))}
</Stack>
{/* Divider after main buttons */}
<Divider
size="xs"
className="content-divider"
/>
{/* Divider after main buttons (creates gap) */}
{middleButtons.length === 0 && (
<Divider
size="xs"
className="content-divider"
/>
)}
{/* Middle section */}
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
{middleButtons.length > 0 && (
<>
<Divider
size="xs"
className="content-divider"
/>
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
</>
)}
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
@@ -14,6 +14,8 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
@@ -39,7 +41,7 @@ export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { toggleTheme, themeMode } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
@@ -195,7 +197,11 @@ export default function RightRail() {
className="right-rail-icon"
onClick={toggleTheme}
>
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
{themeMode === 'dark' ? (
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
) : (
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
)}
</ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme')
)}
@@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
const clickPendingRef = useRef(false);
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
const isDomNode = (value: unknown): value is Node =>
typeof Node !== 'undefined' && value instanceof Node;
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
@@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
const target = e.target as unknown;
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
@@ -172,7 +176,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
@@ -236,7 +241,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
@@ -258,7 +263,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]
@@ -1,47 +1,49 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import rainbowStyles from '@app/styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import GridViewIcon from "@mui/icons-material/GridView";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
const viewOptionStyle: React.CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
gap: '0.5rem',
justifyContent: 'center',
padding: '2px 1rem',
};
// Build view options showing text always
// Helper function to create view options for SegmentedControl
const createViewOptions = (
currentView: WorkbenchType,
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void,
pageEditorState?: PageEditorDropdownState,
customViews?: CustomWorkbenchViewInstance[]
) => {
// Viewer dropdown logic
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const displayName = isInViewer && fileName ? fileName : 'Viewer';
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
const hasMultipleFiles = activeFiles.length > 1;
const showDropdown = isInViewer && hasMultipleFiles;
const showViewerDropdown = isInViewer && hasMultipleFiles;
const viewerOption = {
label: showDropdown ? (
label: showViewerDropdown ? (
<FileDropdownMenu
displayName={displayName}
displayName={viewerDisplayName}
activeFiles={activeFiles}
currentFileIndex={currentFileIndex}
onFileSelect={onFileSelect}
@@ -51,29 +53,38 @@ const createViewOptions = (
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
<Loader size="sm" />
) : (
<VisibilityIcon fontSize="small" />
<InsertDriveFileIcon fontSize="medium" />
)}
<PrivateContent>{displayName}</PrivateContent>
</div>
),
value: "viewer",
};
// Page Editor dropdown logic
const isInPageEditor = currentView === 'pageEditor';
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
const pageEditorOption = {
label: (
label: showPageEditorDropdown ? (
<PageEditorFileDropdown
files={pageEditorState!.files}
onToggleSelection={pageEditorState!.onToggleSelection}
onReorder={pageEditorState!.onReorder}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
fileColorMap={pageEditorState!.fileColorMap}
selectedCount={pageEditorState!.selectedCount}
totalCount={pageEditorState!.totalCount}
/>
) : (
<div style={viewOptionStyle}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
{switchingTo === "pageEditor" ? (
<Loader size="sm" />
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
<GridViewIcon fontSize="medium" />
)}
</div>
),
@@ -83,17 +94,7 @@ const createViewOptions = (
const fileEditorOption = {
label: (
<div style={viewOptionStyle}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
</div>
),
value: "fileEditor",
@@ -111,9 +112,9 @@ const createViewOptions = (
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === view.workbenchId ? (
<Loader size="xs" />
<Loader size="sm" />
) : (
view.icon || <PictureAsPdfIcon fontSize="small" />
view.icon || <PictureAsPdfIcon fontSize="medium" />
)}
<span>{view.label}</span>
</div>
@@ -144,6 +145,8 @@ const TopControls = ({
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const pageEditorState = usePageEditorDropdownState();
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
@@ -166,12 +169,25 @@ const TopControls = ({
});
}, [setCurrentView]);
// Memoize view options to prevent SegmentedControl re-renders
const viewOptions = useMemo(() => createViewOptions(
currentView,
switchingTo,
activeFiles,
currentFileIndex,
onFileSelect,
pageEditorState,
customViews
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<div className="flex justify-center">
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -184,18 +200,32 @@ const TopControls = ({
}}
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
borderRadius: '0 0 16px 16px',
height: '1.8rem',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderTop: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
outline: '1px solid rgba(0, 0, 0, 0.1)',
outlineOffset: '-1px',
padding: '0 0',
gap: '0',
},
control: {
borderRadius: 9999,
borderRadius: '0 0 16px 16px',
padding: '0',
border: 'none',
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
borderRadius: '0 0 16px 16px',
height: '100%',
top: '0rem',
margin: '0',
border: 'none',
},
label: {
paddingTop: '0rem',
paddingTop: '0',
paddingBottom: '0',
}
}}
/>
@@ -0,0 +1,96 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import CancelIcon from "@mui/icons-material/Cancel";
import { CSSProperties } from "react";
interface ZipWarningModalProps {
opened: boolean;
onConfirm: () => void;
onCancel: () => void;
fileCount: number;
zipFileName: string;
}
const WARNING_ICON_STYLE: CSSProperties = {
fontSize: 36,
display: 'block',
margin: '0 auto 8px',
color: 'var(--mantine-color-blue-6)'
};
const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onCancel}
title={t("zipWarning.title", "Large ZIP File")}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack ta="center" p="md" gap="sm">
<WarningAmberIcon style={WARNING_ICON_STYLE} />
<Text size="md" fw={300}>
{zipFileName}
</Text>
<Text size="lg" fw={500}>
{t("zipWarning.message", {
count: fileCount,
defaultValue: "This ZIP contains {{count}} files. Extract anyway?"
})}
</Text>
</Stack>
{/* Desktop layout: centered buttons */}
<Group justify="center" gap="sm" visibleFrom="md">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onCancel}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.cancel", "Cancel")}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onConfirm}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.confirm", "Extract")}
</Button>
</Group>
{/* Mobile layout: vertical stack */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onCancel}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.cancel", "Cancel")}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onConfirm}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.confirm", "Extract")}
</Button>
</Stack>
</Modal>
);
};
export default ZipWarningModal;
@@ -0,0 +1,38 @@
import { Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
interface LoginRequiredBannerProps {
show: boolean;
}
/**
* Banner component that displays when login mode is required but not enabled
* Shows prominent warning that settings are read-only
*/
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<Alert
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
color="blue"
variant="light"
styles={{
root: {
borderLeft: '4px solid var(--mantine-color-blue-6)'
}
}}
>
<Text size="sm">
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
</Text>
<Text size="sm" fw={600} mt="xs" c="dimmed">
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
</Text>
</Alert>
);
}
@@ -2,18 +2,6 @@ import React from 'react';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
export interface ConfigNavItem {
key: NavKey;
@@ -40,8 +28,9 @@ export interface ConfigColors {
}
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false
_isAdmin: boolean = false,
_runningEE: boolean = false,
_loginEnabled: boolean = false
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@@ -63,112 +52,5 @@ export const createConfigNavSections = (
},
];
// Add Admin sections if user is admin
if (isAdmin) {
// Configuration
sections.push({
title: 'Configuration',
items: [
{
key: 'adminGeneral',
label: 'System Settings',
icon: 'settings-rounded',
component: <AdminGeneralSection />
},
{
key: 'adminFeatures',
label: 'Features',
icon: 'extension-rounded',
component: <AdminFeaturesSection />
},
{
key: 'adminEndpoints',
label: 'Endpoints',
icon: 'api-rounded',
component: <AdminEndpointsSection />
},
{
key: 'adminDatabase',
label: 'Database',
icon: 'storage-rounded',
component: <AdminDatabaseSection />
},
{
key: 'adminAdvanced',
label: 'Advanced',
icon: 'tune-rounded',
component: <AdminAdvancedSection />
},
],
});
// Security & Authentication
sections.push({
title: 'Security & Authentication',
items: [
{
key: 'adminSecurity',
label: 'Security',
icon: 'shield-rounded',
component: <AdminSecuritySection />
},
{
key: 'adminConnections',
label: 'Connections',
icon: 'link-rounded',
component: <AdminConnectionsSection />
},
],
});
// Licensing & Analytics
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPlan',
label: 'Plan',
icon: 'receipt-long-rounded',
component: <AdminPlanSection />
},
{
key: 'adminAudit',
label: 'Audit',
icon: 'fact-check-rounded',
component: <AdminAuditSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
},
{
key: 'adminUsage',
label: 'Usage Analytics',
icon: 'analytics-rounded',
component: <AdminUsageSection />,
disabled: !runningEE,
disabledTooltip: 'Requires Enterprise license'
},
],
});
// Policies & Privacy
sections.push({
title: 'Policies & Privacy',
items: [
{
key: 'adminLegal',
label: 'Legal',
icon: 'gavel-rounded',
component: <AdminLegalSection />
},
{
key: 'adminPrivacy',
label: 'Privacy',
icon: 'visibility-rounded',
component: <AdminPrivacySection />
},
],
});
}
return sections;
};
@@ -10,6 +10,7 @@ interface ProviderCardProps {
settings?: Record<string, any>;
onSave?: (settings: Record<string, any>) => void;
onDisconnect?: () => void;
disabled?: boolean;
}
export default function ProviderCard({
@@ -18,6 +19,7 @@ export default function ProviderCard({
settings = {},
onSave,
onDisconnect,
disabled = false,
}: ProviderCardProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -39,6 +41,7 @@ export default function ProviderCard({
};
const handleFieldChange = (key: string, value: any) => {
if (disabled) return; // Block changes when disabled
setLocalSettings((prev) => ({ ...prev, [key]: value }));
};
@@ -63,6 +66,7 @@ export default function ProviderCard({
<Switch
checked={value || false}
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
disabled={disabled}
/>
</div>
);
@@ -76,6 +80,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@@ -88,6 +93,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
@@ -100,6 +106,7 @@ export default function ProviderCard({
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
disabled={disabled}
/>
);
}
@@ -174,11 +181,12 @@ export default function ProviderCard({
color="red"
size="sm"
onClick={onDisconnect}
disabled={disabled}
>
{t('admin.settings.connections.disconnect', 'Disconnect')}
</Button>
)}
<Button size="sm" onClick={handleSave}>
<Button size="sm" onClick={handleSave} disabled={disabled}>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
@@ -1,6 +1,6 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -53,12 +53,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PrivateContent>
<PictureAsPdfIcon
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
/>
{getFileTypeIcon(file)}
</PrivateContent>
</Center>
{children}
@@ -0,0 +1,32 @@
import React from "react";
import JavascriptIcon from "@mui/icons-material/Javascript";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import type { StirlingFileStub } from "@app/types/fileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
type FileLike = File | StirlingFileStub;
/**
* Returns an appropriate file type icon for the provided file.
* - Uses the real file type and extension to decide the icon.
* - No any-casts; accepts File or StirlingFileStub.
*/
export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement {
const name = (file?.name ?? "").toLowerCase();
const mime = (file?.type ?? "").toLowerCase();
const ext = detectFileExtension(name);
// JavaScript
if (ext === "js" || mime.includes("javascript")) {
return <JavascriptIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
}
// PDF
if (ext === "pdf" || mime === "application/pdf") {
return <PictureAsPdfIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
}
// Fallback generic
return <InsertDriveFileIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
}
@@ -0,0 +1,154 @@
import { useRef, useEffect, useState } from 'react';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { FileId } from '@app/types/file';
interface UseFileItemDragDropParams {
fileId: FileId;
index: number;
onReorder: (fromIndex: number, toIndex: number) => void;
}
interface UseFileItemDragDropReturn {
itemRef: React.RefObject<HTMLDivElement | null>;
isDragging: boolean;
isDragOver: boolean;
dropPosition: 'above' | 'below';
movedRef: React.MutableRefObject<boolean>;
startRef: React.MutableRefObject<{ x: number; y: number } | null>;
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
}
/**
* Hook to handle drag and drop functionality for file items in a list.
* Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop.
*/
export const useFileItemDragDrop = ({
fileId,
index,
onReorder,
}: UseFileItemDragDropParams): UseFileItemDragDropReturn => {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
const itemRef = useRef<HTMLDivElement>(null);
// Keep latest values without re-registering DnD
const indexRef = useRef(index);
const fileIdRef = useRef(fileId);
const dropPositionRef = useRef<'above' | 'below'>('below');
const onReorderRef = useRef(onReorder);
useEffect(() => { indexRef.current = index; }, [index]);
useEffect(() => { fileIdRef.current = fileId; }, [fileId]);
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
// Gesture guard for row click vs drag
const movedRef = useRef(false);
const startRef = useRef<{ x: number; y: number } | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
startRef.current = { x: e.clientX, y: e.clientY };
movedRef.current = false;
};
const onPointerMove = (e: React.PointerEvent) => {
if (!startRef.current) return;
const dx = e.clientX - startRef.current.x;
const dy = e.clientY - startRef.current.y;
if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
};
const onPointerUp = () => {
startRef.current = null;
};
useEffect(() => {
const element = itemRef.current;
if (!element) return;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file-item',
fileId: fileIdRef.current,
fromIndex: indexRef.current,
}),
onDragStart: () => setIsDragging((p) => (p ? p : true)),
onDrop: () => setIsDragging((p) => (p ? false : p)),
canDrag: () => true,
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file-item',
fileId: fileIdRef.current,
toIndex: indexRef.current,
}),
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
onDragLeave: () => {
setIsDragOver((p) => (p ? false : p));
setDropPosition('below');
},
onDrag: ({ source }) => {
// Determine drop position based on cursor location
const element = itemRef.current;
if (!element) return;
const rect = element.getBoundingClientRect();
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
const midpoint = rect.top + rect.height / 2;
setDropPosition(clientY < midpoint ? 'below' : 'above');
},
onDrop: ({ source }) => {
setIsDragOver(false);
const dropPos = dropPositionRef.current;
setDropPosition('below');
const sourceData = source.data as any;
if (sourceData?.type === 'file-item') {
const fromIndex = sourceData.fromIndex as number;
let toIndex = indexRef.current;
// Adjust toIndex based on drop position
if (dropPos === 'below' && fromIndex < toIndex) {
// Dragging down, drop after target - no adjustment needed
} else if (dropPos === 'above' && fromIndex > toIndex) {
// Dragging up, drop before target - no adjustment needed
} else if (dropPos === 'below' && fromIndex > toIndex) {
// Dragging up but want below target
toIndex = toIndex + 1;
} else if (dropPos === 'above' && fromIndex < toIndex) {
// Dragging down but want above target
toIndex = toIndex - 1;
}
if (fromIndex !== toIndex) {
onReorderRef.current(fromIndex, toIndex);
}
}
}
});
return () => {
try { dragCleanup(); } catch { /* cleanup */ }
try { dropCleanup(); } catch { /* cleanup */ }
};
}, []); // Stable - no dependencies
return {
itemRef,
isDragging,
isDragOver,
dropPosition,
movedRef,
startRef,
onPointerDown,
onPointerMove,
onPointerUp,
};
};
@@ -1,13 +1,13 @@
/**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
*
* When a user selects a tool from the All Tools list, this component displays the tool's
* When a user selects a tool from the Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the All Tools list.
* tool is currently active and offers a back button to return to the Tools list.
*
* Features:
* - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to All Tools
* - Hover to reveal back arrow for returning to Tools
* - Smooth slide-down/slide-up animations
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
*/
@@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
handleBackToTools();
});
}}
size={'xl'}
size={'lg'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
@@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
>
<span className="iconContainer">
{isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
<ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
) : (
indicatorTool.icon
)}
@@ -1,15 +1,29 @@
.activeIconScale {
transform: scale(1.3);
transform: scale(1);
transition: transform 0.2s;
z-index: 1;
width: calc(1.75rem + 10px) !important;
height: calc(1.75rem + 10px) !important;
}
.activeIconScale .iconContainer {
width: calc(1.5rem + 10px);
height: calc(1.5rem + 10px);
}
.activeIconScale .iconContainer svg,
.activeIconScale .iconContainer img {
width: calc(1.25rem + 10px) !important;
height: calc(1.25rem + 10px) !important;
}
.iconContainer {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
width: 1.5rem;
height: 1.5rem;
transition: width 0.2s, height 0.2s;
}
/* Action icon styles */
@@ -24,9 +38,9 @@
/* Main container styles */
.quick-access-bar-main {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
width: 4rem;
min-width: 4rem;
max-width: 4rem;
position: relative;
z-index: 10;
}
@@ -34,9 +48,9 @@
/* Rainbow mode container */
.quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted);
width: 5rem;
min-width: 5rem;
max-width: 5rem;
width: 4rem;
min-width: 4rem;
max-width: 4rem;
position: relative;
z-index: 10;
}
@@ -57,7 +71,7 @@
/* Nav header divider */
.nav-header-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin-top: 0.5rem;
margin-bottom: 1rem;
@@ -85,7 +99,7 @@
/* Overflow divider */
.overflow-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin: 0 0.5rem;
}
@@ -143,7 +157,7 @@
/* Content divider */
.content-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
}
@@ -241,7 +255,7 @@
/* Divider that animates growing from top */
.current-tool-divider {
width: 3.75rem;
width: 3rem;
border-color: var(--color-gray-300);
margin: 0.5rem auto 0.5rem auto;
transform-origin: top;
@@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
progress,
} as ToastInstance;
// Detect completion
// Detect completion but do not auto-flip to success.
// Callers (e.g., compare workbench) explicitly set alertType when done.
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
// On completion: finalize type as success unless explicitly provided otherwise
next.justCompleted = false;
if (!updates.alertType) {
next.alertType = 'success';
}
next.justCompleted = true;
}
return next;
@@ -31,6 +31,13 @@
flex-direction: column-reverse;
}
.toast-container--bottom-center {
bottom: 16px;
left: 50%;
transform: translateX(-50%);
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;
@@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
'top-right': 'toast-container--top-right',
'bottom-left': 'toast-container--bottom-left',
'bottom-right': 'toast-container--bottom-right',
'bottom-center': 'toast-container--bottom-center',
};
function getToastItemClass(t: ToastInstance): string {
@@ -44,7 +45,7 @@ export default function ToastRenderer() {
if (!acc[key]) acc[key] = [] as ToastInstance[];
acc[key].push(t);
return acc;
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
return (
<>
+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 {
@@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import { useFocusTrap } from '@app/hooks/useFocusTrap';
import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { Tooltip } from '@app/components/shared/Tooltip';
import '@app/components/tools/ToolPanel.css';
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
@@ -51,9 +52,7 @@ const FullscreenToolSurface = ({
useFocusTrap(surfaceRef, !isExiting);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandIconSrc = useLogoPath();
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
@@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from '@app/styles/rainbow.module.css';
import { ActionIcon, ScrollArea } from '@mantine/core';
import { ToolId } from '@app/types/toolId';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
@@ -26,7 +26,7 @@ export default function ToolPanel() {
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useIsMobile();
const {
leftPanelView,
+59 -162
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import React, { useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
@@ -8,11 +8,10 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
import Badge from "@app/components/shared/Badge";
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -23,49 +22,8 @@ interface ToolPickerProps {
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
const scrollableRef = useRef<HTMLDivElement>(null);
const quickHeaderRef = useRef<HTMLDivElement>(null);
const allHeaderRef = useRef<HTMLDivElement>(null);
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(null);
// Keep header heights in sync with any dynamic size changes
useLayoutEffect(() => {
const update = () => {
if (quickHeaderRef.current) {
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
}
if (allHeaderRef.current) {
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
}
};
update();
// Update on window resize
window.addEventListener("resize", update);
// Update on element resize (e.g., font load, badge count change, zoom)
const observers: ResizeObserver[] = [];
if (typeof ResizeObserver !== "undefined") {
const observe = (el: HTMLDivElement | null, cb: () => void) => {
if (!el) return;
const ro = new ResizeObserver(() => cb());
ro.observe(el);
observers.push(ro);
};
observe(quickHeaderRef.current, update);
observe(allHeaderRef.current, update);
}
return () => {
window.removeEventListener("resize", update);
observers.forEach(o => o.disconnect());
};
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
@@ -84,31 +42,25 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
return items;
}, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
const allSection = useMemo(
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
const container = scrollableRef.current;
const target = ref.current;
if (container && target) {
const stackedOffset = ref === allToolsRef
? (quickHeaderHeight + allHeaderHeight)
: quickHeaderHeight;
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
container.scrollTo({
top: Math.max(0, top),
behavior: "smooth"
});
}
};
// Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
const headerTextStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.5rem 0 0.25rem 0.5rem",
textTransform: "none",
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
opacity: 0.7
};
const toTitleCase = (s: string) =>
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
return (
<Box
@@ -141,110 +93,55 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</Stack>
) : (
<>
{quickSection && (
<>
<div
ref={quickHeaderRef}
style={{
position: "sticky",
top: 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
onClick={() => scrollTo(quickAccessRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<Badge>
{recommendedCount}
</Badge>
</div>
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
</Stack>
{/* Flat list: favorites and recommended first, then all subcategories */}
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
{t('toolPanel.fullscreen.favorites', 'Favourites')}
</div>
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
</>
)}
{allSection && (
<>
<div
ref={allHeaderRef}
style={{
position: "sticky",
top: quickSection ? quickHeaderHeight -1 : 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(allToolsRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<Badge>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</Badge>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
{t('toolPanel.fullscreen.recommended', 'Recommended')}
</div>
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
</>
)}
)}
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
</div>
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
</Box>
))}
</Stack>
{!quickSection && !allSection && <NoToolsFound />}
@@ -80,7 +80,7 @@ export default function ToolSelector({
// If no sections, create a simple group from filtered tools
if (baseFilteredTools.length > 0) {
return [{
name: 'All Tools',
name: 'Tools',
subcategoryId: 'all' as any,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
}];
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core";
import { Stack, Text, Divider, Collapse, Button, NumberInput, Checkbox } from "@mantine/core";
import { BookletImpositionParameters } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
@@ -21,28 +21,27 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
{/* Double Sided */}
<Stack gap="sm">
<label
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
title={t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')}
>
<input
type="checkbox"
checked={parameters.doubleSided}
onChange={(e) => {
const isDoubleSided = e.target.checked;
onParameterChange('doubleSided', isDoubleSided);
// Reset to BOTH when turning double-sided back on
if (isDoubleSided) {
onParameterChange('duplexPass', 'BOTH');
} else {
// Default to FIRST pass when going to manual duplex
onParameterChange('duplexPass', 'FIRST');
}
}}
disabled={disabled}
/>
<Text size="sm">{t('bookletImposition.doubleSided.label', 'Double-sided printing')}</Text>
</label>
<Checkbox
checked={parameters.doubleSided}
onChange={(event) => {
const isDoubleSided = event.currentTarget.checked;
onParameterChange('doubleSided', isDoubleSided);
// Reset to BOTH when turning double-sided back on
if (isDoubleSided) {
onParameterChange('duplexPass', 'BOTH');
} else {
// Default to FIRST pass when going to manual duplex
onParameterChange('duplexPass', 'FIRST');
}
}}
disabled={disabled}
label={
<div>
<Text size="sm">{t('bookletImposition.doubleSided.label', 'Double-sided printing')}</Text>
<Text size="xs" c="dimmed">{t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')}</Text>
</div>
}
/>
{/* Manual Duplex Pass Selection - only show when double-sided is OFF */}
{!parameters.doubleSided && (
@@ -90,47 +89,44 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
<Collapse in={advancedOpen}>
<Stack gap="md" mt="md">
{/* Right-to-Left Binding */}
<label
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
title={t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')}
>
<input
type="checkbox"
checked={parameters.spineLocation === 'RIGHT'}
onChange={(e) => onParameterChange('spineLocation', e.target.checked ? 'RIGHT' : 'LEFT')}
disabled={disabled}
/>
<Text size="sm">{t('bookletImposition.rtlBinding.label', 'Right-to-left binding')}</Text>
</label>
<Checkbox
checked={parameters.spineLocation === 'RIGHT'}
onChange={(event) => onParameterChange('spineLocation', event.currentTarget.checked ? 'RIGHT' : 'LEFT')}
disabled={disabled}
label={
<div>
<Text size="sm">{t('bookletImposition.rtlBinding.label', 'Right-to-left binding')}</Text>
<Text size="xs" c="dimmed">{t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')}</Text>
</div>
}
/>
{/* Add Border Option */}
<label
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
title={t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}
>
<input
type="checkbox"
checked={parameters.addBorder}
onChange={(e) => onParameterChange('addBorder', e.target.checked)}
disabled={disabled}
/>
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
</label>
<Checkbox
checked={parameters.addBorder}
onChange={(event) => onParameterChange('addBorder', event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
<Text size="xs" c="dimmed">{t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}</Text>
</div>
}
/>
{/* Gutter Margin */}
<Stack gap="xs">
<label
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
title={t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')}
>
<input
type="checkbox"
checked={parameters.addGutter}
onChange={(e) => onParameterChange('addGutter', e.target.checked)}
disabled={disabled}
/>
<Text size="sm">{t('bookletImposition.addGutter.label', 'Add gutter margin')}</Text>
</label>
<Checkbox
checked={parameters.addGutter}
onChange={(event) => onParameterChange('addGutter', event.currentTarget.checked)}
disabled={disabled}
label={
<div>
<Text size="sm">{t('bookletImposition.addGutter.label', 'Add gutter margin')}</Text>
<Text size="xs" c="dimmed">{t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')}</Text>
</div>
}
/>
{parameters.addGutter && (
<NumberInput
@@ -147,23 +143,24 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
</Stack>
{/* Flip on Short Edge */}
<label
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
title={!parameters.doubleSided
? t('bookletImposition.flipOnShortEdge.manualNote', 'Not needed in manual mode - you flip the stack yourself')
: t('bookletImposition.flipOnShortEdge.tooltip', 'Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)')
<Checkbox
checked={parameters.flipOnShortEdge}
onChange={(event) => onParameterChange('flipOnShortEdge', event.currentTarget.checked)}
disabled={disabled || !parameters.doubleSided}
label={
<div>
<Text size="sm" c={!parameters.doubleSided ? "dimmed" : undefined}>
{t('bookletImposition.flipOnShortEdge.label', 'Flip on short edge')}
</Text>
<Text size="xs" c="dimmed">
{!parameters.doubleSided
? t('bookletImposition.flipOnShortEdge.manualNote', 'Not needed in manual mode - you flip the stack yourself')
: t('bookletImposition.flipOnShortEdge.tooltip', 'Enable for short-edge duplex printing (automatic duplex only - ignored in manual mode)')
}
</Text>
</div>
}
>
<input
type="checkbox"
checked={parameters.flipOnShortEdge}
onChange={(e) => onParameterChange('flipOnShortEdge', e.target.checked)}
disabled={disabled || !parameters.doubleSided}
/>
<Text size="sm" c={!parameters.doubleSided ? "dimmed" : undefined}>
{t('bookletImposition.flipOnShortEdge.label', 'Flip on short edge')}
</Text>
</label>
/>
{/* Paper Size Note */}
<Text size="xs" c="dimmed" fs="italic">
@@ -0,0 +1,299 @@
import { Group, Loader, Stack, Text } from '@mantine/core';
import { useMemo, useRef, useEffect, useState } from 'react';
import type { PagePreview } from '@app/types/compare';
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare';
import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown';
import { useIsMobile } from '@app/hooks/useIsMobile';
// utilities moved to compare.ts
const CompareDocumentPane = ({
pane,
layout,
scrollRef,
peerScrollRef,
handleScrollSync,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
isPanMode,
zoom,
title,
dropdownPlaceholder,
changes,
onNavigateChange,
isLoading,
processingMessage,
pages,
pairedPages,
wordHighlightMap,
metaIndexToGroupId,
documentLabel,
pageLabel,
altLabel,
onVisiblePageChange,
}: CompareDocumentPaneProps) => {
const isMobileViewport = useIsMobile();
const pairedPageMap = useMemo(() => {
const map = new Map<number, PagePreview>();
pairedPages.forEach((item) => {
map.set(item.pageNumber, item);
});
return map;
}, [pairedPages]);
const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)';
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
const pagePanRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 });
// Track which page images have finished loading to avoid flashing between states
const imageLoadedRef = useRef<Map<number, boolean>>(new Map());
// Force a re-render when an image load state changes (refs don't trigger renders)
const [, setImageLoadedTick] = useState(0);
const visiblePageRafRef = useRef<number | null>(null);
const lastReportedVisiblePageRef = useRef<number | null>(null);
const pageNodesRef = useRef<HTMLElement[] | null>(null);
const groupedRectsByPage = useMemo(() => {
const out = new Map<number, Map<string, TokenBoundingBox[]>>();
for (const p of pages) {
const rects = wordHighlightMap.get(p.pageNumber) ?? [];
out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane));
}
return out;
}, [pages, wordHighlightMap, metaIndexToGroupId, pane]);
// When zoom returns to 1 (reset), clear per-page pan state so content is centered again
useEffect(() => {
if (zoom <= 1) {
pagePanRef.current.clear();
}
}, [zoom]);
return (
<div className="compare-pane">
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{title}
</Text>
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
<CompareNavigationDropdown
changes={changes}
placeholder={dropdownPlaceholder ?? null}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
/>
)}
</Group>
</Group>
</div>
<div
ref={scrollRef}
onScroll={(event) => {
handleScrollSync(event.currentTarget, peerScrollRef.current);
// Notify parent about the currently visible page (throttled via rAF)
if (visiblePageRafRef.current != null) return;
if (!onVisiblePageChange || pages.length === 0) return;
visiblePageRafRef.current = requestAnimationFrame(() => {
const container = scrollRef.current;
if (!container) return;
const mid = container.scrollTop + container.clientHeight * 0.5;
let bestPage = pages[0]?.pageNumber ?? 1;
let bestDist = Number.POSITIVE_INFINITY;
let nodes = pageNodesRef.current;
if (!nodes || nodes.length !== pages.length) {
nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
pageNodesRef.current = nodes;
}
for (const el of nodes) {
const top = el.offsetTop;
const height = el.clientHeight || 1;
const center = top + height / 2;
const dist = Math.abs(center - mid);
if (dist < bestDist) {
bestDist = dist;
const attr = el.getAttribute('data-page-number');
const pn = attr ? parseInt(attr, 10) : NaN;
if (!Number.isNaN(pn)) bestPage = pn;
}
}
if (typeof onVisiblePageChange === 'function' && bestPage !== lastReportedVisiblePageRef.current) {
lastReportedVisiblePageRef.current = bestPage;
onVisiblePageChange(pane, bestPage);
}
visiblePageRafRef.current = null;
});
}}
onMouseDown={undefined}
onMouseMove={undefined}
onMouseUp={undefined}
onMouseLeave={undefined}
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
onTouchStart={(event) => onTouchStart(pane, event)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className="compare-pane__scroll"
style={{ cursor: cursorStyle }}
>
<Stack gap={zoom <= 0.6 ? 2 : zoom <= 0.85 ? 'xs' : 'sm'} className="compare-pane__content">
{isLoading && (
<Group justify="center" gap="xs" py="md">
<Loader size="sm" />
<Text size="sm">{processingMessage}</Text>
</Group>
)}
{pages.map((page) => {
const peerPage = pairedPageMap.get(page.pageNumber);
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
const metrics = computePageLayoutMetrics({
page,
peerPage: peerPage ?? null,
layout,
isMobileViewport,
scrollRefWidth: scrollRef.current?.clientWidth ?? null,
viewportWidth,
zoom,
offsetPixels: OFFSET_PIXELS,
});
const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics;
// Compute clamped pan for current zoom so content always touches edges when in bounds
const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth));
const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight));
const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x));
const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y));
const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map();
return (
<>
<div
className="compare-diff-page"
data-page-number={page.pageNumber}
style={{ minHeight: `${containerHeight}px` }}
>
<div
className="compare-page-title"
style={{ width: `${containerWidth}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<Text size="xs" fw={600} c="dimmed" ta="center">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
</div>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={{ width: `${containerWidth}px`, height: `${containerHeight}px`, marginLeft: 'auto', marginRight: 'auto', overflow: 'hidden' }}
onMouseDown={(e) => {
if (!isPanMode || zoom <= 1) return;
dragRef.current.active = true;
dragRef.current.page = page.pageNumber;
dragRef.current.startX = e.clientX;
dragRef.current.startY = e.clientY;
const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
dragRef.current.startPanX = curr.x;
dragRef.current.startPanY = curr.y;
(e.currentTarget as HTMLElement).style.cursor = 'grabbing';
e.preventDefault();
}}
onMouseMove={(e) => {
if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
// Clamp panning based on the actual rendered content size.
// The inner layer is width/height of the container, then scaled by innerScale.
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
const maxX = Math.max(0, contentWidth - Math.round(containerWidth));
const maxY = Math.max(0, contentHeight - Math.round(containerHeight));
const candX = dragRef.current.startPanX - dx;
const candY = dragRef.current.startPanY - dy;
const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) };
pagePanRef.current.set(page.pageNumber, next);
e.preventDefault();
}}
onMouseUp={(e) => {
if (dragRef.current.active) {
dragRef.current.active = false;
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
}
}}
onMouseLeave={(e) => {
if (dragRef.current.active) {
dragRef.current.active = false;
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
}
}}
>
<div
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
style={{
transform: `scale(${innerScale}) translate3d(${-((clampedPanX) / innerScale)}px, ${-((clampedPanY) / innerScale)}px, 0)`,
transformOrigin: 'top left'
}}
>
{/* Image layer */}
<img
src={page.url ?? ''}
alt={altLabel}
loading="lazy"
decoding="async"
className="compare-diff-page__image"
onLoad={() => {
if (!imageLoadedRef.current.get(page.pageNumber)) {
imageLoadedRef.current.set(page.pageNumber, true);
setImageLoadedTick((v) => v + 1); // refs don't trigger renders
}
}}
/>
{/* Overlay loader until the page image is loaded */}
{!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && (
<div className="compare-page-loader-overlay">
<Loader size="sm" />
</div>
)}
{[...groupedRects.entries()].flatMap(([id, rects]) =>
mergeConnectedRects(rects).map((rect, index) => {
const rotation = normalizeRotation(page.rotation);
const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset;
return (
<span
key={`${pane}-highlight-${page.pageNumber}-${id}-${index}`}
data-change-id={id}
className="compare-diff-highlight"
style={{
left: `${rect.left * 100}%`,
top: `${(rect.top + verticalOffset) * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
backgroundColor: HIGHLIGHT_BG_VAR,
}}
/>
);
})
)}
</div>
</div>
</div>
</>
);
})}
</Stack>
</div>
</div>
);
};
export default CompareDocumentPane;
@@ -0,0 +1,229 @@
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { NavigationDropdownProps } from '@app/types/compare';
const CompareNavigationDropdown = ({
changes,
placeholder,
className,
onNavigate,
renderedPageNumbers,
}: NavigationDropdownProps) => {
const { t } = useTranslation();
const newLineLabel = t('compare.newLine', 'new-line');
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
// Cache scrollTop so we can restore on next open
const viewport = viewportRef.current;
if (viewport) scrollTopRef.current = viewport.scrollTop;
setIsOpen(false);
},
onDropdownOpen: () => {
setIsOpen(true);
// Restore scrollTop after mount and rebuild offsets
requestAnimationFrame(() => {
const viewport = viewportRef.current;
if (viewport) viewport.scrollTop = scrollTopRef.current;
const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[];
groupOffsetsRef.current = headers.map((el) => {
const text = el.textContent || '';
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
return { top: el.offsetTop, page };
});
// Update sticky label based on current scroll position
handleScrollPos({ x: 0, y: scrollTopRef.current });
});
},
});
const sanitize = (s: string) => {
// Normalize and remove control/separator characters without regex ranges
return s
.normalize('NFKC')
.split('')
.map(char => {
const code = char.charCodeAt(0);
// Replace control chars (0-31, 127) and special separators with space
if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) {
return ' ';
}
return char;
})
.join('')
.replace(/\s+/g, ' ')
.trim();
};
const isMeaningful = (s: string) => {
const t = sanitize(s);
// Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class.
const rx =
(() => {
try {
// Construct at runtime so old engines don’t fail parse-time
return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u');
} catch {
// Fallback (no Unicode props): letters, digits, and common punctuation/symbols
return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/;
}
})();
if (!rx.test(t)) return false;
return t.length > 0;
};
const [query, setQuery] = useState('');
const viewportRef = useRef<HTMLDivElement | null>(null);
const [stickyPage, setStickyPage] = useState<number | null>(null);
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
const scrollTopRef = useRef(0);
const [isOpen, setIsOpen] = useState(false);
const normalizedChanges = useMemo(() => {
// Helper to strip localized new-line marker occurrences from labels
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const stripNewLine = (s: string) =>
s
.replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ')
.replace(/\s+/g, ' ')
.trim();
const cleaned = changes
.map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber }))
.filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase());
const q = sanitize(query).toLowerCase();
if (!q) return cleaned;
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
}, [changes, query, newLineLabel]);
useEffect(() => {
// Build offsets for group headers whenever list changes while open
if (!isOpen) return;
const viewport = viewportRef.current;
if (!viewport) return;
const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
groupOffsetsRef.current = headers.map((el) => {
const text = el.textContent || '';
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
return { top: el.offsetTop, page };
});
// Update sticky based on current scroll position
handleScrollPos({ x: 0, y: viewport.scrollTop });
}, [normalizedChanges, isOpen]);
const handleScrollPos = ({ y }: { x: number; y: number }) => {
const offsets = groupOffsetsRef.current;
if (offsets.length === 0) return;
// Find the last header whose top is <= scroll, so the next header replaces it
let low = 0;
let high = offsets.length - 1;
let idx = 0;
while (low <= high) {
const mid = (low + high) >> 1;
if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
idx = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
const page = offsets[idx]?.page ?? offsets[0].page;
if (page !== stickyPage) setStickyPage(page);
};
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => {
const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber;
onNavigate(value, pn);
combobox.closeDropdown();
}}
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
>
<Combobox.Target>
<div
className={['compare-changes-select', className].filter(Boolean).join(' ')}
onClick={() => combobox.toggleDropdown()}
>
<span className="compare-changes-select__placeholder">{placeholder}</span>
<Combobox.Chevron />
</div>
</Combobox.Target>
<Combobox.Dropdown className="compare-changes-dropdown">
{/* Header sits outside scroll so it stays fixed at top */}
<div>
<Combobox.Search
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
</div>
{/* Lazy render the scrollable content only when open */}
{isOpen && (
<div className="compare-dropdown-scrollwrap">
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
{stickyPage != null && (
<div className="compare-dropdown-sticky" style={{ top: 0 }}>
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
{renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && (
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
)}
</div>
)}
<Combobox.Options className="compare-dropdown-options">
{normalizedChanges.length > 0 ? (
(() => {
const nodes: React.ReactNode[] = [];
let lastPage: number | null = null;
for (const item of normalizedChanges) {
if (item.pageNumber && item.pageNumber !== lastPage) {
lastPage = item.pageNumber;
nodes.push(
<div
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
key={`group-${lastPage}`}
>
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
{renderedPageNumbers && !renderedPageNumbers.has(lastPage) && (
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
)}
</div>
);
}
nodes.push(
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
onNavigate(item.value, item.pageNumber);
combobox.closeDropdown();
}}
>
<div className="compare-dropdown-option">
<span className="compare-dropdown-option__text">{item.label}</span>
</div>
</Combobox.Option>
);
}
return nodes;
})()
) : (
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
)}
</Combobox.Options>
</ScrollArea.Autosize>
</div>
)}
</Combobox.Dropdown>
</Combobox>
);
};
export default CompareNavigationDropdown;
@@ -0,0 +1,421 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@app/hooks/useIsMobile';
import {
mapChangesForDropdown,
getFileFromSelection,
getStubFromSelection,
computeShowProgressBanner,
computeProgressPct,
computeCountsText,
computeMaxSharedPages,
} from '@app/components/tools/compare/compare';
import {
CompareResultData,
CompareWorkbenchData,
} from '@app/types/compare';
import { useFileContext } from '@app/contexts/file/fileHooks';
import { useRightRailButtons } from '@app/hooks/useRightRailButtons';
import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane';
import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews';
import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom';
import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights';
import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation';
import '@app/components/tools/compare/compareView.css';
import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons';
import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
interface CompareWorkbenchViewProps {
data: CompareWorkbenchData | null;
}
// helpers moved to compare.ts
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const { t } = useTranslation();
const prefersStacked = useIsMobile();
const { selectors } = useFileContext();
const result: CompareResultData | null = data?.result ?? null;
const baseFileId = data?.baseFileId ?? null;
const comparisonFileId = data?.comparisonFileId ?? null;
const isOperationLoading = data?.isLoading ?? false;
const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors);
const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors);
const baseStub = getStubFromSelection(baseFileId, selectors);
const comparisonStub = getStubFromSelection(comparisonFileId, selectors);
const processedAt = result?.totals.processedAt ?? null;
const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({
file: baseFile,
enabled: Boolean(result && baseFile),
cacheKey: processedAt,
});
const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({
file: comparisonFile,
enabled: Boolean(result && comparisonFile),
cacheKey: processedAt,
});
const {
layout,
toggleLayout,
baseScrollRef,
comparisonScrollRef,
handleScrollSync,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
isPanMode,
setIsPanMode,
baseZoom,
setBaseZoom,
comparisonZoom,
setComparisonZoom,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
clearScrollLinkDelta,
captureScrollLinkDelta,
setIsScrollLinked,
isScrollLinked,
zoomLimits,
} = useComparePanZoom({
basePages,
comparisonPages,
prefersStacked,
});
const {
baseWordChanges,
comparisonWordChanges,
metaIndexToGroupId,
wordHighlightMaps,
getRowHeightPx,
} = useCompareHighlights(result, basePages, comparisonPages);
const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => {
const wasLinked = isScrollLinked;
if (wasLinked) setIsScrollLinked(false);
try {
fn();
} finally {
window.setTimeout(() => {
if (wasLinked) {
// recapture anchors to keep panes aligned when relinking
captureScrollLinkDelta();
setIsScrollLinked(true);
}
}, Math.max(200, durationMs));
}
}, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]);
const handleChangeNavigation = useCompareChangeNavigation(
baseScrollRef,
comparisonScrollRef,
{ temporarilySuppressScrollLink }
);
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document');
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document');
const pageLabel = t('compare.summary.pageLabel', 'Page');
// Always show the selected file names from the sidebar; they are known before diff results
const baseTitle = baseStub?.name || result?.base?.fileName || '';
const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || '';
// During diff processing, show compact spinners in the dropdown badges
const baseDropdownPlaceholder = (isOperationLoading || !result)
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
const rightRailButtons = useCompareRightRailButtons({
layout,
toggleLayout,
isPanMode,
setIsPanMode,
baseZoom,
comparisonZoom,
setBaseZoom,
setComparisonZoom,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
clearScrollLinkDelta,
captureScrollLinkDelta,
isScrollLinked,
setIsScrollLinked,
zoomLimits,
baseScrollRef,
comparisonScrollRef,
});
useRightRailButtons(rightRailButtons);
// Rendering progress toast for very large PDFs
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0;
const showProgressBanner = useMemo(() => (
computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD)
), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered);
const progressToastIdRef = useRef<string | null>(null);
const completionTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (completionTimerRef.current != null) {
window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = null;
}
};
}, []);
const allDone = useMemo(() => {
const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length);
const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length);
return baseDone && compDone;
}, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]);
// Drive toast lifecycle and progress updates
useEffect(() => {
// No toast needed
if (!showProgressBanner) {
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = null;
}
return;
}
const countsText = computeCountsText(
baseRendered,
baseTotal,
basePages.length,
compRendered,
compTotal,
comparisonPages.length,
);
if (!allDone) {
// Create toast if missing
if (!progressToastIdRef.current) {
const id = alert({
alertType: 'neutral',
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
durationMs: 0,
expandable: false,
progressBarPercentage: progressPct,
});
progressToastIdRef.current = id;
} else {
updateToast(progressToastIdRef.current, {
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
alertType: 'neutral', // ensure it stays neutral until completion
});
updateToastProgress(progressToastIdRef.current, progressPct);
}
} else {
// Completed: update then auto-dismiss after 3s
if (progressToastIdRef.current) {
updateToast(progressToastIdRef.current, {
title: t('compare.rendering.complete', 'Page rendering complete'),
body: undefined,
isPersistentPopup: false,
durationMs: 3000,
});
updateToastProgress(progressToastIdRef.current, 100);
if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = window.setTimeout(() => {
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = null;
}
if (completionTimerRef.current != null) {
window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
}, 3000);
}
}
return () => {
if (completionTimerRef.current != null) {
window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
};
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
// Shared page navigation state/input
const maxSharedPages = useMemo(() => (
computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length)
), [baseTotal, compTotal, basePages.length, comparisonPages.length]);
const [pageInputValue, setPageInputValue] = useState<string>('1');
const typingTimerRef = useRef<number | null>(null);
const isTypingRef = useRef(false);
// Clamp the displayed input if max changes smaller than current
useEffect(() => {
if (!pageInputValue) return;
const n = Math.max(1, parseInt(pageInputValue, 10) || 1);
if (maxSharedPages > 0 && n > maxSharedPages) {
setPageInputValue(String(maxSharedPages));
}
}, [maxSharedPages]);
const scrollBothToPage = useCallback((pageNum: number) => {
const scrollOne = (container: HTMLDivElement | null) => {
if (!container) return false;
const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null;
if (!pageEl) return false;
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2)));
container.scrollTop = desired;
return true;
};
const hitBase = scrollOne(baseScrollRef.current);
const hitComp = scrollOne(comparisonScrollRef.current);
// Warn if one or both pages are not yet rendered
const baseHas = basePages.some(p => p.pageNumber === pageNum);
const compHas = comparisonPages.some(p => p.pageNumber === pageNum);
if (!baseHas || !compHas) {
alert({
alertType: 'warning',
title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'),
body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: false,
durationMs: 2500,
});
}
return hitBase || hitComp;
}, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]);
const handleTypingChange = useCallback((next: string) => {
// Only digits; allow empty while editing
const digits = next.replace(/[^0-9]/g, '');
if (digits.length === 0) {
setPageInputValue('');
if (typingTimerRef.current != null) {
window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
return;
}
const parsed = Math.max(1, parseInt(digits, 10));
const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed;
const display = String(capped);
setPageInputValue(display);
isTypingRef.current = true;
if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = window.setTimeout(() => {
isTypingRef.current = false;
scrollBothToPage(capped);
}, 300);
}, [maxSharedPages, scrollBothToPage]);
return (
<Stack className="compare-workbench">
<Stack gap="lg" className="compare-workbench__content">
<div
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
>
<CompareDocumentPane
pane="base"
layout={layout}
scrollRef={baseScrollRef}
peerScrollRef={comparisonScrollRef}
handleScrollSync={handleScrollSync}
handleWheelZoom={handleWheelZoom}
handleWheelOverscroll={handleWheelOverscroll}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
isPanMode={isPanMode}
zoom={baseZoom}
title={baseTitle}
dropdownPlaceholder={baseDropdownPlaceholder}
changes={mapChangesForDropdown(baseWordChanges)}
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)}
isLoading={isOperationLoading || baseLoading}
processingMessage={processingMessage}
pages={basePages}
pairedPages={comparisonPages}
getRowHeightPx={getRowHeightPx}
wordHighlightMap={wordHighlightMaps.base}
metaIndexToGroupId={metaIndexToGroupId.base}
documentLabel={baseDocumentLabel}
pageLabel={pageLabel}
altLabel={baseDocumentLabel}
pageInputValue={pageInputValue}
onPageInputChange={handleTypingChange}
maxSharedPages={maxSharedPages}
/>
<CompareDocumentPane
pane="comparison"
layout={layout}
scrollRef={comparisonScrollRef}
peerScrollRef={baseScrollRef}
handleScrollSync={handleScrollSync}
handleWheelZoom={handleWheelZoom}
handleWheelOverscroll={handleWheelOverscroll}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
isPanMode={isPanMode}
zoom={comparisonZoom}
title={comparisonTitle}
dropdownPlaceholder={comparisonDropdownPlaceholder}
changes={mapChangesForDropdown(comparisonWordChanges)}
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)}
isLoading={isOperationLoading || comparisonLoading}
processingMessage={processingMessage}
pages={comparisonPages}
pairedPages={basePages}
getRowHeightPx={getRowHeightPx}
wordHighlightMap={wordHighlightMaps.comparison}
metaIndexToGroupId={metaIndexToGroupId.comparison}
documentLabel={comparisonDocumentLabel}
pageLabel={pageLabel}
altLabel={comparisonDocumentLabel}
pageInputValue={pageInputValue}
onPageInputChange={handleTypingChange}
maxSharedPages={maxSharedPages}
/>
</div>
</Stack>
</Stack>
);
};
export default CompareWorkbenchView;
@@ -0,0 +1,239 @@
import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare';
import type { FileId } from '@app/types/file';
import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
import type { PagePreview } from '@app/types/compare';
/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */
export const toRgba = (hexColor: string, alpha: number): string => {
const hex = hexColor.replace('#', '');
if (hex.length !== 6) return hexColor;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
/** Normalize rotation to [0, 360). */
export const normalizeRotation = (deg: number | undefined | null): number => {
const n = ((deg ?? 0) % 360 + 360) % 360;
return n;
};
/**
* Merge overlapping or touching rectangles into larger non-overlapping blocks.
* Robust across rotations (vertical groups) and prevents dark spots from overlaps.
*/
export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
if (rects.length === 0) return rects;
const EPS = 0.004; // small tolerance in normalized page coords
const sorted = rects
.slice()
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
const merged: TokenBoundingBox[] = [];
const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => {
const aR = a.left + a.width;
const aB = a.top + a.height;
const bR = b.left + b.width;
const bB = b.top + b.height;
return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS);
};
for (const r of sorted) {
let mergedIntoExisting = false;
for (let i = 0; i < merged.length; i += 1) {
const m = merged[i];
if (overlapsOrTouches(m, r)) {
const left = Math.min(m.left, r.left);
const top = Math.min(m.top, r.top);
const right = Math.max(m.left + m.width, r.left + r.width);
const bottom = Math.max(m.top + m.height, r.top + r.height);
merged[i] = {
left,
top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
mergedIntoExisting = true;
break;
}
}
if (!mergedIntoExisting) merged.push({ ...r });
}
return merged;
};
/** Group word rectangles by change id using metaIndexToGroupId. */
export const groupWordRects = (
wordRects: WordHighlightEntry[],
metaIndexToGroupId: Map<number, string>,
pane: 'base' | 'comparison'
): Map<string, TokenBoundingBox[]> => {
const groupedRects = new Map<string, TokenBoundingBox[]>();
for (const { rect, metaIndex } of wordRects) {
const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`;
const current = groupedRects.get(id) ?? [];
current.push(rect);
groupedRects.set(id, current);
}
return groupedRects;
};
/** Compute derived layout metrics for a page render, given environment and zoom. */
export const computePageLayoutMetrics = (args: {
page: PagePreview;
peerPage?: PagePreview | null;
layout: 'side-by-side' | 'stacked';
isMobileViewport: boolean;
scrollRefWidth: number | null;
viewportWidth: number;
zoom: number;
offsetPixels: number; // highlight offset in px relative to original page height
}) => {
const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args;
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
const fit = targetHeight / page.height;
const highlightOffset = offsetPixels / page.height;
const rotationNorm = normalizeRotation(page.rotation);
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
const isStackedPortrait = layout === 'stacked' && isPortrait;
const containerW = scrollRefWidth ?? viewportWidth;
const stackedWidth = isMobileViewport
? Math.max(320, Math.round(containerW))
: Math.max(320, Math.round(viewportWidth * 0.5));
const stackedHeight = Math.round(stackedWidth * 1.4142);
const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit);
const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight);
const containerMaxW = scrollRefWidth ?? viewportWidth;
// Container-first zooming with a stable baseline:
// Treat zoom=1 as "fit to available width" for the page's base size so
// the initial render is fully visible and centered (no cropping), regardless
// of rotation or pane/container width. When zoom < 1, shrink the container;
// when zoom > 1, keep the container at fit width and scale inner content.
const MIN_CONTAINER_WIDTH = 120;
const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth);
const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth);
// Effective baseline scale used at zoom=1 (ensures at least the min width)
const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer);
// Lower bound the zoom so interactions remain stable
const desiredZoom = Math.max(0.1, zoom);
let containerScale: number;
let innerScale: number;
if (desiredZoom >= 1) {
// At or above baseline: keep container at fit width and scale inner content
containerScale = baselineContainerScale;
innerScale = +Math.max(0.1, desiredZoom).toFixed(4);
} else {
// Below baseline: shrink container proportionally, do not upscale inner
const scaled = baselineContainerScale * desiredZoom;
// Never smaller than minimum readable width
containerScale = Math.max(minScaleByWidth, scaled);
innerScale = 1;
}
const containerWidth = Math.max(
MIN_CONTAINER_WIDTH,
Math.min(containerMaxW, Math.round(baseWidth * containerScale))
);
const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth)));
return {
targetHeight,
fit,
highlightOffset,
rotationNorm,
isPortrait,
isStackedPortrait,
baseWidth,
baseHeight,
containerMaxW,
containerWidth,
containerHeight,
innerScale,
};
};
/** Map changes to dropdown options tuple. */
export const mapChangesForDropdown = (
changes: Array<{ value: string; label: string; pageNumber: number }>
) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber }));
/** File selection helpers */
export const getFileFromSelection = (
explicit: StirlingFile | null | undefined,
fileId: FileId | null,
selectors: { getFile: (id: FileId) => StirlingFile | undefined | null }
): StirlingFile | null => {
if (explicit) return explicit;
if (!fileId) return null;
return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null;
};
export const getStubFromSelection = (
fileId: FileId | null,
selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined }
): StirlingFileStub | null => {
if (!fileId) return null;
const stub = selectors.getStirlingFileStub(fileId);
return stub ?? null;
};
/** Progress banner computations */
export const computeShowProgressBanner = (
totalsKnown: boolean,
baseTotal: number | null | undefined,
compTotal: number | null | undefined,
baseLoading: boolean,
compLoading: boolean,
threshold: number = 400
): boolean => {
if (!totalsKnown) return false;
const totals = [baseTotal ?? 0, compTotal ?? 0];
return Math.max(...totals) >= threshold && (baseLoading || compLoading);
};
export const computeProgressPct = (
totalsKnown: boolean,
baseTotal: number | null | undefined,
compTotal: number | null | undefined,
baseRendered: number,
compRendered: number
): number => {
const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0;
const renderedCombined = baseRendered + compRendered;
return totalsKnown && totalCombined > 0
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
: 0;
};
export const computeCountsText = (
baseRendered: number,
baseTotal: number | null | undefined,
baseLength: number,
compRendered: number,
compTotal: number | null | undefined,
compLength: number
): string => {
const baseTotalShown = baseTotal || baseLength;
const compTotalShown = compTotal || compLength;
return `${baseRendered}/${baseTotalShown} • ${compRendered}/${compTotalShown}`;
};
export const computeMaxSharedPages = (
baseTotal: number | null | undefined,
compTotal: number | null | undefined,
baseLen: number,
compLen: number
): number => {
const baseMax = baseTotal || baseLen || 0;
const compMax = compTotal || compLen || 0;
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
if (!Number.isFinite(minKnown)) return 0;
return Math.max(0, minKnown);
};
@@ -0,0 +1,516 @@
.compare-dropdown-scrollwrap {
position: relative;
}
.compare-dropdown-sticky {
position: sticky;
z-index: 2;
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-subtle);
pointer-events: none;
}
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
border-bottom: 1px solid var(--border-default);
}
.compare-workbench {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
padding-top: 3rem;
height: 100%;
min-height: 0;
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
min-width: 0;
}
.compare-workbench__content {
flex: 1;
min-height: 0;
}
.compare-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.compare-pane__scroll {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.compare-pane__content {
position: relative;
}
.compare-workbench__mode {
align-self: center;
}
.compare-workbench__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
grid-auto-rows: 1fr;
}
/* Stacked mode: two rows with synchronized scroll panes */
.compare-workbench__columns--stacked {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.compare-workbench__columns > div {
/* Critical for responsive flex children inside non-wrapping layouts */
min-height: 0;
min-width: 0;
}
.compare-legend {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Sticky header styling overrides */
.compare-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-toolbar);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-default);
padding: 0.5rem;
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
}
/* Dropdown badge-like style - only style the dropdowns, not titles */
.compare-changes-select {
background: var(--spdf-compare-removed-badge-bg) !important;
color: var(--spdf-compare-removed-badge-fg) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 500 !important;
cursor: pointer;
min-width: 200px;
padding: 0.375rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem !important;
box-sizing: border-box;
}
.compare-changes-select__placeholder {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.compare-changes-select__placeholder .mantine-Loader-root {
display: inline-flex;
margin: 0 0.125rem;
}
.compare-changes-select--comparison {
background: var(--spdf-compare-added-badge-bg) !important;
color: var(--spdf-compare-added-badge-fg) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 500 !important;
}
/* Wider dropdown menu for long block text */
.compare-changes-dropdown {
min-width: 520px !important;
max-width: 70vw !important;
padding: 0 !important;
overflow: hidden; /* prevent inner elements from overflowing rounded edges */
border-radius: 8px !important; /* match dropdown container radius */
}
/* Ensure options text uses full width inside wider dropdown */
.compare-dropdown-option__text {
max-width: 100%;
}
/* Remove default padding inside Mantine options list so sticky header touches edges */
.compare-changes-dropdown .mantine-Combobox-options {
padding: 0 !important;
}
.compare-dropdown-scrollwrap { margin: 0; }
/* Ensure search field sits flush and does not overflow */
.compare-changes-dropdown .mantine-Combobox-search {
box-sizing: border-box;
width: 100% !important;
margin: 0 !important;
border-top-left-radius: 8px !important;
border-top-right-radius: 8px !important;
}
/* Style the dropdown container */
.compare-changes-select .mantine-Combobox-dropdown {
border: 1px solid var(--border-subtle) !important;
border-radius: 8px !important;
box-shadow: var(--shadow-md) !important;
background-color: var(--bg-surface) !important;
}
.compare-changes-select--comparison .mantine-Combobox-dropdown {
border: 1px solid var(--border-subtle) !important;
border-radius: 8px !important;
box-shadow: var(--shadow-md) !important;
background-color: var(--bg-surface) !important;
}
/* Custom scrollbar for ScrollArea */
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
width: 6px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
background: var(--bg-muted) !important;
border-radius: 3px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
background: var(--border-strong) !important;
border-radius: 3px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
background: var(--text-muted) !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
width: 6px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
background: var(--bg-muted) !important;
border-radius: 3px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
background: var(--border-strong) !important;
border-radius: 3px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
background: var(--text-muted) !important;
}
/* Style the dropdown options */
.compare-changes-select .mantine-Combobox-option {
font-size: 0.875rem !important;
padding: 8px 12px !important;
}
.compare-changes-select--comparison .mantine-Combobox-option {
font-size: 0.875rem !important;
padding: 8px 12px !important;
}
.compare-changes-select .mantine-Combobox-option:hover {
background-color: var(--spdf-compare-removed-badge-bg) !important;
}
.compare-changes-select--comparison .mantine-Combobox-option:hover {
background-color: var(--spdf-compare-added-badge-bg) !important;
}
/* Style the search input */
.compare-changes-select .mantine-Combobox-search {
font-size: 0.875rem !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.compare-changes-select--comparison .mantine-Combobox-search {
font-size: 0.875rem !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.compare-changes-select .mantine-Combobox-search::placeholder {
color: var(--text-muted) !important;
}
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
color: var(--text-muted) !important;
}
/* Style the chevron - ensure proper coloring */
.compare-changes-select .mantine-Combobox-chevron,
.compare-changes-select--comparison .mantine-Combobox-chevron {
color: inherit !important;
margin-left: 0.5rem;
}
/* Flash/pulse highlight for navigated change */
@keyframes compare-flash {
0% {
outline: 4px solid rgba(255, 235, 59, 0.0);
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
background-color: rgba(255, 235, 59, 0.2) !important;
}
25% {
outline: 4px solid rgba(255, 235, 59, 1.0);
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
background-color: rgba(255, 235, 59, 0.4) !important;
}
50% {
outline: 4px solid rgba(255, 235, 59, 1.0);
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
background-color: rgba(255, 235, 59, 0.5) !important;
}
75% {
outline: 4px solid rgba(255, 235, 59, 0.8);
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
background-color: rgba(255, 235, 59, 0.3) !important;
}
100% {
outline: 4px solid rgba(255, 235, 59, 0.0);
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
background-color: rgba(255, 235, 59, 0.0) !important;
}
}
.compare-diff-highlight--flash {
animation: compare-flash 1.5s ease-in-out 1;
z-index: 1000;
position: relative;
/* Bonus: temporarily override red/green to yellow during flash for clarity */
background-color: rgba(255, 235, 59, 0.5) !important;
}
/* Union overlay for group flash */
.compare-diff-flash-overlay {
animation: compare-flash 1.5s ease-in-out 1;
z-index: 999;
background-color: rgba(255, 235, 59, 0.4);
pointer-events: none;
border-radius: 2px;
}
.compare-legend__item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
}
.compare-legend__swatch {
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.15);
}
.compare-summary__stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.compare-summary__stat-card {
flex: 1;
min-width: 12rem;
}
.compare-summary__segment {
border: 1px solid var(--mantine-color-gray-3);
border-radius: 0.5rem;
padding: 0.75rem;
background-color: var(--mantine-color-gray-0);
}
.compare-diff-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.compare-diff-page__canvas {
position: relative;
border: 1px solid var(--border-strong);
border-radius: 0.75rem;
overflow: hidden;
background-color: var(--bg-surface);
width: 100%;
}
/* Center canvas in stacked portrait mode (width/height set inline) */
.compare-diff-page__canvas[style*="margin-left: auto"] {
display: block;
}
.compare-diff-page__canvas--zoom {
overflow: hidden;
max-width: 100%;
}
.compare-diff-page__inner {
position: relative;
width: 100%;
margin-left: auto;
margin-right: auto;
max-width: 100%;
background-color: #fff; /* ensure stable white backing during load */
border: 1px solid var(--border-subtle);
will-change: transform;
}
.compare-diff-page__image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Centered per-page title wrapper */
.compare-page-title {
text-align: center;
margin-left: auto;
margin-right: auto;
}
.compare-page-title .mantine-Text-root {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background-color: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
}
/* Overlay loader to avoid flash when image not yet loaded */
.compare-page-loader-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background-color: rgba(255, 255, 255, 0.9);
}
.compare-diff-highlight {
position: absolute;
pointer-events: none;
mix-blend-mode: normal;
}
/* Compare dropdown option formatting (page + clamped text) */
.compare-dropdown-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.compare-dropdown-option__page {
font-size: 0.7rem;
color: var(--text-muted);
}
.compare-dropdown-option__text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-clamp: 3;
}
/* Non-sticky in-flow group headers; sticky handled by floating header */
.compare-dropdown-group {
position: static;
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-subtle);
}
.compare-dropdown-group.compare-dropdown-group--hidden {
height: 0;
padding: 0;
margin: 0;
border: 0;
overflow: hidden;
}
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
border-bottom: 1px solid var(--border-default);
}
/* Light grey rendering flag next to page labels in the dropdown */
.compare-dropdown-rendering-flag {
color: var(--text-muted);
margin-left: 0.25rem;
}
/* Inline paragraph highlights in summary */
.compare-inline {
border-radius: 0.2rem;
padding: 0.05rem 0.15rem;
}
.compare-inline--removed {
background-color: var(--spdf-compare-inline-removed-bg);
}
.compare-inline--added {
background-color: var(--spdf-compare-inline-added-bg);
}
.compare-pane-header {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-background);
padding: 0.25rem 0;
}
/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */
.compare-tool__thumbnail {
width: 4rem;
height: 5.25rem;
flex-shrink: 0;
}
.compare-tool__details {
flex: 1;
min-width: 0;
}
/* Mobile: remove side margins and let canvases take full width inside column */
@media (max-width: 768px) {
.compare-workbench__columns {
grid-template-columns: 1fr;
}
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.compare-diff-page__inner {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@@ -0,0 +1,247 @@
import { RefObject, useCallback } from 'react';
type Pane = 'base' | 'comparison';
type SuppressOptions = {
temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void;
};
export const useCompareChangeNavigation = (
baseScrollRef: RefObject<HTMLDivElement | null>,
comparisonScrollRef: RefObject<HTMLDivElement | null>,
options?: SuppressOptions,
) => {
return useCallback(
(changeValue: string, pane: Pane, pageNumber?: number) => {
const suppress = <T extends void>(fn: () => T) => {
if (options?.temporarilySuppressScrollLink) {
options.temporarilySuppressScrollLink(fn, 700);
} else {
fn();
}
};
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
const container = targetRef.current;
if (!container) {
return;
}
const findNodes = (): HTMLElement[] => {
return Array.from(
container.querySelectorAll(`[data-change-id="${changeValue}"]`)
) as HTMLElement[];
};
const scrollToPageIfNeeded = () => {
if (!pageNumber) return false;
const pageEl = container.querySelector(
`.compare-diff-page[data-page-number="${pageNumber}"]`
) as HTMLElement | null;
if (!pageEl) return false;
const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2);
suppress(() => {
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
});
return true;
};
const scrollPeerPageIfPossible = () => {
if (!pageNumber) return;
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
const peer = peerRef.current;
if (!peer) return;
const peerPageEl = peer.querySelector(
`.compare-diff-page[data-page-number="${pageNumber}"]`
) as HTMLElement | null;
if (!peerPageEl) return;
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
const top = Math.max(
0,
Math.min(
peerMaxTop,
peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)
)
);
suppress(() => {
peer.scrollTo({ top, behavior: 'auto' });
});
};
const proceedWithNodes = (nodes: HTMLElement[]) => {
if (nodes.length === 0) return;
// Prefer a percent-in-page based vertical scroll, which is resilient to transforms.
const anchor = nodes[0];
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null;
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
if (pageEl && inner && !Number.isNaN(topPercent)) {
const innerRect = inner.getBoundingClientRect();
const innerHeight = Math.max(1, innerRect.height);
const absoluteTopInPage = (topPercent / 100) * innerHeight;
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
const desiredTop = Math.max(
0,
Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2)
);
suppress(() => {
container.scrollTo({ top: desiredTop, behavior: 'auto' });
});
} else {
// Fallback to bounding-rect based centering if percent approach is unavailable.
const containerRect = container.getBoundingClientRect();
let minTop = Number.POSITIVE_INFINITY;
let minLeft = Number.POSITIVE_INFINITY;
let maxBottom = Number.NEGATIVE_INFINITY;
let maxRight = Number.NEGATIVE_INFINITY;
nodes.forEach((element) => {
const rect = element.getBoundingClientRect();
minTop = Math.min(minTop, rect.top);
minLeft = Math.min(minLeft, rect.left);
maxBottom = Math.max(maxBottom, rect.bottom);
maxRight = Math.max(maxRight, rect.right);
});
const boxHeight = Math.max(1, maxBottom - minTop);
const boxWidth = Math.max(1, maxRight - minLeft);
const absoluteTop = minTop - containerRect.top + container.scrollTop;
const absoluteLeft = minLeft - containerRect.left + container.scrollLeft;
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2));
const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2);
suppress(() => {
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' });
});
}
// Also scroll the peer container to the corresponding location in the
// other PDF (same page and approximate vertical position within page),
// not just the same list/scroll position.
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
const peer = peerRef.current;
if (peer) {
// Use the first node as the anchor
const anchor = nodes[0];
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
const pageNumAttr = pageEl?.getAttribute('data-page-number');
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
if (pageNumAttr) {
const peerPageEl = peer.querySelector(
`.compare-diff-page[data-page-number="${pageNumAttr}"]`
) as HTMLElement | null;
const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null;
if (peerPageEl && peerInner) {
const innerRect = peerInner.getBoundingClientRect();
const innerHeight = Math.max(1, innerRect.height);
const absoluteTopInPage = (topPercent / 100) * innerHeight;
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
const peerDesiredTop = Math.max(
0,
Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2)
);
suppress(() => {
peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' });
});
} else if (peerPageEl) {
// Fallback: Scroll to page top (clamped)
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)));
suppress(() => {
peer.scrollTo({ top, behavior: 'auto' });
});
}
}
}
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
nodes.forEach((element) => {
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
if (!inner) return;
const list = groupsByInner.get(inner) ?? [];
list.push(element);
groupsByInner.set(inner, list);
});
groupsByInner.forEach((elements, inner) => {
let minL = 100;
let minT = 100;
let maxR = 0;
let maxB = 0;
elements.forEach((element) => {
const leftPercent = parseFloat(element.style.left) || 0;
const topPercent = parseFloat(element.style.top) || 0;
const widthPercent = parseFloat(element.style.width) || 0;
const heightPercent = parseFloat(element.style.height) || 0;
minL = Math.min(minL, leftPercent);
minT = Math.min(minT, topPercent);
maxR = Math.max(maxR, leftPercent + widthPercent);
maxB = Math.max(maxB, topPercent + heightPercent);
});
const overlay = document.createElement('span');
overlay.className = 'compare-diff-flash-overlay';
overlay.style.position = 'absolute';
overlay.style.left = `${minL}%`;
overlay.style.top = `${minT}%`;
overlay.style.width = `${Math.max(0.1, maxR - minL)}%`;
overlay.style.height = `${Math.max(0.1, maxB - minT)}%`;
inner.appendChild(overlay);
window.setTimeout(() => overlay.remove(), 1600);
});
nodes.forEach((element) => {
element.classList.remove('compare-diff-highlight--flash');
});
void container.clientWidth; // Force reflow
nodes.forEach((element) => {
element.classList.add('compare-diff-highlight--flash');
window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600);
});
};
const nodes = findNodes();
if (nodes.length > 0) {
proceedWithNodes(nodes);
return;
}
// Page-level fallback immediately so the user sees something happen
const scrolledPage = scrollToPageIfNeeded();
if (scrolledPage) {
scrollPeerPageIfPossible();
} else {
// Even if the page element is not present yet, try to nudge peer pane
scrollPeerPageIfPossible();
}
// Wait for highlights to mount (pages/images render progressively)
let settled = false;
const observer = new MutationObserver(() => {
if (settled) return;
const n = findNodes();
if (n.length > 0) {
settled = true;
observer.disconnect();
proceedWithNodes(n);
}
});
try {
observer.observe(container, { childList: true, subtree: true });
} catch {
// noop
}
// Safety timeout to stop waiting after a while
window.setTimeout(() => {
if (settled) return;
settled = true;
observer.disconnect();
// We already scrolled to the page above; nothing else to do.
}, 5000);
},
[baseScrollRef, comparisonScrollRef]
);
};
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
@@ -0,0 +1,144 @@
import { useCallback, useMemo } from 'react';
import type {
CompareFilteredTokenInfo,
WordHighlightEntry,
CompareResultData,
CompareChangeOption,
PagePreview,
} from '@app/types/compare';
interface MetaGroupMap {
base: Map<number, string>;
comparison: Map<number, string>;
}
interface WordHighlightMaps {
base: Map<number, WordHighlightEntry[]>;
comparison: Map<number, WordHighlightEntry[]>;
}
export interface UseCompareHighlightsResult {
baseWordChanges: CompareChangeOption[];
comparisonWordChanges: CompareChangeOption[];
metaIndexToGroupId: MetaGroupMap;
wordHighlightMaps: WordHighlightMaps;
getRowHeightPx: (pageNumber: number) => number;
}
const buildWordChanges = (
tokens: CompareFilteredTokenInfo[],
metaIndexToGroupId: Map<number, string>,
groupPrefix: string
): CompareChangeOption[] => {
metaIndexToGroupId.clear();
if (!tokens.length) return [];
const items: CompareChangeOption[] = [];
let currentRun: CompareFilteredTokenInfo[] = [];
const flushRun = () => {
if (currentRun.length === 0) return;
const label = currentRun.map((token) => token.token).join(' ').trim();
if (label.length === 0) {
currentRun = [];
return;
}
const first = currentRun[0];
const last = currentRun[currentRun.length - 1];
const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`;
currentRun.forEach((token) => {
metaIndexToGroupId.set(token.metaIndex, groupId);
});
const pageNumber = first.page ?? last.page ?? 1;
items.push({ value: groupId, label, pageNumber });
currentRun = [];
};
for (const token of tokens) {
if (token.hasHighlight && token.bbox) {
currentRun.push(token);
} else {
flushRun();
}
}
flushRun();
return items;
};
const buildHighlightMap = (
tokens: CompareFilteredTokenInfo[]
): Map<number, WordHighlightEntry[]> => {
const map = new Map<number, WordHighlightEntry[]>();
for (const token of tokens) {
if (!token.hasHighlight || !token.bbox || token.page == null) continue;
const list = map.get(token.page) ?? [];
list.push({ rect: token.bbox, metaIndex: token.metaIndex });
map.set(token.page, list);
}
return map;
};
export const useCompareHighlights = (
result: CompareResultData | null,
basePages: PagePreview[],
comparisonPages: PagePreview[],
): UseCompareHighlightsResult => {
const baseMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
const comparisonMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
const baseWordChanges = useMemo(() => {
if (!result) return [];
return buildWordChanges(
result.filteredTokenData.base,
baseMetaIndexToGroupId,
'base-group'
);
}, [baseMetaIndexToGroupId, result]);
const comparisonWordChanges = useMemo(() => {
if (!result) return [];
return buildWordChanges(
result.filteredTokenData.comparison,
comparisonMetaIndexToGroupId,
'comparison-group'
);
}, [comparisonMetaIndexToGroupId, result]);
const wordHighlightMaps = useMemo(() => {
if (!result) {
return {
base: new Map<number, WordHighlightEntry[]>(),
comparison: new Map<number, WordHighlightEntry[]>(),
};
}
return {
base: buildHighlightMap(result.filteredTokenData.base),
comparison: buildHighlightMap(result.filteredTokenData.comparison),
};
}, [result]);
const getRowHeightPx = useCallback(
(pageNumber: number) => {
const basePage = basePages.find((page) => page.pageNumber === pageNumber);
const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber);
const baseHeight = basePage ? basePage.height : 0;
const comparisonHeight = comparisonPage ? comparisonPage.height : 0;
const rowHeight = Math.max(baseHeight, comparisonHeight);
return Math.round(rowHeight);
},
[basePages, comparisonPages]
);
return {
baseWordChanges,
comparisonWordChanges,
metaIndexToGroupId: {
base: baseMetaIndexToGroupId,
comparison: comparisonMetaIndexToGroupId,
},
wordHighlightMaps,
getRowHeightPx,
};
};
@@ -0,0 +1,253 @@
import { useEffect, useRef, useState } from 'react';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import type { PagePreview } from '@app/types/compare';
const DISPLAY_SCALE = 1;
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
// Observable preview cache so rendering progress can resume across remounts and view switches
type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> };
const previewCache: Map<string, CacheEntry> = new Map();
const latestVersionMap: Map<string, symbol> = new Map();
const getOrCreateEntry = (key: string): CacheEntry => {
let entry = previewCache.get(key);
if (!entry) {
entry = { pages: [], total: 0, subscribers: new Set() };
previewCache.set(key, entry);
}
return entry;
};
const notify = (entry: CacheEntry) => {
entry.subscribers.forEach((fn) => {
try { fn(); } catch { /* no-op */ }
});
};
const subscribe = (key: string, fn: () => void): (() => void) => {
const entry = getOrCreateEntry(key);
entry.subscribers.add(fn);
return () => entry.subscribers.delete(fn);
};
const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => {
const entry = getOrCreateEntry(key);
const next = entry.pages.slice();
for (const p of batch) {
const idx = next.findIndex((x) => x.pageNumber > p.pageNumber);
if (idx === -1) next.push(p); else next.splice(idx, 0, p);
}
entry.pages = next;
if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal;
notify(entry);
};
const setTotalInCache = (key: string, total: number) => {
const entry = getOrCreateEntry(key);
entry.total = total;
notify(entry);
};
const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => {
const entry = getOrCreateEntry(key);
entry.pages = pages.slice();
if (typeof total === 'number') entry.total = total;
notify(entry);
};
const renderPdfDocumentToImages = async (
file: File,
onBatch?: (previews: PagePreview[]) => void,
batchSize: number = 12,
onInitTotal?: (totalPages: number) => void,
startAtPage: number = 1,
shouldAbort?: () => boolean,
): Promise<PagePreview[]> => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const previews: PagePreview[] = [];
const dpr = getDevicePixelRatio();
const renderScale = Math.max(2, Math.min(3, dpr * 2));
onInitTotal?.(pdf.numPages);
let batch: PagePreview[] = [];
const shouldStop = () => Boolean(shouldAbort?.());
for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) {
if (shouldStop()) break;
const page = await pdf.getPage(pageNumber);
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
const renderViewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = Math.round(renderViewport.width);
canvas.height = Math.round(renderViewport.height);
if (!context) {
page.cleanup();
continue;
}
try {
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
if (shouldStop()) break;
const preview: PagePreview = {
pageNumber,
width: Math.round(displayViewport.width),
height: Math.round(displayViewport.height),
rotation: (page.rotate || 0) % 360,
url: canvas.toDataURL(),
};
previews.push(preview);
if (onBatch) {
batch.push(preview);
if (batch.length >= batchSize) {
onBatch(batch);
batch = [];
}
}
} finally {
page.cleanup();
canvas.width = 0;
canvas.height = 0;
}
if (shouldStop()) break;
}
if (onBatch && batch.length > 0) onBatch(batch);
return previews;
} finally {
pdfWorkerManager.destroyDocument(pdf);
}
};
interface UseComparePagePreviewsOptions {
file: File | null;
enabled: boolean;
cacheKey: number | null;
}
export const useComparePagePreviews = ({
file,
enabled,
cacheKey,
}: UseComparePagePreviewsOptions) => {
const [pages, setPages] = useState<PagePreview[]>([]);
const [loading, setLoading] = useState(false);
const [totalPages, setTotalPages] = useState(0);
const inFlightRef = useRef(0);
useEffect(() => {
let cancelled = false;
if (!file || !enabled) {
setPages([]);
setLoading(false);
setTotalPages(0);
return () => {
cancelled = true;
};
}
const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`;
const refreshVersion = Symbol(key);
latestVersionMap.set(key, refreshVersion);
const entry = getOrCreateEntry(key);
const cachedTotal = entry.total ?? (entry.pages.length ?? 0);
let lastKnownTotal = cachedTotal;
const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
if (entry.pages.length > 0) {
const nextPages = entry.pages.slice();
setPages(nextPages);
setTotalPages(cachedTotal);
} else {
setTotalPages(0);
}
setLoading(!isFullyCached);
const unsubscribe = subscribe(key, () => {
const e = getOrCreateEntry(key);
setPages(e.pages.slice());
setTotalPages(e.total);
const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total;
setLoading(!done);
});
if (isFullyCached) {
return () => {
cancelled = true;
};
}
const render = async () => {
setLoading(true);
try {
inFlightRef.current += 1;
const current = inFlightRef.current;
const startAt = (entry?.pages?.length ?? 0) + 1;
const previews = await renderPdfDocumentToImages(
file,
(batch) => {
if (cancelled || current !== inFlightRef.current) return;
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
},
16,
(total) => {
if (!cancelled && current === inFlightRef.current) {
lastKnownTotal = total;
setTotalInCache(key, total);
}
},
startAt,
() => cancelled || current !== inFlightRef.current
);
if (!cancelled && current === inFlightRef.current) {
const stillLatest = latestVersionMap.get(key) === refreshVersion;
if (!stillLatest) {
return;
}
const cacheEntry = getOrCreateEntry(key);
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
lastKnownTotal = finalTotal;
const cachePages = cacheEntry.pages ?? [];
const preferPreviews = previews.length > cachePages.length;
const finalPages = preferPreviews ? previews.slice() : cachePages.slice();
replacePagesInCache(key, finalPages, finalTotal);
}
} catch (error) {
console.error('[compare] failed to render document preview', error);
if (!cancelled) {
setPages([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
render();
return () => {
cancelled = true;
unsubscribe();
};
}, [file, enabled, cacheKey]);
return { pages, loading, totalPages, renderedPages: pages.length };
};
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
@@ -0,0 +1,895 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
WheelEvent as ReactWheelEvent,
} from 'react';
import type {
PagePreview,
ComparePane as Pane,
PanState,
ScrollLinkDelta,
ScrollLinkAnchors,
PanDragState,
PinchState,
UseComparePanZoomOptions,
UseComparePanZoomReturn,
} from '@app/types/compare';
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 100000;
const ZOOM_STEP = 0.1;
// Default structural adjustments applied to each rendered page row. These are
// refined at runtime via DOM measurements once the panes have mounted.
const DEFAULT_ROW_STRUCTURAL_EXTRA = 32;
const DEFAULT_ROW_GAP = 8;
// (Interfaces moved to @app/types/compare)
export const useComparePanZoom = ({
basePages,
comparisonPages,
prefersStacked,
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
const baseScrollRef = useRef<HTMLDivElement>(null);
const comparisonScrollRef = useRef<HTMLDivElement>(null);
const isSyncingRef = useRef(false);
const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false });
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
const scrollLinkAnchorsRef = useRef<ScrollLinkAnchors>({
deltaPixelsBaseToComp: 0,
deltaPixelsCompToBase: 0,
});
const [isScrollLinked, setIsScrollLinked] = useState(true);
const [isPanMode, setIsPanMode] = useState(false);
const panDragRef = useRef<PanDragState>({
active: false,
source: null,
startX: 0,
startY: 0,
startPanX: 0,
startPanY: 0,
targetStartPanX: 0,
targetStartPanY: 0,
});
const lastActivePaneRef = useRef<Pane>('base');
const [baseZoom, setBaseZoom] = useState(1);
const [comparisonZoom, setComparisonZoom] = useState(1);
const [basePan, setBasePan] = useState<PanState>({ x: 0, y: 0 });
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA);
const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP);
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
setLayoutState(next);
}, []);
const toggleLayout = useCallback(() => {
setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side'));
}, []);
useEffect(() => {
setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev));
}, [prefersStacked]);
const getPagesForPane = useCallback(
(pane: Pane) => (pane === 'base' ? basePages : comparisonPages),
[basePages, comparisonPages]
);
// rAF-coalesced follower scroll writes
const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
const canonicalLayout = useMemo(() => {
const baseMap = new Map<number, PagePreview>();
const compMap = new Map<number, PagePreview>();
for (const page of basePages) baseMap.set(page.pageNumber, page);
for (const page of comparisonPages) compMap.set(page.pageNumber, page);
const allPageNumbers = Array.from(
new Set([
...basePages.map(p => p.pageNumber),
...comparisonPages.map(p => p.pageNumber),
])
).sort((a, b) => a - b);
const rows = allPageNumbers.map(pageNumber => {
const basePage = baseMap.get(pageNumber) ?? null;
const compPage = compMap.get(pageNumber) ?? null;
const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0);
return {
pageNumber,
canonicalHeight,
hasBase: Boolean(basePage),
hasComparison: Boolean(compPage),
};
});
const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0);
return { rows, totalCanonicalHeight };
}, [basePages, comparisonPages]);
// Measure structural padding (labels, internal gaps) and the inter-row gap
// so the scroll mapper can account for real DOM layout instead of relying on
// bare page image heights.
useEffect(() => {
if (typeof window === 'undefined') return;
if (canonicalLayout.rows.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const sourceContent =
baseScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content') ??
comparisonScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content');
if (!sourceContent) return;
const style = window.getComputedStyle(sourceContent);
const gapStr = style.rowGap || style.gap;
const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN;
const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap));
if (measuredGap !== rowGapPx) {
setRowGapPx(measuredGap);
}
const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap;
const contentHeight = Math.round(sourceContent.scrollHeight);
const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight;
const candidate = canonicalLayout.rows.length > 0
? Math.max(0, Math.round(available / canonicalLayout.rows.length))
: 0;
if (Math.abs(candidate - rowStructuralExtraPx) >= 1) {
setRowStructuralExtraPx(candidate);
}
});
return () => window.cancelAnimationFrame(raf);
}, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]);
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
const rowHeights = useMemo(() => {
const totalRows = canonicalLayout.rows.length;
const base: number[] = [];
const comp: number[] = [];
for (let index = 0; index < totalRows; index += 1) {
const row = canonicalLayout.rows[index];
const canonicalHeight = Math.round(row.canonicalHeight);
const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx));
const includeGap = index < totalRows - 1 ? rowGapPx : 0;
const totalHeight = structuralHeight + includeGap;
if (row.hasBase) base.push(totalHeight);
else if (row.hasComparison) base.push(totalHeight);
if (row.hasComparison) comp.push(totalHeight);
else if (row.hasBase) comp.push(totalHeight);
}
const prefix = (arr: number[]) => {
const out: number[] = new Array(arr.length + 1);
out[0] = 0;
for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i];
return out;
};
return {
base,
comp,
basePrefix: prefix(base),
compPrefix: prefix(comp),
};
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
const mapScrollTopBetweenPanes = useCallback(
(sourceTop: number, sourceIsBase: boolean): number => {
const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp;
const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base;
const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix;
const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix;
if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop;
// Clamp to valid range
const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1);
const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop)));
// Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1]
let lo = 0;
let hi = srcHeights.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1;
}
const i = lo;
const within = top - srcPrefix[i];
const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0;
const j = Math.min(i, dstHeights.length - 1);
const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1);
return dstTop;
},
[rowHeights]
);
const getMaxCanvasSize = useCallback(
(pane: Pane) => {
const pages = getPagesForPane(pane);
const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base');
let maxW = 0;
let maxH = 0;
for (const page of pages) {
const peer = peers.find(p => p.pageNumber === page.pageNumber);
const targetHeight = peer ? Math.max(page.height, peer.height) : page.height;
const fit = targetHeight / page.height;
const width = Math.round(page.width * fit);
const height = Math.round(targetHeight);
if (width > maxW) maxW = width;
if (height > maxH) maxH = height;
}
return { maxW, maxH };
},
[getPagesForPane]
);
const getPanBounds = useCallback(
(pane: Pane, zoomOverride?: number) => {
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null;
let canvasW: number | null = null;
let canvasH: number | null = null;
if (canvasEl) {
const rect = canvasEl.getBoundingClientRect();
canvasW = Math.max(0, Math.round(rect.width));
canvasH = Math.max(0, Math.round(rect.height));
}
const fallback = getMaxCanvasSize(pane);
const W = canvasW ?? fallback.maxW;
const H = canvasH ?? fallback.maxH;
const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom;
const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1));
const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1));
return { maxX: extraX, maxY: extraY };
},
[baseZoom, comparisonZoom, getMaxCanvasSize]
);
const getPaneRotation = useCallback(
(pane: Pane) => {
const pages = getPagesForPane(pane);
const rotation = pages[0]?.rotation ?? 0;
const normalized = ((rotation % 360) + 360) % 360;
return normalized as 0 | 90 | 180 | 270 | number;
},
[getPagesForPane]
);
const mapPanBetweenOrientations = useCallback(
(source: Pane, target: Pane, sourcePan: PanState) => {
const sourceRotation = getPaneRotation(source);
const targetRotation = getPaneRotation(target);
const sourceBounds = getPanBounds(source);
const targetBounds = getPanBounds(target);
const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1;
const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1;
const applyRotation = (nx: number, ny: number, rotation: number) => {
const r = ((rotation % 360) + 360) % 360;
if (r === 0) return { nx, ny };
if (r === 90) return { nx: ny, ny: -nx };
if (r === 180) return { nx: -nx, ny: -ny };
if (r === 270) return { nx: -ny, ny: nx };
return { nx, ny };
};
const logical = applyRotation(sx, sy, sourceRotation);
const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation);
const targetNormX = (targetCentered.nx + 1) / 2;
const targetNormY = (targetCentered.ny + 1) / 2;
const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX));
const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY));
return { x: targetX, y: targetY };
},
[getPaneRotation, getPanBounds]
);
const centerPanForZoom = useCallback(
(pane: Pane, zoomValue: number) => {
const bounds = getPanBounds(pane, zoomValue);
const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) };
if (pane === 'base') {
setBasePan(center);
} else {
setComparisonPan(center);
}
},
[getPanBounds]
);
const setPanToTopLeft = useCallback((pane: Pane) => {
if (pane === 'base') {
setBasePan({ x: 0, y: 0 });
} else {
setComparisonPan({ x: 0, y: 0 });
}
}, []);
const clampPanForZoom = useCallback(
(pane: Pane, zoomValue: number) => {
const bounds = getPanBounds(pane, zoomValue);
const current = pane === 'base' ? basePan : comparisonPan;
const clamped = {
x: Math.max(0, Math.min(bounds.maxX, current.x)),
y: Math.max(0, Math.min(bounds.maxY, current.y)),
};
if (pane === 'base') {
setBasePan(clamped);
} else {
setComparisonPan(clamped);
}
},
[basePan, comparisonPan, getPanBounds]
);
const handleScrollSync = useCallback(
(source: HTMLDivElement | null, target: HTMLDivElement | null) => {
if (panDragRef.current.active) return;
if (!source || !target || isSyncingRef.current || !isScrollLinked) {
return;
}
const sourceIsBase = source === baseScrollRef.current;
const sourceKey = sourceIsBase ? 'base' : 'comparison';
// Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard),
// not by our own programmatic scrolls.
if (!userScrollRef.current[sourceKey]) {
return;
}
lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison';
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
// Use pixel anchors captured at link time to preserve offset
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const rawDesired = mappedTop + deltaPx;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired));
// If the mapping requests a position beyond target bounds and the target is already
// at that bound, skip writing to avoid any subtle feedback that could impede
// continued scrolling in the source pane.
const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0;
const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange;
if (atTopBound || atBottomBound) {
return;
}
const targetIsBase = target === baseScrollRef.current;
const key = targetIsBase ? 'base' : 'comparison';
desiredTopRef.current[key] = desiredTop;
if (syncRafRef.current[key] == null) {
syncRafRef.current[key] = requestAnimationFrame(() => {
const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current;
const top = desiredTopRef.current[key] ?? 0;
if (el) {
isSyncingRef.current = true;
el.scrollTop = top;
}
syncRafRef.current[key] = null;
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
});
}
},
[isScrollLinked, mapScrollTopBetweenPanes]
);
// Track user-initiated scroll state per pane
useEffect(() => {
const baseEl = baseScrollRef.current;
const compEl = comparisonScrollRef.current;
if (!baseEl || !compEl) return;
const onUserScrollStartBase = () => { userScrollRef.current.base = true; };
const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; };
const onUserScrollEndBase = () => { userScrollRef.current.base = false; };
const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; };
const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => {
el.addEventListener('wheel', onStart, { passive: true });
el.addEventListener('mousedown', onStart, { passive: true });
el.addEventListener('touchstart', onStart, { passive: true });
// Heuristic: clear the flag shortly after scroll events settle
let timeout: number | null = null;
const onScroll = () => {
// Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work
if (isSyncingRef.current) return;
onStart();
if (timeout != null) window.clearTimeout(timeout);
timeout = window.setTimeout(onEnd, 120);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => {
el.removeEventListener('wheel', onStart as any);
el.removeEventListener('mousedown', onStart as any);
el.removeEventListener('touchstart', onStart as any);
el.removeEventListener('scroll', onScroll as any);
if (timeout != null) window.clearTimeout(timeout);
};
};
const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase);
const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp);
return () => { cleanupBase(); cleanupComp(); };
}, []);
// Helpers for clearer pan-edge overscroll behavior
const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => {
if (rawY < 0) return rawY; // negative -> scroll up
if (rawY > maxY) return rawY - maxY; // positive -> scroll down
return 0;
}, []);
const normalizeApplyCandidate = useCallback((overflowY: number): number => {
const DEADZONE = 32; // pixels
if (overflowY < -DEADZONE) return overflowY + DEADZONE;
if (overflowY > DEADZONE) return overflowY - DEADZONE;
return 0;
}, []);
const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => {
const STEP = 48; // pixels per incremental scroll
const key = isBase ? 'base' : 'comparison';
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
const magnitude = Math.abs(deltaSinceLast);
if (magnitude < STEP) return;
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
edgeOverscrollRef.current[key] += stepDelta;
const prevTop = container.scrollTop;
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
if (nextTop === prevTop) return;
container.scrollTop = nextTop;
if (isScrollLinked) {
const sourceIsBase = isBase;
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
if (target) {
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
target.scrollTop = desiredTop;
}
}
}, [isScrollLinked, mapScrollTopBetweenPanes]);
const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => {
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
if (!container) return;
const overflowY = getVerticalOverflow(rawY, boundsMaxY);
const applyCandidate = normalizeApplyCandidate(overflowY);
if (applyCandidate !== 0) {
applyIncrementalScroll(container, isBase, applyCandidate);
} else {
// Reset accumulator when back within deadzone
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
}
}, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]);
const beginPan = useCallback(
(pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => {
if (!isPanMode) return;
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
if (zoom <= 1) return;
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
if (!container) return;
const targetEl = event.target as HTMLElement | null;
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
if (!isOnImage) return;
event.preventDefault();
panDragRef.current = {
active: true,
source: pane,
startX: event.clientX,
startY: event.clientY,
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
edgeOverscrollRef.current[pane] = 0;
lastActivePaneRef.current = pane;
(container as HTMLDivElement).style.cursor = 'grabbing';
},
[isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan]
);
const continuePan = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!isPanMode) return;
const drag = panDragRef.current;
if (!drag.active || !drag.source) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
const isBase = drag.source === 'base';
const bounds = getPanBounds(drag.source);
const rawX = drag.startPanX - dx;
const rawY = drag.startPanY - dy;
const desired = {
x: Math.max(0, Math.min(bounds.maxX, rawX)),
y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
if (isBase) {
setBasePan(desired);
} else {
setComparisonPan(desired);
}
},
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
);
const endPan = useCallback(() => {
const drag = panDragRef.current;
if (!drag.active) return;
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
if (sourceEl) {
const zoom = drag.source === 'base' ? baseZoom : comparisonZoom;
(sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : '';
}
panDragRef.current.active = false;
panDragRef.current.source = null;
}, [baseZoom, comparisonZoom, isPanMode]);
const handleWheelZoom = useCallback(
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey) return;
event.preventDefault();
const key = pane === 'base' ? 'base' : 'comparison';
const accum = wheelZoomAccumRef.current;
const threshold = 180;
accum[key] += event.deltaY;
const steps = Math.trunc(Math.abs(accum[key]) / threshold);
if (steps <= 0) return;
const direction = accum[key] > 0 ? -1 : 1;
accum[key] = accum[key] % threshold;
const applySteps = (zoom: number) => {
let next = zoom;
for (let i = 0; i < steps; i += 1) {
next = direction > 0
? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2))
: Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2));
}
return next;
};
if (pane === 'base') {
const prev = baseZoom;
const next = applySteps(prev);
setBaseZoom(next);
if (next < prev) {
centerPanForZoom('base', next);
} else {
clampPanForZoom('base', next);
}
} else {
const prev = comparisonZoom;
const next = applySteps(prev);
setComparisonZoom(next);
if (next < prev) {
centerPanForZoom('comparison', next);
} else {
clampPanForZoom('comparison', next);
}
}
},
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom]
);
// When the source pane hits its scroll limit but the peer still has room,
// propagate the wheel delta to the peer so it continues following.
const handleWheelOverscroll = useCallback(
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
if (event.ctrlKey) return; // handled by zoom handler
if (!isScrollLinked) return;
const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
if (!source || !target) return;
const deltaY = event.deltaY;
if (deltaY === 0) return;
const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight);
const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY));
// If the source can scroll, let the normal scroll event drive syncing
if (nextSource !== source.scrollTop) return;
// Source is at a bound; push mapped delta into the target
const sourceIsBase = pane === 'base';
// Map the desired new source position (scrollTop + deltaY) into target space
const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase);
const mappedDelta = mappedAfter - mappedBefore;
// Include the pixel anchor captured when linking
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const targetMax = Math.max(0, target.scrollHeight - target.clientHeight);
const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY)));
if (desired !== target.scrollTop) {
isSyncingRef.current = true;
// Adjust relative to mapped space to keep the anchor consistent
const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY)));
target.scrollTop = anchored;
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
event.preventDefault();
}
},
[isScrollLinked, mapScrollTopBetweenPanes]
);
const onTouchStart = useCallback(
(pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => {
if (event.touches.length === 2) {
const [t1, t2] = [event.touches[0], event.touches[1]];
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
pinchRef.current = {
active: true,
pane,
startDistance: Math.hypot(dx, dy),
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
};
event.preventDefault();
} else if (event.touches.length === 1) {
if (!isPanMode) return;
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
if (zoom <= 1) return;
const targetEl = event.target as HTMLElement | null;
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
if (!isOnImage) return;
const touch = event.touches[0];
panDragRef.current = {
active: true,
source: pane,
startX: touch.clientX,
startY: touch.clientY,
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
edgeOverscrollRef.current[pane] = 0;
event.preventDefault();
}
},
[basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode]
);
const onTouchMove = useCallback(
(event: ReactTouchEvent<HTMLDivElement>) => {
if (pinchRef.current.active && event.touches.length === 2) {
const [t1, t2] = [event.touches[0], event.touches[1]];
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
const distance = Math.hypot(dx, dy);
const scale = distance / Math.max(1, pinchRef.current.startDistance);
const dampened = 1 + (scale - 1) * 0.6;
const pane = pinchRef.current.pane!;
const startZoom = pinchRef.current.startZoom;
const previousZoom = pane === 'base' ? baseZoom : comparisonZoom;
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
if (pane === 'base') {
setBaseZoom(nextZoom);
if (nextZoom < previousZoom) {
centerPanForZoom('base', nextZoom);
} else if (nextZoom > previousZoom) {
clampPanForZoom('base', nextZoom);
}
} else {
setComparisonZoom(nextZoom);
if (nextZoom < previousZoom) {
centerPanForZoom('comparison', nextZoom);
} else if (nextZoom > previousZoom) {
clampPanForZoom('comparison', nextZoom);
}
}
event.preventDefault();
return;
}
if (panDragRef.current.active && event.touches.length === 1) {
const touch = event.touches[0];
const dx = touch.clientX - panDragRef.current.startX;
const dy = touch.clientY - panDragRef.current.startY;
const isBase = panDragRef.current.source === 'base';
const bounds = getPanBounds(panDragRef.current.source!);
const rawX = panDragRef.current.startPanX - dx;
const rawY = panDragRef.current.startPanY - dy;
const desired = {
x: Math.max(0, Math.min(bounds.maxX, rawX)),
y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
if (isBase) {
setBasePan(desired);
} else {
setComparisonPan(desired);
}
event.preventDefault();
}
},
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations]
);
const onTouchEnd = useCallback(() => {
pinchRef.current.active = false;
pinchRef.current.pane = null;
panDragRef.current.active = false;
}, []);
// Auto-toggle Pan Mode based on zoom level
useEffect(() => {
const shouldPan = baseZoom > 1 || comparisonZoom > 1;
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
}, [baseZoom, comparisonZoom, isPanMode]);
// When new pages render and extend scrollHeight, re-apply the mapping so
// the follower continues tracking instead of getting stuck at its prior max.
useEffect(() => {
if (!isScrollLinked) return;
const sourceIsBase = lastActivePaneRef.current === 'base';
const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current;
const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current;
if (!source || !target) return;
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
if (Math.abs(target.scrollTop - desiredTop) > 1) {
isSyncingRef.current = true;
target.scrollTop = desiredTop;
requestAnimationFrame(() => { isSyncingRef.current = false; });
}
}, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (isScrollLinked) return;
const target = event.target as HTMLElement | null;
const tag = (target?.tagName || '').toLowerCase();
const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true');
if (isEditable) return;
const baseEl = baseScrollRef.current;
const compEl = comparisonScrollRef.current;
if (!baseEl || !compEl) return;
const STEP = 80;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? STEP : -STEP;
isSyncingRef.current = true;
baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight));
compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight));
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isScrollLinked]);
const captureScrollLinkDelta = useCallback(() => {
const baseEl = baseScrollRef.current;
const compEl = comparisonScrollRef.current;
if (!baseEl || !compEl) {
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
return;
}
const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight);
const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight);
const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth);
const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth);
const baseV = baseEl.scrollTop / baseVMax;
const compV = compEl.scrollTop / compVMax;
const baseH = baseEl.scrollLeft / baseHMax;
const compH = compEl.scrollLeft / compHMax;
scrollLinkDeltaRef.current = {
vertical: compV - baseV,
horizontal: compH - baseH,
};
// Capture pixel anchors in mapped space
const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true);
const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false);
scrollLinkAnchorsRef.current = {
deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp,
deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase,
};
}, [mapScrollTopBetweenPanes]);
const clearScrollLinkDelta = useCallback(() => {
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
}, []);
const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []);
return {
layout,
setLayout,
toggleLayout,
baseScrollRef,
comparisonScrollRef,
isScrollLinked,
setIsScrollLinked,
captureScrollLinkDelta,
clearScrollLinkDelta,
isPanMode,
setIsPanMode,
baseZoom,
setBaseZoom,
comparisonZoom,
setComparisonZoom,
basePan,
comparisonPan,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
handleScrollSync,
beginPan,
continuePan,
endPan,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
zoomLimits,
};
};
@@ -0,0 +1,191 @@
import { useMemo } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import { useIsMobile } from '@app/hooks/useIsMobile';
type Pane = 'base' | 'comparison';
export interface UseCompareRightRailButtonsOptions {
layout: 'side-by-side' | 'stacked';
toggleLayout: () => void;
isPanMode: boolean;
setIsPanMode: (value: boolean) => void;
baseZoom: number;
comparisonZoom: number;
setBaseZoom: (value: number) => void;
setComparisonZoom: (value: number) => void;
setPanToTopLeft: (pane: Pane) => void;
centerPanForZoom: (pane: Pane, zoom: number) => void;
clampPanForZoom: (pane: Pane, zoom: number) => void;
clearScrollLinkDelta: () => void;
captureScrollLinkDelta: () => void;
isScrollLinked: boolean;
setIsScrollLinked: (value: boolean) => void;
zoomLimits: { min: number; max: number; step: number };
baseScrollRef?: React.RefObject<HTMLDivElement | null>;
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
}
export const useCompareRightRailButtons = ({
layout,
toggleLayout,
isPanMode,
setIsPanMode,
baseZoom,
comparisonZoom,
setBaseZoom,
setComparisonZoom,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
clearScrollLinkDelta,
captureScrollLinkDelta,
isScrollLinked,
setIsScrollLinked,
zoomLimits,
baseScrollRef,
comparisonScrollRef,
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
const { t } = useTranslation();
const isMobile = useIsMobile();
return useMemo<RightRailButtonWithAction[]>(() => [
{
id: 'compare-toggle-layout',
icon: (
<LocalIcon
icon={layout === 'side-by-side' ? 'vertical-split-rounded' : 'horizontal-split-rounded'}
width="1.5rem"
height="1.5rem"
/>
),
tooltip: layout === 'side-by-side'
? t('compare.actions.stackVertically', 'Stack vertically')
: t('compare.actions.placeSideBySide', 'Place side by side'),
ariaLabel: layout === 'side-by-side'
? t('compare.actions.stackVertically', 'Stack vertically')
: t('compare.actions.placeSideBySide', 'Place side by side'),
section: 'top',
order: 10,
onClick: toggleLayout,
},
{
id: 'compare-zoom-out',
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
tooltip: t('compare.actions.zoomOut', 'Zoom out'),
ariaLabel: t('compare.actions.zoomOut', 'Zoom out'),
section: 'top',
order: 13,
onClick: () => {
const { min, step } = zoomLimits;
const nextBase = Math.max(min, +(baseZoom - step).toFixed(2));
const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2));
setBaseZoom(nextBase);
setComparisonZoom(nextComparison);
centerPanForZoom('base', nextBase);
centerPanForZoom('comparison', nextComparison);
},
},
{
id: 'compare-zoom-in',
icon: <LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />,
tooltip: t('compare.actions.zoomIn', 'Zoom in'),
ariaLabel: t('compare.actions.zoomIn', 'Zoom in'),
section: 'top',
order: 14,
onClick: () => {
const { max, step } = zoomLimits;
const nextBase = Math.min(max, +(baseZoom + step).toFixed(2));
const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2));
setBaseZoom(nextBase);
setComparisonZoom(nextComparison);
clampPanForZoom('base', nextBase);
clampPanForZoom('comparison', nextComparison);
},
},
{
id: 'compare-reset-view',
icon: <LocalIcon icon="refresh-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t('compare.actions.resetView', 'Reset zoom and pan'),
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
section: 'top',
order: 14.5,
disabled: baseZoom === 1 && comparisonZoom === 1,
onClick: () => {
setBaseZoom(1);
setComparisonZoom(1);
setPanToTopLeft('base');
setPanToTopLeft('comparison');
clearScrollLinkDelta();
// Reset scrollTop for both panes to realign view
if (baseScrollRef?.current) {
baseScrollRef.current.scrollTop = 0;
}
if (comparisonScrollRef?.current) {
comparisonScrollRef.current.scrollTop = 0;
}
},
},
{
id: 'compare-toggle-scroll-link',
icon: (
<LocalIcon
icon={isScrollLinked ? 'link-rounded' : 'link-off-rounded'}
width="1.5rem"
height="1.5rem"
/>
),
tooltip: isScrollLinked
? t('compare.actions.unlinkScroll', 'Unlink scroll')
: t('compare.actions.linkScroll', 'Link scroll'),
ariaLabel: isScrollLinked
? t('compare.actions.unlinkScroll', 'Unlink scroll')
: t('compare.actions.linkScroll', 'Link scroll'),
section: 'top',
order: 15,
onClick: () => {
const next = !isScrollLinked;
if (next) {
captureScrollLinkDelta();
} else {
if (!isMobile) {
alert({
alertType: 'neutral',
title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'),
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'),
durationMs: 5000,
location: 'bottom-center' as ToastLocation,
expandable: false,
});
}
}
setIsScrollLinked(next);
},
},
], [
layout,
toggleLayout,
isPanMode,
setIsPanMode,
baseZoom,
comparisonZoom,
setBaseZoom,
setComparisonZoom,
centerPanForZoom,
clampPanForZoom,
setPanToTopLeft,
clearScrollLinkDelta,
captureScrollLinkDelta,
isScrollLinked,
setIsScrollLinked,
zoomLimits,
t,
isMobile,
]);
};
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
@@ -1,5 +1,5 @@
import { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
@@ -123,18 +123,12 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
{/* Compression Options */}
<Stack gap="sm">
<label
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
title="Converts all images in the PDF to grayscale, which can significantly reduce file size while maintaining readability"
>
<input
type="checkbox"
checked={parameters.grayscale}
onChange={(e) => onParameterChange('grayscale', e.target.checked)}
disabled={disabled}
/>
<Text size="sm">{t("compress.grayscale.label", "Apply Grayscale for compression")}</Text>
</label>
<Checkbox
checked={parameters.grayscale}
onChange={(event) => onParameterChange('grayscale', event.currentTarget.checked)}
disabled={disabled}
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
/>
</Stack>
</Stack>
);
@@ -0,0 +1,36 @@
import { Stack, Text, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
interface ConvertFromCbzSettingsProps {
parameters: ConvertParameters;
onParameterChange: <K extends keyof ConvertParameters>(key: K, value: ConvertParameters[K]) => void;
disabled?: boolean;
}
const ConvertFromCbzSettings = ({
parameters,
onParameterChange,
disabled = false
}: ConvertFromCbzSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm" data-testid="cbz-settings">
<Text size="sm" fw={500}>{t('convert.cbzOptions', 'CBZ to PDF Options')}:</Text>
<Checkbox
label={t('convert.optimizeForEbook', 'Optimize PDF for ebook readers (uses Ghostscript)')}
checked={parameters.cbzOptions.optimizeForEbook}
onChange={(event) => onParameterChange('cbzOptions', {
...parameters.cbzOptions,
optimizeForEbook: event.currentTarget.checked
})}
disabled={disabled}
data-testid="optimize-ebook-checkbox"
/>
</Stack>
);
};
export default ConvertFromCbzSettings;
@@ -13,6 +13,8 @@ import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImage
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings";
import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings";
import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings";
import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings";
import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings";
import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import {
@@ -118,6 +120,12 @@ const ConvertSettings = ({
onParameterChange('pdfaOptions', {
outputFormat: 'pdfa-1',
});
onParameterChange('cbzOptions', {
optimizeForEbook: false,
});
onParameterChange('cbzOutputOptions', {
dpi: 150,
});
onParameterChange('isSmartDetection', false);
onParameterChange('smartDetectionType', 'none');
};
@@ -180,6 +188,12 @@ const ConvertSettings = ({
onParameterChange('pdfaOptions', {
outputFormat: 'pdfa-1',
});
onParameterChange('cbzOptions', {
optimizeForEbook: false,
});
onParameterChange('cbzOutputOptions', {
dpi: 150,
});
};
@@ -293,6 +307,30 @@ const ConvertSettings = ({
</>
)}
{/* CBZ to PDF options */}
{parameters.fromExtension === 'cbz' && parameters.toExtension === 'pdf' && (
<>
<Divider />
<ConvertFromCbzSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* PDF to CBZ options */}
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbz' && (
<>
<Divider />
<ConvertToCbzSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{/* PDF to PDF/A options */}
{parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && (
<>

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