cusor issue

This commit is contained in:
saqib mir
2026-08-22 11:28:05 +05:30
parent e04ac57e40
commit 3f340e9689
14 changed files with 772 additions and 34 deletions
+23 -2
View File
@@ -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<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 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)}
/>
)}
<MergePDFModal
isOpen={mergeModalOpen}
onClose={() => setMergeModalOpen(false)}
onMergeComplete={handleMergeCompleted}
apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'}
/>
</div>
);
}
+405
View File
@@ -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>
);
};
+11
View File
@@ -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 /> },
{
+8
View File
@@ -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' }) => (
+3 -1
View File
@@ -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>
@@ -109,8 +109,9 @@ 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 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"
>
</button>
@@ -800,21 +804,26 @@ 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 ${
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<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) {
@@ -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;
+2 -1
View File
@@ -15,7 +15,8 @@ export type ToolId =
| 'squiggly'
| 'stream_edit'
| 'create_pdf'
| 'watermark';
| 'watermark'
| 'merge_pdf';
export interface ToolSettings {
highlightColor: string;
+39 -8
View File
@@ -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);
}
+79 -2
View File
@@ -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}",
)
+11
View File
@@ -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")
+79
View File
@@ -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()
+3
View File
@@ -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]
+68
View File
@@ -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