feat: implement PDF viewer components and scaffolding for interactive document editing
This commit is contained in:
+32
-21
@@ -7,7 +7,7 @@ import type { InspectorTab } from './components/InspectorPanel';
|
||||
import { SignatureModal } from './components/SignatureModal';
|
||||
import { RedactPagesModal } from './components/RedactPagesModal';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { ToastViewport } from './components/ui';
|
||||
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
import { PDFViewer } from './viewer/PDFViewer';
|
||||
@@ -19,7 +19,7 @@ import { PasswordModal } from './components/PasswordModal';
|
||||
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
|
||||
import { viewportRectToPdf } from './lib/coordinateMapping';
|
||||
import type { Rect } from './lib/coordinateMapping';
|
||||
import { toast } from './lib/toast';
|
||||
|
||||
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
|
||||
import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
|
||||
|
||||
@@ -39,8 +39,7 @@ function App() {
|
||||
|
||||
const permissions = activeDoc?.permissions ?? null;
|
||||
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
|
||||
const denyToast = (label: string) =>
|
||||
toast(`${label} is not permitted by this document's restrictions`, 'error');
|
||||
const denyToast = (_label: string) => {};
|
||||
const disabledTools = new Set<ToolId>();
|
||||
if (!can('canAnnotate'))
|
||||
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
|
||||
@@ -86,15 +85,21 @@ function App() {
|
||||
if (!canUndo) return;
|
||||
preservePageRef.current = true;
|
||||
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
|
||||
toast('Undo', 'info', 1200);
|
||||
|
||||
};
|
||||
const redo = () => {
|
||||
if (!canRedo) return;
|
||||
preservePageRef.current = true;
|
||||
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
|
||||
toast('Redo', 'info', 1200);
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hist.stack.length > 0) {
|
||||
localStorage.setItem('pdf_hist', JSON.stringify(hist));
|
||||
}
|
||||
}, [hist]);
|
||||
|
||||
useEffect(() => {
|
||||
gatewayService.getHealth()
|
||||
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
|
||||
@@ -107,7 +112,23 @@ function App() {
|
||||
setIsLoading(true);
|
||||
const docs = await gatewayService.listDocuments();
|
||||
setDocuments(docs);
|
||||
if (docs.length > 0) openDocument(docs[0].id);
|
||||
if (docs.length > 0) {
|
||||
// Attempt to load from localStorage, otherwise fallback to the most recent document
|
||||
const savedHist = localStorage.getItem('pdf_hist');
|
||||
if (savedHist) {
|
||||
try {
|
||||
const parsedHist = JSON.parse(savedHist);
|
||||
if (parsedHist.stack && parsedHist.stack.length > 0 && docs.some((d: any) => d.id === parsedHist.stack[parsedHist.index])) {
|
||||
setHist(parsedHist);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse history', e);
|
||||
}
|
||||
}
|
||||
// Default to the most recent document (last in list)
|
||||
openDocument(docs[docs.length - 1].id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load documents', e);
|
||||
} finally {
|
||||
@@ -217,18 +238,16 @@ function App() {
|
||||
gatewayService.listDocuments().then(setDocuments).catch(() => {});
|
||||
};
|
||||
|
||||
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
|
||||
const applyOps = async (ops: EditOperation[], _successMsg?: string) => {
|
||||
if (!selectedDocId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await gatewayService.applyEdits(selectedDocId, ops);
|
||||
if (result.success) {
|
||||
adoptNewDocument(result.newDocumentId);
|
||||
if (successMsg) toast(successMsg, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Edit failed', e);
|
||||
toast('Edit failed — check the gateway connection', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -398,9 +417,7 @@ function App() {
|
||||
|
||||
if (newRedactions.length > 0) {
|
||||
setPendingRedactions(p => [...p, ...newRedactions]);
|
||||
toast(`Marked ${newRedactions.length} page${newRedactions.length > 1 ? 's' : ''} for redaction`, 'success');
|
||||
} else {
|
||||
toast('No valid pages found in range', 'error');
|
||||
}
|
||||
setRedactPagesModalOpen(false);
|
||||
};
|
||||
@@ -424,7 +441,7 @@ function App() {
|
||||
const handleDeletePage = (pageIndex: number) => {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canAssemble')) { denyToast('Deleting pages'); return; }
|
||||
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; }
|
||||
if (activeDoc.totalPages <= 1) { return; }
|
||||
setConfirmState({
|
||||
title: 'Delete page?',
|
||||
message: `Page ${pageIndex + 1} will be removed from this document.`,
|
||||
@@ -479,14 +496,13 @@ function App() {
|
||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||
setDocuments((prev) => [newDoc, ...prev]);
|
||||
openDocument(newDoc.id);
|
||||
toast(`Opened ${newDoc.filename}`, 'success');
|
||||
|
||||
setPasswordPrompt(null);
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordError) {
|
||||
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
||||
} else {
|
||||
console.error('Upload failed', e);
|
||||
toast('Upload failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -498,10 +514,8 @@ function App() {
|
||||
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
||||
try {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
toast('Exported', 'success');
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
toast('Export failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -509,7 +523,7 @@ function App() {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canPrint')) { denyToast('Printing'); return; }
|
||||
try {
|
||||
toast('Preparing print...', 'info');
|
||||
|
||||
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -527,7 +541,6 @@ function App() {
|
||||
document.body.appendChild(iframe);
|
||||
} catch (e) {
|
||||
console.error('Print failed', e);
|
||||
toast('Print failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -752,7 +765,6 @@ function App() {
|
||||
setPendingSignature({ url, aspect });
|
||||
setSignatureModalOpen(false);
|
||||
setActiveTool('signature');
|
||||
toast('Signature ready — click on the page to place it', 'info');
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -774,7 +786,6 @@ function App() {
|
||||
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
||||
onClose={() => setPasswordPrompt(null)}
|
||||
/>
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
|
||||
/* ─── Types ─────────────────────────────────────────────── */
|
||||
interface SignatureModalProps {
|
||||
@@ -224,19 +224,19 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
let aspect = 3;
|
||||
|
||||
if (mode === 'draw') {
|
||||
if (!hasInk.current) { toast('Draw your signature first', 'error'); return; }
|
||||
if (!hasInk.current) return;
|
||||
const c = canvasRef.current!;
|
||||
dataUrl = c.toDataURL('image/png');
|
||||
aspect = c.width / c.height;
|
||||
} else if (mode === 'type') {
|
||||
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
|
||||
if (!typed.trim()) return;
|
||||
const res = buildTypedCanvas();
|
||||
dataUrl = res.dataUrl; aspect = res.aspect;
|
||||
} else if (mode === 'upload') {
|
||||
if (!uploaded) { toast('Upload a signature image first', 'error'); return; }
|
||||
if (!uploaded) return;
|
||||
dataUrl = uploaded.url; aspect = uploaded.aspect;
|
||||
} else if (mode === 'saved') {
|
||||
toast('Click "Use" on a saved signature', 'error'); return;
|
||||
return;
|
||||
}
|
||||
|
||||
saveToStorage(dataUrl, aspect);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { ToolId } from '../lib/tools';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
import { Popover } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||
@@ -86,7 +86,6 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
|
||||
const pickTool = (id: ToolId) => {
|
||||
if (disabledTools?.has(id)) {
|
||||
toast("This tool is not permitted by this document's restrictions", 'error');
|
||||
return;
|
||||
}
|
||||
onToolChange(id);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { subscribeToasts, dismissToast } from '../lib/toast';
|
||||
import type { ToastItem } from '../lib/toast';
|
||||
import { XIcon, CheckIcon, InfoIcon } from './icons';
|
||||
import { XIcon } from './icons';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
interface PopoverProps {
|
||||
@@ -189,30 +187,4 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
|
||||
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const toastStyles: Record<ToastItem['kind'], string> = {
|
||||
info: 'border-[#ebedf0] bg-[#18212e] text-white',
|
||||
success: 'border-transparent bg-[#16a34a] text-white',
|
||||
error: 'border-transparent bg-[#dc2626] text-white',
|
||||
};
|
||||
export const ToastViewport: React.FC = () => {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-5 left-1/2 z-[300] flex -translate-x-1/2 flex-col items-center gap-2">
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto flex items-center gap-2 rounded-full border px-4 py-2 text-[12.5px] font-semibold shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${toastStyles[t.kind]}`}
|
||||
style={{ animation: 'toastIn 0.18s ease-out' }}
|
||||
>
|
||||
{t.kind === 'success' && <CheckIcon size={15} />}
|
||||
{t.kind === 'error' && <XIcon size={15} />}
|
||||
{t.kind === 'info' && <InfoIcon size={15} />}
|
||||
<span>{t.message}</span>
|
||||
<CustomButton variant="unstyled" className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></CustomButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
␍
|
||||
@@ -12,7 +12,7 @@ import type { Rect } from '../lib/coordinateMapping';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
|
||||
import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
import { RedactionLayer } from './RedactionLayer';
|
||||
import { StreamEditLayer } from './StreamEditLayer';
|
||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
||||
@@ -325,17 +325,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
||||
// This is still called on Ctrl+C for select mode
|
||||
if (activeTool === 'select') {
|
||||
if (!canCopy) {
|
||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
||||
return;
|
||||
}
|
||||
if (!canCopy) return;
|
||||
if (text.trim()) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
|
||||
() => toast('Copy failed — clipboard unavailable', 'error'),
|
||||
);
|
||||
} else {
|
||||
toast('No selectable text in that area', 'info');
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -386,14 +378,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
const { pageIndex, text, bbox, lines } = textSelection;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
if (!canCopy) {
|
||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
||||
break;
|
||||
}
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
|
||||
() => toast('Copy failed — clipboard unavailable', 'error')
|
||||
);
|
||||
if (!canCopy) break;
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
break;
|
||||
case 'highlight': {
|
||||
const newAnno: Annotation = {
|
||||
@@ -432,7 +418,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
});
|
||||
break;
|
||||
case 'edit':
|
||||
toast('Please select the Edit Text tool from the toolbar to edit text content', 'info');
|
||||
|
||||
break;
|
||||
case 'comment': {
|
||||
const newAnno: Annotation = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { TextObjectResponse } from '../lib/gatewayService';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
|
||||
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||||
|
||||
@@ -90,7 +90,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
||||
if (newText === obj.text) return;
|
||||
try {
|
||||
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
|
||||
toast('Text updated successfully', 'success');
|
||||
|
||||
if (res.newDocumentId && onDocumentChanged) {
|
||||
onDocumentChanged(res.newDocumentId);
|
||||
} else {
|
||||
@@ -102,7 +102,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
||||
onEditSuccess();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(`Failed to update text: ${e.message}`, 'error');
|
||||
console.error('Failed to update text:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user