From 3f340e9689b0e5a74b3e81dd0b4eb81e3a4566e9 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 11:28:05 +0530 Subject: [PATCH 1/2] cusor issue --- frontend/src/App.tsx | 25 +- frontend/src/components/MergePDFModal.tsx | 405 ++++++++++++++++++ frontend/src/components/ToolRail.tsx | 11 + frontend/src/components/Toolbar.tsx | 8 + frontend/src/components/TopBar.tsx | 4 +- .../components/DocumentEditor.tsx | 19 +- .../model/PaginationEngine.ts | 42 +- frontend/src/lib/tools.ts | 3 +- frontend/src/viewer/ParagraphEditor.tsx | 47 +- gateway/app/routers/documents/crud.py | 81 +++- gateway/app/schemas/merge.py | 11 + gateway/app/services/pdf_merge.py | 79 ++++ gateway/pyproject.toml | 3 + gateway/tests/test_merge.py | 68 +++ 14 files changed, 772 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/MergePDFModal.tsx create mode 100644 gateway/app/schemas/merge.py create mode 100644 gateway/app/services/pdf_merge.py create mode 100644 gateway/tests/test_merge.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 844580c..3b5bb48 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ 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'; @@ -94,6 +95,9 @@ function App() { if (activeTool === 'create_pdf') { setCreatePdfModalOpen(true); setActiveTool('select'); + } else if (activeTool === 'merge_pdf') { + setMergeModalOpen(true); + setActiveTool('select'); } }, [activeTool]); @@ -120,10 +124,16 @@ function App() { const [protectModalState, setProtectModalState] = useState(null); const [unlockModalState, setUnlockModalState] = useState(null); const [compareModalOpen, setCompareModalOpen] = useState(false); + const [mergeModalOpen, setMergeModalOpen] = useState(false); const [compareResult, setCompareResult] = useState(null); const [compareDocA, setCompareDocA] = useState(null); const [compareDocB, setCompareDocB] = useState(null); + const handleMergeCompleted = (docInfo: DocumentInfo) => { + setDocuments((prev) => [...prev, docInfo]); + openDocument(docInfo.id); + }; + const handleOpenCompareModal = () => { if (!activeDoc) return; setCompareDocA({ @@ -845,7 +855,7 @@ function App() { userPassword: string; ownerPassword?: string; confirmPassword: string; - permissions: any; + permissions?: any; }) => { const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen || selectedDocId === 'new-blank-creator' || !selectedDocId; try { @@ -870,7 +880,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); @@ -1087,6 +1100,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')} @@ -1463,6 +1477,13 @@ function App() { renderPageUrl={(docId, pageIdx, dpi) => gatewayService.getPageRenderUrl(docId, pageIdx, dpi)} /> )} + + setMergeModalOpen(false)} + onMergeComplete={handleMergeCompleted} + apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'} + /> ); } diff --git a/frontend/src/components/MergePDFModal.tsx b/frontend/src/components/MergePDFModal.tsx new file mode 100644 index 0000000..3abc5a3 --- /dev/null +++ b/frontend/src/components/MergePDFModal.tsx @@ -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 = ({ + isOpen, + onClose, + onMergeComplete, + apiBaseUrl = 'http://localhost:8000', +}) => { + const [files, setFiles] = useState([]); + const [outputFilename, setOutputFilename] = useState('merged_document.pdf'); + const [isMerging, setIsMerging] = useState(false); + const [error, setError] = useState(null); + const [mergedResult, setMergedResult] = useState(null); + const fileInputRef = useRef(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 ( +
+
+ {/* Header */} +
+
+
+ + + +
+
+

Merge PDF Files

+

Combine multiple PDFs into a single, ordered document

+
+
+ +
+ + {/* Content Body */} +
+ {mergedResult ? ( + /* Success View */ +
+
+ + + +
+
+

PDFs Merged Successfully!

+

+ Merged {files.length} document{files.length > 1 ? 's' : ''} into {mergedResult.filename} ({mergedResult.totalPages} total pages). +

+
+ +
+ + Download Merged PDF + + + Open in PDF Editor + +
+ + +
+ ) : ( + /* Upload & Configuration Form */ + <> + {error && ( +
+ {error} + +
+ )} + + {/* Upload Dropzone */} +
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" + > + handleAddFiles(e.target.files)} + /> +
+ +
+ Click or drop PDF files here + Select multiple PDF files to combine +
+ + {/* File Queue List */} + {files.length > 0 && ( +
+
+ + Merge Sequence ({files.length} {files.length === 1 ? 'file' : 'files'}) + + +
+ +
+ {files.map((item, index) => ( +
+ {/* Left Info */} +
+
+ + +
+ + + {index + 1} + + +
+

+ {item.file.name} +

+

+ {formatFileSize(item.file.size)} +

+
+
+ + {/* Right Page Controls */} +
+
+ + +
+ + {item.pagesMode === 'custom' && ( + 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" + /> + )} + + +
+
+ ))} +
+
+ )} + + {/* Output Filename Field */} +
+ + 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" + /> +
+ + )} +
+ + {/* Footer */} + {!mergedResult && ( +
+ { onClose(); handleReset(); }} + disabled={isMerging} + > + Cancel + + + {isMerging ? ( + <> + Merging... + + ) : ( + `Merge ${files.length > 0 ? `(${files.length})` : ''} PDFs` + )} + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 6a8fe8c..03bf637 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -34,6 +34,17 @@ const TOOLS: (ToolDef | 'divider')[] = [ ), }, + { + id: 'merge_pdf', + label: 'Merge PDFs', + shortLabel: 'Merge', + shortcut: 'G', + icon: ( + + + + ), + }, { id: 'comment', label: 'Comment', shortcut: 'C', icon: }, { id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: }, { diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 061b58e..bc1eb5c 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -82,6 +82,14 @@ const TOOL_META: Record = { ), }, + merge_pdf: { + label: 'Merge PDFs', + icon: ( + + + + ), + }, }; const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => ( diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index a479e15..9ea9fc6 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -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 = ({ 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(null); @@ -82,6 +83,7 @@ export const TopBar: React.FC = ({
} onClick={() => onNewBlankPDF?.()}>New Blank PDF… } onClick={() => fileRef.current?.click()}>Open PDF… + } onClick={() => onMergePDF?.()}>Merge PDFs… } onClick={onExport} disabled={!documentName || !canExport}>Export / Download } onClick={onPrint} disabled={!documentName || !canPrint}>Print } onClick={() => onCompare?.()} disabled={!documentName}>Compare PDFs… diff --git a/frontend/src/features/document-creator/components/DocumentEditor.tsx b/frontend/src/features/document-creator/components/DocumentEditor.tsx index f3536a1..0695b46 100644 --- a/frontend/src/features/document-creator/components/DocumentEditor.tsx +++ b/frontend/src/features/document-creator/components/DocumentEditor.tsx @@ -109,8 +109,9 @@ const EditableParagraphBlock: React.FC<{ return (
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 rounded px-2.5 py-1.5 transition-all ${ + isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50' + }`} style={{ textAlign: block.alignment || 'left', marginTop: `${block.spaceBefore || 0}px`, @@ -140,6 +141,9 @@ const EditableParagraphBlock: React.FC<{ color: firstRun.color || '#0f172a', backgroundColor: firstRun.highlightColor || 'transparent', lineHeight: block.lineSpacing || 1.25, + wordBreak: 'break-word', + overflowWrap: 'break-word', + whiteSpace: 'pre-wrap', }} /> @@ -151,7 +155,7 @@ const EditableParagraphBlock: React.FC<{ 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]" + className="absolute -top-2.5 -right-2.5 z-20 flex h-5.5 w-5.5 items-center justify-center rounded-full bg-white text-red-500 hover:bg-red-50 hover:text-red-700 border border-slate-300 shadow-sm text-[11px] font-extrabold cursor-pointer transition-transform hover:scale-110" > ✕ @@ -800,21 +804,26 @@ export const DocumentEditor: React.FC = ({ 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 (
handleMouseDownPage(e, pageIdx)} onMouseMove={(e) => handleMouseMovePage(e, pageIdx)} - className={`relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col ${ + 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 +849,7 @@ export const DocumentEditor: React.FC = ({ {/* Page Content Blocks */}
{ // Only trigger if the click landed directly on this container (the empty area below blocks) if (e.target === e.currentTarget) { diff --git a/frontend/src/features/document-creator/model/PaginationEngine.ts b/frontend/src/features/document-creator/model/PaginationEngine.ts index 2e00bdb..67818d4 100644 --- a/frontend/src/features/document-creator/model/PaginationEngine.ts +++ b/frontend/src/features/document-creator/model/PaginationEngine.ts @@ -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,38 @@ 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 avgCharWidthPx = fontSizePx * 0.52; 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 || 4) * 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; diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 407d754..7b7b2f6 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -15,7 +15,8 @@ export type ToolId = | 'squiggly' | 'stream_edit' | 'create_pdf' - | 'watermark'; + | 'watermark' + | 'merge_pdf'; export interface ToolSettings { highlightColor: string; diff --git a/frontend/src/viewer/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index 72ffdcd..361b608 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -541,7 +541,7 @@ function layoutFromOrigLines( export const ParagraphEditor: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ }))); } + // 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 = ({ } } 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 = ({ 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); } diff --git a/gateway/app/routers/documents/crud.py b/gateway/app/routers/documents/crud.py index 5623e8e..93fbf2e 100644 --- a/gateway/app/routers/documents/crud.py +++ b/gateway/app/routers/documents/crud.py @@ -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) \ No newline at end of file + 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}", + ) \ No newline at end of file diff --git a/gateway/app/schemas/merge.py b/gateway/app/schemas/merge.py new file mode 100644 index 0000000..d7e4f80 --- /dev/null +++ b/gateway/app/schemas/merge.py @@ -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") diff --git a/gateway/app/services/pdf_merge.py b/gateway/app/services/pdf_merge.py new file mode 100644 index 0000000..95533a7 --- /dev/null +++ b/gateway/app/services/pdf_merge.py @@ -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() diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml index 3c2199a..72e1efd 100644 --- a/gateway/pyproject.toml +++ b/gateway/pyproject.toml @@ -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] diff --git a/gateway/tests/test_merge.py b/gateway/tests/test_merge.py new file mode 100644 index 0000000..e17a700 --- /dev/null +++ b/gateway/tests/test_merge.py @@ -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 From 83f47488ab32f52c5ff27012fbf067cb69e9554c Mon Sep 17 00:00:00 2001 From: saqib mir Date: Sat, 22 Aug 2026 12:39:00 +0530 Subject: [PATCH 2/2] fix the issue --- frontend/src/App.tsx | 314 ++++++++++++------ frontend/src/components/ToolRail.tsx | 10 +- .../src/components/UnsavedChangesModal.tsx | 100 ++++++ .../components/CreatePDFModal.tsx | 2 + frontend/src/lib/gatewayService.ts | 14 +- 5 files changed, 322 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/UnsavedChangesModal.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3b5bb48..9a22900 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,8 @@ 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'; @@ -67,13 +69,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(null); const [creatorActions, setCreatorActions] = useState<{ canUndo: boolean; canRedo: boolean; @@ -94,7 +90,6 @@ function App() { useEffect(() => { if (activeTool === 'create_pdf') { setCreatePdfModalOpen(true); - setActiveTool('select'); } else if (activeTool === 'merge_pdf') { setMergeModalOpen(true); setActiveTool('select'); @@ -129,6 +124,191 @@ function App() { const [compareDocA, setCompareDocA] = useState(null); const [compareDocB, setCompareDocB] = useState(null); + + const [isInspectorExpanded, setIsInspectorExpanded] = useState(false); + const [activeStamp, setActiveStamp] = useState(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([]); + const [searchCurrentMatch, setSearchCurrentMatch] = useState(0); + + const [isOCRLoading, setIsOCRLoading] = useState(false); + const [creatorPageCount, setCreatorPageCount] = useState(1); + const [creatorPages, setCreatorPages] = useState([]); + const [watermarkPreview, setWatermarkPreview] = useState(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); @@ -184,46 +364,6 @@ function App() { }); } }; - const [isInspectorExpanded, setIsInspectorExpanded] = useState(false); - const [activeStamp, setActiveStamp] = useState(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([]); - const [searchCurrentMatch, setSearchCurrentMatch] = useState(0); - - const [isOCRLoading, setIsOCRLoading] = useState(false); - const [creatorPageCount, setCreatorPageCount] = useState(1); - const [creatorPages, setCreatorPages] = useState([]); - const [watermarkPreview, setWatermarkPreview] = useState(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 })); @@ -800,56 +940,9 @@ 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; @@ -939,23 +1032,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') { @@ -964,7 +1053,6 @@ function App() { } if (tool === 'comment') { creatorActions?.insertComment?.(); - setActiveTool('select'); return; } if (tool === 'draw') { @@ -1086,7 +1174,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} @@ -1184,12 +1272,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 +1540,8 @@ function App() { setConfirmState(null)} /> + setUnsavedModalState(null)} /> + { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }} diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 03bf637..3edae80 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -122,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-[#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' }`} > {active && !disabled && } diff --git a/frontend/src/components/UnsavedChangesModal.tsx b/frontend/src/components/UnsavedChangesModal.tsx new file mode 100644 index 0000000..2b6937d --- /dev/null +++ b/frontend/src/components/UnsavedChangesModal.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { CustomButton } from './custom/CustomButton'; + +export interface UnsavedChangesModalState { + title?: string; + message?: string; + onSaveAndContinue: () => Promise | void; + onDiscardAndContinue: () => void; +} + +interface UnsavedChangesModalProps { + state: UnsavedChangesModalState | null; + onClose: () => void; +} + +export const UnsavedChangesModal: React.FC = ({ 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 ( +
+
+ +
e.stopPropagation()} + > +
+
+
+ + + +
+
+

{title}

+

Document Protection

+
+
+

{message}

+
+ +
+ + Cancel + + + + Discard + + + + {isSaving ? 'Saving...' : 'Save & Continue'} + +
+
+
+ ); +}; diff --git a/frontend/src/features/document-creator/components/CreatePDFModal.tsx b/frontend/src/features/document-creator/components/CreatePDFModal.tsx index 9f503e2..043d369 100644 --- a/frontend/src/features/document-creator/components/CreatePDFModal.tsx +++ b/frontend/src/features/document-creator/components/CreatePDFModal.tsx @@ -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; @@ -684,6 +685,7 @@ export const CreatePDFModal: React.FC = ({ 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); diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index d34bf10..9bc86fa 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -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 { - 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 {