Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c79f83e9 | ||
|
|
2cef7b12c0 | ||
|
|
84d215ebd7 | ||
|
|
c94ffbf73d |
+1
-1
@@ -1 +1 @@
|
||||
VITE_GATEWAY_URL=http://127.0.0.1:8765
|
||||
VITE_GATEWAY_URL=https://pdfapi-dev.maskantech.in
|
||||
|
||||
+16
-75
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user