fix: updated ui and functionalities
This commit is contained in:
+1
-1
@@ -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>
|
||||
|
||||
Generated
+12
-21
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" /></>);
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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(' ');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user