Merge pull request 'feat: implement PDF viewer components and scaffolding for interactive document editing' (#78) from azeem into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/78
This commit is contained in:
furqan
2026-07-10 06:53:32 +00:00
6 changed files with 49 additions and 81 deletions
+32 -21
View File
@@ -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>
);
}
+5 -5
View File
@@ -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 -2
View File
@@ -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);
+2 -30
View File
@@ -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>
);
};
+6 -20
View File
@@ -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 = {
+3 -3
View File
@@ -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);
}
};