Merge branch 'fix_issue' of https://gitea.maskantech.in/gitea_admin/pdf into ribai
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
**/.idea
|
||||
**/coverage
|
||||
**/tmp
|
||||
corpus/
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rule R2 — PDFium boundary check
|
||||
run: bash scripts/check_pdfium_boundary.sh
|
||||
|
||||
- name: Install clang-format (pinned)
|
||||
run: pipx install clang-format==22.1.5
|
||||
|
||||
- name: clang-format
|
||||
run: |
|
||||
clang-format --version
|
||||
find engine \( -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) \
|
||||
-print0 | xargs -0 clang-format --dry-run --Werror
|
||||
|
||||
build:
|
||||
needs: lint
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
preset: linux-debug
|
||||
- os: macos-latest
|
||||
preset: macos-debug
|
||||
experimental: true
|
||||
- os: windows-latest
|
||||
preset: windows-debug
|
||||
runs-on: ${{ matrix.os }}
|
||||
continue-on-error: ${{ matrix.experimental == true }}
|
||||
env:
|
||||
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Ninja
|
||||
uses: seanmiddleditch/gha-setup-ninja@v5
|
||||
|
||||
- name: Set up MSVC environment
|
||||
if: runner.os == 'Windows'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
|
||||
- name: Locate vcpkg
|
||||
shell: bash
|
||||
run: |
|
||||
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
|
||||
git -C "$VCPKG_INSTALLATION_ROOT" fetch --quiet origin || true
|
||||
|
||||
- name: Create vcpkg binary cache dir
|
||||
shell: bash
|
||||
run: mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
|
||||
|
||||
- name: Cache vcpkg artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
|
||||
key: vcpkg-${{ matrix.os }}-${{ hashFiles('vcpkg.json') }}
|
||||
restore-keys: vcpkg-${{ matrix.os }}-
|
||||
|
||||
- name: Pin vcpkg dependency baseline
|
||||
shell: bash
|
||||
run: |
|
||||
if ! grep -q '"builtin-baseline"' vcpkg.json; then
|
||||
"$VCPKG_ROOT/vcpkg" x-update-baseline --add-initial-baseline
|
||||
fi
|
||||
|
||||
- name: Configure
|
||||
run: cmake --preset ${{ matrix.preset }}
|
||||
|
||||
- name: Build
|
||||
run: cmake --build --preset ${{ matrix.preset }}
|
||||
|
||||
- name: Test
|
||||
run: ctest --preset ${{ matrix.preset }}
|
||||
|
||||
gateway:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: gateway
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: gateway/pyproject.toml
|
||||
|
||||
- name: Install gateway (editable, with dev extras)
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
|
||||
- name: Ruff — lint
|
||||
run: python -m ruff check .
|
||||
|
||||
- name: Ruff — format check
|
||||
run: python -m ruff format --check .
|
||||
|
||||
- name: Pytest
|
||||
run: python -m pytest
|
||||
|
||||
|
||||
wasm:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Read pinned emsdk version
|
||||
id: emsdk-version
|
||||
run: |
|
||||
version=$(grep '^EMSDK_VERSION=' wasm/emsdk.pinned | cut -d= -f2)
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error::EMSDK_VERSION not found in wasm/emsdk.pinned"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Ninja
|
||||
uses: seanmiddleditch/gha-setup-ninja@v5
|
||||
|
||||
- name: Set up Emscripten ${{ steps.emsdk-version.outputs.version }}
|
||||
uses: mymindstorm/setup-emsdk@v14
|
||||
with:
|
||||
version: ${{ steps.emsdk-version.outputs.version }}
|
||||
actions-cache-folder: emsdk-cache-${{ steps.emsdk-version.outputs.version }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Configure (WASM)
|
||||
|
||||
run: cmake --preset wasm
|
||||
|
||||
- name: Build
|
||||
run: cmake --build --preset wasm
|
||||
|
||||
- name: Smoke test
|
||||
run: node wasm/hello.test.mjs
|
||||
@@ -1,104 +0,0 @@
|
||||
# Extended robustness: corpus render-stability sweep + libFuzzer run.
|
||||
#
|
||||
# DELIBERATELY ISOLATED from the main CI gate:
|
||||
# - never triggers on push or pull_request, so it can NEVER block a merge,
|
||||
# push, or pull;
|
||||
# - the whole job is continue-on-error, so a crash finding or a build/runner
|
||||
# problem reports red here but does not fail any required check;
|
||||
# - runs on a weekly schedule and on manual dispatch only.
|
||||
#
|
||||
# A full 24h fuzz run needs a self-hosted runner (GitHub-hosted runners cap a job
|
||||
# at 6h). Use the workflow_dispatch `duration_seconds` input for that; the weekly
|
||||
# schedule does a short smoke instead.
|
||||
|
||||
name: Fuzz & corpus sweep
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * 0" # Sundays 03:00 UTC — short smoke
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
duration_seconds:
|
||||
description: "libFuzzer -max_total_time (e.g. 1800 smoke, 86400 for 24h on a self-hosted runner)"
|
||||
default: "1800"
|
||||
sanitizers:
|
||||
description: "Sanitizer set (fuzzer,address,undefined needs an ASan/UBSan-built PDFium; fuzzer = coverage-only)"
|
||||
default: "fuzzer,address,undefined"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fuzz-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
fuzz:
|
||||
# Non-blocking by construction: nothing depends on this job and it is allowed to fail.
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 1500 # permits a 24h dispatch on a self-hosted runner; hosted runners stop at 6h
|
||||
env:
|
||||
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
|
||||
FUZZ_SANITIZERS: ${{ github.event.inputs.sanitizers || 'fuzzer' }}
|
||||
FUZZ_DURATION: ${{ github.event.inputs.duration_seconds || '600' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Ninja + Clang
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ninja-build clang
|
||||
|
||||
- name: Locate vcpkg
|
||||
shell: bash
|
||||
run: |
|
||||
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
|
||||
mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
|
||||
|
||||
- name: Cache vcpkg artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }}
|
||||
key: vcpkg-fuzz-${{ hashFiles('vcpkg.json') }}
|
||||
restore-keys: vcpkg-fuzz-
|
||||
|
||||
- name: Pin vcpkg dependency baseline
|
||||
shell: bash
|
||||
run: |
|
||||
if ! grep -q '"builtin-baseline"' vcpkg.json; then
|
||||
"$VCPKG_ROOT/vcpkg" x-update-baseline --add-initial-baseline
|
||||
fi
|
||||
|
||||
- name: Configure (fuzz-linux)
|
||||
run: cmake --preset fuzz-linux -DPDFENGINE_FUZZ_SANITIZERS="$FUZZ_SANITIZERS"
|
||||
|
||||
- name: Build fuzzer
|
||||
run: cmake --build --preset fuzz-linux --target pdfengine_fuzz
|
||||
|
||||
- name: Fetch corpus (pinned + hash-verified)
|
||||
shell: bash
|
||||
run: python3 scripts/fetch_corpus.py --manifest tests/regression/corpus-manifest.json || echo "corpus fetch failed (network) — continuing with committed corpus"
|
||||
|
||||
- name: Render-stability sweep (replay every corpus PDF once)
|
||||
shell: bash
|
||||
run: |
|
||||
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
|
||||
mkdir -p engine/fuzz/artifacts
|
||||
"$BIN" -runs=0 -artifact_prefix=engine/fuzz/artifacts/ corpus/ corpus/fuzz/ || true
|
||||
|
||||
- name: Fuzz run
|
||||
shell: bash
|
||||
run: |
|
||||
BIN=out/build/fuzz-linux/bin/pdfengine_fuzz
|
||||
"$BIN" -max_total_time="$FUZZ_DURATION" -print_final_stats=1 \
|
||||
-artifact_prefix=engine/fuzz/artifacts/ corpus/fuzz/ corpus/ || true
|
||||
|
||||
- name: Upload crash artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
path: engine/fuzz/artifacts/
|
||||
if-no-files-found: ignore
|
||||
+2
-1
@@ -21,6 +21,7 @@ CMakeUserPresets.json
|
||||
/third_party/pdfium/checkout/
|
||||
/third_party/pdfium/install/
|
||||
/third_party/pdfium/.gclient*
|
||||
/corpus/
|
||||
|
||||
# Skia from-source build (depot_tools / GN / Ninja)
|
||||
/third_party/skia/depot_tools/
|
||||
@@ -88,4 +89,4 @@ models/**/*.pt
|
||||
models/**/*.safetensors
|
||||
models/**/*.index
|
||||
!models/**/.gitkeep
|
||||
|
||||
.github
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ services:
|
||||
image: pdf-engine-frontend:dev
|
||||
container_name: pdf-engine-frontend
|
||||
environment:
|
||||
VITE_GATEWAY_URL: http://localhost:8765
|
||||
VITE_GATEWAY_URL: https://pdfapi-dev.maskantech.in
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
VITE_GATEWAY_URL=http://127.0.0.1:8765
|
||||
VITE_GATEWAY_URL=https://pdfapi-dev.maskantech.in
|
||||
|
||||
+235
-103
@@ -9,11 +9,14 @@ import { RedactPagesModal } from './components/RedactPagesModal';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { VersionHistoryModal } from './components/VersionHistoryModal';
|
||||
import { ExportPDFModal } from './components/ExportPDFModal';
|
||||
import { MergePDFModal } from './components/MergePDFModal';
|
||||
import { WatermarkModal, type WatermarkConfig } from './components/WatermarkModal';
|
||||
import { triggerPDFDownload } from './lib/pdfExport';
|
||||
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
import { UnsavedChangesModal } from './components/UnsavedChangesModal';
|
||||
import type { UnsavedChangesModalState } from './components/UnsavedChangesModal';
|
||||
import { PDFViewer } from './viewer/PDFViewer';
|
||||
import { CreatePDFModal } from './features/document-creator/components/CreatePDFModal';
|
||||
import type { PageLayout } from './features/document-creator/model/PaginationEngine';
|
||||
@@ -53,6 +56,7 @@ function App() {
|
||||
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
|
||||
const denyToast = (_label: string) => { };
|
||||
const disabledTools = new Set<ToolId>();
|
||||
disabledTools.add('ocr');
|
||||
if (!can('canAnnotate'))
|
||||
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
|
||||
if (!can('canModify')) (['edit_text', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t));
|
||||
@@ -66,13 +70,7 @@ function App() {
|
||||
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
|
||||
const [createPdfModalOpen, setCreatePdfModalOpen] = useState(false);
|
||||
const [createPdfKey, setCreatePdfKey] = useState(0);
|
||||
|
||||
const startNewBlankPDF = useCallback(() => {
|
||||
localStorage.setItem('active_mode', 'create_pdf');
|
||||
setCreatePdfModalOpen(true);
|
||||
setActiveTool('create_pdf');
|
||||
setCreatePdfKey((k) => k + 1);
|
||||
}, []);
|
||||
const [unsavedModalState, setUnsavedModalState] = useState<UnsavedChangesModalState | null>(null);
|
||||
const [creatorActions, setCreatorActions] = useState<{
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
@@ -93,6 +91,8 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (activeTool === 'create_pdf') {
|
||||
setCreatePdfModalOpen(true);
|
||||
} else if (activeTool === 'merge_pdf') {
|
||||
setMergeModalOpen(true);
|
||||
setActiveTool('select');
|
||||
}
|
||||
}, [activeTool]);
|
||||
@@ -120,10 +120,201 @@ function App() {
|
||||
const [protectModalState, setProtectModalState] = useState<ProtectModalState | null>(null);
|
||||
const [unlockModalState, setUnlockModalState] = useState<UnlockModalState | null>(null);
|
||||
const [compareModalOpen, setCompareModalOpen] = useState(false);
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [compareResult, setCompareResult] = useState<CompareResponse | null>(null);
|
||||
const [compareDocA, setCompareDocA] = useState<DocumentSummary | null>(null);
|
||||
const [compareDocB, setCompareDocB] = useState<DocumentSummary | null>(null);
|
||||
|
||||
|
||||
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
|
||||
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
|
||||
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
|
||||
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
||||
const [searchWholeWords, setSearchWholeWords] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
|
||||
|
||||
const [isOCRLoading, setIsOCRLoading] = useState(false);
|
||||
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
|
||||
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
|
||||
const [watermarkPreview, setWatermarkPreview] = useState<WatermarkConfig | null>(null);
|
||||
|
||||
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
|
||||
setCreatorPageCount(count);
|
||||
if (pages) setCreatorPages(pages);
|
||||
}, []);
|
||||
|
||||
const handleRunOCR = async () => {
|
||||
if (!activeDoc) return;
|
||||
setIsOCRLoading(true);
|
||||
try {
|
||||
await gatewayService.performPageOCR(activeDoc.id, currentPage);
|
||||
viewerRef.current?.refreshPageLayout(currentPage);
|
||||
} catch (err: any) {
|
||||
alert(`OCR processing failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsOCRLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const forceOpenDocument = useCallback((id: string) => {
|
||||
localStorage.removeItem('active_mode');
|
||||
setCreatePdfModalOpen(false);
|
||||
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
||||
setHist({ stack: [id], index: 0 });
|
||||
}, []);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
|
||||
const urlToken = urlParams.get('token') || undefined;
|
||||
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||
return Boolean(creatorActions?.canUndo);
|
||||
}
|
||||
return hist.stack.length > 1;
|
||||
}, [activeTool, createPdfModalOpen, creatorActions?.canUndo, hist.stack.length]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
|
||||
if (isCreatorActive) {
|
||||
if (creatorActions?.generate) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await creatorActions.generate();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!activeDoc) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isRemote) {
|
||||
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
|
||||
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
|
||||
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed', e);
|
||||
alert('Save failed: ' + String(e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [activeDoc, activeTool, createPdfModalOpen, creatorActions, isRemote, selectedDocId, urlToken]);
|
||||
|
||||
const executeStartNewBlankPDF = useCallback(() => {
|
||||
localStorage.setItem('active_mode', 'create_pdf');
|
||||
setCreatePdfModalOpen(true);
|
||||
setActiveTool('create_pdf');
|
||||
setCreatePdfKey((k) => k + 1);
|
||||
}, []);
|
||||
|
||||
const startNewBlankPDF = useCallback(() => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setUnsavedModalState({
|
||||
title: 'Save Unsaved Document?',
|
||||
message: 'You have unsaved changes in your document. Would you like to save your document before creating a new blank document?',
|
||||
onSaveAndContinue: async () => {
|
||||
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||
if (creatorActions?.generate) {
|
||||
await creatorActions.generate();
|
||||
}
|
||||
} else {
|
||||
await handleSave();
|
||||
}
|
||||
executeStartNewBlankPDF();
|
||||
},
|
||||
onDiscardAndContinue: () => {
|
||||
executeStartNewBlankPDF();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
executeStartNewBlankPDF();
|
||||
}, [hasUnsavedChanges, activeTool, createPdfModalOpen, creatorActions, handleSave, executeStartNewBlankPDF]);
|
||||
|
||||
const openDocument = useCallback((id: string, bypassCheck = false) => {
|
||||
if (!bypassCheck && selectedDocId && selectedDocId !== id && hasUnsavedChanges()) {
|
||||
setUnsavedModalState({
|
||||
title: 'Save Unsaved Document?',
|
||||
message: 'You have unsaved changes in your current document. Would you like to save before opening another document?',
|
||||
onSaveAndContinue: async () => {
|
||||
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||
if (creatorActions?.generate) {
|
||||
await creatorActions.generate();
|
||||
}
|
||||
} else {
|
||||
await handleSave();
|
||||
}
|
||||
forceOpenDocument(id);
|
||||
},
|
||||
onDiscardAndContinue: () => {
|
||||
forceOpenDocument(id);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
forceOpenDocument(id);
|
||||
}, [selectedDocId, hasUnsavedChanges, activeTool, createPdfModalOpen, creatorActions, handleSave, forceOpenDocument]);
|
||||
|
||||
const executeUpload = async (file: File, password = '') => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||
setDocuments((prev) => [newDoc, ...prev]);
|
||||
setCreatePdfModalOpen(false);
|
||||
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
||||
forceOpenDocument(newDoc.id);
|
||||
|
||||
setPasswordPrompt(null);
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordError) {
|
||||
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
||||
} else {
|
||||
console.error('Upload failed', e);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File, password = '', bypassCheck = false) => {
|
||||
if (!bypassCheck && hasUnsavedChanges()) {
|
||||
setUnsavedModalState({
|
||||
title: 'Save Unsaved Document?',
|
||||
message: `You have unsaved changes in your document. Would you like to save before opening "${file.name}"?`,
|
||||
onSaveAndContinue: async () => {
|
||||
if (activeTool === 'create_pdf' || createPdfModalOpen) {
|
||||
if (creatorActions?.generate) {
|
||||
await creatorActions.generate();
|
||||
}
|
||||
} else {
|
||||
await handleSave();
|
||||
}
|
||||
await executeUpload(file, password);
|
||||
},
|
||||
onDiscardAndContinue: async () => {
|
||||
await executeUpload(file, password);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await executeUpload(file, password);
|
||||
};
|
||||
|
||||
const handleMergeCompleted = (docInfo: DocumentInfo) => {
|
||||
setDocuments((prev) => [...prev, docInfo]);
|
||||
openDocument(docInfo.id);
|
||||
};
|
||||
|
||||
const handleOpenCompareModal = () => {
|
||||
if (!activeDoc) return;
|
||||
setCompareDocA({
|
||||
@@ -174,46 +365,6 @@ function App() {
|
||||
});
|
||||
}
|
||||
};
|
||||
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
|
||||
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
|
||||
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
|
||||
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
||||
const [searchWholeWords, setSearchWholeWords] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
|
||||
|
||||
const [isOCRLoading, setIsOCRLoading] = useState(false);
|
||||
const [creatorPageCount, setCreatorPageCount] = useState<number>(1);
|
||||
const [creatorPages, setCreatorPages] = useState<PageLayout[]>([]);
|
||||
const [watermarkPreview, setWatermarkPreview] = useState<WatermarkConfig | null>(null);
|
||||
|
||||
const handleCreatorPageCountChange = useCallback((count: number, pages?: PageLayout[]) => {
|
||||
setCreatorPageCount(count);
|
||||
if (pages) setCreatorPages(pages);
|
||||
}, []);
|
||||
|
||||
const handleRunOCR = async () => {
|
||||
if (!activeDoc) return;
|
||||
setIsOCRLoading(true);
|
||||
try {
|
||||
await gatewayService.performPageOCR(activeDoc.id, currentPage);
|
||||
viewerRef.current?.refreshPageLayout(currentPage);
|
||||
} catch (err: any) {
|
||||
alert(`OCR processing failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsOCRLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDocument = useCallback((id: string) => {
|
||||
localStorage.removeItem('active_mode');
|
||||
setCreatePdfModalOpen(false);
|
||||
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
||||
setHist({ stack: [id], index: 0 });
|
||||
}, []);
|
||||
|
||||
const pushHistory = (id: string) =>
|
||||
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
|
||||
@@ -790,62 +941,15 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File, password = '') => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||
setDocuments((prev) => [newDoc, ...prev]);
|
||||
setCreatePdfModalOpen(false);
|
||||
setActiveTool((t) => (t === 'create_pdf' ? 'select' : t));
|
||||
openDocument(newDoc.id);
|
||||
|
||||
setPasswordPrompt(null);
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordError) {
|
||||
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
||||
} else {
|
||||
console.error('Upload failed', e);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token');
|
||||
// Grab the token that was injected into the URL when this iframe was opened.
|
||||
// This is always fresher than whatever the gateway has cached.
|
||||
const urlToken = urlParams.get('token') || undefined;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!activeDoc) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isRemote) {
|
||||
await gatewayService.exportRemoteDocument(selectedDocId, urlToken);
|
||||
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
|
||||
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
|
||||
} else {
|
||||
// When running locally, simulate a save delay to provide UI feedback.
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
// If the query parameter is present (like from our landing page flow), trigger a local download
|
||||
if (urlParams.has('download_on_save')) {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed', e);
|
||||
alert('Save failed: ' + String(e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProtectSubmit = async (payload: {
|
||||
userPassword: string;
|
||||
ownerPassword?: string;
|
||||
confirmPassword: string;
|
||||
permissions: any;
|
||||
permissions?: any;
|
||||
}) => {
|
||||
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen || selectedDocId === 'new-blank-creator' || !selectedDocId;
|
||||
try {
|
||||
@@ -870,7 +974,10 @@ function App() {
|
||||
throw new Error('No document available to protect');
|
||||
}
|
||||
|
||||
const updatedDoc = await gatewayService.protectDocument(targetDocId, payload);
|
||||
const updatedDoc = await gatewayService.protectDocument(targetDocId, {
|
||||
...payload,
|
||||
permissions: payload.permissions || {},
|
||||
});
|
||||
setDocuments((prev) => prev.map((d) => (d.id === targetDocId ? updatedDoc : d)));
|
||||
setActiveDoc(updatedDoc);
|
||||
setProtectModalState(null);
|
||||
@@ -940,23 +1047,19 @@ function App() {
|
||||
|
||||
if (tool === 'underline' || tool === 'squiggly') {
|
||||
creatorActions?.updateRunFormatting?.({ underline: !activeRun?.underline });
|
||||
setActiveTool('select');
|
||||
return;
|
||||
}
|
||||
if (tool === 'strikeout') {
|
||||
creatorActions?.updateRunFormatting?.({ strikethrough: !activeRun?.strikethrough });
|
||||
setActiveTool('select');
|
||||
return;
|
||||
}
|
||||
if (tool === 'highlight') {
|
||||
const nextColor = activeRun?.highlightColor ? undefined : '#fef08a';
|
||||
creatorActions?.updateRunFormatting?.({ highlightColor: nextColor });
|
||||
setActiveTool('select');
|
||||
return;
|
||||
}
|
||||
if (tool === 'textbox') {
|
||||
creatorActions?.addParagraph?.();
|
||||
setActiveTool('select');
|
||||
return;
|
||||
}
|
||||
if (tool === 'stamp') {
|
||||
@@ -965,7 +1068,6 @@ function App() {
|
||||
}
|
||||
if (tool === 'comment') {
|
||||
creatorActions?.insertComment?.();
|
||||
setActiveTool('select');
|
||||
return;
|
||||
}
|
||||
if (tool === 'draw') {
|
||||
@@ -1087,7 +1189,7 @@ function App() {
|
||||
onUndo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.undo()) : undo}
|
||||
onRedo={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.redo()) : redo}
|
||||
isSaving={isSaving}
|
||||
isDirtySaved={hist.stack.length > 1}
|
||||
isDirtySaved={(activeTool === 'create_pdf' || createPdfModalOpen) ? (creatorActions?.canUndo ?? false) : hist.stack.length > 1}
|
||||
onRotate={handleRotate}
|
||||
onExport={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.generate()) : handleExport}
|
||||
onPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? (() => creatorActions?.print?.()) : handlePrint}
|
||||
@@ -1101,6 +1203,7 @@ function App() {
|
||||
}}
|
||||
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
|
||||
onCompare={handleOpenCompareModal}
|
||||
onMergePDF={() => setMergeModalOpen(true)}
|
||||
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
|
||||
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
|
||||
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
|
||||
@@ -1184,12 +1287,32 @@ function App() {
|
||||
if (preset) setActiveStamp(preset);
|
||||
}}
|
||||
onClose={() => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setUnsavedModalState({
|
||||
title: 'Save Unsaved Document?',
|
||||
message: 'You have unsaved changes in your document. Would you like to save before closing?',
|
||||
onSaveAndContinue: async () => {
|
||||
if (creatorActions?.generate) {
|
||||
await creatorActions.generate();
|
||||
}
|
||||
localStorage.removeItem('active_mode');
|
||||
setCreatePdfModalOpen(false);
|
||||
if (activeTool === 'create_pdf') setActiveTool('select');
|
||||
},
|
||||
onDiscardAndContinue: () => {
|
||||
localStorage.removeItem('active_mode');
|
||||
setCreatePdfModalOpen(false);
|
||||
if (activeTool === 'create_pdf') setActiveTool('select');
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem('active_mode');
|
||||
setCreatePdfModalOpen(false);
|
||||
if (activeTool === 'create_pdf') setActiveTool('select');
|
||||
}}
|
||||
onCreatePDF={async (file) => {
|
||||
await handleUpload(file);
|
||||
await handleUpload(file, '', true);
|
||||
setCreatePdfModalOpen(false);
|
||||
setActiveTool('select');
|
||||
}}
|
||||
@@ -1432,6 +1555,8 @@ function App() {
|
||||
|
||||
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
|
||||
|
||||
<UnsavedChangesModal state={unsavedModalState} onClose={() => setUnsavedModalState(null)} />
|
||||
|
||||
<PasswordModal
|
||||
state={passwordPrompt}
|
||||
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
||||
@@ -1477,6 +1602,13 @@ function App() {
|
||||
renderPageUrl={(docId, pageIdx, dpi) => gatewayService.getPageRenderUrl(docId, pageIdx, dpi)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MergePDFModal
|
||||
isOpen={mergeModalOpen}
|
||||
onClose={() => setMergeModalOpen(false)}
|
||||
onMergeComplete={handleMergeCompleted}
|
||||
apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import { SpinnerIcon, UploadIcon, DownloadIcon } from './icons';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
file: File;
|
||||
pagesMode: 'all' | 'custom';
|
||||
customPages: string;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
interface MergePDFModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onMergeComplete: (docInfo: any) => void;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const MergePDFModal: React.FC<MergePDFModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onMergeComplete,
|
||||
apiBaseUrl = 'http://localhost:8000',
|
||||
}) => {
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [outputFilename, setOutputFilename] = useState<string>('merged_document.pdf');
|
||||
const [isMerging, setIsMerging] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mergedResult, setMergedResult] = useState<any | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAddFiles = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
setError(null);
|
||||
const newItems: FileItem[] = Array.from(selectedFiles).map((file) => ({
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
pagesMode: 'all',
|
||||
customPages: '',
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newItems]);
|
||||
};
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index <= 0) return;
|
||||
setFiles((prev) => {
|
||||
const updated = [...prev];
|
||||
const temp = updated[index - 1];
|
||||
updated[index - 1] = updated[index];
|
||||
updated[index] = temp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index >= files.length - 1) return;
|
||||
setFiles((prev) => {
|
||||
const updated = [...prev];
|
||||
const temp = updated[index + 1];
|
||||
updated[index + 1] = updated[index];
|
||||
updated[index] = temp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handlePagesModeChange = (index: number, mode: 'all' | 'custom') => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, pagesMode: mode } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const handleCustomPagesChange = (index: number, val: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, customPages: val } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFiles([]);
|
||||
setOutputFilename('merged_document.pdf');
|
||||
setError(null);
|
||||
setMergedResult(null);
|
||||
setIsMerging(false);
|
||||
};
|
||||
|
||||
const handlePerformMerge = async () => {
|
||||
if (files.length === 0) {
|
||||
setError('Please add at least one PDF file to merge.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMerging(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const manifest = files.map((item, idx) => ({
|
||||
fileIndex: idx,
|
||||
pages: item.pagesMode === 'all' ? 'all' : item.customPages || 'all',
|
||||
}));
|
||||
|
||||
files.forEach((item) => {
|
||||
formData.append('files', item.file);
|
||||
});
|
||||
formData.append('manifest', JSON.stringify(manifest));
|
||||
formData.append('output_filename', outputFilename || 'merged_document.pdf');
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/documents/merge`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || `Merge failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const docInfo = await response.json();
|
||||
setMergedResult(docInfo);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to merge PDF files. Please try again.');
|
||||
} finally {
|
||||
setIsMerging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadMerged = () => {
|
||||
if (!mergedResult) return;
|
||||
const downloadUrl = `${apiBaseUrl}/documents/${mergedResult.id}/export`;
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = mergedResult.filename || 'merged_document.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const handleOpenInEditor = () => {
|
||||
if (mergedResult) {
|
||||
onMergeComplete(mergedResult);
|
||||
onClose();
|
||||
handleReset();
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="flex flex-col w-full max-w-2xl max-h-[90vh] bg-bg-primary rounded-xl border border-border-primary shadow-2xl overflow-hidden text-text-primary">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border-primary bg-bg-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight">Merge PDF Files</h2>
|
||||
<p className="text-xs text-text-secondary">Combine multiple PDFs into a single, ordered document</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onClose(); handleReset(); }}
|
||||
className="text-text-secondary hover:text-text-primary p-1 rounded-md hover:bg-bg-tertiary transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{mergedResult ? (
|
||||
/* Success View */
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center space-y-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-emerald-500/10 text-emerald-500">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-text-primary">PDFs Merged Successfully!</h3>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
Merged {files.length} document{files.length > 1 ? 's' : ''} into <span className="font-semibold text-text-primary">{mergedResult.filename}</span> ({mergedResult.totalPages} total pages).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3 pt-4">
|
||||
<CustomButton variant="outline" onClick={handleDownloadMerged} className="flex items-center gap-2 px-4 py-2">
|
||||
<DownloadIcon size={16} /> Download Merged PDF
|
||||
</CustomButton>
|
||||
<CustomButton variant="primary" onClick={handleOpenInEditor} className="flex items-center gap-2 px-5 py-2">
|
||||
Open in PDF Editor
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs text-brand-primary hover:underline pt-3"
|
||||
>
|
||||
Merge more PDF files
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Upload & Configuration Form */
|
||||
<>
|
||||
{error && (
|
||||
<div className="p-3.5 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-medium flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700 ml-2">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Dropzone */}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border-primary hover:border-brand-primary rounded-xl cursor-pointer bg-bg-secondary/40 hover:bg-bg-secondary transition-all text-center group"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleAddFiles(e.target.files)}
|
||||
/>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-primary/10 text-brand-primary group-hover:scale-110 transition-transform">
|
||||
<UploadIcon size={22} />
|
||||
</div>
|
||||
<span className="mt-3 text-sm font-bold text-text-primary">Click or drop PDF files here</span>
|
||||
<span className="text-xs text-text-secondary mt-0.5">Select multiple PDF files to combine</span>
|
||||
</div>
|
||||
|
||||
{/* File Queue List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-text-tertiary">
|
||||
Merge Sequence ({files.length} {files.length === 1 ? 'file' : 'files'})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFiles([])}
|
||||
className="text-xs text-red-400 hover:text-red-300 font-medium"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{files.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-lg border border-border-primary bg-bg-secondary/70 hover:bg-bg-secondary transition-colors"
|
||||
>
|
||||
{/* Left Info */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
disabled={index === 0}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
className="p-1 rounded text-text-secondary hover:text-text-primary hover:bg-bg-tertiary disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
title="Move Up"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
disabled={index === files.length - 1}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
className="p-1 rounded text-text-secondary hover:text-text-primary hover:bg-bg-tertiary disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
title="Move Down"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-bg-tertiary text-xs font-bold text-text-secondary">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold text-text-primary truncate" title={item.file.name}>
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Page Controls */}
|
||||
<div className="flex items-center gap-2 self-end sm:self-center shrink-0">
|
||||
<div className="flex items-center gap-1.5 bg-bg-tertiary/60 p-1 rounded-md border border-border-primary">
|
||||
<button
|
||||
onClick={() => handlePagesModeChange(index, 'all')}
|
||||
className={`px-2 py-0.5 text-[11px] font-semibold rounded ${
|
||||
item.pagesMode === 'all'
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePagesModeChange(index, 'custom')}
|
||||
className={`px-2 py-0.5 text-[11px] font-semibold rounded ${
|
||||
item.pagesMode === 'custom'
|
||||
? 'bg-brand-primary text-white shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Pages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{item.pagesMode === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1-3, 5"
|
||||
value={item.customPages}
|
||||
onChange={(e) => handleCustomPagesChange(index, e.target.value)}
|
||||
className="w-24 px-2 py-1 text-xs rounded border border-border-primary bg-bg-primary text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-brand-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
className="p-1 text-text-secondary hover:text-red-500 rounded hover:bg-bg-tertiary transition-colors"
|
||||
title="Remove File"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Filename Field */}
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-text-tertiary mb-1.5">
|
||||
Output Filename
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={outputFilename}
|
||||
onChange={(e) => setOutputFilename(e.target.value)}
|
||||
placeholder="merged_document.pdf"
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border-primary bg-bg-secondary text-text-primary focus:outline-none focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!mergedResult && (
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-border-primary bg-bg-secondary">
|
||||
<CustomButton
|
||||
variant="outline"
|
||||
onClick={() => { onClose(); handleReset(); }}
|
||||
disabled={isMerging}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handlePerformMerge}
|
||||
disabled={files.length === 0 || isMerging}
|
||||
className="flex items-center gap-2 min-w-[120px] justify-center"
|
||||
>
|
||||
{isMerging ? (
|
||||
<>
|
||||
<SpinnerIcon size={16} /> Merging...
|
||||
</>
|
||||
) : (
|
||||
`Merge ${files.length > 0 ? `(${files.length})` : ''} PDFs`
|
||||
)}
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,17 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'merge_pdf',
|
||||
label: 'Merge PDFs',
|
||||
shortLabel: 'Merge',
|
||||
shortcut: 'G',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{
|
||||
@@ -111,11 +122,11 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; is
|
||||
aria-disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled
|
||||
? 'cursor-not-allowed text-[#c5cad1]'
|
||||
: active
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
||||
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
? 'cursor-not-allowed text-text-tertiary opacity-40 dark:text-zinc-600 dark:opacity-60'
|
||||
: active
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary'
|
||||
: t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : 'var(--brand-primary)' }} />}
|
||||
|
||||
@@ -82,6 +82,14 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
merge_pdf: {
|
||||
label: 'Merge PDFs',
|
||||
icon: (
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
|
||||
|
||||
@@ -28,6 +28,7 @@ interface TopBarProps {
|
||||
onProtect?: () => void;
|
||||
onUnlock?: () => void;
|
||||
onCompare?: () => void;
|
||||
onMergePDF?: () => void;
|
||||
isEncrypted?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
onNewBlankPDF?: () => void;
|
||||
@@ -45,7 +46,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||
export const TopBar: React.FC<TopBarProps> = ({
|
||||
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
|
||||
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, onCompare, isEncrypted, onUpload, onNewBlankPDF, onSave,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, onCompare, onMergePDF, isEncrypted, onUpload, onNewBlankPDF, onSave,
|
||||
canPrint = true, canExport = true, canAssemble = true,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -82,6 +83,7 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
<div className="flex flex-col text-[13px]">
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="12" y1="18" x2="12" y2="12" /><line x1="9" y1="15" x2="15" y2="15" /></svg>} onClick={() => onNewBlankPDF?.()}>New Blank PDF…</MenuItem>
|
||||
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF…</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M8 7v10M12 4v16M16 7v10M4 11h16M4 15h16"/></svg>} onClick={() => onMergePDF?.()}>Merge PDFs…</MenuItem>
|
||||
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>} onClick={() => onCompare?.()} disabled={!documentName}>Compare PDFs…</MenuItem>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
export interface UnsavedChangesModalState {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onSaveAndContinue: () => Promise<void> | void;
|
||||
onDiscardAndContinue: () => void;
|
||||
}
|
||||
|
||||
interface UnsavedChangesModalProps {
|
||||
state: UnsavedChangesModalState | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({ state, onClose }) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const title = state.title || 'Unsaved Document Changes';
|
||||
const message = state.message || 'You have unsaved changes in your current document. Would you like to save before proceeding?';
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await state.onSaveAndContinue();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save document:', err);
|
||||
alert('Failed to save document. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
state.onDiscardAndContinue();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[250] flex items-center justify-center p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
className="absolute inset-0 bg-[#0f172a]/40 backdrop-blur-[2px]"
|
||||
style={{ animation: 'toastIn 0.2s ease-out' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{ width: 460, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-bg-primary shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-border-primary"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-7 pb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#fef3c7] text-[#d97706]">
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold tracking-tight text-text-primary">{title}</h2>
|
||||
<p className="text-[12px] font-medium text-text-tertiary">Document Protection</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[13.5px] leading-relaxed text-text-secondary">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 bg-bg-secondary px-7 py-4 border-t border-border-primary">
|
||||
<CustomButton
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="rounded-[8px] font-semibold px-4 text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={isSaving}
|
||||
className="rounded-[8px] px-4 font-semibold text-[#dc2626] hover:bg-[#fdecec]"
|
||||
>
|
||||
Discard
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="rounded-[8px] px-5 font-semibold text-white bg-[#2563eb] hover:bg-[#1d4ed8]"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save & Continue'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import type { PageLayout } from '../model/PaginationEngine';
|
||||
import { PdfDocumentRenderer } from '../renderer/PdfDocumentRenderer';
|
||||
import { DocumentToolbar } from './DocumentToolbar';
|
||||
import { DocumentEditor } from './DocumentEditor';
|
||||
import { triggerPDFDownload } from '../../../lib/pdfExport';
|
||||
|
||||
interface CreatePDFModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -59,25 +60,7 @@ const INITIAL_DOC: DocumentModel = {
|
||||
enabled: true,
|
||||
showPageNumbers: true,
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: 'blk-1',
|
||||
type: 'paragraph',
|
||||
styleName: 'Normal',
|
||||
runs: [
|
||||
{
|
||||
id: 'run-1',
|
||||
text: '',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 12,
|
||||
color: '#0f172a',
|
||||
},
|
||||
],
|
||||
alignment: 'left',
|
||||
spaceBefore: 0,
|
||||
spaceAfter: 6,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||
@@ -96,8 +79,8 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||
}) => {
|
||||
const historyRef = useRef(new HistoryManager(INITIAL_DOC));
|
||||
const [doc, setDoc] = useState<DocumentModel>(INITIAL_DOC);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>('blk-1');
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>('run-1');
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
const [zoomScale, setZoomScale] = useState<number>(1.0);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
@@ -116,37 +99,12 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||
const freshDoc: DocumentModel = {
|
||||
...INITIAL_DOC,
|
||||
id: `doc-creator-${Date.now()}`,
|
||||
blocks: [
|
||||
{
|
||||
id: 'blk-1',
|
||||
type: 'paragraph',
|
||||
styleName: 'Normal',
|
||||
runs: [
|
||||
{
|
||||
id: 'run-1',
|
||||
text: '',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 12,
|
||||
color: '#0f172a',
|
||||
},
|
||||
],
|
||||
alignment: 'left',
|
||||
spaceBefore: 0,
|
||||
spaceAfter: 6,
|
||||
},
|
||||
],
|
||||
blocks: [],
|
||||
};
|
||||
historyRef.current = new HistoryManager(freshDoc);
|
||||
setDoc(freshDoc);
|
||||
setSelectedBlockId('blk-1');
|
||||
setActiveRunId('run-1');
|
||||
|
||||
setTimeout(() => {
|
||||
const el = document.querySelector<HTMLDivElement>('[data-block-id="blk-1"]');
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}, 50);
|
||||
setSelectedBlockId(null);
|
||||
setActiveRunId(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -330,7 +288,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||
],
|
||||
alignment: 'left',
|
||||
spaceBefore: 0,
|
||||
spaceAfter: 6,
|
||||
spaceAfter: 2,
|
||||
};
|
||||
|
||||
let nextBlocks = [...doc.blocks];
|
||||
@@ -684,6 +642,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||
const filename = `${doc.title.replace(/[^a-zA-Z0-9_-]/g, '_') || 'Custom_Document'}.pdf`;
|
||||
const file = new File([pdfBlob], filename, { type: 'application/pdf' });
|
||||
await onCreatePDF(file);
|
||||
await triggerPDFDownload(pdfBlob, filename);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('PDF Generation failed', err);
|
||||
@@ -723,6 +682,7 @@ export const CreatePDFModal: React.FC<CreatePDFModalProps> = ({
|
||||
doc={doc}
|
||||
selectedBlockId={selectedBlockId}
|
||||
activeRun={activeRun}
|
||||
activeParagraph={activeParagraph}
|
||||
activeTool={activeTool}
|
||||
drawColor={drawColor}
|
||||
drawWidth={drawWidth}
|
||||
|
||||
@@ -109,12 +109,11 @@ const EditableParagraphBlock: React.FC<{
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelectBlock(block.id, firstRun.id)}
|
||||
className={`relative rounded px-2 py-1 transition-all ${isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
className="relative py-0.5"
|
||||
style={{
|
||||
textAlign: block.alignment || 'left',
|
||||
marginTop: `${block.spaceBefore || 0}px`,
|
||||
marginBottom: `${block.spaceAfter || 4}px`,
|
||||
marginBottom: `${block.spaceAfter !== undefined ? block.spaceAfter : 2}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -126,8 +125,8 @@ const EditableParagraphBlock: React.FC<{
|
||||
onInput={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)}
|
||||
onBlur={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none"
|
||||
data-placeholder="Start typing paragraph text..."
|
||||
className="outline-none min-h-[1.5em]"
|
||||
data-placeholder=""
|
||||
style={{
|
||||
fontFamily: fontStack,
|
||||
fontSize: `${firstRun.fontSize || 12}pt`,
|
||||
@@ -139,23 +138,13 @@ const EditableParagraphBlock: React.FC<{
|
||||
].filter(Boolean).join(' ') || 'none',
|
||||
color: firstRun.color || '#0f172a',
|
||||
backgroundColor: firstRun.highlightColor || 'transparent',
|
||||
lineHeight: block.lineSpacing || 1.25,
|
||||
lineHeight: block.lineSpacing ? (block.lineSpacing >= 1.0 ? block.lineSpacing : 1.15 + block.lineSpacing) : 1.15,
|
||||
letterSpacing: firstRun.letterSpacing !== undefined ? `${firstRun.letterSpacing}px` : (block.letterSpacing !== undefined ? `${block.letterSpacing}px` : 'normal'),
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
/>
|
||||
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
title="Delete Paragraph"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveBlock(block.id);
|
||||
}}
|
||||
className="absolute -right-6 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-100 text-red-600 hover:bg-red-200 text-[10px]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -233,13 +222,12 @@ const EditableImageBlock: React.FC<{
|
||||
{/* Floating Quick Action Toolbar */}
|
||||
{isSelected && (
|
||||
<div
|
||||
className={`absolute -top-10 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-xl z-30 text-[11px] whitespace-nowrap max-w-none ${
|
||||
block.alignment === 'right'
|
||||
className={`absolute -top-10 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-xl z-30 text-[11px] whitespace-nowrap max-w-none ${block.alignment === 'right'
|
||||
? 'right-0'
|
||||
: block.alignment === 'left'
|
||||
? 'left-0'
|
||||
: 'left-1/2 -translate-x-1/2'
|
||||
}`}
|
||||
? 'left-0'
|
||||
: 'left-1/2 -translate-x-1/2'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -369,9 +357,8 @@ const EditableTableCellDiv: React.FC<{
|
||||
onFocus={onFocus}
|
||||
onInput={(e) => onUpdateText(e.currentTarget.innerText)}
|
||||
onBlur={(e) => onUpdateText(e.currentTarget.innerText)}
|
||||
className={`outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none ${
|
||||
isHeader ? 'font-bold' : ''
|
||||
}`}
|
||||
className={`outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none ${isHeader ? 'font-bold' : ''
|
||||
}`}
|
||||
style={{ textAlign: alignment }}
|
||||
data-placeholder="Cell..."
|
||||
/>
|
||||
@@ -408,305 +395,300 @@ const EditableTableBlock: React.FC<{
|
||||
onUpdateTableBlock,
|
||||
onUpdateTableCellAlignment,
|
||||
}) => {
|
||||
const [activeCell, setActiveCell] = useState<{ r: number; c: number } | null>(null);
|
||||
const [activeCell, setActiveCell] = useState<{ r: number; c: number } | null>(null);
|
||||
|
||||
const colors = [
|
||||
{ label: 'Clear', value: 'transparent' },
|
||||
{ label: 'Gray', value: '#f1f5f9' },
|
||||
{ label: 'Blue', value: '#dbeafe' },
|
||||
{ label: 'Green', value: '#dcfce7' },
|
||||
{ label: 'Yellow', value: '#fef9c3' },
|
||||
{ label: 'Purple', value: '#f3e8ff' },
|
||||
{ label: 'Red', value: '#fee2e2' },
|
||||
];
|
||||
const colors = [
|
||||
{ label: 'Clear', value: 'transparent' },
|
||||
{ label: 'Gray', value: '#f1f5f9' },
|
||||
{ label: 'Blue', value: '#dbeafe' },
|
||||
{ label: 'Green', value: '#dcfce7' },
|
||||
{ label: 'Yellow', value: '#fef9c3' },
|
||||
{ label: 'Purple', value: '#f3e8ff' },
|
||||
{ label: 'Red', value: '#fee2e2' },
|
||||
];
|
||||
|
||||
const tableAlignClass =
|
||||
block.alignment === 'right'
|
||||
? 'justify-end'
|
||||
: block.alignment === 'center'
|
||||
? 'justify-center'
|
||||
: 'justify-start';
|
||||
const tableAlignClass =
|
||||
block.alignment === 'right'
|
||||
? 'justify-end'
|
||||
: block.alignment === 'center'
|
||||
? 'justify-center'
|
||||
: 'justify-start';
|
||||
|
||||
const themeName = block.themeName || 'default';
|
||||
const themeName = block.themeName || 'default';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelectBlock(block.id)}
|
||||
className={`my-3 flex w-full ${tableAlignClass}`}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded p-1 transition-all inline-block max-w-full ${
|
||||
isSelected ? 'ring-2 ring-brand-primary bg-blue-50/20' : 'hover:ring-1 hover:ring-slate-300'
|
||||
}`}
|
||||
onClick={() => onSelectBlock(block.id)}
|
||||
className={`my-3 flex w-full ${tableAlignClass}`}
|
||||
>
|
||||
{/* Advanced Floating Table Action Bar */}
|
||||
{isSelected && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-md bg-slate-900 px-3 py-1.5 text-white shadow-lg text-[11px] z-20 select-none">
|
||||
<span className="font-semibold text-slate-300 mr-1">Table:</span>
|
||||
|
||||
{/* Header Row Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableBlock?.(block.id, { hasHeaderRow: !block.hasHeaderRow });
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 border ${
|
||||
block.hasHeaderRow ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Header Row
|
||||
</button>
|
||||
|
||||
{/* Striped Rows Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableBlock?.(block.id, { stripedRows: !block.stripedRows });
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 border ${
|
||||
block.stripedRows ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Striped Rows
|
||||
</button>
|
||||
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
{/* Theme Preset Selector */}
|
||||
<span className="text-slate-400">Theme:</span>
|
||||
<select
|
||||
value={themeName}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableBlock?.(block.id, { themeName: e.target.value as any });
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-slate-800 text-white rounded px-1.5 py-0.5 outline-none border border-slate-700 text-[11px]"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="modern">Modern Navy</option>
|
||||
<option value="minimal">Minimalist</option>
|
||||
<option value="bordered">Grid Bordered</option>
|
||||
</select>
|
||||
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
{/* Row / Col Manipulations */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableRow?.(block.id, activeCell?.r ?? 0, 'above');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Row Above
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableRow?.(block.id, activeCell?.r ?? block.rows.length - 1, 'below');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Row Below
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableColumn?.(block.id, activeCell?.c ?? 0, 'left');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Col Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableColumn?.(block.id, activeCell?.c ?? (block.rows[0]?.cells.length || 1) - 1, 'right');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Col Right
|
||||
</button>
|
||||
|
||||
{activeCell && (
|
||||
<>
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
{/* Cell Alignment */}
|
||||
<span className="text-slate-400">Cell Align:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'left');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
title="Align Cell Left"
|
||||
>
|
||||
Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'center');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
title="Align Cell Center"
|
||||
>
|
||||
Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'right');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
title="Align Cell Right"
|
||||
>
|
||||
Right
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTableRow?.(block.id, activeCell.r);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200 ml-1"
|
||||
>
|
||||
- Row
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTableColumn?.(block.id, activeCell.c);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200"
|
||||
>
|
||||
- Col
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
<span className="text-slate-400">Fill:</span>
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c.label}
|
||||
type="button"
|
||||
title={`Fill Cell ${c.label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeCell) {
|
||||
onUpdateTableCellBackground?.(block.id, activeCell.r, activeCell.c, c.value);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border border-slate-600 hover:scale-110 transition-transform"
|
||||
style={{ backgroundColor: c.value === 'transparent' ? '#ffffff' : c.value }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveBlock(block.id);
|
||||
}}
|
||||
className="rounded px-2 py-0.5 bg-red-600 hover:bg-red-700 text-white font-medium"
|
||||
>
|
||||
Delete Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table
|
||||
className={`w-full border-collapse bg-white ${
|
||||
themeName === 'minimal'
|
||||
? 'border-b border-slate-200'
|
||||
: themeName === 'bordered'
|
||||
? 'border-2 border-slate-400'
|
||||
: 'border border-slate-300'
|
||||
}`}
|
||||
<div
|
||||
className={`relative rounded p-1 transition-all inline-block max-w-full ${isSelected ? 'ring-2 ring-brand-primary bg-blue-50/20' : 'hover:ring-1 hover:ring-slate-300'
|
||||
}`}
|
||||
>
|
||||
<tbody>
|
||||
{block.rows.map((row, rIdx) => {
|
||||
const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader;
|
||||
const isStriped = block.stripedRows && rIdx % 2 === 1;
|
||||
{/* Advanced Floating Table Action Bar */}
|
||||
{isSelected && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-md bg-slate-900 px-3 py-1.5 text-white shadow-lg text-[11px] z-20 select-none">
|
||||
<span className="font-semibold text-slate-300 mr-1">Table:</span>
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={
|
||||
isHeaderRow
|
||||
? themeName === 'modern'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 font-bold'
|
||||
: isStriped
|
||||
? 'bg-slate-50/80'
|
||||
: ''
|
||||
}
|
||||
{/* Header Row Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableBlock?.(block.id, { hasHeaderRow: !block.hasHeaderRow });
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 border ${block.hasHeaderRow ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Header Row
|
||||
</button>
|
||||
|
||||
{/* Striped Rows Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableBlock?.(block.id, { stripedRows: !block.stripedRows });
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 border ${block.stripedRows ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
Striped Rows
|
||||
</button>
|
||||
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
{/* Theme Preset Selector */}
|
||||
<span className="text-slate-400">Theme:</span>
|
||||
<select
|
||||
value={themeName}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableBlock?.(block.id, { themeName: e.target.value as any });
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-slate-800 text-white rounded px-1.5 py-0.5 outline-none border border-slate-700 text-[11px]"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="modern">Modern Navy</option>
|
||||
<option value="minimal">Minimalist</option>
|
||||
<option value="bordered">Grid Bordered</option>
|
||||
</select>
|
||||
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
{/* Row / Col Manipulations */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableRow?.(block.id, activeCell?.r ?? 0, 'above');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Row Above
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableRow?.(block.id, activeCell?.r ?? block.rows.length - 1, 'below');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Row Below
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableColumn?.(block.id, activeCell?.c ?? 0, 'left');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Col Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddTableColumn?.(block.id, activeCell?.c ?? (block.rows[0]?.cells.length || 1) - 1, 'right');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
>
|
||||
+ Col Right
|
||||
</button>
|
||||
|
||||
{activeCell && (
|
||||
<>
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
{/* Cell Alignment */}
|
||||
<span className="text-slate-400">Cell Align:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'left');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
title="Align Cell Left"
|
||||
>
|
||||
Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'center');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
title="Align Cell Center"
|
||||
>
|
||||
Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdateTableCellAlignment?.(block.id, activeCell.r, activeCell.c, 'right');
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-slate-800 hover:bg-slate-700"
|
||||
title="Align Cell Right"
|
||||
>
|
||||
Right
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTableRow?.(block.id, activeCell.r);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200 ml-1"
|
||||
>
|
||||
- Row
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteTableColumn?.(block.id, activeCell.c);
|
||||
}}
|
||||
className="rounded px-1.5 py-0.5 bg-red-900/80 hover:bg-red-800 text-red-200"
|
||||
>
|
||||
- Col
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-3 w-[1px] bg-slate-700 my-auto" />
|
||||
|
||||
<span className="text-slate-400">Fill:</span>
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c.label}
|
||||
type="button"
|
||||
title={`Fill Cell ${c.label}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeCell) {
|
||||
onUpdateTableCellBackground?.(block.id, activeCell.r, activeCell.c, c.value);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border border-slate-600 hover:scale-110 transition-transform"
|
||||
style={{ backgroundColor: c.value === 'transparent' ? '#ffffff' : c.value }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveBlock(block.id);
|
||||
}}
|
||||
className="rounded px-2 py-0.5 bg-red-600 hover:bg-red-700 text-white font-medium"
|
||||
>
|
||||
{row.cells.map((cell, cIdx) => {
|
||||
const cellText = cell.runs.map((r) => r.text).join('');
|
||||
const isCellFocused = activeCell?.r === rIdx && activeCell?.c === cIdx;
|
||||
Delete Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
const cellBg =
|
||||
cell.backgroundColor ||
|
||||
(isHeaderRow
|
||||
<table
|
||||
className={`w-full border-collapse bg-white ${themeName === 'minimal'
|
||||
? 'border-b border-slate-200'
|
||||
: themeName === 'bordered'
|
||||
? 'border-2 border-slate-400'
|
||||
: 'border border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<tbody>
|
||||
{block.rows.map((row, rIdx) => {
|
||||
const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader;
|
||||
const isStriped = block.stripedRows && rIdx % 2 === 1;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={
|
||||
isHeaderRow
|
||||
? themeName === 'modern'
|
||||
? '#0f172a'
|
||||
: '#f1f5f9'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 font-bold'
|
||||
: isStriped
|
||||
? '#f8fafc'
|
||||
: 'transparent');
|
||||
? 'bg-slate-50/80'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{row.cells.map((cell, cIdx) => {
|
||||
const cellText = cell.runs.map((r) => r.text).join('');
|
||||
const isCellFocused = activeCell?.r === rIdx && activeCell?.c === cIdx;
|
||||
|
||||
const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a';
|
||||
const cellBg =
|
||||
cell.backgroundColor ||
|
||||
(isHeaderRow
|
||||
? themeName === 'modern'
|
||||
? '#0f172a'
|
||||
: '#f1f5f9'
|
||||
: isStriped
|
||||
? '#f8fafc'
|
||||
: 'transparent');
|
||||
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
onClick={() => setActiveCell({ r: rIdx, c: cIdx })}
|
||||
className={`p-2.5 text-[12px] relative transition-colors ${
|
||||
themeName === 'minimal'
|
||||
? 'border-b border-slate-200'
|
||||
: themeName === 'bordered'
|
||||
? 'border border-slate-400'
|
||||
: 'border border-slate-300'
|
||||
} ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`}
|
||||
style={{ backgroundColor: cellBg, color: cellTextColor }}
|
||||
>
|
||||
<EditableTableCellDiv
|
||||
cellText={cellText}
|
||||
alignment={cell.alignment}
|
||||
isHeader={isHeaderRow}
|
||||
onUpdateText={(text) => onUpdateTableCell?.(block.id, rIdx, cIdx, text)}
|
||||
onFocus={() => setActiveCell({ r: rIdx, c: cIdx })}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a';
|
||||
|
||||
return (
|
||||
<td
|
||||
key={cell.id}
|
||||
onClick={() => setActiveCell({ r: rIdx, c: cIdx })}
|
||||
className={`p-2.5 text-[12px] relative transition-colors ${themeName === 'minimal'
|
||||
? 'border-b border-slate-200'
|
||||
: themeName === 'bordered'
|
||||
? 'border border-slate-400'
|
||||
: 'border border-slate-300'
|
||||
} ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`}
|
||||
style={{ backgroundColor: cellBg, color: cellTextColor }}
|
||||
>
|
||||
<EditableTableCellDiv
|
||||
cellText={cellText}
|
||||
alignment={cell.alignment}
|
||||
isHeader={isHeaderRow}
|
||||
onUpdateText={(text) => onUpdateTableCell?.(block.id, rIdx, cIdx, text)}
|
||||
onFocus={() => setActiveCell({ r: rIdx, c: cIdx })}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
doc,
|
||||
@@ -800,21 +782,25 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const activePoints = activeDrawing?.pageIdx === pageIdx ? activeDrawing.points : [];
|
||||
const activeD = activePoints.reduce((acc, pt, i) => (i === 0 ? `M ${pt.x} ${pt.y}` : `${acc} L ${pt.x} ${pt.y}`), '');
|
||||
|
||||
const usableContentHeightPx = pageH - padTop - padBottom - (header.enabled ? 36 * PT_TO_PX : 0) - (footer.enabled ? 36 * PT_TO_PX : 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`page-${page.pageNumber}`}
|
||||
onMouseDown={(e) => handleMouseDownPage(e, pageIdx)}
|
||||
onMouseMove={(e) => handleMouseMovePage(e, pageIdx)}
|
||||
className={`relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col ${
|
||||
isDrawTool ? 'cursor-crosshair' : ''
|
||||
}`}
|
||||
className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${isDrawTool ? 'cursor-crosshair' : ''
|
||||
}`}
|
||||
style={{
|
||||
width: `${pageW}px`,
|
||||
height: `${pageH}px`,
|
||||
minHeight: `${pageH}px`,
|
||||
maxHeight: `${pageH}px`,
|
||||
paddingTop: `${padTop}px`,
|
||||
paddingBottom: `${padBottom}px`,
|
||||
paddingLeft: `${padLeft}px`,
|
||||
paddingRight: `${padRight}px`,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Ink Drawing Overlay */}
|
||||
@@ -840,7 +826,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
{/* Page Content Blocks */}
|
||||
<div
|
||||
className="flex flex-col gap-2 flex-1 overflow-visible relative"
|
||||
className="flex flex-col gap-2 flex-1 relative overflow-hidden pb-10"
|
||||
onClick={(e) => {
|
||||
// Only trigger if the click landed directly on this container (the empty area below blocks)
|
||||
if (e.target === e.currentTarget) {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface DocumentToolbarProps {
|
||||
doc: DocumentModel;
|
||||
selectedBlockId: string | null;
|
||||
activeRun: TextRun | null;
|
||||
activeParagraph?: ParagraphBlock | null;
|
||||
activeTool?: string;
|
||||
drawColor?: string;
|
||||
drawWidth?: number;
|
||||
@@ -38,6 +39,7 @@ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 64, 72
|
||||
export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
selectedBlockId,
|
||||
activeRun,
|
||||
activeParagraph,
|
||||
activeTool = 'select',
|
||||
drawColor = '#2563eb',
|
||||
drawWidth = 3,
|
||||
@@ -86,9 +88,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onUpdateDrawColor?.(c)}
|
||||
className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${
|
||||
drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110'
|
||||
}`}
|
||||
className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110'
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
@@ -135,9 +136,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
onSelectStamp?.(s.label);
|
||||
onInsertStamp?.(s.label);
|
||||
}}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${
|
||||
activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : ''
|
||||
}`}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : ''
|
||||
}`}
|
||||
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}
|
||||
title={`Click to insert ${s.label} stamp`}
|
||||
>
|
||||
@@ -205,14 +205,56 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
|
||||
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||
|
||||
{/* Line Gap / Line Spacing */}
|
||||
<div className="flex items-center gap-1" title="Line Spacing / Line Gap">
|
||||
<span className="text-[11.5px] font-semibold text-text-secondary">↕ Line Gap:</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border-primary bg-bg-primary px-1.5 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary text-[12px]"
|
||||
value={activeParagraph?.lineSpacing || 1.25}
|
||||
onChange={(e) => onUpdateParagraphFormatting({ lineSpacing: Number(e.target.value) })}
|
||||
>
|
||||
<option value={1.0}>1.0 (Single)</option>
|
||||
<option value={1.15}>1.15 (Compact)</option>
|
||||
<option value={1.25}>1.25 (Default)</option>
|
||||
<option value={1.5}>1.5 (1.5 lines)</option>
|
||||
<option value={2.0}>2.0 (Double)</option>
|
||||
<option value={2.5}>2.5 (Wide)</option>
|
||||
<option value={3.0}>3.0 (Extra Wide)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Letter Space / Letter Spacing */}
|
||||
<div className="flex items-center gap-1" title="Letter Spacing / Character Gap">
|
||||
<span className="text-[11.5px] font-semibold text-text-secondary">↔ Letter Space:</span>
|
||||
<select
|
||||
className="h-8 rounded-md border border-border-primary bg-bg-primary px-1.5 font-medium text-text-primary outline-none hover:bg-bg-tertiary focus:border-brand-primary text-[12px]"
|
||||
value={activeRun?.letterSpacing ?? (activeParagraph?.letterSpacing ?? 0)}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
onUpdateRunFormatting({ letterSpacing: val });
|
||||
onUpdateParagraphFormatting({ letterSpacing: val });
|
||||
}}
|
||||
>
|
||||
<option value={-1}>-1px (Tight)</option>
|
||||
<option value={0}>0px (Default)</option>
|
||||
<option value={0.5}>0.5px (Slight)</option>
|
||||
<option value={1}>1px (Medium)</option>
|
||||
<option value={1.5}>1.5px (Expanded)</option>
|
||||
<option value={2}>2px (Wide)</option>
|
||||
<option value={3}>3px (Very Wide)</option>
|
||||
<option value={5}>5px (Ultra Wide)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-[1px] bg-border-primary mx-0.5" />
|
||||
|
||||
{/* Bold / Italic / Underline / Strikethrough */}
|
||||
<button
|
||||
type="button"
|
||||
title="Bold (Ctrl+B)"
|
||||
onClick={() => onUpdateRunFormatting({ bold: !activeRun?.bold })}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md font-bold transition-colors ${
|
||||
activeRun?.bold ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md font-bold transition-colors ${activeRun?.bold ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
@@ -221,9 +263,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
type="button"
|
||||
title="Italic (Ctrl+I)"
|
||||
onClick={() => onUpdateRunFormatting({ italic: !activeRun?.italic })}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${
|
||||
activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
I
|
||||
</button>
|
||||
@@ -232,9 +273,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
type="button"
|
||||
title="Underline (Ctrl+U)"
|
||||
onClick={() => onUpdateRunFormatting({ underline: !activeRun?.underline })}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${
|
||||
activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
U
|
||||
</button>
|
||||
@@ -243,9 +283,8 @@ export const DocumentToolbar: React.FC<DocumentToolbarProps> = ({
|
||||
type="button"
|
||||
title="Strikethrough"
|
||||
onClick={() => onUpdateRunFormatting({ strikethrough: !activeRun?.strikethrough })}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${
|
||||
activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
S
|
||||
</button>
|
||||
|
||||
@@ -18,7 +18,7 @@ export class PaginationEngine {
|
||||
const pages: PageLayout[] = [];
|
||||
|
||||
// Usable height per page in screen pixels (96 DPI)
|
||||
const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 0);
|
||||
const headerFooterHeightPt = (doc.header.enabled ? 36 : 0) + (doc.footer.enabled ? 36 : 30);
|
||||
const usableHeightPx = (settings.heightPt - settings.marginTopPt - settings.marginBottomPt - headerFooterHeightPt) * PT_TO_PX;
|
||||
|
||||
let currentPageBlocks: DocumentBlock[] = [];
|
||||
@@ -27,10 +27,12 @@ export class PaginationEngine {
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'page-break') {
|
||||
// Explicit manual page break — always start a new page
|
||||
pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks });
|
||||
currentPageBlocks = [];
|
||||
currentHeightPx = 0;
|
||||
// Explicit manual page break — start new page if current page has content
|
||||
if (currentPageBlocks.length > 0) {
|
||||
pages.push({ pageNumber: pageNum++, blocks: currentPageBlocks });
|
||||
currentPageBlocks = [];
|
||||
currentHeightPx = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -59,28 +61,39 @@ export class PaginationEngine {
|
||||
|
||||
if (block.type === 'table') {
|
||||
const rowCount = block.rows.length;
|
||||
return Math.max(50, rowCount * (30 * PT_TO_PX) + (12 * PT_TO_PX));
|
||||
return Math.max(50, rowCount * (30 * PT_TO_PX) + (16 * PT_TO_PX));
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return Math.min(block.height + (20 * PT_TO_PX), 500 * PT_TO_PX);
|
||||
return Math.min(block.height + (16 * PT_TO_PX), 500 * PT_TO_PX);
|
||||
}
|
||||
|
||||
if (block.type === 'paragraph' || block.type === 'heading' || block.type === 'list-item' || block.type === 'quote') {
|
||||
const fullText = block.runs.map((r) => r.text).join('');
|
||||
if (!fullText) return 24 * PT_TO_PX; // Empty paragraph height
|
||||
|
||||
const fontSizePt = block.runs[0]?.fontSize || 12;
|
||||
const fontSizePx = fontSizePt * PT_TO_PX;
|
||||
const lineSpacing = block.lineSpacing || 1.15;
|
||||
const avgCharWidthPx = fontSizePx * 0.55;
|
||||
const lineSpacing = block.lineSpacing || 1.25;
|
||||
const letterSpacingPx = ((block.runs[0]?.letterSpacing !== undefined ? block.runs[0].letterSpacing : block.letterSpacing) || 0) * PT_TO_PX;
|
||||
const avgCharWidthPx = fontSizePx * 0.52 + letterSpacingPx;
|
||||
const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx));
|
||||
|
||||
const lineCount = Math.ceil(fullText.length / charsPerLine);
|
||||
const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX;
|
||||
const spaceAfterPx = (block.spaceAfter || 6) * PT_TO_PX;
|
||||
const paragraphs = fullText.split('\n');
|
||||
let totalLines = 0;
|
||||
for (const p of paragraphs) {
|
||||
if (!p) {
|
||||
totalLines += 1;
|
||||
} else {
|
||||
totalLines += Math.max(1, Math.ceil(p.length / charsPerLine));
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(20 * PT_TO_PX, lineCount * (fontSizePx * lineSpacing) + spaceBeforePx + spaceAfterPx);
|
||||
const paddingPx = 4;
|
||||
const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX;
|
||||
const spaceAfterPx = (block.spaceAfter !== undefined ? block.spaceAfter : 2) * PT_TO_PX;
|
||||
const flexGapPx = 4;
|
||||
|
||||
const contentHeight = totalLines * (fontSizePx * lineSpacing) + paddingPx + spaceBeforePx + spaceAfterPx + flexGapPx;
|
||||
return Math.max(24 * PT_TO_PX, contentHeight);
|
||||
}
|
||||
|
||||
return 24 * PT_TO_PX;
|
||||
|
||||
@@ -127,7 +127,12 @@ export class PdfDocumentRenderer {
|
||||
indentOffset = 18;
|
||||
}
|
||||
|
||||
const lineSpacing = pBlock.lineSpacing || 1.15;
|
||||
const rawLineSpacing = pBlock.lineSpacing || 0;
|
||||
const lineSpacing = rawLineSpacing >= 1.0 ? rawLineSpacing : 1.15 + rawLineSpacing;
|
||||
const letterSpacing = (pBlock.runs[0]?.letterSpacing !== undefined ? pBlock.runs[0].letterSpacing : pBlock.letterSpacing) || 0;
|
||||
if (typeof (pdf as any).setCharSpace === 'function') {
|
||||
(pdf as any).setCharSpace(letterSpacing * 0.75);
|
||||
}
|
||||
const lines = pdf.splitTextToSize(textToRender, usableWidth - indentOffset);
|
||||
const blockHeight = lines.length * (fontSize * lineSpacing);
|
||||
|
||||
@@ -150,6 +155,9 @@ export class PdfDocumentRenderer {
|
||||
pdf.text(line, xPos, currentY + fontSize, { align: alignOption });
|
||||
currentY += fontSize * lineSpacing;
|
||||
});
|
||||
if (typeof (pdf as any).setCharSpace === 'function') {
|
||||
(pdf as any).setCharSpace(0);
|
||||
}
|
||||
|
||||
// Hyperlinks handling
|
||||
pBlock.runs.forEach((run) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TextRun {
|
||||
color?: string;
|
||||
highlightColor?: string;
|
||||
linkUrl?: string;
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
export type BlockType = 'paragraph' | 'heading' | 'list-item' | 'table' | 'image' | 'quote' | 'page-break';
|
||||
@@ -21,7 +22,8 @@ export interface ParagraphBlock {
|
||||
headingLevel?: 1 | 2 | 3 | 4;
|
||||
runs: TextRun[];
|
||||
alignment?: 'left' | 'center' | 'right' | 'justify';
|
||||
lineSpacing?: number; // 1.0, 1.15, 1.5, 2.0
|
||||
lineSpacing?: number; // 1.0, 1.15, 1.25, 1.5, 2.0, 2.5, 3.0
|
||||
letterSpacing?: number; // -1, 0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0
|
||||
spaceBefore?: number;
|
||||
spaceAfter?: number;
|
||||
indentLeft?: number;
|
||||
@@ -125,7 +127,7 @@ export const DEFAULT_STYLES: Record<string, StyleDefinition> = {
|
||||
color: '#0f172a',
|
||||
alignment: 'left',
|
||||
spaceBefore: 0,
|
||||
spaceAfter: 6,
|
||||
spaceAfter: 2,
|
||||
},
|
||||
Title: {
|
||||
name: 'Title',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReflowLayout } from './pdfiumEngine';
|
||||
import { triggerPDFDownload } from './pdfExport';
|
||||
|
||||
export interface PageInfo {
|
||||
index: number;
|
||||
@@ -1074,17 +1075,8 @@ class GatewayService {
|
||||
}
|
||||
|
||||
async exportDocument(documentId: string, filename: string): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
|
||||
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
const blob = await this.exportDocumentBlob(documentId);
|
||||
await triggerPDFDownload(blob, filename);
|
||||
}
|
||||
|
||||
async exportRemoteDocument(documentId: string, freshToken?: string): Promise<any> {
|
||||
|
||||
@@ -15,7 +15,8 @@ export type ToolId =
|
||||
| 'squiggly'
|
||||
| 'stream_edit'
|
||||
| 'create_pdf'
|
||||
| 'watermark';
|
||||
| 'watermark'
|
||||
| 'merge_pdf';
|
||||
|
||||
export interface ToolSettings {
|
||||
highlightColor: string;
|
||||
|
||||
@@ -541,7 +541,7 @@ function layoutFromOrigLines(
|
||||
|
||||
export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride,
|
||||
caretClick: _caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel,
|
||||
}) => {
|
||||
const layout = useMemo(() => computeLayout(para), [para]);
|
||||
const leading = leadingOverride ?? layout.leading;
|
||||
@@ -755,6 +755,20 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
return lineStarts(lay, fullText)[li] + offset;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the caret should land the moment the editor opens. `caretClick` carries the
|
||||
* viewport coordinates of the click that opened this paragraph for editing (plumbed
|
||||
* down from TextEditLayer) — without it the caret always fell back to the end of the
|
||||
* paragraph regardless of where the user actually clicked, which is the "cursor jumps
|
||||
* to the wrong place" bug. Falls back to end-of-text for programmatic opens that have
|
||||
* no originating click (e.g. reflowing into a bullet sub-paragraph).
|
||||
*/
|
||||
const initialCaretIndexFor = (el: HTMLElement, lay: ReflowLayout): number => {
|
||||
const fullText = el.textContent ?? '';
|
||||
if (caretClick) return globalFromPoint(caretClick.x, caretClick.y, lay, fullText);
|
||||
return fullText.length;
|
||||
};
|
||||
|
||||
const positionCaret = () => {
|
||||
const el = editRef.current, lay = engineLayoutRef.current;
|
||||
if (!el || !lay) return;
|
||||
@@ -792,8 +806,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
engineLayoutRef.current = origLay;
|
||||
if (!initialCaretApplied.current) {
|
||||
initialCaretApplied.current = true;
|
||||
const fullLen = (el.textContent ?? '').length;
|
||||
setGlobalCaretOffset(el, fullLen);
|
||||
const target = initialCaretIndexFor(el, origLay);
|
||||
caretIndexRef.current = target;
|
||||
setGlobalCaretOffset(el, target);
|
||||
}
|
||||
positionCaret();
|
||||
setHasPreview(true);
|
||||
@@ -805,6 +820,11 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const dpr = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1;
|
||||
const dpi = Math.round(96 * zoom * dpr);
|
||||
const runs = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||||
// Snapshot of the editor text this preview request is based on. If the user keeps
|
||||
// typing/deleting while this request is in flight, the response below will lag behind
|
||||
// and must not be allowed to move the caret to a position computed against stale text
|
||||
// (that's the "cursor jumps to the wrong place" / jittery caret while typing symptom).
|
||||
const capturedText = el.textContent ?? '';
|
||||
const data = buildReflowData(runs);
|
||||
const yTopPt = bandTop / zoom;
|
||||
const operations = [{
|
||||
@@ -881,6 +901,13 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
})));
|
||||
}
|
||||
|
||||
// The editor text has moved on since this request was sent (user kept typing/deleting
|
||||
// while it was in flight). Drop this stale response rather than repaint the canvas or
|
||||
// reposition the caret from it — the in-flight/queued render loop (scheduleRender's
|
||||
// do/while) will immediately re-run against the current text and catch up. The fast,
|
||||
// synchronous optimistic caret set in onInput keeps the cursor smooth in the meantime.
|
||||
if ((editRef.current?.textContent ?? '') !== capturedText) return;
|
||||
|
||||
console.log('[STAGE_7_PREVIEW_DRAW]', {
|
||||
fontSize: domSize,
|
||||
fontPx,
|
||||
@@ -932,10 +959,11 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
}
|
||||
}
|
||||
if (!hasPreview) setHasPreview(true);
|
||||
if (!initialCaretApplied.current) {
|
||||
if (!initialCaretApplied.current && lay) {
|
||||
initialCaretApplied.current = true;
|
||||
const fullLen = (el.textContent ?? '').length;
|
||||
setGlobalCaretOffset(el, fullLen);
|
||||
const target = initialCaretIndexFor(el, lay);
|
||||
caretIndexRef.current = target;
|
||||
setGlobalCaretOffset(el, target);
|
||||
}
|
||||
positionCaret();
|
||||
};
|
||||
@@ -1008,9 +1036,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||||
// Caret layout after DOM seed is ready (documentId effect may have run first on an empty editor).
|
||||
if (!editedRef.current) {
|
||||
engineLayoutRef.current = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||
const origLay = layoutFromOrigLines(layout.origLines, columnLeft, pageIndex, domSize);
|
||||
engineLayoutRef.current = origLay;
|
||||
initialCaretApplied.current = true;
|
||||
setGlobalCaretOffset(el, (el.textContent ?? '').length);
|
||||
const target = initialCaretIndexFor(el, origLay);
|
||||
caretIndexRef.current = target;
|
||||
setGlobalCaretOffset(el, target);
|
||||
positionCaret();
|
||||
setHasPreview(true);
|
||||
}
|
||||
|
||||
+22
-23
@@ -8,7 +8,6 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
cmake \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
git \
|
||||
@@ -35,23 +34,20 @@ RUN git clone https://github.com/microsoft/vcpkg.git /opt/vcpkg \
|
||||
&& /opt/vcpkg/bootstrap-vcpkg.sh -disableMetrics
|
||||
ENV VCPKG_ROOT=/opt/vcpkg
|
||||
|
||||
# Ensure a recent CMake is available (system cmake in slim images can be too old for vcpkg)
|
||||
RUN python -m pip install --upgrade pip cmake
|
||||
|
||||
# Cache vcpkg dependencies in a separate layer
|
||||
COPY vcpkg.json ./
|
||||
RUN --mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/opt/vcpkg/downloads \
|
||||
/opt/vcpkg/vcpkg install --triplet x64-linux
|
||||
|
||||
# Install depot_tools globally with caching
|
||||
RUN --mount=type=cache,target=/opt/depot_tools \
|
||||
if [ ! -d /opt/depot_tools/.git ]; then \
|
||||
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git /opt/depot_tools; \
|
||||
fi
|
||||
ENV PATH="/opt/depot_tools:${PATH}"
|
||||
|
||||
# Build PDFium for Linux (heavily cached, keeping the huge source tree out of the image layer)
|
||||
COPY third_party/pdfium/ ./third_party/pdfium/
|
||||
RUN --mount=type=cache,target=/build/third_party/pdfium/checkout \
|
||||
./third_party/pdfium/build_pdfium.sh
|
||||
RUN --mount=type=cache,target=/build/third_party/pdfium/depot_tools \
|
||||
--mount=type=cache,target=/build/third_party/pdfium/checkout \
|
||||
sed -i 's/\r$//' ./third_party/pdfium/build_pdfium.sh && bash ./third_party/pdfium/build_pdfium.sh
|
||||
|
||||
# Copy everything needed for the engine and bindings build
|
||||
COPY CMakeLists.txt CMakePresets.json ./
|
||||
@@ -59,26 +55,24 @@ COPY cmake/ ./cmake/
|
||||
COPY engine/ ./engine/
|
||||
COPY bindings/ ./bindings/
|
||||
COPY gateway/ ./gateway/
|
||||
COPY corpus/ ./corpus/
|
||||
|
||||
# Configure CMake with tests enabled
|
||||
# Configure and build in the same layer. Keeping these operations together
|
||||
# avoids Docker/overlayfs timestamp skew causing Ninja to regenerate
|
||||
# build.ninja indefinitely ("manifest still dirty after 100 tries"). The
|
||||
# source tree is immutable for this image, so automatic build-system
|
||||
# regeneration is unnecessary once configure has completed.
|
||||
RUN --mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/opt/vcpkg/downloads \
|
||||
cmake --preset linux-release \
|
||||
-DPDFENGINE_BUILD_TESTS=ON \
|
||||
-DPDFENGINE_BUILD_TESTS=OFF \
|
||||
-DPDFENGINE_WITH_PDFIUM=ON \
|
||||
-DPDFENGINE_WITH_SKIA=OFF \
|
||||
-DPDFENGINE_WITH_QPDF=ON \
|
||||
-DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake
|
||||
-DCMAKE_TOOLCHAIN_FILE=${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake \
|
||||
-DCMAKE_SUPPRESS_REGENERATION=ON \
|
||||
&& cmake --build out/build/linux-release
|
||||
|
||||
# Build all targets including tests and pdfengine_py
|
||||
RUN cmake --build out/build/linux-release
|
||||
|
||||
# Stage 2: Tester
|
||||
FROM builder AS tester
|
||||
RUN ctest --test-dir out/build/linux-release --output-on-failure
|
||||
|
||||
# Stage 3: Runtime
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -93,10 +87,15 @@ RUN apt-get update \
|
||||
zlib1g \
|
||||
libpng16-16 \
|
||||
fonts-liberation \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libgomp1 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install the extension globally so it's not shadowed by the volume mount ./gateway:/home/app
|
||||
COPY --from=tester /build/gateway/pdfengine*.so /usr/local/lib/python3.11/site-packages/
|
||||
COPY --from=builder /build/gateway/pdfengine*.so /usr/local/lib/python3.11/site-packages/
|
||||
|
||||
RUN groupadd --system app \
|
||||
&& useradd --system --gid app --create-home --home-dir /home/app app
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import re
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.document import (
|
||||
@@ -371,4 +371,81 @@ async def unlock_document(document_id: str) -> DocumentInfoResponse:
|
||||
if not updated_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found during update")
|
||||
|
||||
return make_document_response(updated_info)
|
||||
return make_document_response(updated_info)
|
||||
|
||||
|
||||
import json
|
||||
from app.services.pdf_merge import merge_pdf_files
|
||||
|
||||
|
||||
@router.post("/merge", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def merge_documents(
|
||||
files: list[UploadFile] = File(...),
|
||||
manifest: str = Form(default="[]"),
|
||||
output_filename: str = Form(default="merged.pdf"),
|
||||
) -> DocumentInfoResponse:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
if not files or len(files) < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one PDF file must be provided for merging.",
|
||||
)
|
||||
|
||||
files_bytes: list[bytes] = []
|
||||
for file in files:
|
||||
data = await file.read()
|
||||
fn_lower = (file.filename or "").lower()
|
||||
image_exts = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif"]
|
||||
is_img = any(fn_lower.endswith(ext) for ext in image_exts) or \
|
||||
data.startswith(b"\x89PNG") or \
|
||||
data.startswith(b"\xff\xd8") or \
|
||||
data.startswith(b"RIFF") or \
|
||||
data.startswith(b"BM")
|
||||
if is_img:
|
||||
try:
|
||||
data = _convert_image_to_pdf_bytes(data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to convert image {file.filename} to PDF: {e!s}",
|
||||
)
|
||||
files_bytes.append(data)
|
||||
|
||||
parsed_manifest: list[dict] = []
|
||||
if manifest and manifest.strip():
|
||||
try:
|
||||
parsed = json.loads(manifest)
|
||||
if isinstance(parsed, list):
|
||||
parsed_manifest = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not parsed_manifest:
|
||||
parsed_manifest = [{"fileIndex": idx, "pages": "all"} for idx in range(len(files))]
|
||||
|
||||
try:
|
||||
merged_bytes = merge_pdf_files(files_bytes, parsed_manifest)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to merge PDF files: {e!s}",
|
||||
)
|
||||
|
||||
if not output_filename or not output_filename.lower().endswith(".pdf"):
|
||||
output_filename = f"{output_filename or 'merged'}.pdf"
|
||||
|
||||
try:
|
||||
pdfengine = engine.require()
|
||||
doc = pdfengine.PdfDocument.load_from_memory(merged_bytes, "")
|
||||
info = document_store.add_document(output_filename, merged_bytes, doc)
|
||||
return make_document_response(info)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Failed to load merged PDF into engine: {e!s}",
|
||||
)
|
||||
@@ -77,9 +77,15 @@ def perform_ocr_on_page(document_id: str, page_index: int) -> OCRPageResponse:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Page index out of bounds")
|
||||
|
||||
if not ocr_service.is_ocr_available():
|
||||
init_err = ocr_service.get_ocr_init_error()
|
||||
err_detail = (
|
||||
f"RapidOCR engine is not installed or available ({init_err})."
|
||||
if init_err
|
||||
else "RapidOCR engine is not installed or available."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="RapidOCR engine is not installed or available.",
|
||||
detail=err_detail,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MergeFileItem(BaseModel):
|
||||
fileIndex: int = Field(..., description="0-based index of the uploaded file")
|
||||
pages: str | None = Field(default="all", description="Page range e.g. 'all', '1-3, 5', '2'")
|
||||
|
||||
|
||||
class MergeRequestManifest(BaseModel):
|
||||
outputFilename: str = Field(default="merged.pdf", description="Output filename for merged PDF")
|
||||
items: list[MergeFileItem] = Field(default_factory=list, description="Ordered list of files and pages to merge")
|
||||
@@ -8,11 +8,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ocr_engine = None
|
||||
_ocr_available = False
|
||||
_ocr_init_error: Optional[str] = None
|
||||
|
||||
|
||||
def load_ocr() -> bool:
|
||||
"""Explicitly initializes the RapidOCR engine once during application startup."""
|
||||
global _ocr_engine, _ocr_available
|
||||
global _ocr_engine, _ocr_available, _ocr_init_error
|
||||
if _ocr_engine is not None:
|
||||
return _ocr_available
|
||||
|
||||
@@ -20,10 +21,14 @@ def load_ocr() -> bool:
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
_ocr_engine = RapidOCR()
|
||||
_ocr_available = True
|
||||
_ocr_init_error = None
|
||||
logger.info("[Startup] RapidOCR engine loaded successfully into memory.")
|
||||
print("[Startup] RapidOCR engine loaded successfully into memory.")
|
||||
except Exception as e:
|
||||
_ocr_engine = None
|
||||
_ocr_available = False
|
||||
_ocr_init_error = str(e)
|
||||
logger.error(f"[Startup] Warning: RapidOCR engine initialization failed: {e}", exc_info=True)
|
||||
print(f"[Startup] Warning: RapidOCR engine not available: {e}")
|
||||
|
||||
return _ocr_available
|
||||
@@ -37,6 +42,10 @@ def is_ocr_available() -> bool:
|
||||
return _ocr_available and _ocr_engine is not None
|
||||
|
||||
|
||||
def get_ocr_init_error() -> Optional[str]:
|
||||
return _ocr_init_error
|
||||
|
||||
|
||||
|
||||
def recognize_image_bytes(image_bytes: bytes) -> Dict[str, Any]:
|
||||
if not is_ocr_available():
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import io
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
|
||||
def parse_page_selection(page_spec: str | None, total_pages: int) -> list[int]:
|
||||
"""
|
||||
Parses a page selection string (1-indexed, user-facing) into a list of 0-indexed page indices.
|
||||
Supports formats like: 'all', '', '1-3, 5', '2', '4-1'.
|
||||
Out-of-bound page numbers are ignored.
|
||||
"""
|
||||
if not page_spec or page_spec.strip().lower() in ("all", "*", ""):
|
||||
return list(range(total_pages))
|
||||
|
||||
result_indices: list[int] = []
|
||||
parts = page_spec.split(",")
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
subparts = part.split("-", 1)
|
||||
try:
|
||||
start = int(subparts[0].strip())
|
||||
end = int(subparts[1].strip())
|
||||
if start <= end:
|
||||
step = 1
|
||||
else:
|
||||
step = -1
|
||||
for p in range(start, end + step, step):
|
||||
idx = p - 1
|
||||
if 0 <= idx < total_pages:
|
||||
result_indices.append(idx)
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
p = int(part)
|
||||
idx = p - 1
|
||||
if 0 <= idx < total_pages:
|
||||
result_indices.append(idx)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return result_indices
|
||||
|
||||
|
||||
def merge_pdf_files(
|
||||
files_data: list[bytes],
|
||||
items: list[dict],
|
||||
) -> bytes:
|
||||
"""
|
||||
Merges multiple PDF byte buffers according to items configuration.
|
||||
Each item dict should contain:
|
||||
- 'fileIndex': index in files_data
|
||||
- 'pages': page selection string e.g. 'all' or '1-3, 5'
|
||||
"""
|
||||
writer = PdfWriter()
|
||||
|
||||
for item in items:
|
||||
f_idx = item.get("fileIndex", 0)
|
||||
pages_spec = item.get("pages", "all")
|
||||
|
||||
if f_idx < 0 or f_idx >= len(files_data):
|
||||
continue
|
||||
|
||||
pdf_bytes = files_data[f_idx]
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(pdf_bytes))
|
||||
total_pages = len(reader.pages)
|
||||
target_indices = parse_page_selection(pages_spec, total_pages)
|
||||
|
||||
for page_idx in target_indices:
|
||||
writer.add_page(reader.pages[page_idx])
|
||||
except Exception as err:
|
||||
raise ValueError(f"Failed to process PDF at index {f_idx}: {err!s}") from err
|
||||
|
||||
output_stream = io.BytesIO()
|
||||
writer.write(output_stream)
|
||||
return output_stream.getvalue()
|
||||
@@ -16,6 +16,9 @@ dependencies = [
|
||||
"python-multipart==0.0.19",
|
||||
"pillow==10.4.0",
|
||||
"httpx==0.28.1",
|
||||
"pypdf==5.3.0",
|
||||
"rapidocr-onnxruntime==1.2.3",
|
||||
"onnxruntime==1.28.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import io
|
||||
import json
|
||||
import pytest
|
||||
from pypdf import PdfWriter, PdfReader
|
||||
from fastapi.testclient import TestClient
|
||||
from app.services.pdf_merge import parse_page_selection, merge_pdf_files
|
||||
from app.services import engine
|
||||
|
||||
|
||||
def _create_sample_pdf(page_count: int = 1) -> bytes:
|
||||
writer = PdfWriter()
|
||||
for _ in range(page_count):
|
||||
writer.add_blank_page(width=612, height=792)
|
||||
stream = io.BytesIO()
|
||||
writer.write(stream)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
def test_parse_page_selection():
|
||||
assert parse_page_selection("all", 5) == [0, 1, 2, 3, 4]
|
||||
assert parse_page_selection("", 3) == [0, 1, 2]
|
||||
assert parse_page_selection("1, 3", 5) == [0, 2]
|
||||
assert parse_page_selection("1-3", 5) == [0, 1, 2]
|
||||
assert parse_page_selection("1-2, 4-5", 5) == [0, 1, 3, 4]
|
||||
assert parse_page_selection("10", 3) == [] # out of bounds
|
||||
|
||||
|
||||
def test_merge_pdf_files_service():
|
||||
pdf1 = _create_sample_pdf(2)
|
||||
pdf2 = _create_sample_pdf(3)
|
||||
|
||||
items = [
|
||||
{"fileIndex": 0, "pages": "1-2"},
|
||||
{"fileIndex": 1, "pages": "1, 3"},
|
||||
]
|
||||
|
||||
merged_bytes = merge_pdf_files([pdf1, pdf2], items)
|
||||
reader = PdfReader(io.BytesIO(merged_bytes))
|
||||
assert len(reader.pages) == 4
|
||||
|
||||
|
||||
def test_merge_documents_api_endpoint(client: TestClient):
|
||||
if not engine.is_available():
|
||||
pytest.skip("PDF Engine binary binding not available in test environment.")
|
||||
|
||||
pdf1 = _create_sample_pdf(2)
|
||||
pdf2 = _create_sample_pdf(1)
|
||||
|
||||
manifest = json.dumps([
|
||||
{"fileIndex": 0, "pages": "1"},
|
||||
{"fileIndex": 1, "pages": "all"},
|
||||
])
|
||||
|
||||
files = [
|
||||
("files", ("doc1.pdf", pdf1, "application/pdf")),
|
||||
("files", ("doc2.pdf", pdf2, "application/pdf")),
|
||||
]
|
||||
data = {
|
||||
"manifest": manifest,
|
||||
"output_filename": "final_merged.pdf",
|
||||
}
|
||||
|
||||
res = client.post("/documents/merge", files=files, data=data)
|
||||
assert res.status_code == 201
|
||||
payload = res.json()
|
||||
assert payload["filename"] == "final_merged.pdf"
|
||||
assert payload["totalPages"] == 2
|
||||
assert "id" in payload
|
||||
Vendored
+7
-2
@@ -13,8 +13,8 @@ Day-1 risk — budget a full day for depot_tools quirks."*
|
||||
still the placeholder — Rule: never track rolling HEAD).
|
||||
2. Clone `depot_tools` under the **build root** and run its Windows bootstrap
|
||||
(fetches bundled git + python via CIPD).
|
||||
3. `gclient config` + `gclient sync` the PDFium tree into `<build root>/checkout/`.
|
||||
4. Check out the exact pinned commit and re-sync its DEPS.
|
||||
3. `gclient config` + a throttled, retried `gclient sync --revision` of the
|
||||
exact pinned PDFium commit into `<build root>/checkout/`.
|
||||
5. Write `args.gn` for a **static, standalone, monolithic, embed-friendly** build:
|
||||
- `is_component_build = false` — one static lib, not many DLLs
|
||||
- `pdf_is_standalone = true`
|
||||
@@ -88,6 +88,11 @@ several GB, and the compile is lengthy. `depot_tools/`, `checkout/`, and
|
||||
- **`gclient sync` aborts with "uncommitted changes"** — git's `core.autocrlf`
|
||||
rewrote a dependency checkout. The scripts already inject `core.autocrlf=false`
|
||||
per-process; if you bypass them, set it yourself.
|
||||
- **`gclient sync` reports HTTP 429** — the public Chromium source host has
|
||||
throttled dependency downloads. The scripts retry the sync and limit it to
|
||||
two concurrent source-control operations. If your network is reliable and
|
||||
has sufficient capacity, increase `PDFIUM_GCLIENT_JOBS`; otherwise re-run
|
||||
the same command and it will resume from the cached checkout.
|
||||
- **depot_tools `git`/`python` not found** — depot_tools was not bootstrapped.
|
||||
The scripts run `bootstrap\win_tools.bat`; do not set `DEPOT_TOOLS_UPDATE=0`,
|
||||
which suppresses that bootstrap.
|
||||
|
||||
Vendored
+41
-12
@@ -38,6 +38,33 @@ $DepotTools = Join-Path $BuildRoot 'depot_tools'
|
||||
$Checkout = Join-Path $BuildRoot 'checkout'
|
||||
Write-Host ">> Build root: $BuildRoot"
|
||||
|
||||
# gclient otherwise defaults to at least eight concurrent repository updates.
|
||||
# A modest default avoids HTTP 429 responses from public googlesource endpoints.
|
||||
$GclientJobs = if ($env:PDFIUM_GCLIENT_JOBS) { $env:PDFIUM_GCLIENT_JOBS } else { '2' }
|
||||
if ($GclientJobs -notmatch '^[1-9][0-9]*$') {
|
||||
Write-Error "PDFIUM_GCLIENT_JOBS must be a positive integer, got: $GclientJobs"
|
||||
}
|
||||
|
||||
function Invoke-ExternalWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Description,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Command
|
||||
)
|
||||
|
||||
$maxAttempts = if ($env:PDFIUM_MAX_ATTEMPTS) { [int]$env:PDFIUM_MAX_ATTEMPTS } else { 6 }
|
||||
$delay = if ($env:PDFIUM_INITIAL_RETRY_DELAY) { [int]$env:PDFIUM_INITIAL_RETRY_DELAY } else { 15 }
|
||||
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
|
||||
& $Command
|
||||
if ($LASTEXITCODE -eq 0) { return }
|
||||
if ($attempt -eq $maxAttempts) {
|
||||
Write-Error "$Description failed after $attempt attempts."
|
||||
}
|
||||
Write-Warning "$Description failed (attempt $attempt/$maxAttempts). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
$delay *= 2
|
||||
}
|
||||
}
|
||||
|
||||
# --- 1. Read and validate the pinned revision -------------------------------
|
||||
$pinned = Get-Content $PinnedFile | Where-Object { $_ -match '^\s*PDFIUM_' }
|
||||
$repo = ($pinned | Where-Object { $_ -match '^PDFIUM_REPO=' }) -replace '^PDFIUM_REPO=', ''
|
||||
@@ -61,11 +88,12 @@ $env:DEPOT_TOOLS_WIN_TOOLCHAIN = '0'
|
||||
# so the user's global git config is never touched. core.autocrlf=false is the
|
||||
# critical one: without it, gclient sees CRLF-converted dependency checkouts as
|
||||
# "uncommitted changes" and aborts the sync.
|
||||
$env:GIT_CONFIG_COUNT = '4'
|
||||
$env:GIT_CONFIG_COUNT = '5'
|
||||
$env:GIT_CONFIG_KEY_0 = 'core.autocrlf'; $env:GIT_CONFIG_VALUE_0 = 'false'
|
||||
$env:GIT_CONFIG_KEY_1 = 'core.filemode'; $env:GIT_CONFIG_VALUE_1 = 'false'
|
||||
$env:GIT_CONFIG_KEY_2 = 'core.fscache'; $env:GIT_CONFIG_VALUE_2 = 'true'
|
||||
$env:GIT_CONFIG_KEY_3 = 'core.preloadindex'; $env:GIT_CONFIG_VALUE_3 = 'true'
|
||||
$env:GIT_CONFIG_KEY_4 = 'http.maxRequests'; $env:GIT_CONFIG_VALUE_4 = '2'
|
||||
|
||||
# Bootstrap depot_tools. On Windows it must fetch its bundled git + python via
|
||||
# CIPD and generate the git.bat / python3.bat wrappers before gclient can run.
|
||||
@@ -101,21 +129,22 @@ $env:GYP_MSVS_VERSION = $vsYear
|
||||
New-Item -ItemType Directory -Force -Path $Checkout | Out-Null
|
||||
Push-Location $Checkout
|
||||
if (-not (Test-Path (Join-Path $Checkout 'pdfium'))) {
|
||||
Write-Host '>> gclient config (unmanaged)'
|
||||
& gclient config --unmanaged $repo
|
||||
Write-Host '>> gclient config'
|
||||
& gclient config $repo
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error 'gclient config failed.' }
|
||||
}
|
||||
Write-Host '>> gclient sync (pulls several GB; slow)'
|
||||
& gclient sync --no-history --shallow --reset --force
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error 'gclient sync failed.' }
|
||||
Write-Host ">> gclient sync pinned at $commit (pulls several GB; slow)"
|
||||
Invoke-ExternalWithRetry 'gclient sync' {
|
||||
& gclient sync "--jobs=$GclientJobs" --no-history --shallow --reset --force --delete_unversioned_trees --revision "pdfium@$commit"
|
||||
}
|
||||
|
||||
# --- 4. Pin to the exact commit + sync its DEPS -----------------------------
|
||||
# gclient --revision keeps both the root and its DEPS at the requested
|
||||
# revision, avoiding a sync at HEAD followed by a second full pinned sync.
|
||||
Push-Location (Join-Path $Checkout 'pdfium')
|
||||
& git fetch origin $commit
|
||||
& git checkout --detach $commit
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "git checkout $commit failed." }
|
||||
& gclient sync --no-history --shallow --reset --force -D
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error 'gclient sync (pinned DEPS) failed.' }
|
||||
$actualCommit = (& git rev-parse HEAD).Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $commit) {
|
||||
Write-Error "gclient did not check out the requested PDFium revision. Expected $commit, got $actualCommit"
|
||||
}
|
||||
|
||||
# --- 5. GN args: static, standalone, monolithic, embed-friendly -------------
|
||||
New-Item -ItemType Directory -Force -Path 'out\Release' | Out-Null
|
||||
|
||||
Vendored
+52
-11
@@ -34,6 +34,43 @@ DEPOT_TOOLS_DIR="${BUILD_ROOT}/depot_tools"
|
||||
CHECKOUT_DIR="${BUILD_ROOT}/checkout"
|
||||
echo ">> Build root: ${BUILD_ROOT}"
|
||||
|
||||
# Simple retry helper for transient network/git errors. A PDFium sync contacts
|
||||
# many Chromium-hosted repositories, so a retry resumes the partially populated
|
||||
# checkout instead of starting the multi-GB download again.
|
||||
retry() {
|
||||
local -r -i max_attempts=${PDFIUM_MAX_ATTEMPTS:-6}
|
||||
local -r -i initial_sleep=${PDFIUM_INITIAL_RETRY_DELAY:-15}
|
||||
local -i attempt=1
|
||||
local sleep_time=$initial_sleep
|
||||
while true; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "Command failed after $attempt attempts: $*" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Command failed (attempt $attempt/$max_attempts). Retrying in ${sleep_time}s..." >&2
|
||||
sleep "$sleep_time"
|
||||
attempt=$((attempt+1))
|
||||
sleep_time=$((sleep_time*2))
|
||||
done
|
||||
}
|
||||
|
||||
# gclient defaults to at least eight simultaneous source-control operations.
|
||||
# That is unnecessarily aggressive for public googlesource endpoints and can
|
||||
# trigger HTTP 429 responses while Docker is fetching the PDFium dependency
|
||||
# graph. Keep the default conservative; callers that have a trusted mirror or
|
||||
# more capacity can override it explicitly.
|
||||
GCLIENT_JOBS="${PDFIUM_GCLIENT_JOBS:-2}"
|
||||
if ! [[ "${GCLIENT_JOBS}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "ERROR: PDFIUM_GCLIENT_JOBS must be a positive integer, got: ${GCLIENT_JOBS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gclient_sync() {
|
||||
retry gclient sync --jobs="${GCLIENT_JOBS}" --no-history --shallow --reset --force "$@"
|
||||
}
|
||||
# --- 1. Read and validate the pinned revision -------------------------------
|
||||
PDFIUM_REPO="$(grep -E '^PDFIUM_REPO=' "${PINNED_FILE}" | cut -d= -f2- | tr -d '\r')"
|
||||
PDFIUM_COMMIT="$(grep -E '^PDFIUM_COMMIT=' "${PINNED_FILE}" | cut -d= -f2- | tr -d '\r')"
|
||||
@@ -60,33 +97,37 @@ fi
|
||||
# Git settings injected per-process via GIT_CONFIG_* so the user's global git
|
||||
# config is never touched. core.autocrlf=false avoids gclient seeing dependency
|
||||
# checkouts as "uncommitted changes" on platforms where autocrlf is enabled.
|
||||
export GIT_CONFIG_COUNT=4
|
||||
export GIT_CONFIG_COUNT=5
|
||||
export GIT_CONFIG_KEY_0=core.autocrlf GIT_CONFIG_VALUE_0=false
|
||||
export GIT_CONFIG_KEY_1=core.filemode GIT_CONFIG_VALUE_1=false
|
||||
export GIT_CONFIG_KEY_2=http.postBuffer GIT_CONFIG_VALUE_2=1048576000
|
||||
export GIT_CONFIG_KEY_3=core.compression GIT_CONFIG_VALUE_3=0
|
||||
export GIT_CONFIG_KEY_4=http.maxRequests GIT_CONFIG_VALUE_4=2
|
||||
|
||||
# --- 3. Fetch / sync the PDFium tree ----------------------------------------
|
||||
mkdir -p "${CHECKOUT_DIR}"
|
||||
cd "${CHECKOUT_DIR}"
|
||||
if [[ ! -d "${CHECKOUT_DIR}/pdfium" ]]; then
|
||||
echo ">> gclient config (unmanaged)"
|
||||
gclient config --unmanaged "${PDFIUM_REPO}"
|
||||
echo ">> gclient config"
|
||||
gclient config "${PDFIUM_REPO}"
|
||||
fi
|
||||
echo ">> gclient sync (pulls several GB; slow)"
|
||||
gclient sync --no-history --shallow --reset --force
|
||||
echo ">> gclient sync pinned at ${PDFIUM_COMMIT} (pulls several GB; slow)"
|
||||
gclient_sync --delete_unversioned_trees --revision "pdfium@${PDFIUM_COMMIT}"
|
||||
|
||||
# --- 4. Pin to the exact commit + sync its DEPS -----------------------------
|
||||
# gclient --revision keeps both the root and its DEPS at the requested
|
||||
# revision, avoiding the former expensive sync-at-HEAD followed by a second
|
||||
# complete pinned sync.
|
||||
cd "${CHECKOUT_DIR}/pdfium"
|
||||
git fetch origin "${PDFIUM_COMMIT}"
|
||||
git checkout --detach "${PDFIUM_COMMIT}"
|
||||
gclient sync --no-history --shallow --reset --force -D
|
||||
if [[ "$(git rev-parse HEAD)" != "${PDFIUM_COMMIT}" ]]; then
|
||||
echo "ERROR: gclient did not check out the requested PDFium revision." >&2
|
||||
echo "Expected: ${PDFIUM_COMMIT}" >&2
|
||||
echo "Actual: $(git rev-parse HEAD)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 5. GN args: static, standalone, monolithic, embed-friendly -------------
|
||||
mkdir -p out/Release
|
||||
cp "${SCRIPT_DIR}/args.gn" out/Release/args.gn
|
||||
|
||||
# --- 6. Generate + build ----------------------------------------------------
|
||||
echo ">> gn gen + ninja"
|
||||
gn gen out/Release
|
||||
ninja -C out/Release pdfium
|
||||
|
||||
Reference in New Issue
Block a user