feat: implement PDF merging module with UI components and document editor integration

This commit is contained in:
momorew
2026-08-24 14:56:43 +05:30
parent 7cfbac1a07
commit df53a60cda
8 changed files with 60 additions and 24 deletions
+1 -1
View File
@@ -1 +1 @@
VITE_GATEWAY_URL=https://pdfapi-dev.maskantech.in
VITE_GATEWAY_URL=http://127.0.0.1:8765
+27 -8
View File
@@ -88,14 +88,34 @@ function App() {
reorderPage?: (fromIndex: number, toIndex: number) => void;
} | null>(null);
const [mergeInitialFile, setMergeInitialFile] = useState<File | null>(null);
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;
useEffect(() => {
if (activeTool === 'create_pdf') {
setCreatePdfModalOpen(true);
} else if (activeTool === 'merge_pdf') {
setMergeModalOpen(true);
setActiveTool('select');
const handleMergeOpen = async () => {
if (isRemote && activeDoc && selectedDocId) {
try {
const blob = await gatewayService.exportDocumentBlob(selectedDocId);
setMergeInitialFile(new File([blob], activeDoc.filename || 'current_document.pdf', { type: 'application/pdf' }));
} catch (err) {
console.error('Failed to fetch initial blob for merge', err);
setMergeInitialFile(null);
}
} else {
setMergeInitialFile(null);
}
setMergeModalOpen(true);
setActiveTool('select');
};
handleMergeOpen();
}
}, [activeTool]);
}, [activeTool, isRemote, activeDoc, selectedDocId]);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
@@ -167,10 +187,6 @@ function App() {
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);
@@ -1206,7 +1222,7 @@ function App() {
}}
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
onCompare={handleOpenCompareModal}
onMergePDF={() => setMergeModalOpen(true)}
onMergePDF={() => setActiveTool('merge_pdf')}
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
@@ -1223,6 +1239,7 @@ function App() {
totalPages={activeDoc?.totalPages || 1}
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
onSave={handleSave}
isRemote={isRemote}
/>
<div className="flex min-h-0 flex-1">
@@ -1235,6 +1252,7 @@ function App() {
disabledTools={disabledTools}
onRunOCR={handleRunOCR}
isOCRLoading={isOCRLoading}
isRemote={isRemote}
/>
<div className="flex min-w-0 flex-1 flex-col">
@@ -1611,6 +1629,7 @@ function App() {
onClose={() => setMergeModalOpen(false)}
onMergeComplete={handleMergeCompleted}
apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'}
initialFile={mergeInitialFile}
/>
</div>
);
+21
View File
@@ -15,6 +15,7 @@ interface MergePDFModalProps {
onClose: () => void;
onMergeComplete: (docInfo: any) => void;
apiBaseUrl?: string;
initialFile?: File | null;
}
export const MergePDFModal: React.FC<MergePDFModalProps> = ({
@@ -22,6 +23,7 @@ export const MergePDFModal: React.FC<MergePDFModalProps> = ({
onClose,
onMergeComplete,
apiBaseUrl = 'http://localhost:8000',
initialFile = null,
}) => {
const [files, setFiles] = useState<FileItem[]>([]);
const [outputFilename, setOutputFilename] = useState<string>('merged_document.pdf');
@@ -30,6 +32,25 @@ export const MergePDFModal: React.FC<MergePDFModalProps> = ({
const [mergedResult, setMergedResult] = useState<any | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (isOpen) {
if (initialFile) {
setFiles([{
id: `initial-${Date.now()}`,
file: initialFile,
pagesMode: 'all',
customPages: '',
}]);
} else {
setFiles([]);
}
setOutputFilename('merged_document.pdf');
setError(null);
setMergedResult(null);
setIsMerging(false);
}
}, [isOpen, initialFile]);
if (!isOpen) return null;
const handleAddFiles = (selectedFiles: FileList | null) => {
+4 -3
View File
@@ -112,6 +112,7 @@ interface ToolRailProps {
disabledTools?: Set<ToolId>;
onRunOCR?: () => void;
isOCRLoading?: boolean;
isRemote?: boolean;
}
const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; isLoading?: boolean; onClick: () => void }> = ({ t, active, disabled, isLoading, onClick }) => (
@@ -139,7 +140,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; is
</CustomButton>
);
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools, onRunOCR, isOCRLoading }) => {
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools, onRunOCR, isOCRLoading, isRemote }) => {
const pickTool = (id: ToolId) => {
if (disabledTools?.has(id)) {
return;
@@ -151,7 +152,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
return (
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-border-primary bg-bg-primary scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
{TOOLS.map((t, i) =>
{TOOLS.filter(t => !isRemote || t === 'divider' || t.id !== 'create_pdf').map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-border-primary" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} isLoading={t.id === 'ocr' && isOCRLoading} onClick={() => pickTool(t.id)} />,
@@ -174,7 +175,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
>
<div className="text-[12px]">
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
{TOOLS.filter((t): t is ToolDef => t !== 'divider').map((t) => (
{TOOLS.filter((t): t is ToolDef => t !== 'divider' && (!isRemote || t.id !== 'create_pdf')).map((t) => (
<div key={t.id} className="flex items-center justify-between px-1 py-0.5">
<span className="text-text-secondary">{t.label}</span>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
+4 -3
View File
@@ -39,6 +39,7 @@ interface TopBarProps {
canPrint?: boolean;
canExport?: boolean;
canAssemble?: boolean;
isRemote?: boolean;
}
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -47,7 +48,7 @@ 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, onMergePDF, isEncrypted, onUpload, onNewBlankPDF, onSave,
canPrint = true, canExport = true, canAssemble = true,
canPrint = true, canExport = true, canAssemble = true, isRemote = false,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -81,8 +82,8 @@ 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>
{!isRemote && <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>}
{!isRemote && <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>
@@ -77,7 +77,7 @@ export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({ state,
</CustomButton>
<CustomButton
variant="secondary"
variant="subtle"
onClick={handleDiscard}
disabled={isSaving}
className="rounded-[8px] px-4 font-semibold text-[#dc2626] hover:bg-[#fdecec]"
@@ -33,7 +33,6 @@ interface DocumentEditorProps {
*/
const EditableParagraphBlock: React.FC<{
block: ParagraphBlock;
isSelected: boolean;
onSelectBlock: (blockId: string, runId?: string) => void;
onUpdateParagraphText: (blockId: string, text: string) => void;
onAddParagraph: (afterBlockId?: string) => void;
@@ -41,7 +40,6 @@ const EditableParagraphBlock: React.FC<{
onFormattingShortcut: (type: 'bold' | 'italic' | 'underline' | 'undo' | 'redo' | 'pageBreak') => void;
}> = ({
block,
isSelected,
onSelectBlock,
onUpdateParagraphText,
onAddParagraph,
@@ -782,8 +780,6 @@ 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}`}
@@ -875,7 +871,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<EditableParagraphBlock
key={block.id}
block={block as ParagraphBlock}
isSelected={isSelected}
onSelectBlock={onSelectBlock}
onUpdateParagraphText={onUpdateParagraphText}
onAddParagraph={onAddParagraph}
+2 -3
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect } from 'react';
import { gatewayService } from '../lib/gatewayService';
interface CanvasLayerProps {
@@ -23,11 +23,10 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
onRenderComplete,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [vectorOps, setVectorOps] = useState<any[] | null>(null);
useEffect(() => {
gatewayService.getPageDisplayList(documentId, pageIndex)
.then(ops => setVectorOps(ops))
.catch(console.error);
}, [documentId, pageIndex]);