Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab37b4405 |
@@ -14,7 +14,7 @@ on:
|
||||
- macos
|
||||
- linux
|
||||
pull_request:
|
||||
branches: [main, V2, V2-tauri-windows]
|
||||
branches: [main, V2]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/desktop/**'
|
||||
@@ -61,9 +61,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
@@ -177,84 +174,6 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npm install
|
||||
|
||||
# DigiCert KeyLocker Setup (Cloud HSM)
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
uses: digicert/ssm-code-signing@v1.1.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
|
||||
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
|
||||
SM_HOST: ${{ secrets.SM_HOST }}
|
||||
|
||||
- name: Setup DigiCert KeyLocker Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Setting up DigiCert KeyLocker environment..."
|
||||
|
||||
# Decode client certificate
|
||||
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
|
||||
$certPath = "D:\Certificate_pkcs12.p12"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Set environment variables
|
||||
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
|
||||
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
|
||||
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
|
||||
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
|
||||
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
|
||||
|
||||
# Get PKCS11 config path from DigiCert action
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if ($pkcs11Config) {
|
||||
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
|
||||
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
|
||||
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
|
||||
if (Test-Path $defaultPath) {
|
||||
Write-Host "Found config at default path: $defaultPath"
|
||||
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
|
||||
} else {
|
||||
Write-Host "Warning: Could not find PKCS11 config file"
|
||||
}
|
||||
}
|
||||
|
||||
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
|
||||
- name: Import Windows Code Signing Certificate
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
shell: powershell
|
||||
run: |
|
||||
if ($env:WINDOWS_CERTIFICATE) {
|
||||
Write-Host "Importing Windows Code Signing Certificate..."
|
||||
|
||||
# Decode base64 certificate and save to file
|
||||
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
|
||||
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
|
||||
[IO.File]::WriteAllBytes($certPath, $certBytes)
|
||||
|
||||
# Import certificate to CurrentUser\My store
|
||||
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
|
||||
|
||||
# Extract and set thumbprint as environment variable
|
||||
$thumbprint = $cert.Thumbprint
|
||||
Write-Host "Certificate imported with thumbprint: $thumbprint"
|
||||
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
|
||||
|
||||
# Clean up certificate file
|
||||
Remove-Item $certPath
|
||||
|
||||
Write-Host "Windows certificate import completed."
|
||||
} else {
|
||||
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
|
||||
}
|
||||
|
||||
- name: Import Apple Developer Certificate
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
env:
|
||||
@@ -310,174 +229,13 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
SIGN: 1
|
||||
CI: true
|
||||
with:
|
||||
projectPath: ./frontend
|
||||
tauriScript: npx tauri
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# Sign with DigiCert KeyLocker (post-build)
|
||||
- name: Sign Windows binaries with DigiCert KeyLocker
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "=== DigiCert KeyLocker Signing ==="
|
||||
|
||||
# Test smctl connectivity first
|
||||
Write-Host "Testing smctl connection..."
|
||||
$healthCheck = & smctl healthcheck 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
|
||||
Write-Host $healthCheck
|
||||
exit 1
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# Sync certificates to Windows certificate store
|
||||
Write-Host "Syncing certificates to Windows certificate store..."
|
||||
$syncOutput = & smctl windows certsync 2>&1
|
||||
Write-Host "Cert sync result: $syncOutput"
|
||||
Write-Host ""
|
||||
|
||||
# List available certificates and check if they have certificates attached
|
||||
Write-Host "Checking for available certificates..."
|
||||
$certList = & smctl keypair ls 2>&1
|
||||
Write-Host "Keypair list output:"
|
||||
Write-Host $certList
|
||||
Write-Host ""
|
||||
|
||||
# Parse the output to check certificate status
|
||||
$lines = $certList -split "`n"
|
||||
$foundKeypair = $false
|
||||
$hasCertificate = $false
|
||||
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
|
||||
$foundKeypair = $true
|
||||
Write-Host "[SUCCESS] Found keypair in list"
|
||||
|
||||
# Check if this line has certificate info (not just empty spaces after alias)
|
||||
$parts = $line -split "\s+"
|
||||
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
|
||||
$hasCertificate = $true
|
||||
Write-Host "[SUCCESS] Certificate is associated with keypair"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $foundKeypair) {
|
||||
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
|
||||
Write-Host "Available keypairs are listed above"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify:"
|
||||
Write-Host " 1. Keypair alias is correct in GitHub secret"
|
||||
Write-Host " 2. API key has access to this keypair"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $hasCertificate) {
|
||||
Write-Host "[ERROR] No certificate associated with keypair"
|
||||
Write-Host "This usually means:"
|
||||
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
|
||||
Write-Host " 2. Certificate is pending approval"
|
||||
Write-Host " 3. Certificate needs to be attached to the keypair"
|
||||
Write-Host ""
|
||||
Write-Host "Try running in DigiCert ONE portal:"
|
||||
Write-Host " smctl keypair sync"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[SUCCESS] Certificate check passed"
|
||||
Write-Host ""
|
||||
|
||||
# Find only the files we need to sign (not build scripts)
|
||||
$filesToSign = @()
|
||||
|
||||
# Main application executable
|
||||
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
|
||||
if ($mainExe) { $filesToSign += $mainExe }
|
||||
|
||||
# MSI installer
|
||||
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
|
||||
$filesToSign += $msiFiles
|
||||
|
||||
if ($filesToSign.Count -eq 0) {
|
||||
Write-Host "[ERROR] No files found to sign"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($filesToSign.Count) files to sign:"
|
||||
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
|
||||
Write-Host ""
|
||||
|
||||
$signedCount = 0
|
||||
foreach ($file in $filesToSign) {
|
||||
Write-Host "Signing: $($file.Name)"
|
||||
|
||||
# Get PKCS11 config file path (set by DigiCert action)
|
||||
$pkcs11Config = $env:PKCS11_CONFIG
|
||||
if (-not $pkcs11Config) {
|
||||
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
|
||||
Write-Host "DigiCert KeyLocker action may not have run correctly"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Using PKCS11 config: $pkcs11Config"
|
||||
|
||||
# Try signing with certificate fingerprint first (if available)
|
||||
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
|
||||
if ($fingerprint -and $fingerprint -ne "") {
|
||||
Write-Host "Attempting to sign with certificate fingerprint..."
|
||||
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
} else {
|
||||
Write-Host "No fingerprint provided, using keypair alias..."
|
||||
# Use smctl to sign with keypair alias
|
||||
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
|
||||
Write-Host "Exit code: $exitCode"
|
||||
Write-Host "Output: $output"
|
||||
|
||||
# Check if output contains "FAILED" even with exit code 0
|
||||
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
|
||||
Write-Host ""
|
||||
Write-Host "[ERROR] Signing failed for $($file.Name)"
|
||||
Write-Host "[ERROR] smctl returned success but output indicates failure"
|
||||
Write-Host ""
|
||||
Write-Host "Possible issues:"
|
||||
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
|
||||
Write-Host " 2. Incorrect keypair alias"
|
||||
Write-Host " 3. API key lacks signing permissions"
|
||||
Write-Host ""
|
||||
Write-Host "Please verify in DigiCert ONE portal:"
|
||||
Write-Host " - Certificate status is 'Issued' (not Pending)"
|
||||
Write-Host " - Keypair status is 'Online'"
|
||||
Write-Host " - 'Can sign' is set to 'Yes'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to sign $($file.Name)"
|
||||
Write-Host "Full error output:"
|
||||
Write-Host $output
|
||||
exit 1
|
||||
}
|
||||
|
||||
$signedCount++
|
||||
Write-Host "[SUCCESS] Signed: $($file.Name)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "=== Summary ==="
|
||||
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
|
||||
|
||||
- name: Verify notarization (macOS only)
|
||||
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
|
||||
run: |
|
||||
@@ -511,66 +269,6 @@ jobs:
|
||||
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
|
||||
fi
|
||||
|
||||
- name: Verify Windows Code Signature
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Verifying Windows code signatures..."
|
||||
|
||||
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
|
||||
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
|
||||
|
||||
$allSigned = $true
|
||||
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
|
||||
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
|
||||
|
||||
# Check EXE signature
|
||||
if (Test-Path $exePath) {
|
||||
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
|
||||
Write-Host "EXE Signature Status: $($exeSig.Status)"
|
||||
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
|
||||
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($exeSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] EXE is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
# Check MSI signature
|
||||
if (Test-Path $msiPath) {
|
||||
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
|
||||
Write-Host "MSI Signature Status: $($msiSig.Status)"
|
||||
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
|
||||
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
|
||||
|
||||
if ($msiSig.Status -ne "Valid") {
|
||||
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
|
||||
if ($usingKeyLocker -or $usingPfx) {
|
||||
Write-Host "[ERROR] Certificate was provided but signing failed"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SUCCESS] MSI is properly signed"
|
||||
}
|
||||
}
|
||||
|
||||
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
|
||||
Write-Host "[ERROR] Code signing verification failed"
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "[SUCCESS] Code signature verification completed"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
# Windows Code Signing Setup Guide
|
||||
|
||||
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
|
||||
|
||||
## Overview
|
||||
|
||||
Windows code signing is essential for:
|
||||
- Preventing Windows SmartScreen warnings
|
||||
- Building trust with users
|
||||
- Enabling Microsoft Store distribution
|
||||
- Professional application distribution
|
||||
|
||||
## Certificate Types
|
||||
|
||||
### OV Certificate (Organization Validated)
|
||||
- More affordable option
|
||||
- Requires business verification
|
||||
- May trigger SmartScreen warnings initially until reputation builds
|
||||
- Suitable for most independent software vendors
|
||||
|
||||
### EV Certificate (Extended Validation)
|
||||
- Premium option with immediate SmartScreen reputation
|
||||
- Requires hardware security module (HSM) or cloud-based signing
|
||||
- Higher cost but provides immediate trust
|
||||
- Required since June 2023 for new certificates
|
||||
|
||||
## Obtaining a Certificate
|
||||
|
||||
### Certificate Authorities
|
||||
Popular certificate authorities for Windows code signing:
|
||||
- DigiCert
|
||||
- Sectigo (formerly Comodo)
|
||||
- GlobalSign
|
||||
- SSL.com
|
||||
|
||||
### Certificate Format
|
||||
You'll receive a certificate in one of these formats:
|
||||
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
|
||||
- `.cer` + private key (needs conversion to .pfx)
|
||||
|
||||
### Converting to PFX (if needed)
|
||||
If you have separate certificate and private key files:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
|
||||
```
|
||||
|
||||
## Setting Up GitHub Secrets
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
|
||||
|
||||
Add the following secrets:
|
||||
|
||||
#### 1. `WINDOWS_CERTIFICATE`
|
||||
- **Description**: Base64-encoded .pfx certificate file
|
||||
- **How to create**:
|
||||
|
||||
**On macOS/Linux:**
|
||||
```bash
|
||||
base64 -i certificate.pfx | pbcopy # Copies to clipboard
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the entire base64 string into the GitHub secret.
|
||||
|
||||
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
|
||||
- **Description**: Password for the .pfx certificate
|
||||
- **Value**: The password you set when creating/exporting the .pfx file
|
||||
|
||||
### Optional Secrets for Tauri Updater
|
||||
|
||||
If you're using Tauri's built-in updater feature:
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- Generated using Tauri CLI: `npm run tauri signer generate`
|
||||
- Used for update package verification
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
- Password for the Tauri signing key
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
|
||||
|
||||
The Windows signing configuration is already set up:
|
||||
|
||||
```json
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
|
||||
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
|
||||
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
|
||||
|
||||
**Alternative Timestamp Servers:**
|
||||
- DigiCert: `http://timestamp.digicert.com`
|
||||
- Sectigo: `http://timestamp.sectigo.com`
|
||||
- GlobalSign: `http://timestamp.globalsign.com`
|
||||
|
||||
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
|
||||
|
||||
The workflow includes three Windows signing steps:
|
||||
|
||||
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
|
||||
2. **Build Tauri App**: Builds and signs the application using the imported certificate
|
||||
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Local Testing (Windows Only)
|
||||
|
||||
Before pushing to GitHub, test locally:
|
||||
|
||||
```powershell
|
||||
# Set environment variables
|
||||
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
|
||||
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
|
||||
|
||||
# Build the application
|
||||
cd frontend
|
||||
npm run tauri build
|
||||
|
||||
# Verify the signature
|
||||
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
|
||||
```
|
||||
|
||||
### 2. GitHub Actions Testing
|
||||
|
||||
1. Push your changes to a branch
|
||||
2. Manually trigger the workflow:
|
||||
- Go to Actions → Build Tauri Applications
|
||||
- Click "Run workflow"
|
||||
- Select "windows" platform
|
||||
3. Check the build logs for:
|
||||
- ✅ Certificate import success
|
||||
- ✅ Build completion
|
||||
- ✅ Signature verification
|
||||
|
||||
### 3. Verifying Signed Binaries
|
||||
|
||||
After downloading the built artifacts:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Status: `Valid`
|
||||
- Signer: Your organization name
|
||||
- Timestamp: Recent date/time
|
||||
|
||||
**Windows (GUI):**
|
||||
1. Right-click the .exe or .msi file
|
||||
2. Select "Properties"
|
||||
3. Go to "Digital Signatures" tab
|
||||
4. Verify signature details
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "HashMismatch" Status
|
||||
- Certificate doesn't match the binary
|
||||
- Possible file corruption during download
|
||||
- Re-download and verify
|
||||
|
||||
### "NotSigned" Status
|
||||
- Certificate wasn't imported correctly
|
||||
- Check GitHub secrets are set correctly
|
||||
- Verify base64 encoding is complete (no truncation)
|
||||
|
||||
### "UnknownError" Status
|
||||
- Timestamp server unreachable
|
||||
- Try alternative timestamp URL in tauri.conf.json
|
||||
- Check network connectivity in GitHub Actions
|
||||
|
||||
### SmartScreen Still Shows Warnings
|
||||
- Normal for OV certificates initially
|
||||
- Reputation builds over time with user downloads
|
||||
- Consider EV certificate for immediate reputation
|
||||
|
||||
### Certificate Not Found During Build
|
||||
- Verify `WINDOWS_CERTIFICATE` secret is set
|
||||
- Check base64 encoding is correct (no extra whitespace)
|
||||
- Ensure password is correct
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit certificates to version control**
|
||||
- Keep .pfx files secure and backed up
|
||||
- Use GitHub secrets for CI/CD
|
||||
|
||||
2. **Rotate certificates before expiration**
|
||||
- Set calendar reminders
|
||||
- Update GitHub secrets with new certificate
|
||||
|
||||
3. **Use strong passwords**
|
||||
- Certificate password should be complex
|
||||
- Store securely (password manager)
|
||||
|
||||
4. **Monitor certificate usage**
|
||||
- Review GitHub Actions logs
|
||||
- Set up notifications for failed builds
|
||||
|
||||
5. **Limit access to secrets**
|
||||
- Only repository admins should access secrets
|
||||
- Audit secret access regularly
|
||||
|
||||
## Certificate Lifecycle
|
||||
|
||||
### Before Expiration
|
||||
1. Obtain new certificate from CA (typically annual renewal)
|
||||
2. Convert to .pfx format if needed
|
||||
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
|
||||
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
|
||||
5. Test build to verify new certificate works
|
||||
|
||||
### Expired Certificates
|
||||
- Signed binaries remain valid (timestamp proves signing time)
|
||||
- New builds will fail until certificate is renewed
|
||||
- Users can still install previously signed versions
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### Certificate Costs (Annual, as of 2024)
|
||||
- **OV Certificate**: $100-400/year
|
||||
- **EV Certificate**: $400-1000/year
|
||||
|
||||
### Choosing the Right Certificate
|
||||
- **Open source / early stage**: Start with OV
|
||||
- **Commercial / enterprise**: Consider EV for better trust
|
||||
- **Microsoft Store**: EV certificate required
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
|
||||
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
|
||||
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
|
||||
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues with Windows code signing:
|
||||
1. Check GitHub Actions logs for detailed error messages
|
||||
2. Verify all secrets are set correctly
|
||||
3. Test certificate locally first (Windows environment required)
|
||||
4. Open an issue in the repository with relevant logs (remove sensitive data)
|
||||
@@ -1,49 +0,0 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
|
||||
/**
|
||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
|
||||
* Only active when security is disabled (no-login mode).
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WAUTrackingFilter implements Filter {
|
||||
|
||||
private final WeeklyActiveUsersService wauService;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (request instanceof HttpServletRequest httpRequest) {
|
||||
// Extract browser ID from header
|
||||
String browserId = httpRequest.getHeader("X-Browser-Id");
|
||||
|
||||
if (browserId != null && !browserId.trim().isEmpty()) {
|
||||
// Record browser access
|
||||
wauService.recordBrowserAccess(browserId);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue the filter chain
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -46,24 +46,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
@@ -89,8 +73,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
"X-XSRF-TOKEN")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
@@ -115,8 +98,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
"X-XSRF-TOKEN")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
|
||||
@@ -23,7 +23,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointInspector;
|
||||
import stirling.software.SPDF.config.StartupApplicationListener;
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
import stirling.software.common.annotations.api.InfoApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@@ -35,7 +34,6 @@ public class MetricsController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final EndpointInspector endpointInspector;
|
||||
private final Optional<WeeklyActiveUsersService> wauService;
|
||||
private boolean metricsEnabled;
|
||||
|
||||
@PostConstruct
|
||||
@@ -354,35 +352,6 @@ public class MetricsController {
|
||||
return ResponseEntity.ok(formatDuration(uptime));
|
||||
}
|
||||
|
||||
@GetMapping("/wau")
|
||||
@Operation(
|
||||
summary = "Weekly Active Users statistics",
|
||||
description =
|
||||
"Returns WAU (Weekly Active Users) count and total unique browsers. "
|
||||
+ "Only available when security is disabled (no-login mode). "
|
||||
+ "Tracks unique browsers via client-generated UUID in localStorage.")
|
||||
public ResponseEntity<?> getWeeklyActiveUsers() {
|
||||
if (!metricsEnabled) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
|
||||
}
|
||||
|
||||
// Check if WAU service is available (only when security.enableLogin=false)
|
||||
if (wauService.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("WAU tracking is only available when security is disabled (no-login mode)");
|
||||
}
|
||||
|
||||
WeeklyActiveUsersService service = wauService.get();
|
||||
|
||||
Map<String, Object> wauStats = new HashMap<>();
|
||||
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
|
||||
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
|
||||
wauStats.put("daysOnline", service.getDaysOnline());
|
||||
wauStats.put("trackingSince", service.getStartTime().toString());
|
||||
|
||||
return ResponseEntity.ok(wauStats);
|
||||
}
|
||||
|
||||
private String formatDuration(Duration duration) {
|
||||
long days = duration.toDays();
|
||||
long hours = duration.toHoursPart();
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for tracking Weekly Active Users (WAU) in no-login mode.
|
||||
* Uses in-memory storage with automatic cleanup of old entries.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class WeeklyActiveUsersService {
|
||||
|
||||
// Map of browser ID -> last seen timestamp
|
||||
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||
|
||||
// Track total unique browsers seen (overall)
|
||||
private long totalUniqueBrowsers = 0;
|
||||
|
||||
// Application start time
|
||||
private final Instant startTime = Instant.now();
|
||||
|
||||
/**
|
||||
* Records a browser access with the current timestamp
|
||||
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||
*/
|
||||
public void recordBrowserAccess(String browserId) {
|
||||
if (browserId == null || browserId.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||
activeBrowsers.put(browserId, Instant.now());
|
||||
|
||||
if (isNewBrowser) {
|
||||
totalUniqueBrowsers++;
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of unique browsers seen in the last 7 days
|
||||
* @return Weekly Active Users count
|
||||
*/
|
||||
public long getWeeklyActiveUsers() {
|
||||
cleanupOldEntries();
|
||||
return activeBrowsers.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of unique browsers ever seen
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
return totalUniqueBrowsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of days the service has been running
|
||||
* @return Days online
|
||||
*/
|
||||
public long getDaysOnline() {
|
||||
return ChronoUnit.DAYS.between(startTime, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp when tracking started
|
||||
* @return Start time
|
||||
*/
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes entries older than 7 days
|
||||
*/
|
||||
private void cleanupOldEntries() {
|
||||
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual cleanup trigger (can be called by scheduled task if needed)
|
||||
*/
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
int sizeAfter = activeBrowsers.size();
|
||||
|
||||
if (sizeBefore != sizeAfter) {
|
||||
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2134,54 +2134,13 @@
|
||||
"title": "Draw your signature",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"canvas": {
|
||||
"heading": "Draw your signature",
|
||||
"clickToOpen": "Click to open the drawing canvas",
|
||||
"modalTitle": "Draw your signature",
|
||||
"colorLabel": "Colour",
|
||||
"penSizeLabel": "Pen size",
|
||||
"penSizePlaceholder": "Size",
|
||||
"clear": "Clear canvas",
|
||||
"colorPickerTitle": "Choose stroke colour"
|
||||
},
|
||||
"text": {
|
||||
"name": "Signer Name",
|
||||
"placeholder": "Enter your full name",
|
||||
"fontLabel": "Font",
|
||||
"fontSizeLabel": "Font size",
|
||||
"fontSizePlaceholder": "Type or select font size (8-200)",
|
||||
"colorLabel": "Text colour"
|
||||
"placeholder": "Enter your full name"
|
||||
},
|
||||
"clear": "Clear",
|
||||
"add": "Add",
|
||||
"saved": {
|
||||
"heading": "Saved signatures",
|
||||
"description": "Reuse saved signatures at any time.",
|
||||
"emptyTitle": "No saved signatures yet",
|
||||
"emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.",
|
||||
"type": {
|
||||
"canvas": "Drawing",
|
||||
"image": "Upload",
|
||||
"text": "Text"
|
||||
},
|
||||
"limitTitle": "Limit reached",
|
||||
"limitDescription": "Remove a saved signature before adding new ones (max {{max}}).",
|
||||
"carouselPosition": "{{current}} of {{total}}",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"delete": "Remove",
|
||||
"label": "Label",
|
||||
"defaultLabel": "Signature",
|
||||
"defaultCanvasLabel": "Drawing signature",
|
||||
"defaultImageLabel": "Uploaded signature",
|
||||
"defaultTextLabel": "Typed signature",
|
||||
"saveButton": "Save signature",
|
||||
"saveUnavailable": "Create a signature first to save it.",
|
||||
"noChanges": "Current signature is already saved.",
|
||||
"status": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
},
|
||||
"saved": "Saved Signatures",
|
||||
"save": "Save Signature",
|
||||
"applySignatures": "Apply Signatures",
|
||||
"personalSigs": "Personal Signatures",
|
||||
@@ -2200,18 +2159,12 @@
|
||||
"steps": {
|
||||
"configure": "Configure Signature"
|
||||
},
|
||||
"step": {
|
||||
"createDesc": "Choose how you want to create the signature",
|
||||
"place": "Place & save",
|
||||
"placeDesc": "Position the signature on your PDF"
|
||||
},
|
||||
"type": {
|
||||
"title": "Signature Type",
|
||||
"draw": "Draw",
|
||||
"canvas": "Canvas",
|
||||
"image": "Image",
|
||||
"text": "Text",
|
||||
"saved": "Saved"
|
||||
"text": "Text"
|
||||
},
|
||||
"image": {
|
||||
"label": "Upload signature image",
|
||||
@@ -2222,17 +2175,11 @@
|
||||
"title": "How to add signature",
|
||||
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
|
||||
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
|
||||
"saved": "Select a saved signature above, then click anywhere on the PDF to place it.",
|
||||
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
|
||||
"paused": "Placement paused",
|
||||
"resumeHint": "Resume placement to click and add your signature.",
|
||||
"noSignature": "Create a signature above to enable placement tools."
|
||||
"text": "After entering your name above, click anywhere on the PDF to place your signature."
|
||||
},
|
||||
"mode": {
|
||||
"move": "Move Signature",
|
||||
"place": "Place Signature",
|
||||
"pause": "Pause placement",
|
||||
"resume": "Resume placement"
|
||||
"place": "Place Signature"
|
||||
},
|
||||
"updateAndPlace": "Update and Place",
|
||||
"activate": "Activate Signature Placement",
|
||||
@@ -4831,9 +4778,6 @@
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"title": "Choose colour"
|
||||
},
|
||||
"common": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
@@ -4849,8 +4793,7 @@
|
||||
"used": "used",
|
||||
"available": "available",
|
||||
"cancel": "Cancel",
|
||||
"preview": "Preview",
|
||||
"done": "Done"
|
||||
"preview": "Preview"
|
||||
},
|
||||
"config": {
|
||||
"overview": {
|
||||
|
||||
@@ -51,11 +51,6 @@
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
@@ -33,34 +32,10 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
const { historyApiRef } = useSignature();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const historyApiInstance = historyApiRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyApiInstance) {
|
||||
setHistoryAvailability({ canUndo: false, canRedo: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAvailability = () => {
|
||||
setHistoryAvailability({
|
||||
canUndo: historyApiInstance.canUndo?.() ?? false,
|
||||
canRedo: historyApiInstance.canRedo?.() ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
|
||||
updateAvailability();
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [historyApiInstance]);
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
setSignatureData(data);
|
||||
@@ -79,8 +54,6 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
@@ -113,4 +86,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,16 +14,13 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title
|
||||
title = "Choose Color"
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={resolvedTitle}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
@@ -40,7 +36,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
{t('common.done', 'Done')}
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -68,4 +64,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
@@ -21,7 +20,6 @@ interface DrawingCanvasProps {
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
initialSignatureData?: string;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
@@ -36,14 +34,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
initialSignatureData,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
@@ -59,18 +55,6 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
minDistance: 5,
|
||||
velocityFilterWeight: 0.7,
|
||||
});
|
||||
|
||||
// Restore saved signature data if it exists
|
||||
if (savedSignatureData) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
img.src = savedSignatureData;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,35 +104,36 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const renderPreview = (dataUrl: string) => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (canvas.width - scaledWidth) / 2;
|
||||
const y = (canvas.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
};
|
||||
img.src = dataUrl;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
const untrimmedPng = canvas.toDataURL('image/png');
|
||||
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
||||
onSignatureDataChange(trimmedPng);
|
||||
renderPreview(trimmedPng);
|
||||
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
|
||||
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
previewCanvasRef.current.width / img.width,
|
||||
previewCanvasRef.current.height / img.height
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
|
||||
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = trimmedPng;
|
||||
|
||||
if (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
@@ -172,7 +157,6 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
setSavedSignatureData(null); // Clear saved signature
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
@@ -189,73 +173,67 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updatePenColor(selectedColor);
|
||||
}, [selectedColor]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePenSize(penSize);
|
||||
}, [penSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
if (!initialSignatureData) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
setSavedSignatureData(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
renderPreview(initialSignatureData);
|
||||
setSavedSignatureData(initialSignatureData);
|
||||
}, [initialSignatureData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<PrivateContent>
|
||||
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
</PrivateContent>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
|
||||
Click to open drawing canvas
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
|
||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
||||
<Stack gap="md">
|
||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.colorLabel', 'Colour')}
|
||||
</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.penSizeLabel', 'Pen size')}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<Popover
|
||||
opened={colorPickerOpen}
|
||||
onChange={setColorPickerOpen}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={(color) => {
|
||||
onColorSwatchClick();
|
||||
updatePenColor(color);
|
||||
}}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
@@ -264,12 +242,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '80px' }}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrivateContent>
|
||||
<canvas
|
||||
@@ -284,8 +262,8 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '50rem',
|
||||
height: '25rem',
|
||||
maxWidth: '800px',
|
||||
height: '400px',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
@@ -293,10 +271,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
{t('sign.canvas.clear', 'Clear canvas')}
|
||||
Clear Canvas
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
{t('common.done', 'Done')}
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
@@ -9,11 +8,8 @@ interface DrawingControlsProps {
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
additionalControls?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
@@ -22,48 +18,30 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
canUndo = true,
|
||||
canRedo = true,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place",
|
||||
additionalControls,
|
||||
placeButtonText = "Update and Place"
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const undoDisabled = disabled || !canUndo;
|
||||
const redoDisabled = disabled || !canRedo;
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
{onUndo && (
|
||||
<Tooltip label={t('sign.undo', 'Undo')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.undo', 'Undo')}
|
||||
onClick={onUndo}
|
||||
disabled={undoDisabled}
|
||||
color={undoDisabled ? 'gray' : 'blue'}
|
||||
>
|
||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onRedo && (
|
||||
<Tooltip label={t('sign.redo', 'Redo')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.redo', 'Redo')}
|
||||
onClick={onRedo}
|
||||
disabled={redoDisabled}
|
||||
color={redoDisabled ? 'gray' : 'blue'}
|
||||
>
|
||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{additionalControls}
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
@@ -72,11 +50,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
ml="auto"
|
||||
flex={1}
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -34,18 +34,12 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [colorInput, setColorInput] = useState(textColor);
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
// Sync color input with prop changes
|
||||
useEffect(() => {
|
||||
setColorInput(textColor);
|
||||
}, [textColor]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
@@ -56,15 +50,10 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
|
||||
// Validate hex color
|
||||
const isValidHexColor = (color: string): boolean => {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer name')}
|
||||
label={label || t('sign.text.name', 'Signer Name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
@@ -74,7 +63,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label={t('sign.text.fontLabel', 'Font')}
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
@@ -99,8 +88,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label={t('sign.text.fontSizeLabel', 'Font size')}
|
||||
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-200)"
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
@@ -146,29 +135,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label={t('sign.text.colorLabel', 'Text colour')}
|
||||
value={colorInput}
|
||||
placeholder="#000000"
|
||||
label="Text Color"
|
||||
value={textColor}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setColorInput(value);
|
||||
|
||||
// Update color if valid hex
|
||||
if (isValidHexColor(value)) {
|
||||
onTextColorChange(value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Revert to valid color on blur if invalid
|
||||
if (!isValidHexColor(colorInput)) {
|
||||
setColorInput(textColor);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
rightSection={
|
||||
<Box
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -195,4 +169,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,292 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
|
||||
interface SavedSignaturesSectionProps {
|
||||
signatures: SavedSignature[];
|
||||
disabled?: boolean;
|
||||
isAtCapacity: boolean;
|
||||
onUseSignature: (signature: SavedSignature) => void;
|
||||
onDeleteSignature: (signature: SavedSignature) => void;
|
||||
onRenameSignature: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
const typeBadgeColor: Record<SavedSignatureType, string> = {
|
||||
canvas: 'indigo',
|
||||
image: 'teal',
|
||||
text: 'grape',
|
||||
};
|
||||
|
||||
export const SavedSignaturesSection = ({
|
||||
signatures,
|
||||
disabled = false,
|
||||
isAtCapacity,
|
||||
onUseSignature,
|
||||
onDeleteSignature,
|
||||
onRenameSignature,
|
||||
}: SavedSignaturesSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const activeSignature = signatures[activeIndex];
|
||||
const appliedSignatureIdRef = useRef<string | null>(null);
|
||||
const onUseSignatureRef = useRef(onUseSignature);
|
||||
|
||||
useEffect(() => {
|
||||
onUseSignatureRef.current = onUseSignature;
|
||||
}, [onUseSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelDrafts(prev => {
|
||||
const nextDrafts: Record<string, string> = {};
|
||||
signatures.forEach(sig => {
|
||||
nextDrafts[sig.id] = prev[sig.id] ?? sig.label ?? '';
|
||||
});
|
||||
return nextDrafts;
|
||||
});
|
||||
}, [signatures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (signatures.length === 0) {
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
|
||||
}, [signatures.length]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(direction: 'prev' | 'next') => {
|
||||
setActiveIndex(prev => {
|
||||
if (direction === 'prev') {
|
||||
return Math.max(0, prev - 1);
|
||||
}
|
||||
return Math.min(signatures.length - 1, prev + 1);
|
||||
});
|
||||
},
|
||||
[signatures.length]
|
||||
);
|
||||
|
||||
const renderPreview = (signature: SavedSignature) => {
|
||||
if (signature.type === 'text') {
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
style={{
|
||||
fontFamily: signature.fontFamily,
|
||||
fontSize: `${signature.fontSize}px`,
|
||||
color: signature.textColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '120px',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '0.5rem',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
size="lg"
|
||||
style={{
|
||||
fontFamily: signature.fontFamily,
|
||||
color: signature.textColor,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{signature.signerName}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="div"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '0.5rem',
|
||||
height: '120px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={signature.dataUrl}
|
||||
alt={signature.label}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const emptyState = (
|
||||
<Card withBorder>
|
||||
<Stack gap="xs">
|
||||
<Text fw={500}>{t('sign.saved.emptyTitle', 'No saved signatures yet')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'sign.saved.emptyDescription',
|
||||
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
|
||||
{ max: MAX_SAVED_SIGNATURES }
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const typeLabel = (type: SavedSignatureType) => {
|
||||
switch (type) {
|
||||
case 'canvas':
|
||||
return t('sign.saved.type.canvas', 'Drawing');
|
||||
case 'image':
|
||||
return t('sign.saved.type.image', 'Upload');
|
||||
case 'text':
|
||||
return t('sign.saved.type.text', 'Text');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLabelBlur = (signature: SavedSignature) => {
|
||||
const nextValue = labelDrafts[signature.id]?.trim() ?? '';
|
||||
if (!nextValue || nextValue === signature.label) {
|
||||
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
|
||||
return;
|
||||
}
|
||||
onRenameSignature(signature.id, nextValue);
|
||||
};
|
||||
|
||||
const handleLabelChange = (event: React.ChangeEvent<HTMLInputElement>, signature: SavedSignature) => {
|
||||
const { value } = event.currentTarget;
|
||||
setLabelDrafts(prev => ({ ...prev, [signature.id]: value }));
|
||||
};
|
||||
|
||||
const handleLabelKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, signature: SavedSignature) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSignature || disabled) {
|
||||
appliedSignatureIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliedSignatureIdRef.current === activeSignature.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedSignatureIdRef.current = activeSignature.id;
|
||||
onUseSignatureRef.current(activeSignature);
|
||||
}, [activeSignature, disabled]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="md">
|
||||
{t('sign.saved.heading', 'Saved signatures')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('sign.saved.description', 'Reuse saved signatures at any time.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isAtCapacity && (
|
||||
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}>
|
||||
<Text size="sm">
|
||||
{t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||
max: MAX_SAVED_SIGNATURES,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{signatures.length === 0 ? (
|
||||
emptyState
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', {
|
||||
current: activeIndex + 1,
|
||||
total: signatures.length,
|
||||
})}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
aria-label={t('sign.saved.prev', 'Previous')}
|
||||
onClick={() => handleNavigate('prev')}
|
||||
disabled={disabled || activeIndex === 0}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
aria-label={t('sign.saved.next', 'Next')}
|
||||
onClick={() => handleNavigate('next')}
|
||||
disabled={disabled || activeIndex >= signatures.length - 1}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{activeSignature && (
|
||||
<Card withBorder padding="sm" key={activeSignature.id}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
|
||||
{typeLabel(activeSignature.type)}
|
||||
</Badge>
|
||||
<Tooltip label={t('sign.saved.delete', 'Remove')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('sign.saved.delete', 'Remove')}
|
||||
onClick={() => onDeleteSignature(activeSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{renderPreview(activeSignature)}
|
||||
|
||||
<TextInput
|
||||
label={t('sign.saved.label', 'Label')}
|
||||
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
|
||||
onChange={event => handleLabelChange(event, activeSignature)}
|
||||
onBlur={() => handleLabelBlur(activeSignature)}
|
||||
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SavedSignaturesSection;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '@app/types/fileContext';
|
||||
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
|
||||
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
|
||||
import { useWheelZoom } from '@app/hooks/useWheelZoom';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
@@ -35,7 +34,6 @@ const EmbedPdfViewerContent = ({
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
@@ -55,7 +53,7 @@ const EmbedPdfViewerContent = ({
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@@ -73,9 +71,6 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
@@ -252,17 +247,15 @@ const EmbedPdfViewerContent = ({
|
||||
) : (
|
||||
<>
|
||||
{/* EmbedPDF Viewer */}
|
||||
<Box
|
||||
ref={pdfContainerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
@@ -275,11 +268,6 @@ const EmbedPdfViewerContent = ({
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
<SignaturePlacementOverlay
|
||||
containerRef={pdfContainerRef}
|
||||
isActive={isPlacementOverlayActive}
|
||||
signatureConfig={signatureConfig}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { uuidV4 } from '@embedpdf/models';
|
||||
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
|
||||
|
||||
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
const restoringIds = useRef<Set<string>>(new Set());
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
@@ -20,58 +18,17 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
const annotation = event.annotation;
|
||||
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id && annotation.imageSrc) {
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData) {
|
||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
|
||||
// Prevent infinite loops when we recreate annotations
|
||||
if (restoringIds.current.has(annotation.id)) {
|
||||
restoringIds.current.delete(annotation.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
|
||||
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
|
||||
const newId = uuidV4();
|
||||
restoringIds.current.add(newId);
|
||||
storeImageData(newId, storedImageData);
|
||||
|
||||
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
|
||||
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||
|
||||
try {
|
||||
annotationApi.deleteAnnotation(pageIndex, annotation.id);
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: annotation.type,
|
||||
rect,
|
||||
author: annotation.author || 'Digital Signature',
|
||||
subject: annotation.subject || 'Digital Signature',
|
||||
pageIndex,
|
||||
id: newId,
|
||||
created: annotation.created || new Date(),
|
||||
imageSrc: storedImageData,
|
||||
contents: storedImageData,
|
||||
data: storedImageData,
|
||||
appearance: storedImageData,
|
||||
});
|
||||
}, ANNOTATION_RECREATION_DELAY_MS);
|
||||
} catch (restoreError) {
|
||||
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
getImageData(annotation.id);
|
||||
|
||||
// Delay the check to allow the annotation to be fully created
|
||||
@@ -104,12 +61,12 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
// Small delay to ensure deletion completes
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(event.pageIndex, restoredData);
|
||||
}, ANNOTATION_RECREATION_DELAY_MS);
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error('HistoryAPI: Failed to restore annotation:', error);
|
||||
}
|
||||
}
|
||||
}, ANNOTATION_VERIFICATION_DELAY_MS);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -145,21 +102,6 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
|
||||
canRedo: () => {
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
|
||||
subscribe: (listener: () => void) => {
|
||||
if (!historyApi?.onHistoryChange) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const wrapped = () => listener();
|
||||
const unsubscribe = historyApi.onHistoryChange(wrapped);
|
||||
listener();
|
||||
|
||||
if (typeof unsubscribe === 'function') {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {};
|
||||
},
|
||||
}), [historyApi]);
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -43,32 +43,23 @@ export function PdfViewerToolbar({
|
||||
|
||||
// Register for immediate scroll updates and sync with actual scroll state
|
||||
useEffect(() => {
|
||||
const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
||||
registerImmediateScrollUpdate((currentPage, _totalPages) => {
|
||||
setPageInput(currentPage);
|
||||
});
|
||||
setPageInput(scrollState.currentPage);
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [registerImmediateScrollUpdate, scrollState.currentPage]);
|
||||
}, [registerImmediateScrollUpdate]);
|
||||
|
||||
// Register for immediate zoom updates and sync with actual zoom state
|
||||
useEffect(() => {
|
||||
const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent);
|
||||
registerImmediateZoomUpdate(setDisplayZoomPercent);
|
||||
setDisplayZoomPercent(zoomState.zoomPercent || 140);
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [registerImmediateZoomUpdate, zoomState.zoomPercent]);
|
||||
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = registerImmediateSpreadUpdate((_mode, isDual) => {
|
||||
registerImmediateSpreadUpdate((_mode, isDual) => {
|
||||
setIsDualPageActive(isDual);
|
||||
});
|
||||
setIsDualPageActive(spreadState.isDualPage);
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
|
||||
|
||||
const handleZoomOut = () => {
|
||||
|
||||
@@ -1,211 +1,12 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
|
||||
// This prevents rendering issues and ensures signatures are always visible and usable.
|
||||
const MIN_SIGNATURE_DIMENSION = 12;
|
||||
|
||||
// Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images.
|
||||
// This provides a good balance between visual fidelity and performance/memory usage.
|
||||
const TEXT_OVERSAMPLE_FACTOR = 2;
|
||||
|
||||
type TextStampImageResult = {
|
||||
dataUrl: string;
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
};
|
||||
|
||||
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
|
||||
if (!value || depth > 6) return undefined;
|
||||
|
||||
// Prevent circular references
|
||||
if (typeof value === 'object' && visited.has(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.startsWith('data:image') ? value : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
visited.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
const result = extractDataUrl(entry, depth + 1, visited);
|
||||
if (result) return result;
|
||||
}
|
||||
} else {
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1, visited);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createTextStampImage = (
|
||||
config: SignParameters,
|
||||
displaySize?: { width: number; height: number } | null
|
||||
): TextStampImageResult | null => {
|
||||
const text = (config.signerName ?? '').trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSize = config.fontSize ?? 16;
|
||||
const fontFamily = config.fontFamily ?? 'Helvetica';
|
||||
const textColor = config.textColor ?? '#000000';
|
||||
|
||||
const paddingX = Math.max(4, Math.round(fontSize * 0.8));
|
||||
const paddingY = Math.max(4, Math.round(fontSize * 0.6));
|
||||
|
||||
const measureCanvas = document.createElement('canvas');
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
if (!measureCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measureCtx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = measureCtx.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2);
|
||||
const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2));
|
||||
|
||||
const scale =
|
||||
displaySize && naturalWidth > 0 && naturalHeight > 0
|
||||
? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight)
|
||||
: 1;
|
||||
|
||||
const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale);
|
||||
const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale);
|
||||
|
||||
const canvasWidth = Math.max(
|
||||
MIN_SIGNATURE_DIMENSION,
|
||||
Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR)
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
MIN_SIGNATURE_DIMENSION,
|
||||
Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR)
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR;
|
||||
ctx.scale(effectiveScale, effectiveScale);
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
pixelWidth: canvasWidth,
|
||||
pixelHeight: canvasHeight,
|
||||
displayWidth,
|
||||
displayHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
|
||||
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
|
||||
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
|
||||
const lastStampImageRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentZoom(getZoomState()?.currentZoom ?? 1);
|
||||
const unregister = registerImmediateZoomUpdate(percent => {
|
||||
setCurrentZoom(Math.max(percent / 100, 0.01));
|
||||
});
|
||||
return () => {
|
||||
unregister?.();
|
||||
};
|
||||
}, [getZoomState, registerImmediateZoomUpdate]);
|
||||
|
||||
const cssToPdfSize = useCallback(
|
||||
(size: { width: number; height: number }) => {
|
||||
const zoom = currentZoom || 1;
|
||||
const factor = 1 / zoom;
|
||||
return {
|
||||
width: size.width * factor,
|
||||
height: size.height * factor,
|
||||
};
|
||||
},
|
||||
[currentZoom]
|
||||
);
|
||||
|
||||
const applyStampDefaults = useCallback(
|
||||
(imageSrc: string, subject: string, size?: { width: number; height: number }) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc,
|
||||
subject,
|
||||
...(size ? { imageSize: { width: size.width, height: size.height } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
[annotationApi]
|
||||
);
|
||||
|
||||
const configureStampDefaults = useCallback(async () => {
|
||||
if (!annotationApi || !signatureConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
const textStamp = createTextStampImage(signatureConfig, placementPreviewSize);
|
||||
if (textStamp) {
|
||||
const displaySize =
|
||||
placementPreviewSize ?? {
|
||||
width: textStamp.displayWidth,
|
||||
height: textStamp.displayHeight,
|
||||
};
|
||||
const pdfSize = cssToPdfSize(displaySize);
|
||||
lastStampImageRef.current = textStamp.dataUrl;
|
||||
applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (signatureConfig.signatureData) {
|
||||
const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined;
|
||||
lastStampImageRef.current = signatureConfig.signatureData;
|
||||
applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preparing signature defaults:', error);
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
@@ -307,9 +108,58 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
activateSignaturePlacementMode: () => {
|
||||
if (!annotationApi || !signatureConfig) return;
|
||||
|
||||
configureStampDefaults().catch((error) => {
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Skip native text tools - always use stamp for consistent sizing
|
||||
const activatedTool = null;
|
||||
|
||||
if (!activatedTool) {
|
||||
// Create text image as stamp with actual pixel size matching desired display size
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const baseFontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
const textColor = signatureConfig.textColor || '#000000';
|
||||
|
||||
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
|
||||
canvas.height = baseFontSize + 20;
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${baseFontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||
const dataURL = canvas.toDataURL();
|
||||
|
||||
// Deactivate and reactivate to force refresh
|
||||
annotationApi.setActiveTool(null);
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: dataURL,
|
||||
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (signatureConfig.signatureData) {
|
||||
// Use stamp tool for image/canvas signatures
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
|
||||
if (activeTool && activeTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: signatureConfig.signatureData,
|
||||
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error activating signature tool:', error);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateDrawSettings: (color: string, size: number) => {
|
||||
@@ -346,7 +196,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
|
||||
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.imageSrc) {
|
||||
if (annotation && annotation.type === 13 && annotation.imageSrc) {
|
||||
// Store image data before deletion
|
||||
storeImageData(annotationId, annotation.imageSrc);
|
||||
}
|
||||
@@ -380,61 +230,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotationApi?.onAnnotationEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = annotationApi.onAnnotationEvent(event => {
|
||||
if (event.type !== 'create' && event.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const annotation: any = event.annotation;
|
||||
const annotationId: string | undefined = annotation?.id;
|
||||
if (!annotationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directData =
|
||||
extractDataUrl(annotation.imageSrc) ||
|
||||
extractDataUrl(annotation.imageData) ||
|
||||
extractDataUrl(annotation.appearance) ||
|
||||
extractDataUrl(annotation.stampData) ||
|
||||
extractDataUrl(annotation.contents) ||
|
||||
extractDataUrl(annotation.data) ||
|
||||
extractDataUrl(annotation.customData) ||
|
||||
extractDataUrl(annotation.asset);
|
||||
|
||||
const dataToStore = directData || lastStampImageRef.current;
|
||||
if (dataToStore) {
|
||||
storeImageData(annotationId, dataToStore);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [annotationApi, storeImageData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlacementMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
configureStampDefaults().catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.error('Error updating signature defaults:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
|
||||
}), [annotationApi, signatureConfig]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import {
|
||||
MAX_PREVIEW_WIDTH_RATIO,
|
||||
MAX_PREVIEW_HEIGHT_RATIO,
|
||||
MAX_PREVIEW_WIDTH_REM,
|
||||
MAX_PREVIEW_HEIGHT_REM,
|
||||
MIN_SIGNATURE_DIMENSION_REM,
|
||||
OVERLAY_EDGE_PADDING_REM,
|
||||
} from '@app/constants/signConstants';
|
||||
|
||||
// Convert rem to pixels using browser's base font size (typically 16px)
|
||||
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
|
||||
interface SignaturePlacementOverlayProps {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
isActive: boolean;
|
||||
signatureConfig: SignParameters | null;
|
||||
}
|
||||
|
||||
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
|
||||
containerRef,
|
||||
isActive,
|
||||
signatureConfig,
|
||||
}) => {
|
||||
const [preview, setPreview] = useState<SignaturePreview | null>(null);
|
||||
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
|
||||
const { setPlacementPreviewSize } = useSignature();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const buildPreview = async () => {
|
||||
try {
|
||||
const value = await buildSignaturePreview(signatureConfig ?? null);
|
||||
if (!cancelled) {
|
||||
setPreview(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to build signature preview:', error);
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
buildPreview();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [signatureConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current;
|
||||
if (!isActive || !element) {
|
||||
setCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setCursor({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLeave = () => setCursor(null);
|
||||
|
||||
element.addEventListener('mousemove', handleMove);
|
||||
element.addEventListener('mouseleave', handleLeave);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('mousemove', handleMove);
|
||||
element.removeEventListener('mouseleave', handleLeave);
|
||||
};
|
||||
}, [containerRef, isActive]);
|
||||
|
||||
const scaledSize = useMemo(() => {
|
||||
if (!preview || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
|
||||
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
|
||||
|
||||
const scale = Math.min(
|
||||
1,
|
||||
maxWidth / Math.max(preview.width, 1),
|
||||
maxHeight / Math.max(preview.height, 1)
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
|
||||
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
|
||||
};
|
||||
}, [preview, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !scaledSize) {
|
||||
setPlacementPreviewSize(null);
|
||||
} else {
|
||||
setPlacementPreviewSize(scaledSize);
|
||||
}
|
||||
}, [isActive, scaledSize, setPlacementPreviewSize]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPlacementPreviewSize(null);
|
||||
};
|
||||
}, [setPlacementPreviewSize]);
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (!preview || !scaledSize || !cursor || !containerRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerWidth = container.clientWidth || 1;
|
||||
const containerHeight = container.clientHeight || 1;
|
||||
|
||||
const width = scaledSize.width;
|
||||
const height = scaledSize.height;
|
||||
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
|
||||
|
||||
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
|
||||
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
|
||||
|
||||
return {
|
||||
left: clampedLeft,
|
||||
top: clampedTop,
|
||||
width,
|
||||
height,
|
||||
dataUrl: preview.dataUrl,
|
||||
};
|
||||
}, [preview, scaledSize, cursor, containerRef]);
|
||||
|
||||
if (!isActive || !display || !preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
left: `${display.left}px`,
|
||||
top: `${display.top}px`,
|
||||
width: `${display.width}px`,
|
||||
height: `${display.height}px`,
|
||||
backgroundImage: `url(${display.dataUrl})`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
|
||||
borderRadius: '4px',
|
||||
transition: 'transform 70ms ease-out',
|
||||
transform: 'translateZ(0)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -214,4 +214,4 @@ export function ZoomAPIBridge() {
|
||||
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,4 @@ export interface HistoryAPI {
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
// When no subpath, use empty string instead of '.' to avoid relative path issues
|
||||
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
|
||||
|
||||
// EmbedPDF needs time to remove annotations internally before a recreation runs.
|
||||
// Without the buffer we occasionally end up with duplicate annotations or stale image data.
|
||||
export const ANNOTATION_RECREATION_DELAY_MS = 50;
|
||||
export const ANNOTATION_VERIFICATION_DELAY_MS = 100;
|
||||
|
||||
/** For in-app navigations when you must touch window.location (rare). */
|
||||
export const withBasePath = (path: string): string => {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// Timeout delays (ms) to allow PDF viewer to complete rendering before activating placement mode
|
||||
export const PLACEMENT_ACTIVATION_DELAY = 60; // Standard delay for signature changes
|
||||
export const FILE_SWITCH_ACTIVATION_DELAY = 80; // Slightly longer delay when switching files
|
||||
|
||||
// Signature preview sizing
|
||||
export const MAX_PREVIEW_WIDTH_RATIO = 0.35; // Max preview width as percentage of container
|
||||
export const MAX_PREVIEW_HEIGHT_RATIO = 0.35; // Max preview height as percentage of container
|
||||
export const MAX_PREVIEW_WIDTH_REM = 15; // Absolute max width in rem
|
||||
export const MAX_PREVIEW_HEIGHT_REM = 10; // Absolute max height in rem
|
||||
export const MIN_SIGNATURE_DIMENSION_REM = 0.75; // Min dimension for visibility
|
||||
export const OVERLAY_EDGE_PADDING_REM = 0.25; // Padding from container edges
|
||||
|
||||
// Text signature padding (relative to font size)
|
||||
export const HORIZONTAL_PADDING_RATIO = 0.8;
|
||||
export const VERTICAL_PADDING_RATIO = 0.6;
|
||||
@@ -10,8 +10,6 @@ interface SignatureState {
|
||||
isPlacementMode: boolean;
|
||||
// Whether signatures have been applied (allows export)
|
||||
signaturesApplied: boolean;
|
||||
// Size (in screen units) we want newly placed signatures to use
|
||||
placementPreviewSize: { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
// Signature actions interface
|
||||
@@ -28,7 +26,6 @@ interface SignatureActions {
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
setSignaturesApplied: (applied: boolean) => void;
|
||||
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
|
||||
}
|
||||
|
||||
// Combined context interface
|
||||
@@ -45,7 +42,6 @@ const initialState: SignatureState = {
|
||||
signatureConfig: null,
|
||||
isPlacementMode: false,
|
||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||
placementPreviewSize: null,
|
||||
};
|
||||
|
||||
// Provider component
|
||||
@@ -135,27 +131,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setPlacementPreviewSize = useCallback((size: { width: number; height: number } | null) => {
|
||||
setState(prev => {
|
||||
const prevSize = prev.placementPreviewSize;
|
||||
const same =
|
||||
(prevSize === null && size === null) ||
|
||||
(prevSize !== null &&
|
||||
size !== null &&
|
||||
Math.abs(prevSize.width - size.width) < 0.5 &&
|
||||
Math.abs(prevSize.height - size.height) < 0.5);
|
||||
|
||||
if (same) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
placementPreviewSize: size,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// No auto-activation - all modes use manual buttons
|
||||
|
||||
const contextValue: SignatureContextValue = {
|
||||
@@ -174,7 +149,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
storeImageData,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
setPlacementPreviewSize,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,23 +39,14 @@ import {
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
|
||||
function useImmediateNotifier<Args extends unknown[]>() {
|
||||
const callbacksRef = useRef(new Set<(...args: Args) => void>());
|
||||
const callbackRef = useRef<((...args: Args) => void) | null>(null);
|
||||
|
||||
const register = useCallback((callback: (...args: Args) => void) => {
|
||||
callbacksRef.current.add(callback);
|
||||
return () => {
|
||||
callbacksRef.current.delete(callback);
|
||||
};
|
||||
callbackRef.current = callback;
|
||||
}, []);
|
||||
|
||||
const trigger = useCallback((...args: Args) => {
|
||||
callbacksRef.current.forEach(cb => {
|
||||
try {
|
||||
cb(...args);
|
||||
} catch (error) {
|
||||
console.error('Immediate callback error:', error);
|
||||
}
|
||||
});
|
||||
callbackRef.current?.(...args);
|
||||
}, []);
|
||||
|
||||
return { register, trigger };
|
||||
@@ -100,9 +91,9 @@ interface ViewerContextType {
|
||||
getExportState: () => ExportState;
|
||||
|
||||
// Immediate update callbacks
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
|
||||
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
|
||||
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
|
||||
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
|
||||
|
||||
// Internal - for bridges to trigger immediate updates
|
||||
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||
export const MAX_SAVED_SIGNATURES = 10;
|
||||
|
||||
export type SavedSignatureType = 'canvas' | 'image' | 'text';
|
||||
|
||||
export type SavedSignaturePayload =
|
||||
| {
|
||||
type: 'canvas';
|
||||
dataUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
dataUrl: string;
|
||||
}
|
||||
| {
|
||||
type: 'text';
|
||||
signerName: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
export type SavedSignature = SavedSignaturePayload & {
|
||||
id: string;
|
||||
label: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type AddSignatureResult =
|
||||
| { success: true; signature: SavedSignature }
|
||||
| { success: false; reason: 'limit' | 'invalid' };
|
||||
|
||||
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
|
||||
const safeParse = (raw: string | null): SavedSignature[] => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((entry: any): entry is SavedSignature => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (typeof entry.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.type === 'text') {
|
||||
return (
|
||||
typeof entry.signerName === 'string' &&
|
||||
typeof entry.fontFamily === 'string' &&
|
||||
typeof entry.fontSize === 'number' &&
|
||||
typeof entry.textColor === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
return typeof entry.dataUrl === 'string';
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const readFromStorage = (): SavedSignature[] => {
|
||||
if (!isSupportedEnvironment()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return safeParse(window.localStorage.getItem(STORAGE_KEY));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeToStorage = (entries: SavedSignature[]) => {
|
||||
if (!isSupportedEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// Swallow storage errors silently; we still keep state in memory.
|
||||
}
|
||||
};
|
||||
|
||||
const generateId = () => crypto.randomUUID();
|
||||
|
||||
export const useSavedSignatures = () => {
|
||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupportedEnvironment()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncFromStorage = () => {
|
||||
setSavedSignatures(readFromStorage());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', syncFromStorage);
|
||||
return () => window.removeEventListener('storage', syncFromStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
writeToStorage(savedSignatures);
|
||||
}, [savedSignatures]);
|
||||
|
||||
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
|
||||
|
||||
const addSignature = useCallback(
|
||||
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
|
||||
if (
|
||||
(payload.type === 'text' && !payload.signerName.trim()) ||
|
||||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
|
||||
) {
|
||||
return { success: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
let createdSignature: SavedSignature | null = null;
|
||||
setSavedSignatures(prev => {
|
||||
if (prev.length >= MAX_SAVED_SIGNATURES) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const nextEntry: SavedSignature = {
|
||||
...payload,
|
||||
id: generateId(),
|
||||
label: (label || 'Signature').trim() || 'Signature',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
createdSignature = nextEntry;
|
||||
return [nextEntry, ...prev];
|
||||
});
|
||||
|
||||
return createdSignature
|
||||
? { success: true, signature: createdSignature }
|
||||
: { success: false, reason: 'limit' };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeSignature = useCallback((id: string) => {
|
||||
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
|
||||
setSavedSignatures(prev =>
|
||||
prev.map(entry =>
|
||||
entry.id === id
|
||||
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
|
||||
setSavedSignatures(prev =>
|
||||
prev.map(entry =>
|
||||
entry.id === id
|
||||
? {
|
||||
...entry,
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: entry
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearSignatures = useCallback(() => {
|
||||
setSavedSignatures([]);
|
||||
}, []);
|
||||
|
||||
const byTypeCounts = useMemo(() => {
|
||||
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
|
||||
(acc, entry) => {
|
||||
acc[entry.type] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ canvas: 0, image: 0, text: 0 }
|
||||
);
|
||||
}, [savedSignatures]);
|
||||
|
||||
return {
|
||||
savedSignatures,
|
||||
isAtCapacity,
|
||||
addSignature,
|
||||
removeSignature,
|
||||
updateSignatureLabel,
|
||||
replaceSignature,
|
||||
clearSignatures,
|
||||
byTypeCounts,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseSavedSignaturesReturn = ReturnType<typeof useSavedSignatures>;
|
||||
@@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
|
||||
}
|
||||
|
||||
// Add signature type
|
||||
formData.append('signatureType', params.signatureType || 'canvas');
|
||||
formData.append('signatureType', params.signatureType || 'draw');
|
||||
|
||||
// Add other parameters
|
||||
if (params.reason) {
|
||||
@@ -56,4 +56,4 @@ export const useSignOperation = (): ToolOperationHook<SignParameters> => {
|
||||
...signOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -9,7 +9,7 @@ export interface SignaturePosition {
|
||||
}
|
||||
|
||||
export interface SignParameters {
|
||||
signatureType: 'image' | 'text' | 'canvas';
|
||||
signatureType: 'image' | 'text' | 'draw' | 'canvas';
|
||||
signatureData?: string; // Base64 encoded image or text content
|
||||
signaturePosition?: SignaturePosition;
|
||||
reason?: string;
|
||||
@@ -60,4 +60,4 @@ export const useSignParameters = () => {
|
||||
endpointName: 'add-signature',
|
||||
validateFn: validateSignParameters,
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -87,7 +87,7 @@ export function useTooltipPosition({
|
||||
if (sidebarTooltip) {
|
||||
// Require sidebar refs and state for proper positioning
|
||||
if (!sidebarRefs || !sidebarState) {
|
||||
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
|
||||
setPositionReady(false);
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export function useTooltipPosition({
|
||||
|
||||
// Only show tooltip if we have the tool panel active
|
||||
if (!sidebarInfo.isToolPanelActive) {
|
||||
console.log('Not showing tooltip - tool panel not active');
|
||||
console.log('🚫 Not showing tooltip - tool panel not active');
|
||||
setPositionReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { getBrowserId } from '@app/utils/browserIdentifier';
|
||||
|
||||
export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
// Add browser ID header for WAU tracking
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
const browserId = getBrowserId();
|
||||
config.headers['X-Browser-Id'] = browserId;
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
export function setupApiInterceptors(_client: AxiosInstance): void {
|
||||
// Core version: no interceptors to add
|
||||
}
|
||||
|
||||
@@ -115,32 +115,6 @@ const Sign = (props: BaseToolProps) => {
|
||||
// Deactivate signature placement mode after everything completes
|
||||
handleDeactivateSignature();
|
||||
|
||||
const hasSignatureReady = (() => {
|
||||
const params = base.params.parameters;
|
||||
switch (params.signatureType) {
|
||||
case 'canvas':
|
||||
case 'image':
|
||||
return Boolean(params.signatureData);
|
||||
case 'text':
|
||||
return Boolean(params.signerName && params.signerName.trim() !== '');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (hasSignatureReady) {
|
||||
if (typeof window !== 'undefined') {
|
||||
// TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready.
|
||||
// However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload.
|
||||
// This value was empirically determined to be sufficient for most environments, but should be revisited.
|
||||
window.setTimeout(() => {
|
||||
handleActivateSignaturePlacement();
|
||||
}, 150);
|
||||
} else {
|
||||
handleActivateSignaturePlacement();
|
||||
}
|
||||
}
|
||||
|
||||
// File has been consumed - viewer should reload automatically via key prop
|
||||
} else {
|
||||
console.error('Signature flattening failed');
|
||||
@@ -148,7 +122,7 @@ const Sign = (props: BaseToolProps) => {
|
||||
} catch (error) {
|
||||
console.error('Error saving signed document:', error);
|
||||
}
|
||||
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
||||
|
||||
const getSteps = () => {
|
||||
const steps = [];
|
||||
@@ -205,4 +179,4 @@ Sign.getDefaultParameters = () => ({
|
||||
signerName: '',
|
||||
});
|
||||
|
||||
export default Sign as ToolComponent;
|
||||
export default Sign as ToolComponent;
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Browser identifier utility for anonymous usage tracking
|
||||
* Generates and persists a unique UUID in localStorage for WAU tracking
|
||||
*/
|
||||
|
||||
const BROWSER_ID_KEY = 'stirling_browser_id';
|
||||
|
||||
/**
|
||||
* Gets or creates a unique browser identifier
|
||||
* Used for Weekly Active Users (WAU) tracking in no-login mode
|
||||
*/
|
||||
export function getBrowserId(): string {
|
||||
try {
|
||||
// Try to get existing ID from localStorage
|
||||
let browserId = localStorage.getItem(BROWSER_ID_KEY);
|
||||
|
||||
if (!browserId) {
|
||||
// Generate new UUID v4
|
||||
browserId = generateUUID();
|
||||
localStorage.setItem(BROWSER_ID_KEY, browserId);
|
||||
}
|
||||
|
||||
return browserId;
|
||||
} catch (error) {
|
||||
// Fallback to session-based ID if localStorage is unavailable
|
||||
console.warn('localStorage unavailable, using session-based ID', error);
|
||||
return `session_${generateUUID()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// Use crypto.randomUUID if available (modern browsers)
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Fallback to manual UUID generation
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile, createChildStub } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||
@@ -229,7 +228,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
size: 12,
|
||||
color: rgb(0, 0, 0)
|
||||
});
|
||||
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
|
||||
} else if (annotation.type === 14 || annotation.type === 15) {
|
||||
// Handle ink annotations (drawn signatures)
|
||||
page.drawRectangle({
|
||||
x: pdfX,
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/constants/signConstants';
|
||||
|
||||
export interface SignaturePreview {
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const loadImage = (src: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
|
||||
export const buildSignaturePreview = async (config: SignParameters | null): Promise<SignaturePreview | null> => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.signatureType === 'text') {
|
||||
const text = config.signerName?.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSize = config.fontSize ?? 16;
|
||||
const fontFamily = config.fontFamily ?? 'Helvetica';
|
||||
const textColor = config.textColor ?? '#000000';
|
||||
|
||||
const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO);
|
||||
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
|
||||
|
||||
const measureCanvas = document.createElement('canvas');
|
||||
const measureCtx = measureCanvas.getContext('2d');
|
||||
|
||||
if (!measureCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
measureCtx.font = `${fontSize}px ${fontFamily}`;
|
||||
const metrics = measureCtx.measureText(text);
|
||||
const textWidth = Math.ceil(metrics.width);
|
||||
|
||||
const width = Math.max(1, textWidth + paddingX * 2);
|
||||
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(text, paddingX, height / 2);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
return { dataUrl, width, height };
|
||||
}
|
||||
|
||||
const dataUrl = config.signatureData;
|
||||
if (!dataUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = await loadImage(dataUrl);
|
||||
return {
|
||||
dataUrl,
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
};
|
||||
};
|
||||
@@ -50,6 +50,7 @@ ignore = [
|
||||
|
||||
[ca_CA]
|
||||
ignore = [
|
||||
'adminUserSettings.admin',
|
||||
'lang.amh',
|
||||
'lang.ceb',
|
||||
'lang.chr',
|
||||
@@ -407,6 +408,8 @@ ignore = [
|
||||
'endpointStatistics.top20',
|
||||
'home.pipeline.title',
|
||||
'language.direction',
|
||||
'pipeline.title',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'pro',
|
||||
'showJS.tags',
|
||||
]
|
||||
@@ -609,6 +612,7 @@ ignore = [
|
||||
'lang.urd',
|
||||
'lang.yor',
|
||||
'language.direction',
|
||||
'oops',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
@@ -784,6 +788,7 @@ ignore = [
|
||||
'lang.urd',
|
||||
'lang.uzb',
|
||||
'language.direction',
|
||||
'navbar.sections.security',
|
||||
'text',
|
||||
'watermark.type.1',
|
||||
]
|
||||
@@ -833,6 +838,7 @@ ignore = [
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'font',
|
||||
'info',
|
||||
'lang.div',
|
||||
'lang.epo',
|
||||
'lang.hin',
|
||||
@@ -890,6 +896,7 @@ ignore = [
|
||||
'lang.tir',
|
||||
'lang.uzb_cyrl',
|
||||
'language.direction',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
@@ -980,5 +987,6 @@ ignore = [
|
||||
[zh_TW]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'poweredBy',
|
||||
'showJS.tags',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user