fix: updated ui and functionalities

This commit is contained in:
Furqan-14
2026-06-10 11:41:58 +05:30
parent f8d7d4c042
commit ef544263a5
26 changed files with 2568 additions and 2188 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>PDF Editor</title>
</head>
<body>
<div id="root"></div>
+12 -21
View File
@@ -59,6 +59,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -268,27 +269,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
"integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1138,6 +1118,7 @@
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1148,6 +1129,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1207,6 +1189,7 @@
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3",
@@ -1437,6 +1420,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1527,6 +1511,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -1674,6 +1659,7 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -2569,6 +2555,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2629,6 +2616,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2800,6 +2788,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2885,6 +2874,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -3009,6 +2999,7 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+418 -448
View File
@@ -1,493 +1,431 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { TopBar } from './components/TopBar';
import { ToolRail } from './components/ToolRail';
import { Toolbar } from './components/Toolbar';
import { Sidebar } from './components/Sidebar';
import { LeftSidebar } from './components/LeftSidebar';
import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { ToastViewport, ConfirmDialog } from './components/ui';
import type { ConfirmOptions } from './components/ui';
import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo, SearchResult, EditOperation } from './lib/gatewayService';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
import { wasmLoader } from './lib/wasmLoader';
import { WasmInspector } from './components/WasmInspector';
import './App.css';
import { toast } from './lib/toast';
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
import type { ToolId, ToolSettings } from './lib/tools';
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
function App() {
const viewerRef = useRef<PDFViewerRef>(null);
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string>('');
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
// Settings
const [zoom, setZoom] = useState<number>(1.0);
const [activeTool, setActiveTool] = useState<string>('select');
const [highlightColor, setHighlightColor] = useState<string>('#ffeb3b');
const [currentPage, setCurrentPage] = useState<number>(0);
// States
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [sidebarTab, setSidebarTab] = useState<'documents' | 'annotations' | 'outline' | 'settings'>('documents');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [wasmInspectorOpen, setWasmInspectorOpen] = useState<boolean>(false);
const [showToast, setShowToast] = useState(true);
// Search State
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
// Document history powers undo/redo: each edit produces a new document id.
const [hist, setHist] = useState<{ stack: string[]; index: number }>({ stack: [], index: -1 });
const selectedDocId = hist.index >= 0 ? hist.stack[hist.index] : '';
const canUndo = hist.index > 0;
const canRedo = hist.index < hist.stack.length - 1;
const preservePageRef = useRef(false);
// View / tools
const [zoom, setZoom] = useState(1.0);
const [activeTool, setActiveTool] = useState<ToolId>('select');
const [toolSettings, setToolSettings] = useState<ToolSettings>(DEFAULT_TOOL_SETTINGS);
const [currentPage, setCurrentPage] = useState(0);
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
// Data
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [metadata, setMetadata] = useState<DocumentMetadata | null>(null);
const [fonts, setFonts] = useState<FontInfo[]>([]);
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [engineReady, setEngineReady] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// Tool aux state
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
const [confirmState, setConfirmState] = useState<(ConfirmOptions & { onConfirm: () => void }) | null>(null);
// Search
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchResultCount, setSearchResultCount] = useState(0);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
// Live Search with Debounce
useEffect(() => {
const doSearch = async () => {
if (!searchQuery || !selectedDocId) {
setSearchResults([]);
setSearchResultCount(0);
setSearchCurrentMatch(0);
return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, searchQuery);
setSearchResults(results);
setSearchResultCount(results.length);
setSearchCurrentMatch(0);
if (results.length > 0) {
viewerRef.current?.scrollToPage(results[0].pageIndex);
}
} catch (err) {
console.error("Search failed", err);
setSearchResults([]);
setSearchResultCount(0);
setSearchCurrentMatch(0);
}
};
const timeoutId = setTimeout(() => {
doSearch();
}, 300);
return () => clearTimeout(timeoutId);
}, [searchQuery, selectedDocId]);
const handleSearchNext = () => {
if (searchResultCount > 0) {
const nextMatch = (searchCurrentMatch + 1) % searchResultCount;
setSearchCurrentMatch(nextMatch);
viewerRef.current?.scrollToPage(searchResults[nextMatch].pageIndex);
}
/* ----------------------------------------------------- history helpers */
const openDocument = useCallback((id: string) => setHist({ stack: [id], index: 0 }), []);
const pushHistory = (id: string) =>
setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 }));
const undo = () => {
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);
};
const handleSearchPrev = () => {
if (searchResultCount > 0) {
const prevMatch = (searchCurrentMatch - 1 + searchResultCount) % searchResultCount;
setSearchCurrentMatch(prevMatch);
viewerRef.current?.scrollToPage(searchResults[prevMatch].pageIndex);
}
};
// Check Gateway Health on mount
/* ------------------------------------------------------ initial loads */
useEffect(() => {
const checkHealth = async () => {
try {
const health = await gatewayService.getHealth();
setBackendHealthy(health.engine_available || true);
} catch {
setBackendHealthy(false);
}
};
checkHealth();
// Healthy = the gateway answered. Engine availability is a separate concern
// (gateway can be up while the C++ engine bindings aren't built yet).
gatewayService.getHealth()
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
.catch(() => setBackendHealthy(false));
}, []);
// Fetch Documents list
useEffect(() => {
const fetchDocs = async () => {
(async () => {
try {
setIsLoading(true);
const docs = await gatewayService.listDocuments();
setDocuments(docs);
if (docs.length > 0) {
const defaultDoc = docs[0];
setSelectedDocId(defaultDoc.id);
setActiveDoc(defaultDoc);
}
} catch (err) {
console.error('Failed to load documents list', err);
if (docs.length > 0) openDocument(docs[0].id);
} catch (e) {
console.error('Failed to load documents', e);
} finally {
setIsLoading(false);
}
};
fetchDocs();
})();
}, [openDocument]);
useEffect(() => {
wasmLoader.loadEngine().then((i) => console.log(`[WASM] ${i.engineBuildInfo()}`)).catch(() => {});
}, []);
// Load selected document metadata
// Load metadata, annotations, fonts when the active document version changes.
useEffect(() => {
const loadDocMetadata = async () => {
if (!selectedDocId) return;
if (!selectedDocId) return;
let active = true;
(async () => {
try {
setIsLoading(true);
const doc = await gatewayService.getDocument(selectedDocId);
const [doc, backendAnnots, meta, fontList] = await Promise.all([
gatewayService.getDocument(selectedDocId),
gatewayService.getDocumentAnnotations(selectedDocId),
gatewayService.getDocumentMetadata(selectedDocId),
gatewayService.getDocumentFonts(selectedDocId),
]);
if (!active) return;
setActiveDoc(doc);
setCurrentPage(0);
// Fetch document annotations
const backendAnnots = await gatewayService.getDocumentAnnotations(selectedDocId);
// Map backend annotations to frontend format
const frontendAnnots: Annotation[] = backendAnnots.map(a => ({
setMetadata(meta);
setFonts(fontList);
setAnnotations(backendAnnots.map((a): Annotation => ({
id: a.id,
type: a.type as any,
type: a.type,
bbox: { x: a.x, y: a.y, width: a.width, height: a.height },
color: a.color,
author: a.author,
content: a.content,
timestamp: (a as any).timestamp,
pageIndex: a.pageIndex
}));
setAnnotations(frontendAnnots);
} catch (err) {
console.error('Failed to load document metadata', err);
timestamp: a.timestamp,
pageIndex: a.pageIndex,
})));
if (preservePageRef.current) preservePageRef.current = false;
else setCurrentPage(0);
} catch (e) {
console.error('Failed to load document', e);
} finally {
setIsLoading(false);
if (active) setIsLoading(false);
}
};
loadDocMetadata();
})();
return () => { active = false; };
}, [selectedDocId]);
// Load WASM Engine (Simulated Phase 0 loading)
/* ---------------------------------------------------------- live search */
useEffect(() => {
const loadWasm = async () => {
const instance = await wasmLoader.loadEngine();
console.log(`[WASM] ${instance.engineBuildInfo()}`);
};
loadWasm();
}, []);
const t = setTimeout(async () => {
if (!searchQuery || !selectedDocId) {
setSearchResults([]);
setSearchCurrentMatch(0);
return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, searchQuery);
setSearchResults(results);
setSearchCurrentMatch(0);
if (results.length > 0) viewerRef.current?.scrollToPage(results[0].pageIndex);
} catch {
setSearchResults([]);
}
}, searchQuery ? 300 : 0);
return () => clearTimeout(t);
}, [searchQuery, selectedDocId]);
const handleUploadStart = async (file: File) => {
const selectSearchMatch = (i: number) => {
if (i < 0 || i >= searchResults.length) return;
setSearchCurrentMatch(i);
viewerRef.current?.scrollToPage(searchResults[i].pageIndex);
};
/* ------------------------------------------------- keyboard shortcuts */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = ['INPUT', 'TEXTAREA'].includes(target.tagName) || target.isContentEditable;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'f') { e.preventDefault(); setInspectorTab('search'); return; }
if (typing || mod) return;
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()];
if (tool) { setActiveTool(tool); if (tool === 'signature' && !pendingSignature) setSignatureModalOpen(true); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canUndo, canRedo, pendingSignature]);
/* -------------------------------------------------------- edit pipeline */
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
if (!selectedDocId) return;
setIsSaving(true);
try {
const result = await gatewayService.applyEdits(selectedDocId, ops);
if (result.success) {
preservePageRef.current = true;
pushHistory(result.newDocumentId);
gatewayService.listDocuments().then(setDocuments).catch(() => {});
if (successMsg) toast(successMsg, 'success');
}
} catch (e) {
console.error('Edit failed', e);
toast('Edit failed — check the gateway connection', 'error');
} finally {
setIsSaving(false);
}
};
const pageHeightPts = (pageIndex: number) => activeDoc?.pages?.[pageIndex]?.height ?? activeDoc?.pageHeight ?? 792;
/* -------------------------------------- annotation creation (optimistic) */
const handleAnnotationAdded = (a: Annotation) => {
setAnnotations((prev) => [...prev, a]);
if (inspectorTab !== 'notes') setInspectorTab('notes');
const page = a.pageIndex ?? currentPage;
if (a.type === 'highlight') {
applyOps([{
id: a.id, type: 'highlight', pageIndex: page,
data: {
quadPoints: [{
x1: a.bbox.x, y1: a.bbox.y + a.bbox.height,
x2: a.bbox.x + a.bbox.width, y2: a.bbox.y + a.bbox.height,
x3: a.bbox.x + a.bbox.width, y3: a.bbox.y,
x4: a.bbox.x, y4: a.bbox.y,
}],
color: a.color || '#ffff00', opacity: a.opacity ?? 0.5,
author: a.author, content: a.content,
},
}]);
} else if (a.type === 'ink' && a.paths) {
applyOps([{
id: a.id, type: 'freehand', pageIndex: page,
data: { paths: a.paths, color: a.color || '#2563eb', thickness: a.thickness ?? 2 },
}]);
} else if (a.type === 'comment') {
applyOps([{
id: a.id, type: 'comment', pageIndex: page,
data: { x: a.bbox.x, y: a.bbox.y, author: a.author, content: a.content || '', timestamp: a.timestamp },
}]);
}
};
/* ---------------------------------------------- new overlay placements */
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('txt'), type: 'free_text', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text, fontSize: toolSettings.fontSize, color: toolSettings.textColor },
}], 'Text box added');
setActiveTool('select');
};
const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => {
if (!activeStamp) return;
const fontSize = 22;
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
const height = fontSize * 1.5;
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('stamp'), type: 'text_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
}], `Stamp “${activeStamp.label}” placed`);
};
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => {
if (!pendingSignature) return;
const width = 160;
const height = width / (pendingSignature.aspect || 3);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('sig'), type: 'image_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, imageData: pendingSignature.url },
}], 'Signature placed');
setActiveTool('select');
};
/* ---------------------------------------------------------- page ops */
const handleRotate = () => {
if (!activeDoc) return;
applyOps([{ id: rid('rot'), type: 'page_rotation', pageIndex: currentPage, data: { rotation: 90 } }], 'Page rotated');
};
const handleDeletePage = (pageIndex: number) => {
if (!activeDoc) return;
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; }
setConfirmState({
title: 'Delete page?',
message: `Page ${pageIndex + 1} will be removed from this document.`,
confirmLabel: 'Delete page', danger: true,
onConfirm: () => {
applyOps([{ id: rid('del'), type: 'page_deletion', pageIndex, data: {} }], 'Page deleted');
if (currentPage >= activeDoc.totalPages - 1) setCurrentPage(Math.max(0, activeDoc.totalPages - 2));
},
});
};
const handleReorderPage = (from: number, to: number) => {
if (!activeDoc || to < 0 || to >= activeDoc.totalPages) return;
applyOps([{ id: rid('reorder'), type: 'page_reorder', pageIndex: from, data: { destPageIndex: to } }], 'Page moved');
setCurrentPage(to);
};
const handleRedactArea = (pageIndex: number, bounds: Rect) => {
if (!activeDoc) return;
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
setConfirmState({
title: 'Redact area?',
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
confirmLabel: 'Redact', danger: true,
onConfirm: () => {
applyOps([{
id: rid('redact'), type: 'redaction', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
}], 'Area redacted');
setActiveTool('select');
},
});
};
/* ----------------------------------------------------------- doc-level */
const handleUpload = async (file: File) => {
try {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file);
setDocuments((prev) => [newDoc, ...prev]);
setSelectedDocId(newDoc.id);
setActiveDoc(newDoc);
} catch (err) {
console.error('File upload failed', err);
} finally {
setIsLoading(false);
}
};
const handleAnnotationAdded = async (newAnno: Annotation) => {
setAnnotations((prev) => [...prev, newAnno]);
setSidebarTab('annotations');
if (!selectedDocId || !activeDoc) return;
try {
setIsLoading(true);
let op: EditOperation | null = null;
if (newAnno.type === 'highlight') {
op = {
id: newAnno.id,
type: 'highlight',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
quadPoints: [{
x1: newAnno.bbox.x, y1: newAnno.bbox.y + newAnno.bbox.height,
x2: newAnno.bbox.x + newAnno.bbox.width, y2: newAnno.bbox.y + newAnno.bbox.height,
x3: newAnno.bbox.x + newAnno.bbox.width, y3: newAnno.bbox.y,
x4: newAnno.bbox.x, y4: newAnno.bbox.y
}],
color: newAnno.color || '#ffff00',
opacity: 0.5,
author: newAnno.author,
content: newAnno.content
}
};
} else if (newAnno.type === 'ink' && newAnno.paths) {
op = {
id: newAnno.id,
type: 'freehand',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
paths: newAnno.paths,
color: newAnno.color || '#3b82f6',
thickness: 2.0
}
};
} else if (newAnno.type === 'comment') {
op = {
id: newAnno.id,
type: 'comment',
pageIndex: newAnno.pageIndex ?? currentPage,
data: {
x: newAnno.bbox.x,
y: newAnno.bbox.y,
author: newAnno.author,
content: newAnno.content || '',
timestamp: newAnno.timestamp
}
};
}
if (op) {
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
}
}
} catch (err) {
console.error('Failed to save annotation:', err);
} finally {
setIsLoading(false);
}
};
const handleSaveEdits = async () => {
if (!selectedDocId || !activeDoc || annotations.length === 0) return;
try {
setIsLoading(true);
const operations: any[] = annotations.map((anno) => {
if (anno.type === 'highlight') {
return {
id: anno.id,
type: 'highlight',
pageIndex: anno.pageIndex || 0,
data: {
quadPoints: [{
x1: anno.bbox.x, y1: anno.bbox.y,
x2: anno.bbox.x + anno.bbox.width, y2: anno.bbox.y,
x3: anno.bbox.x, y3: anno.bbox.y + anno.bbox.height,
x4: anno.bbox.x + anno.bbox.width, y4: anno.bbox.y + anno.bbox.height,
}],
color: anno.color || '#ffeb3b',
opacity: 0.5,
author: anno.author,
content: anno.content
}
};
} else if (anno.type === 'ink') {
return {
id: anno.id,
type: 'freehand',
pageIndex: anno.pageIndex || 0,
data: {
paths: anno.paths || [],
color: anno.color || '#3b82f6',
thickness: 2.0
}
};
}
return null;
}).filter(Boolean);
if (operations.length === 0) {
setIsLoading(false);
return;
}
const result = await gatewayService.applyEdits(selectedDocId, operations);
if (result.success) {
setAnnotations([]);
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
}
} catch (err) {
console.error('Failed to save edits:', err);
alert('Failed to save edits. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
const handleRotateClick = async () => {
if (!selectedDocId || !activeDoc) return;
const pageIndex = currentPage;
try {
setIsLoading(true);
const op = {
id: `rot_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_rotation' as const,
pageIndex: pageIndex,
data: { rotation: 90 as const }
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
}
} catch (err) {
console.error('Failed to rotate page:', err);
} finally {
setIsLoading(false);
}
};
const handleDeletePage = async (pageIndex: number) => {
if (!selectedDocId || !activeDoc) return;
if (activeDoc.totalPages <= 1) {
alert("Cannot delete the only page in the document.");
return;
}
if (!confirm(`Are you sure you want to delete Page ${pageIndex + 1}? This cannot be undone.`)) return;
try {
setIsLoading(true);
const op = {
id: `del_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_deletion' as const,
pageIndex: pageIndex,
data: {}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
if (currentPage >= activeDoc.totalPages - 1) {
setCurrentPage(Math.max(0, activeDoc.totalPages - 2));
}
}
} catch (err) {
console.error('Failed to delete page:', err);
} finally {
setIsLoading(false);
}
};
const handleReorderPage = async (pageIndex: number, destPageIndex: number) => {
if (!selectedDocId || !activeDoc) return;
if (destPageIndex < 0 || destPageIndex >= activeDoc.totalPages) return;
try {
setIsLoading(true);
const op = {
id: `reorder_${Math.random().toString(36).substring(2, 11)}`,
type: 'page_reorder' as const,
pageIndex: pageIndex,
data: { destPageIndex: destPageIndex }
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
setCurrentPage(destPageIndex);
}
} catch (err) {
console.error('Failed to reorder page:', err);
} finally {
setIsLoading(false);
}
};
const handleRedactArea = async (pageIndex: number, bounds: { x: number; y: number; width: number; height: number }) => {
if (!selectedDocId || !activeDoc) return;
const pageInfo = activeDoc.pages?.[pageIndex];
const pageHeight = pageInfo ? pageInfo.height : 792;
const x = bounds.x / zoom;
const y = pageHeight - (bounds.y + bounds.height) / zoom;
const width = bounds.width / zoom;
const height = bounds.height / zoom;
if (!confirm("Are you sure you want to permanently redact this area? All text, images, and vectors underneath will be permanently deleted from the file structure. This cannot be undone.")) return;
try {
setIsLoading(true);
const op = {
id: `redact_${Math.random().toString(36).substring(2, 11)}`,
type: 'redaction' as const,
pageIndex: pageIndex,
data: { x, y, width, height, fillColor: '#ffffff' }
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
const docs = await gatewayService.listDocuments();
setDocuments(docs);
setSelectedDocId(result.newDocumentId);
setActiveTool('select');
}
} catch (err) {
console.error('Failed to apply redaction:', err);
openDocument(newDoc.id);
toast(`Opened ${newDoc.filename}`, 'success');
} catch (e) {
console.error('Upload failed', e);
toast('Upload failed', 'error');
} finally {
setIsLoading(false);
}
};
const handleExport = async () => {
if (!selectedDocId || !activeDoc) return;
if (!activeDoc) return;
try {
setIsLoading(true);
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
} catch (err) {
console.error('Failed to export document:', err);
} finally {
setIsLoading(false);
toast('Exported', 'success');
} catch (e) {
console.error('Export failed', e);
toast('Export failed', 'error');
}
};
const toggleInspector = () => {
setIsInspectorOpen((prev) => {
const next = !prev;
setTimeout(() => {
const w = activeDoc?.pageWidth || 612;
const inspectorWidth = next ? 322 : 0;
const avail = window.innerWidth - inspectorWidth - 48;
setZoom(Math.max(0.25, Math.min(3, avail / w)));
}, 50);
return next;
});
};
const fitWidth = () => {
const w = activeDoc?.pageWidth || 612;
const inspectorWidth = isInspectorOpen ? 322 : 0;
const avail = window.innerWidth - inspectorWidth - 48 /*spacing leeway*/;
setZoom(Math.max(0.25, Math.min(3, avail / w)));
};
const navigateToAnnotation = (a: Annotation) => {
if (a.pageIndex !== undefined) viewerRef.current?.scrollToPage(a.pageIndex);
};
/* -------------------------------------------------------------- render */
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-gray-50 font-sans text-gray-900 antialiased">
{/* Top Navigation / Toolbar */}
<Toolbar
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--canvas)] text-[var(--text)] antialiased">
<TopBar
documentName={activeDoc?.filename}
backendHealthy={backendHealthy}
engineReady={engineReady}
zoom={zoom}
onZoomChange={setZoom}
rotation={0}
onRotationChange={handleRotateClick}
backendHealthy={backendHealthy}
onFitWidth={fitWidth}
currentPage={currentPage}
totalPages={activeDoc?.totalPages || 1}
onGoToPage={(p) => viewerRef.current?.scrollToPage(p)}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
isSaving={isSaving}
isDirtySaved={hist.stack.length > 1}
onRotate={handleRotate}
onExport={handleExport}
onSaveEdits={handleSaveEdits}
hasAnnotations={annotations.length > 0}
onUpload={handleUpload}
isInspectorOpen={isInspectorOpen}
onToggleInspector={toggleInspector}
/>
<div className="flex-1 w-full flex overflow-hidden relative">
{/* Left Interactive Sidebar (Tools & Search) */}
<LeftSidebar
activeTool={activeTool}
onActiveToolChange={setActiveTool}
onUploadStart={handleUploadStart}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
searchResultCount={searchResultCount}
searchCurrentMatch={searchCurrentMatch}
onSearchNext={handleSearchNext}
onSearchPrev={handleSearchPrev}
<div className="flex min-h-0 flex-1">
<ToolRail
activeTool={activeTool}
onToolChange={setActiveTool}
hasSignature={!!pendingSignature}
onOpenSignature={() => setSignatureModalOpen(true)}
/>
{/* Main PDF Scroll Viewer Area */}
<div className="flex-1 flex flex-col relative h-full w-full bg-gray-50 overflow-hidden">
{showToast && (
<div className="fixed top-20 left-1/2 transform -translate-x-1/2 bg-white px-6 py-3 rounded-full shadow-lg border border-gray-200 flex items-center gap-3 z-[100] transition-all duration-300">
<span className="text-sm font-medium text-gray-800">Nice work! You completed 2/6 steps!</span>
<button onClick={() => setShowToast(false)} className="text-gray-400 hover:text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<Toolbar
activeTool={activeTool}
settings={toolSettings}
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
/>
{isLoading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4">
<div className="relative w-12 h-12 flex items-center justify-center">
<div className="absolute w-full h-full border-4 border-blue-500/20 rounded-full" />
<div className="absolute w-full h-full border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
<div className="relative min-h-0 flex-1">
{isLoading && !activeDoc ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div className="spinner" />
<p className="text-[12px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">Loading</p>
</div>
<p className="text-sm font-bold tracking-widest uppercase text-blue-500 animate-pulse">Loading Document...</p>
</div>
) : activeDoc ? (
<div className="flex-1 w-full h-full relative">
) : activeDoc ? (
<PDFViewer
ref={viewerRef}
documentId={activeDoc.id}
@@ -497,7 +435,9 @@ function App() {
zoom={zoom}
pagesInfo={activeDoc.pages}
activeTool={activeTool}
highlightColor={highlightColor}
toolSettings={toolSettings}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
annotations={annotations}
searchQuery={searchQuery}
searchResults={searchResults}
@@ -505,39 +445,69 @@ function App() {
onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea}
onPlaceText={handlePlaceText}
onPlaceStamp={handlePlaceStamp}
onPlaceSignature={handlePlaceSignature}
/>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" className="w-12 h-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414-5.414a1 1 0 01.707.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm font-bold">No active document. Please upload a PDF file.</p>
</div>
)}
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[var(--text-dim)]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--surface-3)]">
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.4}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="text-[14px] font-semibold text-[var(--text)]">No document open</p>
<p className="text-[12px]">Open a PDF to start editing.</p>
<label className="mt-2 inline-flex h-10 cursor-pointer items-center gap-2 rounded-[var(--r-md)] bg-[var(--accent)] px-5 text-[13.5px] font-semibold text-white shadow-sm transition-colors hover:bg-[var(--accent-hover)]">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round"><path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" /></svg>
Open PDF
<input type="file" accept=".pdf" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
</label>
</div>
)}
</div>
</div>
{/* Right Interactive Sidebar (Tabs & Outline) */}
<Sidebar
activeTab={sidebarTab as any}
setActiveTab={setSidebarTab}
documents={documents}
selectedDocumentId={selectedDocId}
onSelectDocument={setSelectedDocId}
annotations={annotations}
totalPages={activeDoc?.totalPages || 0}
currentPage={currentPage}
onNavigateToPage={(pageIndex) => {
if (viewerRef.current) {
viewerRef.current.scrollToPage(pageIndex);
}
}}
onDeletePage={handleDeletePage}
onReorderPage={handleReorderPage}
/>
{isInspectorOpen && (
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
documents={documents}
selectedDocumentId={selectedDocId}
onSelectDocument={openDocument}
documentId={activeDoc?.id || ''}
totalPages={activeDoc?.totalPages || 0}
currentPage={currentPage}
sizeBytes={activeDoc?.sizeBytes}
onNavigateToPage={(i) => viewerRef.current?.scrollToPage(i)}
onDeletePage={handleDeletePage}
onReorderPage={handleReorderPage}
annotations={annotations}
onNavigateAnnotation={navigateToAnnotation}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onSelectSearchMatch={selectSearchMatch}
metadata={metadata}
fonts={fonts}
/>
)}
</div>
<SignatureModal
open={signatureModalOpen}
onClose={() => setSignatureModalOpen(false)}
onConfirm={(url, aspect) => {
setPendingSignature({ url, aspect });
setSignatureModalOpen(false);
setActiveTool('signature');
toast('Signature ready — click on the page to place it', 'info');
}}
/>
<ConfirmDialog state={confirmState} onClose={() => setConfirmState(null)} />
<ToastViewport />
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
import React from 'react';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
import { Thumbnail } from './Thumbnail';
import { EmptyState, Popover } from './ui';
import {
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
ChevronDownIcon, SearchIcon as SearchGlyph,
} from './icons';
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
interface InspectorPanelProps {
activeTab: InspectorTab;
onTabChange: (t: InspectorTab) => void;
documents: DocumentInfo[];
selectedDocumentId: string;
onSelectDocument: (id: string) => void;
documentId: string;
totalPages: number;
currentPage: number;
sizeBytes?: number;
onNavigateToPage: (i: number) => void;
onDeletePage: (i: number) => void;
onReorderPage: (from: number, to: number) => void;
annotations: Annotation[];
onNavigateAnnotation: (a: Annotation) => void;
searchQuery: string;
onSearchQueryChange: (q: string) => void;
searchResults: SearchResult[];
searchCurrentMatch: number;
onSelectSearchMatch: (i: number) => void;
metadata: DocumentMetadata | null;
fonts: FontInfo[];
}
const TABS: { id: InspectorTab; label: string; icon: React.ReactNode; stub?: boolean }[] = [
{ id: 'pages', label: 'Pages', icon: <PagesIcon /> },
{ id: 'notes', label: 'Notes', icon: <NotesIcon /> },
{ id: 'search', label: 'Search', icon: <SearchIcon /> },
{ id: 'properties', label: 'Properties', icon: <PropertiesIcon /> },
{ id: 'fonts', label: 'Fonts', icon: <FontsIcon /> },
{ id: 'outline', label: 'Outline', icon: <OutlineIcon />, stub: true },
{ id: 'forms', label: 'Forms', icon: <FormsIcon />, stub: true },
];
function formatBytes(bytes?: number) {
if (!bytes) return '—';
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
const activeDef = TABS.find((t) => t.id === p.activeTab)!;
return (
<aside className="flex h-full shrink-0 border-l border-[var(--border)] bg-[var(--surface)]" style={{ width: 'var(--inspector-w)' }}>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Header: active section title + compact document switcher */}
<div className="flex h-[var(--strip-h)] shrink-0 items-center justify-between gap-2 border-b border-[var(--border)]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
<span className="shrink-0 text-[13px] font-bold text-[var(--text)]">{activeDef.label}</span>
{p.documents.length > 0 ? (
<Popover
align="right"
width={272}
trigger={(open) => (
<button className={`flex h-7 min-w-0 max-w-[180px] items-center gap-1.5 rounded-[var(--r-md)] px-2 text-left transition-colors ${open ? 'bg-[var(--surface-3)]' : 'hover:bg-[var(--surface-2)]'}`}>
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--text-muted)]">{selectedDoc?.filename ?? 'Document'}</span>
<ChevronDownIcon size={13} className="shrink-0 text-[var(--text-dim)]" />
</button>
)}
>
<div className="flex flex-col">
<p className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[var(--text-dim)]">Open documents</p>
{p.documents.map((d) => (
<button
key={d.id}
onClick={() => p.onSelectDocument(d.id)}
className={`flex flex-col rounded-[var(--r-sm)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--surface-2)] ${d.id === p.selectedDocumentId ? 'bg-[var(--accent-soft)]' : ''}`}
>
<span className={`truncate text-[12.5px] font-semibold ${d.id === p.selectedDocumentId ? 'text-[var(--accent)]' : 'text-[var(--text)]'}`}>{d.filename}</span>
<span className="text-[10.5px] text-[var(--text-dim)]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
</button>
))}
</div>
</Popover>
) : (
<span className="text-[11.5px] font-medium text-[var(--text-dim)]">No document</span>
)}
</div>
{/* Icon-only tab switcher — evenly spaced so all 7 fit with breathing room */}
<div className="flex shrink-0 items-center justify-between border-b border-[var(--border)] bg-[var(--surface-2)]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
{TABS.map((t) => {
const active = p.activeTab === t.id;
return (
<button
key={t.id}
title={t.stub ? `${t.label} (coming soon)` : t.label}
aria-label={t.label}
onClick={() => p.onTabChange(t.id)}
className={`relative flex h-8 w-8 items-center justify-center rounded-[var(--r-md)] transition-colors cursor-pointer ${
active
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'
}`}
>
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 18 }) : t.icon}
{t.id === 'notes' && p.annotations.length > 0 && (
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />
)}
</button>
);
})}
</div>
{/* Body */}
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
{p.activeTab === 'pages' && <PagesTab {...p} />}
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations} onNavigate={p.onNavigateAnnotation} />}
{p.activeTab === 'search' && <SearchTab {...p} />}
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
{p.activeTab === 'outline' && (
<EmptyState badge="Coming soon" icon={<OutlineIcon size={30} />} title="Bookmarks & outline"
hint="Document outline navigation will appear here once the engine exposes bookmarks." />
)}
{p.activeTab === 'forms' && (
<EmptyState badge="Coming soon" icon={<FormsIcon size={30} />} title="Form fields"
hint="Viewing and filling AcroForm fields is on the engine roadmap and will light up here." />
)}
</div>
</div>
</aside>
);
};
/* ----------------------------------------------------------------- Pages */
const PagesTab: React.FC<InspectorPanelProps> = (p) => {
if (!p.totalPages) return <EmptyState icon={<PagesIcon size={30} />} title="No pages" hint="Open a PDF to see its pages." />;
return (
<div className="grid grid-cols-2 gap-3 p-3">
{Array.from({ length: p.totalPages }).map((_, idx) => (
<div key={idx} className={idx === p.currentPage ? '[&_.thumbnail-preview]:!border-[var(--accent)] [&_.thumbnail-preview]:!ring-2 [&_.thumbnail-preview]:!ring-[var(--accent-soft)]' : ''}>
<Thumbnail
documentId={p.documentId}
pageIndex={idx}
onClick={() => p.onNavigateToPage(idx)}
onDelete={() => p.onDeletePage(idx)}
onMoveUp={idx > 0 ? () => p.onReorderPage(idx, idx - 1) : undefined}
onMoveDown={idx < p.totalPages - 1 ? () => p.onReorderPage(idx, idx + 1) : undefined}
totalPages={p.totalPages}
/>
</div>
))}
</div>
);
};
/* ----------------------------------------------------------------- Notes */
const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation) => void }> = ({ annotations, onNavigate }) => {
if (annotations.length === 0) {
return <EmptyState icon={<NotesIcon size={30} />} title="No annotations yet"
hint="Highlights, ink, and comments you add appear here." />;
}
return (
<div className="flex flex-col gap-2 p-3">
{annotations.map((a) => (
<button key={a.id} onClick={() => onNavigate(a)}
className="flex flex-col gap-1.5 rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] p-2.5 text-left transition-colors hover:border-[var(--border-strong)]">
<div className="flex items-center justify-between">
<span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-wide"
style={{ background: 'var(--accent-soft)', color: 'var(--accent)' }}>{a.type}</span>
<span className="text-[10px] text-[var(--text-dim)]">{a.pageIndex !== undefined ? `Page ${a.pageIndex + 1}` : ''}</span>
</div>
{a.content && <p className="line-clamp-3 text-[12px] italic text-[var(--text-muted)]">{a.content}</p>}
<span className="text-[10px] font-medium text-[var(--text-dim)]">{a.author}</span>
</button>
))}
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[var(--text-dim)]">
Editing & deleting saved annotations is coming soon for now annotations are append-only.
</p>
</div>
);
};
/* ---------------------------------------------------------------- Search */
const SearchTab: React.FC<InspectorPanelProps> = (p) => {
let flatIndex = -1;
return (
<div className="flex h-full flex-col">
<div className="border-b border-[var(--border)] p-3">
<div className="flex items-center gap-2 rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-1.5 focus-within:border-[var(--accent)]">
<SearchGlyph size={15} className="text-[var(--text-dim)]" />
<input
autoFocus
value={p.searchQuery}
onChange={(e) => p.onSearchQueryChange(e.target.value)}
placeholder="Search document…"
className="w-full bg-transparent text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-dim)]"
/>
</div>
{p.searchQuery && (
<p className="mt-2 px-0.5 text-[11px] font-medium text-[var(--text-muted)]">
{p.searchResults.length > 0 ? `${p.searchResults.length} match${p.searchResults.length > 1 ? 'es' : ''}` : 'No matches'}
</p>
)}
</div>
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
{p.searchResults.map((r) => {
flatIndex++;
const i = flatIndex;
return (
<button key={i} onClick={() => p.onSelectSearchMatch(i)}
className={`mb-1 flex w-full items-center gap-2 rounded-[var(--r-sm)] px-2.5 py-2 text-left text-[12px] transition-colors ${
i === p.searchCurrentMatch ? 'bg-[var(--accent-soft)] text-[var(--text)]' : 'hover:bg-[var(--surface-2)] text-[var(--text-muted)]'
}`}>
<span className="rounded bg-[var(--surface-3)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--text-dim)]">p.{r.pageIndex + 1}</span>
<span className="truncate">{r.text || p.searchQuery}</span>
</button>
);
})}
{!p.searchQuery && (
<EmptyState icon={<SearchIcon size={28} />} title="Search the document" hint="Type a term to find and jump to matches." />
)}
</div>
</div>
);
};
/* ------------------------------------------------------------ Properties */
const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: number; totalPages: number; filename?: string }> = ({ metadata, sizeBytes, totalPages, filename }) => {
const rows: [string, string | undefined][] = [
['File name', filename],
['Title', metadata?.title],
['Author', metadata?.author],
['Subject', metadata?.subject],
['Keywords', metadata?.keywords],
['Creator', metadata?.creator],
['Producer', metadata?.producer],
['Created', metadata?.creation_date],
['Modified', metadata?.modification_date],
['Pages', String(totalPages || '—')],
['Size', formatBytes(sizeBytes)],
];
return (
<div className="flex flex-col gap-0.5 p-3">
{rows.map(([k, v]) => (
<div key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-[var(--border)] py-2 last:border-0">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">{k}</span>
<span className="break-words text-[12.5px] text-[var(--text)]">{v && v.trim() ? v : <span className="text-[var(--text-dim)]"></span>}</span>
</div>
))}
<p className="pt-2 text-[10.5px] text-[var(--text-dim)]">Document properties are read-only.</p>
</div>
);
};
/* ----------------------------------------------------------------- Fonts */
const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
if (fonts.length === 0) {
return <EmptyState icon={<FontsIcon size={30} />} title="No font data" hint="Font inventory appears once a document with embedded fonts is open." />;
}
// De-duplicate by name
const seen = new Set<string>();
const unique = fonts.filter((f) => (seen.has(f.name) ? false : (seen.add(f.name), true)));
return (
<div className="flex flex-col gap-2 p-3">
{unique.map((f, i) => (
<div key={i} className="rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-[12px] font-semibold text-[var(--text)]" title={f.name}>{f.name || 'Unknown'}</span>
{f.type && <span className="shrink-0 rounded bg-[var(--surface-3)] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[var(--text-dim)]">{f.type}</span>}
</div>
<div className="mt-1.5 flex flex-wrap gap-1">
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
{f.isSubset && <Badge ok label="Subset" />}
{f.substitutedTo && <Badge label={`${f.substitutedTo}`} warn />}
{f.encoding && <Badge label={f.encoding} />}
{f.hasToUnicode && <Badge ok label="ToUnicode" />}
</div>
</div>
))}
</div>
);
};
const Badge: React.FC<{ label: string; ok?: boolean; warn?: boolean }> = ({ label, ok, warn }) => (
<span className="rounded px-1.5 py-0.5 text-[9.5px] font-semibold"
style={{
background: warn ? 'var(--warning-soft)' : ok ? 'var(--success-soft)' : 'var(--surface-3)',
color: warn ? 'var(--warning)' : ok ? 'var(--success)' : 'var(--text-dim)',
}}>
{label}
</span>
);
-179
View File
@@ -1,179 +0,0 @@
import React from 'react';
interface LeftSidebarProps {
activeTool: string;
onActiveToolChange: (tool: string) => void;
onUploadStart: (file: File) => void;
searchQuery: string;
setSearchQuery: (q: string) => void;
searchResultCount: number;
searchCurrentMatch: number;
onSearchNext: () => void;
onSearchPrev: () => void;
}
export const LeftSidebar: React.FC<LeftSidebarProps> = ({
activeTool,
onActiveToolChange,
onUploadStart,
searchQuery,
setSearchQuery,
searchResultCount,
searchCurrentMatch,
onSearchNext,
onSearchPrev
}) => {
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && onUploadStart) {
onUploadStart(file);
}
e.target.value = '';
};
const tools = [
{
id: 'select', label: 'Select', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
)
},
{
id: 'search', label: 'Search', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)
},
{
id: 'pan', label: 'Pan', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11" />
</svg>
)
},
{
id: 'highlight', label: 'Highlight', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
)
},
{
id: 'draw', label: 'Draw', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
)
},
{
id: 'comment', label: 'Comment', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
)
},
{
id: 'signature', label: 'Signature', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
)
},
{
id: 'redact', label: 'Redact', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h18v18H3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18M21 3L3 21" />
</svg>
)
}
];
return (
<div className="w-[72px] bg-white border-r border-gray-200 flex flex-col items-center py-4 gap-4 z-40 shadow-sm relative">
{/* Primary Action Button (Screen/Upload) */}
<div className="flex flex-col items-center gap-1 mb-2">
<label className="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center shadow-md cursor-pointer hover:bg-blue-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
<input type="file" accept=".pdf" onChange={handleFileUpload} className="hidden" />
</label>
<span className="text-[10px] font-semibold text-gray-700 mt-1">Upload</span>
</div>
<div className="w-8 h-px bg-gray-200 my-1"></div>
{tools.map(tool => (
<button
key={tool.id}
onClick={() => onActiveToolChange(tool.id)}
className={`flex flex-col items-center gap-1 w-14 py-2 rounded-xl transition-all ${activeTool === tool.id
? 'bg-blue-50 text-blue-600 shadow-sm border border-blue-100'
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-800'
}`}
title={tool.label}
>
<div className="flex items-center justify-center">
{tool.icon}
</div>
<span className="text-[10px] font-medium">{tool.label}</span>
</button>
))}
{/* Slide-out Search Panel Overlay */}
{activeTool === 'search' && (
<div className="absolute top-0 left-[72px] w-[300px] h-full bg-white border-r border-gray-200 shadow-xl flex flex-col transform transition-transform z-30">
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-sm font-bold text-gray-800">Search Document</h2>
<button onClick={() => onActiveToolChange('select')} className="text-gray-400 hover:text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4">
<div className="flex items-center bg-gray-50 border border-gray-200 rounded-lg px-3 py-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" className="text-gray-400 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-transparent border-none outline-none text-sm text-gray-800 placeholder-gray-400 w-full"
autoFocus
/>
</div>
{searchQuery && (
<div className="mt-4 flex items-center justify-between bg-white border border-gray-100 rounded-lg p-3 shadow-sm">
<span className="text-xs font-semibold text-gray-600">
{searchResultCount > 0 ? `${searchCurrentMatch + 1} of ${searchResultCount} matches` : 'No matches'}
</span>
{searchResultCount > 0 && (
<div className="flex gap-1">
<button onClick={onSearchPrev} className="p-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button onClick={onSearchNext} className="p-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
);
};
-58
View File
@@ -1,58 +0,0 @@
import React, { useState } from 'react';
interface SearchBarProps {
onSearch: (query: string) => void;
resultCount: number;
currentMatch: number;
onNext: () => void;
onPrev: () => void;
}
export const SearchBar: React.FC<SearchBarProps> = ({
onSearch,
resultCount,
currentMatch,
onNext,
onPrev,
}) => {
const [query, setQuery] = useState('');
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSearch(query);
}
};
return (
<div className="flex items-center bg-slate-900/40 border border-slate-700/70 rounded-xl px-3 py-1.5 gap-2 shadow-inner">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" className="text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="bg-transparent border-none outline-none text-slate-100 text-sm w-32 placeholder-slate-500"
/>
{query && (
<div className="flex items-center gap-1 text-xs text-slate-400 font-semibold border-l border-slate-700 pl-2">
<span>{resultCount > 0 ? `${currentMatch + 1}/${resultCount}` : '0/0'}</span>
<div className="flex flex-col gap-0.5 ml-1">
<button onClick={onPrev} className="hover:text-white" title="Previous match">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button onClick={onNext} className="hover:text-white" title="Next match">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
)}
</div>
);
};
-179
View File
@@ -1,179 +0,0 @@
import React from 'react';
import type { DocumentInfo } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
import { Thumbnail } from './Thumbnail';
interface SidebarProps {
documents: DocumentInfo[];
selectedDocumentId: string;
onSelectDocument: (id: string) => void;
annotations: Annotation[];
totalPages: number;
activeTab: 'documents' | 'annotations' | 'outline' | 'settings';
setActiveTab: (tab: 'documents' | 'annotations' | 'outline' | 'settings') => void;
onNavigateToPage?: (pageIndex: number) => void;
onDeletePage?: (pageIndex: number) => void;
onReorderPage?: (pageIndex: number, destPageIndex: number) => void;
currentPage: number;
}
export const Sidebar: React.FC<SidebarProps> = ({
documents,
selectedDocumentId,
onSelectDocument,
annotations,
totalPages,
activeTab,
setActiveTab,
onNavigateToPage,
onDeletePage,
onReorderPage,
currentPage,
}) => {
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const tabs = [
{
id: 'documents', label: 'Files', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)
},
{
id: 'outline', label: 'Pages', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
)
},
{
id: 'annotations', label: 'Notes', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
},
{
id: 'settings', label: 'Settings', icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
}
];
return (
<div className="w-[88px] bg-white border-l border-gray-200 flex flex-col items-center py-4 gap-4 z-40 shadow-sm shrink-0">
{/* Progress / Page indicator */}
<div className="flex flex-col items-center justify-center w-10 h-10 rounded-full bg-blue-500 text-white font-bold text-[10px] shadow-md mb-2">
{currentPage + 1}/{Math.max(1, totalPages)}
</div>
<div className="w-8 h-px bg-gray-200 my-1"></div>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex flex-col items-center justify-center w-16 h-14 rounded-xl transition-all relative ${activeTab === tab.id
? 'bg-blue-50 text-blue-600 shadow-sm border border-blue-100'
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-800'
}`}
title={tab.label}
>
{tab.id === 'annotations' && annotations.length > 0 && (
<span className="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white"></span>
)}
<div className="mb-1">{tab.icon}</div>
<span className="text-[9px] font-medium tracking-wide">{tab.label}</span>
</button>
))}
<div className="flex-1"></div>
{/* Help Icon */}
<button className="flex items-center justify-center w-10 h-10 rounded-full bg-black text-white hover:bg-gray-800 transition-colors shadow-md mt-auto">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{/* Slide-out Panel Overlay */}
{activeTab !== 'settings' && activeTab !== 'outline' && activeTab !== 'documents' && activeTab !== 'annotations' ? null : (
<div className="absolute top-0 right-[88px] w-64 h-full bg-white border-l border-gray-200 shadow-xl flex flex-col transform transition-transform z-30">
<div className="p-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-sm font-bold text-gray-800">
{activeTab === 'documents' ? 'Files' : activeTab === 'outline' ? 'Pages' : activeTab === 'annotations' ? 'Notes' : 'Settings'}
</h2>
<button onClick={() => setActiveTab('' as any)} className="text-gray-400 hover:text-gray-600">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
{activeTab === 'documents' && (
<div className="flex flex-col gap-3">
{documents.map((doc) => (
<div key={doc.id} onClick={() => onSelectDocument(doc.id)} className={`px-4 py-3 rounded-xl border cursor-pointer transition-colors ${doc.id === selectedDocumentId ? 'border-blue-500 bg-blue-50/50 text-blue-700' : 'border-gray-200 hover:border-gray-300'}`}>
<p className={`ml-1 mr-1 pr-1 text-[13px] font-bold truncate mb-1.5 ${doc.id === selectedDocumentId ? 'text-gray-900' : 'text-gray-700'}`}>{doc.filename}</p>
<p className="ml-1 mr-1 pr-1 text-[11px] text-gray-500">{formatBytes(doc.sizeBytes)} {doc.totalPages} Pages</p>
</div>
))}
</div>
)}
{activeTab === 'outline' && (
<div className="grid grid-cols-2 gap-3">
{Array.from({ length: totalPages }).map((_, idx) => (
<Thumbnail
key={idx}
documentId={selectedDocumentId}
pageIndex={idx}
onClick={() => onNavigateToPage?.(idx)}
onDelete={onDeletePage ? () => onDeletePage(idx) : undefined}
onMoveUp={onReorderPage && idx > 0 ? () => onReorderPage(idx, idx - 1) : undefined}
onMoveDown={onReorderPage && idx < totalPages - 1 ? () => onReorderPage(idx, idx + 1) : undefined}
totalPages={totalPages}
/>
))}
</div>
)}
{activeTab === 'annotations' && (
<div className="flex flex-col gap-3">
{annotations.length === 0 ? (
<p className="text-xs text-gray-500 text-center py-8">No notes yet.</p>
) : (
annotations.map((anno) => (
<div key={anno.id} className="p-3 bg-gray-50 border border-gray-200 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-orange-100 text-orange-600">{anno.type}</span>
<span className="text-[10px] text-gray-400">{anno.author}</span>
</div>
<p className="text-xs text-gray-700 italic">"{anno.content}"</p>
</div>
))
)}
</div>
)}
{activeTab === 'settings' && (
<div className="flex flex-col gap-3">
<p className="text-xs text-gray-500 text-center py-8">WASM settings can go here.</p>
</div>
)}
</div>
</div>
)}
</div>
);
};
+168
View File
@@ -0,0 +1,168 @@
import React, { useRef, useState } from 'react';
import { Modal, Button } from './ui';
import { toast } from '../lib/toast';
interface SignatureModalProps {
open: boolean;
onClose: () => void;
onConfirm: (dataUrl: string, aspect: number) => void; // aspect = width/height
}
type Mode = 'draw' | 'type' | 'upload';
export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => {
const [mode, setMode] = useState<Mode>('draw');
const [typed, setTyped] = useState('');
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
const hasInk = useRef(false);
const resetLocal = () => { setMode('draw'); setTyped(''); setUploaded(null); hasInk.current = false; };
const handleClose = () => { resetLocal(); onClose(); };
const clearCanvas = () => {
const c = canvasRef.current;
if (!c) return;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
hasInk.current = false;
};
const pos = (e: React.PointerEvent) => {
const c = canvasRef.current!;
const r = c.getBoundingClientRect();
return { x: (e.clientX - r.left) * (c.width / r.width), y: (e.clientY - r.top) * (c.height / r.height) };
};
const onDown = (e: React.PointerEvent) => {
drawing.current = true;
const ctx = canvasRef.current!.getContext('2d')!;
const { x, y } = pos(e);
ctx.beginPath(); ctx.moveTo(x, y);
(e.target as Element).setPointerCapture(e.pointerId);
};
const onMove = (e: React.PointerEvent) => {
if (!drawing.current) return;
const ctx = canvasRef.current!.getContext('2d')!;
const { x, y } = pos(e);
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
hasInk.current = true;
};
const onUp = () => { drawing.current = false; };
const handleConfirm = () => {
if (mode === 'draw') {
if (!hasInk.current) { toast('Draw a signature first', 'error'); return; }
const c = canvasRef.current!;
onConfirm(c.toDataURL('image/png'), c.width / c.height);
} else if (mode === 'type') {
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
const c = document.createElement('canvas');
c.width = 600; c.height = 200;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = '#1b2430';
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
onConfirm(c.toDataURL('image/png'), c.width / c.height);
} else if (mode === 'upload') {
if (!uploaded) { toast('Upload an image first', 'error'); return; }
onConfirm(uploaded.url, uploaded.aspect);
}
resetLocal();
};
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
const url = reader.result as string;
const img = new Image();
img.onload = () => setUploaded({ url, aspect: img.width / img.height });
img.src = url;
};
reader.readAsDataURL(f);
e.target.value = '';
};
return (
<Modal
open={open}
onClose={handleClose}
title="Create signature"
width={580}
footer={
<>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button variant="primary" onClick={handleConfirm}>Use signature</Button>
</>
}
>
<div className="mb-5 flex gap-1.5 rounded-[var(--r-md)] bg-[var(--surface-3)] p-1.5">
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
<button key={m} onClick={() => setMode(m)}
className={`flex-1 rounded-[var(--r-sm)] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[var(--surface)] text-[var(--accent)] shadow-sm' : 'text-[var(--text-muted)] hover:text-[var(--text)]'}`}>
{m}
</button>
))}
</div>
{mode === 'draw' && (
<div>
<canvas
ref={canvasRef}
width={920}
height={300}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="h-[200px] w-full touch-none rounded-[var(--r-md)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-2)]"
style={{ cursor: 'crosshair' }}
/>
<div className="mt-2 flex justify-between">
<span className="text-[11px] text-[var(--text-dim)]">Draw your signature above</span>
<button className="text-[12px] font-semibold text-[var(--accent)] hover:underline" onClick={clearCanvas}>Clear</button>
</div>
</div>
)}
{mode === 'type' && (
<div>
<input
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder="Type your name"
className="w-full rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2.5 text-[14px] outline-none focus:border-[var(--accent)]"
/>
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[var(--r-md)] border border-[var(--border)] bg-[var(--surface-2)]">
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: 'var(--text)' }}>
{typed || 'Preview'}
</span>
</div>
</div>
)}
{mode === 'upload' && (
<div>
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[var(--r-md)] border border-dashed border-[var(--border-strong)] bg-[var(--surface-2)] text-[var(--text-muted)] hover:border-[var(--accent)]">
{uploaded ? (
<img src={uploaded.url} alt="signature" className="max-h-[130px] max-w-[90%] object-contain" />
) : (
<>
<span className="text-[13px] font-semibold">Click to upload an image</span>
<span className="text-[11px] text-[var(--text-dim)]">PNG with transparent background works best</span>
</>
)}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
</div>
)}
<p className="mt-4 text-[11px] text-[var(--text-dim)]">Visual signature only not a certified e-signature.</p>
</Modal>
);
};
+6 -6
View File
@@ -56,11 +56,11 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
return (
<div className="thumbnail-card" onClick={onClick}>
<div className="thumbnail-preview relative group overflow-hidden bg-white hover:border-indigo-500 transition-colors">
<div className="thumbnail-preview relative group overflow-hidden bg-white transition-colors">
{loading ? (
<div className="flex flex-col items-center justify-center h-full w-full bg-slate-900">
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mb-2" />
<span className="thumbnail-label">Loading...</span>
<div className="flex flex-col items-center justify-center h-full w-full bg-[var(--surface-2)]">
<div className="w-5 h-5 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin mb-2" />
<span className="thumbnail-label">Loading</span>
</div>
) : imageUrl ? (
<>
@@ -94,7 +94,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
e.stopPropagation();
onMoveUp();
}}
className="p-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded shadow transition-colors cursor-pointer"
className="p-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Up"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
@@ -110,7 +110,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
e.stopPropagation();
onMoveDown();
}}
className="p-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded shadow transition-colors cursor-pointer"
className="p-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded shadow transition-colors cursor-pointer"
title="Move Page Down"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import type { ToolId } from '../lib/tools';
import { Popover } from './ui';
import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
SignatureIcon, StampIcon, RedactIcon, InfoIcon,
} from './icons';
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
const TOOLS: (ToolDef | 'divider')[] = [
{ id: 'select', label: 'Select & copy text', shortcut: 'V', icon: <SelectIcon /> },
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
'divider',
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
{ id: 'signature', label: 'Signature', shortcut: 'S', icon: <SignatureIcon /> },
{ id: 'stamp', label: 'Stamp', shortcut: 'M', icon: <StampIcon /> },
'divider',
{ id: 'redact', label: 'Redact', shortcut: 'R', icon: <RedactIcon />, danger: true },
];
interface ToolRailProps {
activeTool: ToolId;
onToolChange: (t: ToolId) => void;
hasSignature: boolean;
onOpenSignature: () => void;
}
const RailButton: React.FC<{ t: ToolDef; active: boolean; onClick: () => void }> = ({ t, active, onClick }) => (
<button
title={`${t.label} · ${t.shortcut}`}
aria-label={t.label}
aria-pressed={active}
onClick={onClick}
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${
active
? t.danger ? 'bg-[var(--danger-soft)] text-[var(--danger)]' : 'bg-[var(--accent-soft)] text-[var(--accent)]'
: t.danger ? 'text-[var(--text-muted)] hover:bg-[var(--danger-soft)] hover:text-[var(--danger)]'
: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'
}`}
>
{active && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? 'var(--danger)' : 'var(--accent)' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
</button>
);
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature }) => {
const pickTool = (id: ToolId) => {
onToolChange(id);
if (id === 'signature' && !hasSignature) onOpenSignature();
};
return (
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[var(--border)] bg-[var(--surface)]" style={{ width: 'var(--rail-w)', paddingTop: '14px', paddingBottom: '12px' }}>
{TOOLS.map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[var(--border)]" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} onClick={() => pickTool(t.id)} />,
)}
<div className="flex-1" />
<Popover
align="left"
width={210}
trigger={(open) => (
<button title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[var(--surface-3)] text-[var(--text)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'}`}>
<InfoIcon size={19} />
</button>
)}
>
<div className="text-[12px]">
<p className="mb-1.5 px-1 font-bold text-[var(--text)]">Shortcuts</p>
{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-[var(--text-muted)]">{t.label}</span>
<kbd className="rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
</div>
))}
<div className="my-1 h-px bg-[var(--border)]" />
<div className="flex items-center justify-between px-1 py-0.5">
<span className="text-[var(--text-muted)]">Undo / Redo</span>
<kbd className="rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
</div>
</div>
</Popover>
</nav>
);
};
+122 -146
View File
@@ -1,159 +1,135 @@
import React from 'react';
import type { ToolId, ToolSettings } from '../lib/tools';
import { STAMP_PRESETS } from '../lib/tools';
import { ColorSwatches, Slider, Button } from './ui';
import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
SignatureIcon, StampIcon, RedactIcon,
} from './icons';
interface ToolbarProps {
zoom: number;
onZoomChange: (zoom: number) => void;
rotation: number;
onRotationChange: (rot: number) => void;
backendHealthy: boolean | null;
currentPage: number;
totalPages: number;
onExport?: () => void;
onSaveEdits?: () => void;
hasAnnotations?: boolean;
activeTool: ToolId;
settings: ToolSettings;
onSettingsChange: (patch: Partial<ToolSettings>) => void;
onOpenSignature: () => void;
hasSignature: boolean;
activeStamp: string | null;
onSelectStamp: (label: string, color: string) => void;
}
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
select: { label: 'Select', icon: <SelectIcon size={17} /> },
pan: { label: 'Pan', icon: <PanIcon size={17} /> },
highlight: { label: 'Highlight', icon: <HighlightIcon size={17} /> },
draw: { label: 'Draw', icon: <DrawIcon size={17} /> },
comment: { label: 'Comment', icon: <CommentIcon size={17} /> },
textbox: { label: 'Text box', icon: <TextBoxIcon size={17} /> },
signature: { label: 'Signature', icon: <SignatureIcon size={17} /> },
stamp: { label: 'Stamp', icon: <StampIcon size={17} /> },
redact: { label: 'Redact', icon: <RedactIcon size={17} /> },
};
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[var(--danger)]' : 'text-[var(--text-dim)]'}`}>{children}</span>
);
const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">{children}</span>
);
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[var(--border)]" />;
export const Toolbar: React.FC<ToolbarProps> = ({
zoom,
onZoomChange,
rotation,
onRotationChange,
backendHealthy,
currentPage,
totalPages,
onExport,
onSaveEdits,
hasAnnotations,
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
}) => {
const handleZoomOut = () => onZoomChange(Math.max(0.5, zoom - 0.1));
const handleZoomIn = () => onZoomChange(Math.min(3.0, zoom + 0.1));
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
return (
<div className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 z-50 shadow-sm shrink-0">
{/* Left Section - Logo & Title */}
<div className="flex items-center gap-3.5">
<div className="relative group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-tr from-blue-600 to-indigo-500 rounded-xl transform scale-110 opacity-30 blur-md group-hover:opacity-50 transition-opacity duration-300"></div>
<img
src="/logo.png"
alt="EditQube Logo"
className="w-10 h-10 rounded-xl shadow-lg border border-gray-100 relative z-10 object-cover"
/>
</div>
<div className="text-[22px] font-black tracking-tighter flex items-center cursor-pointer ml-1">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-600">Edit</span>
<span className="text-gray-800">Qube</span>
</div>
</div>
{/* Middle Section - Status & Zoom */}
<div className="flex items-center gap-6">
{/* Progress Bar Mock / Status */}
<div className="flex flex-col gap-1 w-64 hidden md:flex">
<div className="flex justify-between text-[11px] font-medium text-gray-600">
<span>
{backendHealthy === null
? 'Checking Gateway...'
: backendHealthy
? 'Gateway Connected'
: 'Gateway Offline'}
</span>
<span className={backendHealthy ? 'text-green-600' : 'text-red-600'}>
{backendHealthy ? '100%' : '0%'}
</span>
</div>
<div className="h-1 w-full bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full ${backendHealthy ? 'bg-blue-500' : 'bg-red-500'}`} style={{ width: backendHealthy ? '100%' : '10%' }}></div>
</div>
</div>
<div className="flex items-center gap-2 text-gray-500">
<button className="p-1.5 hover:bg-gray-100 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</button>
<button className="p-1.5 hover:bg-gray-100 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</button>
</div>
<div className="h-4 w-px bg-gray-300"></div>
<div className="flex items-center gap-1 text-gray-500">
<button className="p-1.5 hover:bg-gray-100 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button className="p-1.5 hover:bg-gray-100 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
</svg>
</button>
</div>
<div className="h-4 w-px bg-gray-300"></div>
<div className="flex items-center gap-2">
<button onClick={handleZoomOut} className="p-1 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4" />
</svg>
</button>
<span className="text-xs font-semibold w-12 text-center text-gray-700">{Math.round(zoom * 100)}%</span>
<button onClick={handleZoomIn} className="p-1 text-gray-500 hover:text-gray-800 hover:bg-gray-100 rounded">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
{/* Right Section - Actions */}
<div className="flex items-center gap-3">
<button onClick={() => onRotationChange((rotation + 90) % 360)} className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors hidden sm:flex">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89M9 11l3-3 3 3m-3-3v12" />
</svg>
</button>
<button className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-black">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="hidden lg:inline">Comments</span>
</button>
<button className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-black">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span className="hidden lg:inline">Share</span>
</button>
<button onClick={onExport} className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-black">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span className="hidden lg:inline">Export</span>
</button>
<button
onClick={onSaveEdits}
disabled={!hasAnnotations}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors ml-2 ${hasAnnotations ? 'bg-blue-500 hover:bg-blue-600 text-white shadow-md' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
title="Save Edits"
<div
className="flex h-[var(--strip-h)] shrink-0 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)]"
style={{ paddingLeft: '16px', paddingRight: '16px' }}
>
{/* Active tool identity */}
<div className="flex shrink-0 items-center gap-2">
<span
className="flex h-7 w-7 items-center justify-center rounded-[var(--r-md)]"
style={{
background: isRedact ? 'var(--danger-soft)' : 'var(--accent-soft)',
color: isRedact ? 'var(--danger)' : 'var(--accent)',
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</button>
{meta.icon}
</span>
<span className="text-[13px] font-bold text-[var(--text)]">{meta.label}</span>
</div>
<Divider />
{/* Per-tool controls (no overflow clip — would cut off the active-swatch ring) */}
<div className="flex min-w-0 flex-1 items-center gap-3">
{activeTool === 'select' && <Hint>Drag to select text release to copy, or press <Kbd>Ctrl/ + C</Kbd></Hint>}
{activeTool === 'pan' && <Hint>Drag anywhere to move the page.</Hint>}
{activeTool === 'highlight' && (
<>
<Label>Color</Label>
<ColorSwatches value={settings.highlightColor} onChange={(c) => onSettingsChange({ highlightColor: c })} />
<Divider />
<Slider label="Opacity" min={10} max={100} step={5} value={Math.round(settings.highlightOpacity * 100)} onChange={(v) => onSettingsChange({ highlightOpacity: v / 100 })} suffix="%" />
</>
)}
{activeTool === 'draw' && (
<>
<Label>Color</Label>
<ColorSwatches value={settings.inkColor} onChange={(c) => onSettingsChange({ inkColor: c })}
palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']} />
<Divider />
<Slider label="Thickness" min={1} max={12} step={1} value={settings.inkThickness} onChange={(v) => onSettingsChange({ inkThickness: v })} suffix="px" />
</>
)}
{activeTool === 'comment' && <Hint>Click anywhere on the page to drop a sticky note.</Hint>}
{activeTool === 'textbox' && (
<>
<Label>Color</Label>
<ColorSwatches value={settings.textColor} onChange={(c) => onSettingsChange({ textColor: c })}
palette={['#18212e', '#dc2626', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '#ffffff']} />
<Divider />
<Slider label="Size" min={8} max={48} step={1} value={settings.fontSize} onChange={(v) => onSettingsChange({ fontSize: v })} suffix="pt" />
<Hint>Click to place a text box.</Hint>
</>
)}
{activeTool === 'signature' && (
<>
<Button variant={hasSignature ? 'outline' : 'primary'} size="sm" onClick={onOpenSignature}>
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
</Button>
{hasSignature ? <Hint>Click on the page to place it.</Hint> : <Hint>Draw, type, or upload a signature.</Hint>}
<span className="ml-auto shrink-0 rounded-full bg-[var(--surface-2)] px-2 py-0.5 text-[10px] font-semibold text-[var(--text-dim)]">Visual signature not certified</span>
</>
)}
{activeTool === 'stamp' && (
<>
<div className="flex items-center gap-2">
{STAMP_PRESETS.map((s) => (
<button key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
className={`shrink-0 rounded-[var(--r-sm)] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[var(--accent)]' : ''}`}
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
{s.label}
</button>
))}
</div>
<Hint>Pick a stamp, then click to place it.</Hint>
</>
)}
{activeTool === 'redact' && <Hint tone="warn"> Drag a box to permanently remove content underneath.</Hint>}
</div>
</div>
);
};
const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<kbd className="rounded border border-[var(--border)] bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] font-semibold text-[var(--text)]">{children}</kbd>
);
+200
View File
@@ -0,0 +1,200 @@
import React, { useRef } from 'react';
import { Button, IconButton, Popover } from './ui';
import {
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
} from './icons';
interface TopBarProps {
documentName?: string;
backendHealthy: boolean | null;
engineReady?: boolean;
zoom: number;
onZoomChange: (z: number) => void;
onFitWidth: () => void;
currentPage: number;
totalPages: number;
onGoToPage: (p: number) => void;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
isSaving: boolean;
isDirtySaved: boolean;
onRotate: () => void;
onExport: () => void;
onUpload: (file: File) => void;
isInspectorOpen: boolean;
onToggleInspector: () => void;
}
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
export const TopBar: React.FC<TopBarProps> = ({
documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth,
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
isSaving, isDirtySaved, onRotate, onExport, onUpload,
isInspectorOpen, onToggleInspector,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) onUpload(f);
e.target.value = '';
};
return (
<header
className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--border)] bg-[var(--surface)]"
style={{ height: 'var(--topbar-h)', paddingLeft: '24px', paddingRight: '24px' }}
>
{/* Left: brand + file menu + doc name */}
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[var(--accent)] text-white shadow-sm">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M7 3h7l5 5v13H7a2 2 0 01-2-2V5a2 2 0 012-2z" /><path d="M14 3v5h5" />
</svg>
</div>
<span className="text-[14px] font-extrabold tracking-tight text-[var(--text)]">PDF Editor</span>
</div>
<Popover
align="left"
width={210}
trigger={(open) => (
<button className={`flex h-8 items-center gap-1 rounded-[var(--r-md)] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[var(--surface-3)] text-[var(--text)]' : 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'}`}>
File <ChevronDownIcon size={14} />
</button>
)}
>
<div className="flex flex-col text-[13px]">
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName}>Export / Download</MenuItem>
<div className="my-1 h-px bg-[var(--border)]" />
<div className="px-2.5 py-2 text-[12px] text-[var(--text-dim)]">Print coming soon</div>
</div>
</Popover>
<IconButton label="Open PDF file" size={34} onClick={() => fileRef.current?.click()} className="text-[var(--text-muted)] hover:text-[var(--text)]">
<UploadIcon size={18} />
</IconButton>
{documentName && (
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[var(--text-muted)]" title={documentName}>
{documentName}
</span>
)}
</div>
{/* Center: history · zoom · rotate · page nav */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<IconButton label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></IconButton>
<IconButton label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon size={18} /></IconButton>
</div>
<div className="flex items-center gap-0.5 rounded-[var(--r-md)] bg-[var(--surface-2)] p-0.5">
<IconButton label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></IconButton>
<Popover
align="left"
width={140}
trigger={(open) => (
<button className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-[var(--surface-3)]' : 'text-[var(--text)] hover:bg-[var(--surface-3)]'}`}>
{Math.round(zoom * 100)}%
</button>
)}
>
<div className="flex flex-col text-[12.5px]">
<MenuItem icon={<FitIcon size={14} />} onClick={onFitWidth}>Fit width</MenuItem>
<div className="my-1 h-px bg-[var(--border)]" />
{ZOOM_PRESETS.map((z) => (
<button key={z} className="rounded-[var(--r-sm)] px-2 py-1 text-left tabular-nums hover:bg-[var(--surface-2)]" onClick={() => onZoomChange(z)}>
{Math.round(z * 100)}%
</button>
))}
</div>
</Popover>
<IconButton label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></IconButton>
</div>
<IconButton label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName}><RotateIcon size={18} /></IconButton>
{documentName && (
<div className="flex items-center gap-1 text-[12px] font-semibold text-[var(--text-muted)]">
<input
type="number"
min={1}
max={Math.max(1, totalPages)}
value={currentPage + 1}
onChange={(e) => {
const p = parseInt(e.target.value, 10);
if (!Number.isNaN(p)) onGoToPage(Math.min(Math.max(1, p), totalPages) - 1);
}}
className="h-7 w-9 rounded-[var(--r-sm)] border border-[var(--border-strong)] bg-[var(--surface)] text-center tabular-nums outline-none focus:border-[var(--accent)]"
/>
<span className="text-[var(--text-dim)]">/ {Math.max(1, totalPages)}</span>
</div>
)}
</div>
{/* Right: save-state + health + export + inspector toggle */}
<div className="flex shrink-0 items-center gap-2">
<SaveState isSaving={isSaving} saved={isDirtySaved} />
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
<Button variant="primary" size="sm" onClick={onExport} disabled={!documentName}><DownloadIcon size={15} /> Export</Button>
<IconButton
label={isInspectorOpen ? "Hide panel" : "Show panel"}
size={34}
active={isInspectorOpen}
onClick={onToggleInspector}
className="text-[var(--text-muted)] hover:text-[var(--text)]"
>
<PagesIcon size={18} />
</IconButton>
</div>
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
</header>
);
};
const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?: boolean; children: React.ReactNode }> = ({ icon, onClick, disabled, children }) => (
<button
onClick={onClick}
disabled={disabled}
className="flex items-center gap-2 rounded-[var(--r-sm)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--surface-2)] disabled:opacity-40"
>
{icon} {children}
</button>
);
const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving, saved }) => {
if (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-muted)]"><SpinnerIcon size={14} /> Saving</span>;
if (!saved) return null;
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[var(--success)] md:flex"><CheckIcon size={14} /> Saved</span>;
};
const HealthChip: React.FC<{ healthy: boolean | null; engineReady: boolean }> = ({ healthy, engineReady }) => {
// Fully connected (gateway up + engine on) → no noise.
if (healthy && engineReady) return null;
let color = 'var(--text-dim)';
let label = 'Offline · mock mode';
let tip = 'Gateway not reachable — running on mock data.';
if (healthy === null) {
color = 'var(--warning)'; label = 'Connecting…'; tip = 'Connecting to gateway…';
} else if (healthy && !engineReady) {
color = 'var(--warning)'; label = 'Engine off · mock data';
tip = 'Gateway is connected, but the PDF engine is disabled. Set PDFENGINE_ENGINE_AVAILABLE=true and restart the gateway for live rendering/editing.';
}
return (
<span
className="flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-[11px] font-medium text-[var(--text-muted)]"
title={tip}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
{label}
</span>
);
};
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
interface IconProps {
size?: number;
className?: string;
strokeWidth?: number;
}
const base = (
size: number,
className: string | undefined,
strokeWidth: number,
children: React.ReactNode,
fill = false,
) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={fill ? 'currentColor' : 'none'}
stroke={fill ? 'none' : 'currentColor'}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{children}
</svg>
);
type IC = React.FC<IconProps>;
const mk = (children: React.ReactNode, defStroke = 1.6): IC =>
({ size = 18, className, strokeWidth }) => base(size, className, strokeWidth ?? defStroke, children);
// Tools
export const SelectIcon: IC = mk(<path d="M5 3l14 7-6 1.5L10 18 5 3z" />);
export const PanIcon: IC = mk(<path d="M7 11.5V6a1.5 1.5 0 013 0m0 0v-.5a1.5 1.5 0 013 0V8m0-1a1.5 1.5 0 013 0v4.5a6.5 6.5 0 01-13 0V10a1.5 1.5 0 013 0" />);
export const HighlightIcon: IC = mk(<><path d="M4 20h4l9.5-9.5-3.5-3.5L4.5 16.5 4 20z" /><path d="M14 6.5l3.5 3.5" /></>);
export const DrawIcon: IC = mk(<path d="M15.2 5.2l3.6 3.6m-2-5.1a2.5 2.5 0 013.6 3.6L6.5 21H3v-3.6L16.7 3.7z" />);
export const CommentIcon: IC = mk(<path d="M21 12a8 8 0 01-11.3 7.3L4 21l1.7-5.7A8 8 0 1121 12z" />);
export const SignatureIcon: IC = mk(<><path d="M3 17c3 0 3-9 6-9s3 6 5 6 2-3 4-3" /><path d="M3 21h18" /></>);
export const TextBoxIcon: IC = mk(<><path d="M4 7V5h16v2" /><path d="M9 19h6M12 5v14" /></>);
export const StampIcon: IC = mk(<><path d="M9 12a3 3 0 113 0c-.7.6-1 1.3-1 2v1h-1v-1c0-.7-.3-1.4-1-2z" /><path d="M5 17h14M4 20h16" /></>);
export const RedactIcon: IC = mk(<rect x="4" y="4" width="16" height="16" rx="1.5" fill="currentColor" stroke="none" />);
export const ImageIcon: IC = mk(<><rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="8.5" cy="9.5" r="1.5" /><path d="M21 17l-5-5L5 21" /></>);
// Top bar
export const UndoIcon: IC = mk(<path d="M9 14L4 9l5-5M4 9h11a5 5 0 010 10h-3" />);
export const RedoIcon: IC = mk(<path d="M15 14l5-5-5-5M20 9H9a5 5 0 000 10h3" />);
export const ZoomInIcon: IC = mk(<><circle cx="11" cy="11" r="7" /><path d="M11 8v6M8 11h6M20 20l-3.5-3.5" /></>);
export const ZoomOutIcon: IC = mk(<><circle cx="11" cy="11" r="7" /><path d="M8 11h6M20 20l-3.5-3.5" /></>);
export const RotateIcon: IC = mk(<path d="M4 4v5h5M4.5 13a7.5 7.5 0 102-7.5L4 9" />);
export const DownloadIcon: IC = mk(<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />);
export const ChevronDownIcon: IC = mk(<path d="M6 9l6 6 6-6" />);
export const MenuIcon: IC = mk(<path d="M4 7h16M4 12h16M4 17h16" />);
export const CheckIcon: IC = mk(<path d="M5 12l5 5L20 7" />);
export const XIcon: IC = mk(<path d="M6 6l12 12M18 6L6 18" />);
export const ShareIcon: IC = mk(<><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>);
export const FitIcon: IC = mk(<path d="M8 3H5a2 2 0 00-2 2v3m0 8v3a2 2 0 002 2h3m8 0h3a2 2 0 002-2v-3m0-8V5a2 2 0 00-2-2h-3" />);
// Inspector
export const PagesIcon: IC = mk(<><rect x="4" y="4" width="6" height="6" rx="1" /><rect x="14" y="4" width="6" height="6" rx="1" /><rect x="4" y="14" width="6" height="6" rx="1" /><rect x="14" y="14" width="6" height="6" rx="1" /></>);
export const NotesIcon: IC = mk(<><path d="M5 4h14a1 1 0 011 1v11a1 1 0 01-1 1H9l-4 4V5a1 1 0 011-1z" /><path d="M8 9h8M8 12h5" /></>);
export const PropertiesIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
export const FontsIcon: IC = mk(<path d="M5 18L9.5 6h1L15 18M6.5 14h7M16 18l3-8h.5l3 8m-5-2.5h4" />);
export const OutlineIcon: IC = mk(<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />);
export const FormsIcon: IC = mk(<><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h4" /></>);
export const SearchIcon: IC = mk(<><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></>);
export const TrashIcon: IC = mk(<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m2 0l-.6 12a1 1 0 01-1 1H7.6a1 1 0 01-1-1L6 7" />);
export const ArrowUpIcon: IC = mk(<path d="M12 19V5M5 12l7-7 7 7" />);
export const ArrowDownIcon: IC = mk(<path d="M12 5v14M5 12l7 7 7-7" />);
export const CopyIcon: IC = mk(<><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 012-2h8" /></>);
export const PlusIcon: IC = mk(<path d="M12 5v14M5 12h14" />);
export const UploadIcon: IC = mk(<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" />);
export const InfoIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></>);
export const SpinnerIcon: IC = ({ size = 18, className }) =>
base(size, `${className ?? ''} animate-spin`, 2, <><path d="M12 3a9 9 0 109 9" opacity="0.85" /><path d="M21 12a9 9 0 00-9-9" opacity="0.25" /></>);
+261
View File
@@ -0,0 +1,261 @@
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';
type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'danger' | 'subtle';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: 'sm' | 'md';
}
const buttonVariants: Record<ButtonVariant, string> = {
primary: 'bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] shadow-sm',
ghost: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]',
outline: 'border border-[var(--border-strong)] bg-[var(--surface)] text-[var(--text)] hover:bg-[var(--surface-2)]',
danger: 'bg-[var(--danger)] text-white hover:brightness-95 shadow-sm',
subtle: 'bg-[var(--surface-3)] text-[var(--text)] hover:bg-[var(--border)]',
};
export const Button: React.FC<ButtonProps> = ({ variant = 'outline', size = 'md', className = '', children, ...rest }) => (
<button
className={`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--r-md)] font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
size === 'sm' ? 'h-9 px-4 text-[13px]' : 'h-10 px-5 text-[13.5px]'
} ${buttonVariants[variant]} ${className}`}
{...rest}
>
{children}
</button>
);
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
active?: boolean;
size?: number;
}
export const IconButton: React.FC<IconButtonProps> = ({ label, active, className = '', children, size = 34, ...rest }) => (
<button
title={label}
aria-label={label}
aria-pressed={active}
style={{ width: size, height: size, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
className={`rounded-[var(--r-md)] transition-colors disabled:opacity-35 disabled:cursor-not-allowed ${
active
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
: 'text-[var(--text-muted)] hover:bg-[var(--surface-3)] hover:text-[var(--text)]'
} ${className}`}
{...rest}
>
{children}
</button>
);
interface PopoverProps {
trigger: (open: boolean) => React.ReactNode;
children: React.ReactNode;
align?: 'left' | 'right';
width?: number;
}
export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'left', width }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="relative" ref={ref}>
<div onClick={() => setOpen((o) => !o)}>{trigger(open)}</div>
{open && (
<div
style={{ width }}
className={`absolute top-[calc(100%+6px)] z-50 rounded-[var(--r-lg)] border border-[var(--border)] bg-[var(--surface)] p-2.5 shadow-[var(--shadow-3)] ${
align === 'right' ? 'right-0' : 'left-0'
}`}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
)}
</div>
);
};
interface ColorSwatchesProps {
value: string;
onChange: (color: string) => void;
palette?: string[];
}
const DEFAULT_PALETTE = ['#facc15', '#fb923c', '#f87171', '#f472b6', '#a78bfa', '#60a5fa', '#34d399', '#1f2937', '#ffffff'];
export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, palette = DEFAULT_PALETTE }) => (
<div className="flex items-center gap-1.5">
{palette.map((c) => (
<button
key={c}
title={c}
onClick={() => onChange(c)}
className={`h-5 w-5 rounded-full border transition-transform hover:scale-110 ${
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[var(--accent)] ring-offset-1' : 'border-[var(--border-strong)]'
}`}
style={{ background: c }}
/>
))}
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[var(--border-strong)]"
title="Custom color"
style={{ background: 'conic-gradient(from 0deg, #f87171, #facc15, #34d399, #60a5fa, #a78bfa, #f87171)' }}>
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="absolute inset-0 cursor-pointer opacity-0" />
</label>
</div>
);
interface SliderProps {
value: number;
min: number;
max: number;
step?: number;
onChange: (v: number) => void;
label?: string;
suffix?: string;
width?: number;
}
export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => (
<div className="flex items-center gap-2">
{label && <span className="text-[11px] font-medium text-[var(--text-muted)]">{label}</span>}
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width }}
className="accent-[var(--accent)]"
/>
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[var(--text)]">{value}{suffix}</span>
</div>
);
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
badge?: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, hint, badge }) => (
<div className="flex flex-col items-center justify-center gap-2.5 px-6 py-14 text-center">
{icon && (
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--surface-3)] text-[var(--text-dim)]">
{icon}
</div>
)}
{badge && (
<span className="rounded-full bg-[var(--accent-soft)] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[var(--accent)]">
{badge}
</span>
)}
<p className="text-[13.5px] font-semibold text-[var(--text)]">{title}</p>
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[var(--text-dim)]">{hint}</p>}
</div>
);
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
width?: number;
footer?: React.ReactNode;
}
export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, width = 460, footer }) => {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/35 p-4" onMouseDown={onClose}>
<div
style={{ width }}
className="max-h-[88vh] overflow-hidden rounded-[var(--r-lg)] border border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-3)]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--border)] px-6 py-4">
<h2 className="text-[15px] font-bold text-[var(--text)]">{title}</h2>
<IconButton label="Close" size={30} onClick={onClose}><XIcon size={18} /></IconButton>
</div>
<div className="overflow-y-auto p-6">{children}</div>
{footer && <div className="flex justify-end gap-2.5 border-t border-[var(--border)] bg-[var(--surface-2)] px-6 py-4">{footer}</div>}
</div>
</div>
);
};
export interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
}
interface ConfirmDialogProps {
state: (ConfirmOptions & { onConfirm: () => void }) | null;
onClose: () => void;
}
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose }) => (
<Modal
open={!!state}
onClose={onClose}
title={state?.title ?? ''}
width={420}
footer={
state && (
<>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
variant={state.danger ? 'danger' : 'primary'}
onClick={() => { state.onConfirm(); onClose(); }}
>
{state.confirmLabel ?? 'Confirm'}
</Button>
</>
)
}
>
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">{state?.message}</p>
</Modal>
);
const toastStyles: Record<ToastItem['kind'], string> = {
info: 'border-[var(--border)] bg-[var(--text)] text-white',
success: 'border-transparent bg-[var(--success)] text-white',
error: 'border-transparent bg-[var(--danger)] 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-[var(--shadow-3)] ${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>
<button className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></button>
</div>
))}
</div>
);
};
+190 -768
View File
File diff suppressed because it is too large Load Diff
+12 -23
View File
@@ -1,12 +1,3 @@
/**
* PDF Coordinate Mapping Utilities
*
* Manages conversions between three coordinate systems:
* 1. PDF Space: Standard points (1/72 inch), (0,0) is usually top-left or bottom-left depending on the engine.
* 2. Canvas Space: Zoomed/scaled pixels on the target canvas, including device pixel ratio (DPR).
* 3. Viewport Space: Client screen coordinates relative to the scroll container / viewer window.
*/
export interface Point {
x: number;
y: number;
@@ -27,6 +18,18 @@ export interface MappingContext {
scrollY: number;
}
export function viewportRectToPdf(
rect: Rect,
zoom: number,
pageHeightPts: number,
): Rect {
const x = rect.x / zoom;
const width = rect.width / zoom;
const height = rect.height / zoom;
const y = pageHeightPts - (rect.y + rect.height) / zoom;
return { x, y, width, height };
}
export class CoordinateMapper {
private ctx: MappingContext;
@@ -42,12 +45,7 @@ export class CoordinateMapper {
return { ...this.ctx };
}
/**
* Converts PDF points to zoomed Canvas pixels
*/
pdfToCanvas(pt: Point, pageHeight: number): Point {
// If the PDF library treats (0,0) as bottom-left, we flip Y coordinate.
// For standard screen coordinates we use top-left.
const scaledX = pt.x * this.ctx.zoom * this.ctx.dpr;
const flippedY = pageHeight - pt.y;
const scaledY = flippedY * this.ctx.zoom * this.ctx.dpr;
@@ -55,9 +53,6 @@ export class CoordinateMapper {
return this.applyRotation({ x: scaledX, y: scaledY }, pageHeight * this.ctx.zoom * this.ctx.dpr);
}
/**
* Converts zoomed Canvas pixels to PDF points
*/
canvasToPdf(pt: Point, pageHeight: number): Point {
const unrotated = this.undoRotation(pt, pageHeight * this.ctx.zoom * this.ctx.dpr);
const x = unrotated.x / (this.ctx.zoom * this.ctx.dpr);
@@ -66,18 +61,12 @@ export class CoordinateMapper {
return { x, y };
}
/**
* Converts Viewport (client screen) coordinates to Canvas pixels
*/
viewportToCanvas(pt: Point, canvasRect: DOMRect): Point {
const canvasX = (pt.x - canvasRect.left + this.ctx.scrollX) * this.ctx.dpr;
const canvasY = (pt.y - canvasRect.top + this.ctx.scrollY) * this.ctx.dpr;
return { x: canvasX, y: canvasY };
}
/**
* Converts Canvas pixels to Viewport coordinates
*/
canvasToViewport(pt: Point, canvasRect: DOMRect): Point {
const viewX = (pt.x / this.ctx.dpr) + canvasRect.left - this.ctx.scrollX;
const viewY = (pt.y / this.ctx.dpr) + canvasRect.top - this.ctx.scrollY;
+73 -3
View File
@@ -38,6 +38,45 @@ export interface SearchResult {
text: string;
}
export interface Glyph {
text: string;
x: number;
y: number;
w: number;
h: number;
fontSize?: number;
}
export interface PageText {
text: string;
glyphs: Glyph[];
}
export interface DocumentMetadata {
title?: string;
author?: string;
subject?: string;
keywords?: string;
creator?: string;
producer?: string;
creation_date?: string;
modification_date?: string;
}
export interface FontInfo {
name: string;
type?: string;
isEmbedded?: boolean;
isSubset?: boolean;
isVertical?: boolean;
encoding?: string;
hasToUnicode?: boolean;
subsetTag?: string;
substitutedFrom?: string;
substitutedTo?: string;
normalizedFamily?: string;
}
export interface TextOverlayData {
text: string;
x: number;
@@ -348,7 +387,6 @@ class GatewayService {
}
private generateMockPage(pageIndex: number): string {
// Generate a premium vector representation of a mock document page
const width = 800;
const height = 1100;
const svg = `
@@ -358,7 +396,7 @@ class GatewayService {
<rect x="5" y="5" width="${width - 10}" height="${height - 10}" fill="white" rx="8" filter="drop-shadow(0 4px 6px rgba(0,0,0,0.05))" />
<!-- Header -->
<text x="60" y="80" font-family="system-ui, sans-serif" font-size="28" font-weight="800" fill="#1e293b">DocQube PDF Engine</text>
<text x="60" y="80" font-family="system-ui, sans-serif" font-size="28" font-weight="800" fill="#1e293b">PDF Editor</text>
<text x="60" y="110" font-family="system-ui, sans-serif" font-size="12" font-weight="500" fill="#64748b" letter-spacing="1">PAGE ${pageIndex + 1} OF THE SPECIFICATION</text>
<line x1="60" y1="130" x2="${width - 60}" y2="130" stroke="#f1f5f9" stroke-width="2" />
@@ -412,13 +450,45 @@ class GatewayService {
<!-- Footer -->
<line x1="60" y1="1020" x2="${width - 60}" y2="1020" stroke="#f1f5f9" stroke-width="1.5" />
<text x="60" y="1045" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#94a3b8">DocQube Core Engine (Gate G0a scaffolding)</text>
<text x="60" y="1045" font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#94a3b8">PDF Editor — preview render</text>
<text x="${width - 100}" y="1045" font-family="system-ui, sans-serif" font-size="11" font-weight="700" fill="#94a3b8">PAGE ${pageIndex + 1}</text>
</svg>
`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}`;
}
async getPageText(documentId: string, pageIndex: number): Promise<PageText> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/text`);
if (!response.ok) return { text: '', glyphs: [] };
const data = await response.json();
return { text: data.text || '', glyphs: Array.isArray(data.glyphs) ? data.glyphs : [] };
} catch {
return { text: '', glyphs: [] };
}
}
async getDocumentMetadata(documentId: string): Promise<DocumentMetadata> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/metadata`);
if (!response.ok) return {};
return response.json();
} catch {
return {};
}
}
async getDocumentFonts(documentId: string): Promise<FontInfo[]> {
try {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/fonts`);
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
async exportDocument(documentId: string, filename: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
+39
View File
@@ -0,0 +1,39 @@
export type ToastKind = 'info' | 'success' | 'error';
export interface ToastItem {
id: string;
kind: ToastKind;
message: string;
}
let items: ToastItem[] = [];
let counter = 0;
const listeners = new Set<(items: ToastItem[]) => void>();
function emit() {
const snapshot = [...items];
listeners.forEach((l) => l(snapshot));
}
export function toast(message: string, kind: ToastKind = 'info', ttlMs = 2800) {
const id = `t_${++counter}`;
items = [...items, { id, kind, message }];
emit();
window.setTimeout(() => {
items = items.filter((i) => i.id !== id);
emit();
}, ttlMs);
}
export function dismissToast(id: string) {
items = items.filter((i) => i.id !== id);
emit();
}
export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void {
listeners.add(fn);
fn([...items]);
return () => {
listeners.delete(fn);
};
}
+49
View File
@@ -0,0 +1,49 @@
export type ToolId =
| 'select'
| 'pan'
| 'highlight'
| 'draw'
| 'comment'
| 'textbox'
| 'signature'
| 'stamp'
| 'redact';
export interface ToolSettings {
highlightColor: string;
highlightOpacity: number; // 0..1
inkColor: string;
inkThickness: number;
textColor: string;
fontSize: number;
}
export const DEFAULT_TOOL_SETTINGS: ToolSettings = {
highlightColor: '#facc15',
highlightOpacity: 0.4,
inkColor: '#2563eb',
inkThickness: 2,
textColor: '#1f2937',
fontSize: 14,
};
export const TOOL_SHORTCUTS: Record<string, ToolId> = {
v: 'select',
h: 'pan',
k: 'highlight',
d: 'draw',
c: 'comment',
t: 'textbox',
s: 'signature',
m: 'stamp',
r: 'redact',
};
export const STAMP_PRESETS = [
{ label: 'APPROVED', color: '#16a34a' },
{ label: 'DRAFT', color: '#6b7280' },
{ label: 'CONFIDENTIAL', color: '#dc2626' },
{ label: 'REVIEWED', color: '#2563eb' },
{ label: 'FINAL', color: '#7c3aed' },
{ label: 'VOID', color: '#dc2626' },
];
+4 -2
View File
@@ -6,6 +6,8 @@ export interface Annotation {
type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink';
bbox: Rect;
color?: string;
opacity?: number;
thickness?: number;
author: string;
content?: string;
timestamp?: string;
@@ -65,7 +67,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
width: `${scaledBbox.width}px`,
height: `${scaledBbox.height}px`,
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
opacity: anno.type === 'highlight' ? 0.4 : undefined,
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined,
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined,
pointerEvents: 'auto',
}}
@@ -98,7 +100,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
.filter((anno) => anno.type === 'ink' && anno.paths)
.map((anno) => (
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={2 * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={(anno.thickness ?? 2) * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
{anno.paths!.map((path, i) => {
if (path.length === 0) return null;
const d = path.map((pt, j) => `${j === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ');
+130 -121
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
import type { Rect } from '../lib/coordinateMapping';
interface OverlayLayerProps {
pageIndex: number;
@@ -7,202 +8,210 @@ interface OverlayLayerProps {
height: number;
activeTool: string;
zoom: number;
inkColor: string;
inkThickness: number;
textColor: string;
fontSize: number;
hasSignature: boolean;
activeStamp: string | null;
onAnnotationAdded?: (anno: Annotation) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
}
const TEXTBOX_WIDTH_PTS = 200;
const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
pageIndex,
width,
height,
activeTool,
zoom,
onAnnotationAdded,
pageIndex, width, height, activeTool, zoom,
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature,
}) => {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
const [commentPopup, setCommentPopup] = useState<{ x: number; y: number } | null>(null);
const [commentText, setCommentText] = useState('');
const [textBox, setTextBox] = useState<{ x: number; y: number } | null>(null);
const [textValue, setTextValue] = useState('');
const svgRef = useRef<SVGSVGElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const getCoordinates = (e: React.MouseEvent | MouseEvent) => {
if (!svgRef.current) return { x: 0, y: 0 };
const rect = svgRef.current.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / zoom,
y: (e.clientY - rect.top) / zoom,
};
// Coordinates in page points (top-left origin)
const getCoordinates = (e: React.MouseEvent | React.PointerEvent) => {
const el = svgRef.current ?? rootRef.current;
if (!el) return { x: 0, y: 0 };
const rect = el.getBoundingClientRect();
return { x: (e.clientX - rect.left) / zoom, y: (e.clientY - rect.top) / zoom };
};
/* ---------------------------------------------------------------- ink */
const handlePointerDown = (e: React.PointerEvent) => {
if (activeTool !== 'draw') return;
setIsDrawing(true);
if (e.target instanceof Element) {
(e.target as Element).setPointerCapture(e.pointerId);
}
if (e.target instanceof Element) e.target.setPointerCapture(e.pointerId);
setCurrentPath([getCoordinates(e)]);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setCurrentPath((prev) => [...prev, getCoordinates(e)]);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setIsDrawing(false);
if (e.target instanceof Element) {
e.target.releasePointerCapture(e.pointerId);
}
if (e.target instanceof Element) e.target.releasePointerCapture(e.pointerId);
if (currentPath.length > 1) {
// Calculate bounding box
const xs = currentPath.map(p => p.x);
const ys = currentPath.map(p => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const newAnno: Annotation = {
const xs = currentPath.map((p) => p.x);
const ys = currentPath.map((p) => p.y);
onAnnotationAdded?.({
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'ink',
pageIndex,
bbox: {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
bbox: { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) },
author: 'Current User',
paths: [currentPath],
color: '#3b82f6', // Default blue color for now
};
onAnnotationAdded?.(newAnno);
color: inkColor,
thickness: inkThickness,
});
}
setCurrentPath([]);
};
/* ------------------------------------------------------------- click */
const handleLayerClick = (e: React.MouseEvent) => {
const coords = getCoordinates(e);
if (activeTool === 'comment') {
const coords = getCoordinates(e);
setCommentPopup(coords);
setCommentText('');
} else if (activeTool === 'textbox') {
setTextBox(coords);
setTextValue('');
} else if (activeTool === 'stamp' && activeStamp) {
onPlaceStamp?.(pageIndex, coords);
} else if (activeTool === 'signature' && hasSignature) {
onPlaceSignature?.(pageIndex, coords);
}
};
/* ----------------------------------------------------------- comment */
const handleCommentSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (commentPopup && commentText.trim() !== '') {
const newAnno: Annotation = {
onAnnotationAdded?.({
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'comment',
bbox: {
x: commentPopup.x,
y: commentPopup.y,
width: 24, // Standard sticky note icon size
height: 24,
},
bbox: { x: commentPopup.x, y: commentPopup.y, width: 24, height: 24 },
author: 'Current User',
content: commentText.trim(),
pageIndex: pageIndex,
pageIndex,
timestamp: new Date().toISOString(),
};
onAnnotationAdded?.(newAnno);
});
}
setCommentPopup(null);
setCommentText('');
};
const [commentPopup, setCommentPopup] = useState<{ x: number, y: number } | null>(null);
const [commentText, setCommentText] = useState('');
/* ----------------------------------------------------------- textbox */
const commitTextBox = () => {
if (textBox && textValue.trim() !== '') {
const heightPts = fontSize * 1.8;
onPlaceText?.(pageIndex, { x: textBox.x, y: textBox.y, width: TEXTBOX_WIDTH_PTS, height: heightPts }, textValue.trim());
}
setTextBox(null);
setTextValue('');
};
return (
<div
ref={rootRef}
className="overlay-layer"
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: ['draw', 'comment'].includes(activeTool) ? 'auto' : 'none' }}
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: POINTER_TOOLS.includes(activeTool) ? 'auto' : 'none' }}
onClick={handleLayerClick}
>
{/* Signature overlay state indicator */}
{activeTool === 'signature' && (
<div className="overlay-signature-alert">
<span className="overlay-badge">
READY TO PLACE SIGNATURE (Page {pageIndex + 1})
</span>
</div>
{/* Tool hints */}
{activeTool === 'signature' && !hasSignature && (
<div className="overlay-signature-alert"><span className="overlay-badge">Create a signature to place it here</span></div>
)}
{activeTool === 'signature' && hasSignature && !commentPopup && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to place signature</div>
)}
{/* Comment Tool Tip */}
{activeTool === 'comment' && !commentPopup && (
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
Click anywhere to add a Sticky Note
</div>
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
)}
{activeTool === 'stamp' && activeStamp && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to place {activeStamp}</div>
)}
{activeTool === 'textbox' && !textBox && (
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
)}
{/* Comment Input Popup */}
{/* Comment popup */}
{commentPopup && (
<div
<div
className="comment-popup-container"
style={{
position: 'absolute',
left: `${commentPopup.x * zoom}px`,
top: `${commentPopup.y * zoom}px`,
zIndex: 50,
}}
onClick={(e) => e.stopPropagation()} // Prevent triggering another comment
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleCommentSubmit} className="comment-form shadow-premium">
<form onSubmit={handleCommentSubmit}>
<div className="comment-form-header">
<span className="text-xs font-bold text-slate-700">Add Sticky Note</span>
<button type="button" onClick={() => setCommentPopup(null)} className="text-slate-400 hover:text-slate-600">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-xs font-bold">Add sticky note</span>
<button type="button" onClick={() => setCommentPopup(null)} className="text-[var(--text-dim)] hover:text-[var(--text)]">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<textarea
autoFocus
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here..."
className="comment-textarea"
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCommentSubmit(e);
}
}}
<textarea
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
placeholder="Type your comment here…" className="comment-textarea" rows={3}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
/>
<div className="comment-form-footer">
<button type="submit" className="comment-submit-btn">Save Note</button>
</div>
<div className="comment-form-footer"><button type="submit" className="comment-submit-btn">Save note</button></div>
</form>
</div>
)}
{/* Inline text box editor */}
{textBox && (
<textarea
autoFocus
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={commitTextBox}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
}}
className="textbox-editor"
placeholder="Type…"
style={{
left: `${textBox.x * zoom}px`,
top: `${textBox.y * zoom}px`,
width: `${TEXTBOX_WIDTH_PTS * zoom}px`,
minHeight: `${fontSize * 1.8 * zoom}px`,
fontSize: `${fontSize * zoom}px`,
color: textColor,
}}
/>
)}
{/* Ink drawing surface */}
{activeTool === 'draw' && (
<>
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
Freehand Ink Pen Enabled
</div>
<svg
ref={svgRef}
style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{currentPath.length > 0 && (
<path
d={currentPath.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ')}
stroke="#3b82f6"
strokeWidth={2 * zoom}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</>
<svg
ref={svgRef}
style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{currentPath.length > 0 && (
<path
d={currentPath.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ')}
stroke={inkColor} strokeWidth={inkThickness * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round"
/>
)}
</svg>
)}
</div>
);
+63 -5
View File
@@ -7,7 +7,9 @@ import { OverlayLayer } from './OverlayLayer';
import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { SearchResult, PageInfo } from '../lib/gatewayService';
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
import type { ToolSettings } from '../lib/tools';
import { toast } from '../lib/toast';
import { RedactionLayer } from './RedactionLayer';
interface PDFViewerProps {
@@ -18,7 +20,9 @@ interface PDFViewerProps {
zoom: number;
pagesInfo?: PageInfo[];
activeTool: string;
highlightColor: string;
toolSettings: ToolSettings;
hasSignature: boolean;
activeStamp: string | null;
annotations: Annotation[];
searchQuery?: string;
searchResults?: SearchResult[];
@@ -26,6 +30,9 @@ interface PDFViewerProps {
onAnnotationAdded?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
}
interface PageLayout {
@@ -49,7 +56,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
zoom,
pagesInfo,
activeTool,
highlightColor,
toolSettings,
hasSignature,
activeStamp,
annotations,
searchQuery,
searchResults,
@@ -57,18 +66,23 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onAnnotationAdded,
onPageVisible,
onRedactArea,
onPlaceText,
onPlaceStamp,
onPlaceSignature,
}, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
const [renderedPages, setRenderedPages] = useState<string[]>([]);
const [verifiedPages, setVerifiedPages] = useState<Record<number, boolean>>({});
const [containerHeight, setContainerHeight] = useState(800);
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
const verifiedPagesRef = useRef<Set<number>>(new Set());
// Reset cached page renders when switching documents
useEffect(() => {
setRenderedPages([]);
setVerifiedPages({});
setPageTexts({});
verifiedPagesRef.current.clear();
}, [documentId]);
@@ -241,7 +255,40 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
verifyPageModel();
}, [visiblePages, documentId, verifiedPages]);
// Fetch real glyph bounds for visible pages while text tools are active.
const textToolActive = activeTool === 'select' || activeTool === 'highlight';
useEffect(() => {
if (!textToolActive || !documentId) return;
let active = true;
const fetchTexts = async () => {
const missing = visiblePages.filter((p) => !(p.index in pageTexts));
if (missing.length === 0) return;
const results = await Promise.all(
missing.map(async (p) => ({ index: p.index, page: await gatewayService.getPageText(documentId, p.index) })),
);
if (!active) return;
setPageTexts((prev) => {
const next = { ...prev };
results.forEach(({ index, page }) => { next[index] = page.glyphs; });
return next;
});
};
fetchTexts();
return () => { active = false; };
}, [textToolActive, visiblePages, documentId, pageTexts]);
const handleTextSelection = (text: string, bbox: Rect, pageIndex: number) => {
if (activeTool === 'select') {
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');
}
return;
}
if (activeTool === 'highlight') {
const newAnno: Annotation = {
id: generateUniqueId(),
@@ -253,7 +300,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
width: bbox.width / zoom,
height: bbox.height / zoom,
},
color: highlightColor,
color: toolSettings.highlightColor,
opacity: toolSettings.highlightOpacity,
author: 'Current User',
content: text,
};
@@ -346,6 +394,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
width={page.width}
height={page.height}
zoom={zoom}
glyphs={pageTexts[page.index] || []}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
/>
)}
@@ -360,14 +409,23 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
/>
)}
{/* signature/ink overlay tool layer */}
{/* ink / comment / text-box / stamp / signature overlay tool layer */}
<OverlayLayer
pageIndex={page.index}
width={page.width}
height={page.height}
activeTool={activeTool}
zoom={zoom}
inkColor={toolSettings.inkColor}
inkThickness={toolSettings.inkThickness}
textColor={toolSettings.textColor}
fontSize={toolSettings.fontSize}
hasSignature={hasSignature}
activeStamp={activeStamp}
onAnnotationAdded={onAnnotationAdded}
onPlaceText={onPlaceText}
onPlaceStamp={onPlaceStamp}
onPlaceSignature={onPlaceSignature}
/>
{/* Search highlights overlay */}
+87 -43
View File
@@ -1,56 +1,103 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useMemo } from 'react';
import type { Point, Rect } from '../lib/coordinateMapping';
import type { Glyph } from '../lib/gatewayService';
interface SelectionLayerProps {
pageIndex: number;
width: number;
height: number;
zoom: number;
glyphs: Glyph[];
onTextSelected?: (text: string, bbox: Rect) => void;
}
export const SelectionLayer: React.FC<SelectionLayerProps> = ({
pageIndex,
width,
height,
onTextSelected,
}) => {
interface ZGlyph { gx: number; gy: number; gw: number; gh: number; text: string }
function rectsIntersect(ax: number, ay: number, aw: number, ah: number, b: Rect) {
return ax < b.x + b.width && ax + aw > b.x && ay < b.y + b.height && ay + ah > b.y;
}
export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, zoom, glyphs, onTextSelected }) => {
const [dragStart, setDragStart] = useState<Point | null>(null);
const [selectionBox, setSelectionBox] = useState<Rect | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setDragStart({ x, y });
setSelectionBox({ x, y, width: 0, height: 0 });
// Glyphs projected into zoomed pixel space once per glyphs/zoom change.
const zGlyphs = useMemo<ZGlyph[]>(
() => glyphs.map((g) => ({ gx: g.x * zoom, gy: g.y * zoom, gw: g.w * zoom, gh: g.h * zoom, text: g.text })),
[glyphs, zoom],
);
// Glyphs covered by the current drag box (for live highlight feedback).
const hitGlyphs = useMemo<ZGlyph[]>(() => {
if (!selectionBox || zGlyphs.length === 0) return [];
return zGlyphs.filter((g) => rectsIntersect(g.gx, g.gy, g.gw, g.gh, selectionBox));
}, [selectionBox, zGlyphs]);
const point = (e: React.MouseEvent) => {
const rect = containerRef.current!.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!dragStart || !selectionBox || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const handleMouseDown = (e: React.MouseEvent) => {
if (!containerRef.current) return;
const p = point(e);
setDragStart(p);
setSelectionBox({ x: p.x, y: p.y, width: 0, height: 0 });
};
const newBox: Rect = {
x: Math.min(dragStart.x, x),
y: Math.min(dragStart.y, y),
width: Math.abs(dragStart.x - x),
height: Math.abs(dragStart.y - y),
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragStart || !containerRef.current) return;
const p = point(e);
setSelectionBox({
x: Math.min(dragStart.x, p.x),
y: Math.min(dragStart.y, p.y),
width: Math.abs(dragStart.x - p.x),
height: Math.abs(dragStart.y - p.y),
});
};
setSelectionBox(newBox);
const buildText = (hits: ZGlyph[]): string => {
if (hits.length === 0) return '';
// Group into lines by vertical proximity, then order left→right.
const sorted = [...hits].sort((a, b) => a.gy - b.gy || a.gx - b.gx);
const lines: ZGlyph[][] = [];
for (const g of sorted) {
const last = lines[lines.length - 1];
if (last && Math.abs(last[0].gy - g.gy) < last[0].gh * 0.6) last.push(g);
else lines.push([g]);
}
return lines
.map((line) =>
line
.sort((a, b) => a.gx - b.gx)
.map((g, i, arr) => {
const prev = arr[i - 1];
const gap = prev ? g.gx - (prev.gx + prev.gw) : 0;
const sep = prev && gap > g.gw * 0.4 ? ' ' : '';
return sep + g.text;
})
.join(''),
)
.join('\n')
.replace(/\s+/g, ' ')
.trim();
};
const handleMouseUp = () => {
if (selectionBox && selectionBox.width > 5 && selectionBox.height > 5) {
// Mock selected text within this box for Phase 0
const mockedText = `[Selected text from Page ${pageIndex + 1} at coordinates x:${Math.floor(selectionBox.x)}, y:${Math.floor(selectionBox.y)}]`;
onTextSelected?.(mockedText, selectionBox);
if (selectionBox && selectionBox.width > 3 && selectionBox.height > 3) {
if (hitGlyphs.length > 0) {
// Tight union bbox around actually-selected glyphs (zoomed px).
const minX = Math.min(...hitGlyphs.map((g) => g.gx));
const minY = Math.min(...hitGlyphs.map((g) => g.gy));
const maxX = Math.max(...hitGlyphs.map((g) => g.gx + g.gw));
const maxY = Math.max(...hitGlyphs.map((g) => g.gy + g.gh));
onTextSelected?.(buildText(hitGlyphs), { x: minX, y: minY, width: maxX - minX, height: maxY - minY });
} else {
// No glyph data (e.g. mock mode) — fall back to the raw drag box so the
// highlight workflow still functions.
onTextSelected?.('', selectionBox);
}
}
setDragStart(null);
setSelectionBox(null);
@@ -64,18 +111,15 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
{/* Selection Box highlight visual */}
{selectionBox && (
<div
className="selection-highlight"
style={{
left: `${selectionBox.x}px`,
top: `${selectionBox.y}px`,
width: `${selectionBox.width}px`,
height: `${selectionBox.height}px`,
}}
/>
{/* Live per-glyph highlight */}
{hitGlyphs.map((g, i) => (
<div key={i} className="selection-glyph" style={{ left: g.gx, top: g.gy, width: g.gw, height: g.gh }} />
))}
{/* Drag rectangle (only when nothing is being hit, e.g. mock mode) */}
{selectionBox && hitGlyphs.length === 0 && (
<div className="selection-highlight" style={{ left: selectionBox.x, top: selectionBox.y, width: selectionBox.width, height: selectionBox.height }} />
)}
</div>
);