4 Commits
14 changed files with 76 additions and 403 deletions
+1 -1
View File
@@ -1 +1 @@
VITE_GATEWAY_URL=http://127.0.0.1:8765
VITE_GATEWAY_URL=https://pdfapi-dev.maskantech.in
+16 -75
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { TopBar } from './components/TopBar';
import { Sidebar } from './components/Sidebar';
import { ToolRail } from './components/ToolRail';
import { Toolbar } from './components/Toolbar';
import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel';
@@ -65,7 +65,6 @@ function App() {
const [zoom, setZoom] = useState(1.0);
const [activeTool, setActiveTool] = useState<ToolId>('select');
const [activeGroup, setActiveGroup] = useState<import('./lib/tools').ToolGroup>('primary');
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
const [currentPage, setCurrentPage] = useState(0);
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
@@ -89,34 +88,14 @@ 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') {
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();
setMergeModalOpen(true);
setActiveTool('select');
}
}, [activeTool, isRemote, activeDoc, selectedDocId]);
}, [activeTool]);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
@@ -188,6 +167,10 @@ 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);
@@ -410,8 +393,6 @@ function App() {
.catch(() => setBackendHealthy(false));
}, []);
const hasImportedRemoteRef = useRef(false);
useEffect(() => {
(async () => {
try {
@@ -422,13 +403,7 @@ function App() {
const token = params.get('token');
const resourceId = params.get('resource_id');
const isRemoteContext = Boolean(streamUrl && uploadUrl && token);
if (isRemoteContext) {
if (hasImportedRemoteRef.current) {
setIsLoading(false);
return;
}
hasImportedRemoteRef.current = true;
if (streamUrl && uploadUrl && token) {
try {
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || '';
const res = await fetch(`${gatewayUrl}/documents/import-remote`, {
@@ -453,8 +428,6 @@ function App() {
console.error('Remote import failed:', err);
setImportError('Failed to open the remote document.');
}
setIsLoading(false);
return;
}
const docs = await gatewayService.listDocuments();
@@ -1010,14 +983,7 @@ function App() {
setProtectModalState(null);
setIsInspectorOpen(true);
setInspectorTab('properties');
if (isRemote) {
await gatewayService.exportRemoteDocument(targetDocId, urlToken);
const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*';
window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin);
} else {
setExportModalOpen(true);
}
setExportModalOpen(true);
} catch (e: any) {
console.error('Protection failed', e);
throw e;
@@ -1036,14 +1002,7 @@ function App() {
setUnlockModalState(null);
setIsInspectorOpen(true);
setInspectorTab('properties');
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 {
setExportModalOpen(true);
}
setExportModalOpen(true);
} catch (e: any) {
console.error('Unlock failed', e);
throw e;
@@ -1230,7 +1189,7 @@ function App() {
}}
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
onCompare={handleOpenCompareModal}
onMergePDF={() => setActiveTool('merge_pdf')}
onMergePDF={() => setMergeModalOpen(true)}
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
@@ -1247,29 +1206,24 @@ function App() {
totalPages={activeDoc?.totalPages || 1}
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
onSave={handleSave}
isRemote={isRemote}
/>
<div className="flex min-h-0 flex-1">
<Sidebar
<ToolRail
activeTool={activeTool}
activeGroup={activeGroup}
onToolChange={handleToolChange}
onGroupChange={setActiveGroup}
hasSignature={!!pendingSignature}
onOpenSignature={() => setSignatureModalOpen(true)}
onOpenAbout={() => setAboutModalOpen(true)}
disabledTools={disabledTools}
onRunOCR={handleRunOCR}
isOCRLoading={isOCRLoading}
isRemote={isRemote}
/>
<div className="flex min-w-0 flex-1 flex-col">
{activeTool !== 'create_pdf' && !createPdfModalOpen && (
<Toolbar
activeGroup={activeGroup}
activeTool={activeTool}
onToolChange={handleToolChange}
settings={toolSettings}
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
@@ -1452,16 +1406,6 @@ function App() {
<p className="text-[14px] font-semibold text-[#18212e]">Import Error</p>
<p className="text-[13px]">{importError}</p>
</div>
) : isRemote && !activeDoc && !importError ? (
<div className="flex h-full flex-col items-center justify-center gap-4 text-[#98a1ad]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-50/50">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[#2563eb] border-t-transparent"></div>
</div>
<div className="text-center">
<p className="text-[14px] font-semibold text-[#18212e]">Loading Document</p>
<p className="text-[12px] mt-1">Preparing your document for editing...</p>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
@@ -1647,12 +1591,9 @@ function App() {
<MergePDFModal
isOpen={mergeModalOpen}
onClose={() => { setMergeModalOpen(false); setMergeInitialFile(null); }}
onMergeComplete={(docInfo) => { setMergeModalOpen(false); forceOpenDocument(docInfo.id); }}
onClose={() => setMergeModalOpen(false)}
onMergeComplete={handleMergeCompleted}
apiBaseUrl={import.meta.env.VITE_GATEWAY_URL || 'http://localhost:8000'}
initialFile={mergeInitialFile}
parentDocumentId={selectedDocId}
isRemote={isRemote}
/>
</div>
);
+25 -112
View File
@@ -15,9 +15,6 @@ interface MergePDFModalProps {
onClose: () => void;
onMergeComplete: (docInfo: any) => void;
apiBaseUrl?: string;
initialFile?: File | null;
parentDocumentId?: string;
isRemote?: boolean;
}
export const MergePDFModal: React.FC<MergePDFModalProps> = ({
@@ -25,38 +22,17 @@ export const MergePDFModal: React.FC<MergePDFModalProps> = ({
onClose,
onMergeComplete,
apiBaseUrl = 'http://localhost:8000',
initialFile = null,
parentDocumentId,
isRemote = false,
}) => {
const [files, setFiles] = useState<FileItem[]>([]);
const [outputFilename, setOutputFilename] = useState<string>('merged_document.pdf');
const [isDownloading, setIsDownloading] = useState(false);
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);
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 = React.useCallback((selectedFiles: FileList | null) => {
const handleAddFiles = (selectedFiles: FileList | null) => {
if (!selectedFiles || selectedFiles.length === 0) return;
setError(null);
const newItems: FileItem[] = Array.from(selectedFiles).map((file) => ({
@@ -66,38 +42,7 @@ export const MergePDFModal: React.FC<MergePDFModalProps> = ({
customPages: '',
}));
setFiles((prev) => [...prev, ...newItems]);
}, []);
React.useEffect(() => {
if (!isRemote) return;
const handleMessage = async (event: MessageEvent) => {
if (event.data?.type === 'DOCQUBE_FILE_SELECTED') {
const { stream_url, token, filename } = event.data.payload;
try {
setIsDownloading(true);
const response = await fetch(stream_url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to fetch file');
const blob = await response.blob();
const file = new File([blob], filename || 'document.pdf', { type: 'application/pdf' });
handleAddFiles([file] as unknown as FileList);
} catch (err) {
console.error('Failed to download file from DocQube:', err);
setError('Failed to download file from DocQube');
} finally {
setIsDownloading(false);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [isRemote, handleAddFiles]);
if (!isOpen) return null;
};
const handleMoveUp = (index: number) => {
if (index <= 0) return;
@@ -166,9 +111,6 @@ export const MergePDFModal: React.FC<MergePDFModalProps> = ({
});
formData.append('manifest', JSON.stringify(manifest));
formData.append('output_filename', outputFilename || 'merged_document.pdf');
if (parentDocumentId) {
formData.append('parent_document_id', parentDocumentId);
}
const response = await fetch(`${apiBaseUrl}/documents/merge`, {
method: 'POST',
@@ -284,58 +226,29 @@ export const MergePDFModal: React.FC<MergePDFModalProps> = ({
</div>
)}
{/* Upload Dropzones */}
<div className="flex flex-col sm:flex-row gap-4 w-full">
<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 flex-1"
>
<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>
{/* 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>
{isRemote && (
<div
onClick={() => {
if (!isDownloading) {
window.parent.postMessage({ type: 'DOCQUBE_REQUEST_FILE_PICKER' }, '*');
}
}}
className={`flex flex-col items-center justify-center p-6 border-2 border-dashed border-border-primary hover:border-brand-primary rounded-xl bg-bg-secondary/40 hover:bg-bg-secondary transition-all text-center flex-1 ${isDownloading ? 'opacity-70 cursor-not-allowed' : 'cursor-pointer group'}`}
>
<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">
{isDownloading ? (
<SpinnerIcon size={22} className="animate-spin" />
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
)}
</div>
<span className="mt-3 text-sm font-bold text-text-primary">
{isDownloading ? 'Downloading...' : 'Pull from DocQube'}
</span>
<span className="text-xs text-text-secondary mt-0.5">
{isDownloading ? 'Please wait' : 'Select a PDF from your Drive'}
</span>
</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 */}
-135
View File
@@ -1,135 +0,0 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId, ToolGroup } from '../lib/tools';
import { Popover } from './ui';
import {
SelectIcon, PanIcon, DrawIcon, TextBoxIcon,
SignatureIcon, PagesIcon, InfoIcon, HelpIcon
} from './icons';
interface SidebarProps {
activeTool: ToolId;
activeGroup: ToolGroup;
onToolChange: (t: ToolId) => void;
onGroupChange: (g: ToolGroup) => void;
hasSignature: boolean;
onOpenSignature: () => void;
onOpenAbout: () => void;
disabledTools?: Set<ToolId>;
}
interface GroupDef {
id: ToolGroup;
label: string;
icon: React.ReactNode;
}
const GROUPS: GroupDef[] = [
{ id: 'annotations', label: 'Annotations', icon: <DrawIcon /> },
{ id: 'edit', label: 'Edit', icon: <TextBoxIcon /> },
{ id: 'security', label: 'Security', icon: <SignatureIcon /> },
{ id: 'organize', label: 'Organize', icon: <PagesIcon /> },
];
const SidebarButton: React.FC<{
label: string;
icon: React.ReactNode;
active: boolean;
disabled?: boolean;
onClick: () => void;
}> = ({ label, icon, active, disabled, onClick }) => (
<CustomButton variant="unstyled"
title={disabled ? `${label} (Disabled)` : label}
aria-label={label}
aria-pressed={active}
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-text-tertiary opacity-40 dark:text-zinc-600 dark:opacity-60'
: active
? 'bg-brand-secondary text-brand-primary'
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
>
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: 'var(--brand-primary)' }} />}
{React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 }) : icon}
<span className="text-[10px] font-medium leading-none tracking-tight">{label}</span>
</CustomButton>
);
export const Sidebar: React.FC<SidebarProps> = ({
activeTool, activeGroup, onToolChange, onGroupChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools
}) => {
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' }}>
{/* Primary Tools */}
<SidebarButton
label="Select"
icon={<SelectIcon />}
active={activeTool === 'select'}
disabled={disabledTools?.has('select')}
onClick={() => {
onGroupChange('primary');
onToolChange('select');
}}
/>
<SidebarButton
label="Pan"
icon={<PanIcon />}
active={activeTool === 'pan'}
disabled={disabledTools?.has('pan')}
onClick={() => {
onGroupChange('primary');
onToolChange('pan');
}}
/>
<div className="my-1 h-px w-10 shrink-0 bg-border-primary" />
{/* Tool Groups */}
{GROUPS.map(g => (
<SidebarButton
key={g.id}
label={g.label}
icon={g.icon}
active={activeGroup === g.id}
onClick={() => {
onGroupChange(g.id);
// Default tools for groups when clicked
if (g.id === 'annotations') onToolChange('highlight');
if (g.id === 'edit') onToolChange('edit_text');
if (g.id === 'security') onToolChange('signature');
if (g.id === 'organize') onToolChange('create_pdf');
}}
/>
))}
<div className="flex-1 shrink-0 min-h-[16px]" />
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary">
<InfoIcon size={19} />
</CustomButton>
<Popover
align="left"
width={210}
trigger={(open) => (
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-bg-tertiary text-text-primary' : 'text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary'}`}>
<HelpIcon size={19} />
</CustomButton>
)}
>
<div className="text-[12px]">
<p className="mb-1.5 px-1 font-bold text-text-primary">Shortcuts</p>
<div className="my-1 h-px bg-border-primary" />
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-text-secondary">Undo / Redo</span>
<kbd className="rounded border border-border-primary bg-bg-secondary px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
</div>
</div>
</Popover>
</nav>
);
};
+3 -4
View File
@@ -112,7 +112,6 @@ 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 }) => (
@@ -140,7 +139,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, isRemote }) => {
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools, onRunOCR, isOCRLoading }) => {
const pickTool = (id: ToolId) => {
if (disabledTools?.has(id)) {
return;
@@ -152,7 +151,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.filter(t => !isRemote || t === 'divider' || t.id !== 'create_pdf').map((t, i) =>
{TOOLS.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)} />,
@@ -175,7 +174,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' && (!isRemote || t.id !== 'create_pdf')).map((t) => (
{TOOLS.filter((t): t is ToolDef => t !== 'divider').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>
+13 -31
View File
@@ -10,9 +10,7 @@ import {
} from './icons';
interface ToolbarProps {
activeGroup: import('../lib/tools').ToolGroup;
activeTool: ToolId;
onToolChange: (t: ToolId) => void;
settings: ToolSettings;
onSettingsChange: (patch: Partial<ToolSettings>) => void;
onOpenSignature: () => void;
@@ -94,14 +92,6 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
},
};
const GROUP_TOOLS: Record<import('../lib/tools').ToolGroup, ToolId[]> = {
primary: ['select', 'pan'],
annotations: ['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox'],
edit: ['edit_text', 'ocr', 'stream_edit'],
security: ['signature', 'stamp', 'watermark', 'redact'],
organize: ['create_pdf', 'merge_pdf']
};
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-brand-primary' : 'text-text-secondary'}`}>{children}</span>
);
@@ -111,14 +101,13 @@ const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary" />;
export const Toolbar: React.FC<ToolbarProps> = ({
activeGroup, activeTool, onToolChange, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
redactionMode = 'area', onRedactionModeChange,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages, onOpenWatermark,
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
}) => {
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
const toolsInGroup = GROUP_TOOLS[activeGroup] || [];
if (selectedAnnotation) {
const selectedMeta = TOOL_META[selectedAnnotation.type as ToolId];
@@ -167,27 +156,20 @@ export const Toolbar: React.FC<ToolbarProps> = ({
return (
<div
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary overflow-x-auto"
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-border-primary bg-bg-secondary"
style={{ paddingLeft: '16px', paddingRight: '16px' }}
>
<div className="flex shrink-0 items-center gap-1">
{toolsInGroup.map((toolId) => {
const t = TOOL_META[toolId];
const isActive = activeTool === toolId;
return (
<button
key={toolId}
onClick={() => onToolChange(toolId)}
className={`flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors ${
isActive ? 'bg-brand-secondary text-brand-primary' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
}`}
title={t.label}
>
{t.icon}
<span className="text-[12px] font-semibold">{t.label}</span>
</button>
);
})}
<div className="flex shrink-0 items-center gap-2">
<span
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
style={{
background: isRedact ? 'var(--brand-tertiary)' : 'var(--brand-secondary)',
color: isRedact ? 'var(--brand-primary)' : 'var(--brand-primary)',
}}
>
{meta?.icon}
</span>
<span className="text-[13px] font-bold text-text-primary">{meta?.label}</span>
</div>
<Divider />
+3 -4
View File
@@ -39,7 +39,6 @@ interface TopBarProps {
canPrint?: boolean;
canExport?: boolean;
canAssemble?: boolean;
isRemote?: boolean;
}
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -48,7 +47,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, isRemote = false,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -82,8 +81,8 @@ export const TopBar: React.FC<TopBarProps> = ({
)}
>
<div className="flex flex-col text-[13px]">
{!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="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>
@@ -77,7 +77,7 @@ export const UnsavedChangesModal: React.FC<UnsavedChangesModalProps> = ({ state,
</CustomButton>
<CustomButton
variant="subtle"
variant="secondary"
onClick={handleDiscard}
disabled={isSaving}
className="rounded-[8px] px-4 font-semibold text-[#dc2626] hover:bg-[#fdecec]"
@@ -33,6 +33,7 @@ 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;
@@ -40,6 +41,7 @@ const EditableParagraphBlock: React.FC<{
onFormattingShortcut: (type: 'bold' | 'italic' | 'underline' | 'undo' | 'redo' | 'pageBreak') => void;
}> = ({
block,
isSelected,
onSelectBlock,
onUpdateParagraphText,
onAddParagraph,
@@ -780,6 +782,8 @@ 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}`}
@@ -871,6 +875,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<EditableParagraphBlock
key={block.id}
block={block as ParagraphBlock}
isSelected={isSelected}
onSelectBlock={onSelectBlock}
onUpdateParagraphText={onUpdateParagraphText}
onAddParagraph={onAddParagraph}
-23
View File
@@ -18,29 +18,6 @@ export type ToolId =
| 'watermark'
| 'merge_pdf';
export type ToolGroup = 'primary' | 'annotations' | 'edit' | 'security' | 'organize';
export const TOOL_GROUP_MAPPING: Record<ToolId, ToolGroup> = {
select: 'primary',
pan: 'primary',
highlight: 'annotations',
underline: 'annotations',
strikeout: 'annotations',
squiggly: 'annotations',
draw: 'annotations',
comment: 'annotations',
textbox: 'annotations',
edit_text: 'edit',
ocr: 'edit',
stream_edit: 'edit',
signature: 'security',
stamp: 'security',
watermark: 'security',
redact: 'security',
create_pdf: 'organize',
merge_pdf: 'organize',
};
export interface ToolSettings {
highlightColor: string;
highlightOpacity: number;
+3
View File
@@ -9,6 +9,9 @@ export function useTheme() {
if (savedTheme) {
return savedTheme;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
return 'light';
});
+4 -3
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import { gatewayService } from '../lib/gatewayService';
interface CanvasLayerProps {
@@ -23,10 +23,11 @@ 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]);
@@ -65,7 +66,7 @@ export const CanvasLayer: React.FC<CanvasLayerProps> = ({
};
img.src = imageUrl;
}, [imageUrl, zoom, rotation, width, height, onRenderComplete]);
}, [imageUrl, zoom, rotation, width, height, onRenderComplete, vectorOps]);
return (
<canvas
-7
View File
@@ -383,7 +383,6 @@ async def merge_documents(
files: list[UploadFile] = File(...),
manifest: str = Form(default="[]"),
output_filename: str = Form(default="merged.pdf"),
parent_document_id: str = Form(default=""),
) -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
@@ -444,12 +443,6 @@ async def merge_documents(
pdfengine = engine.require()
doc = pdfengine.PdfDocument.load_from_memory(merged_bytes, "")
info = document_store.add_document(output_filename, merged_bytes, doc)
if parent_document_id:
parent_doc = document_store.get_document(parent_document_id)
if parent_doc and "remote_context" in parent_doc:
info["remote_context"] = parent_doc["remote_context"]
return make_document_response(info)
except Exception as e:
raise HTTPException(
+2 -7
View File
@@ -77,13 +77,8 @@ async def export_remote_document(document_id: str, body: ExportRemoteRequest | N
)
try:
# ALWAYS use the stored bytes_data, which accurately reflects the current state (including encryption).
# doc.save_full_for_export() drops encryption or corrupts the output when exporting a protected PDF.
bytes_data = d.get("bytes_data")
if not bytes_data:
doc = d["doc_instance"]
bytes_data = doc.save_full_for_export()
doc = d["doc_instance"]
bytes_data = doc.save_full_for_export()
filename = d["filename"]
if not filename.endswith(".pdf"):
filename += ".pdf"