diff --git a/.gitignore b/.gitignore
index 68a52e9..47f0d99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,4 +67,8 @@ gateway/*.pyd
gateway/*.so
gateway/*.dylib
-PDF Editor Timeline.xlsx
\ No newline at end of file
+PDF Editor Timeline.xlsx
+# Local environment config / secrets
+.env
+*.local.env
+gateway/.env
diff --git a/docs/ui-redesign-plan.md b/docs/ui-redesign-plan.md
new file mode 100644
index 0000000..e6e4851
--- /dev/null
+++ b/docs/ui-redesign-plan.md
@@ -0,0 +1,254 @@
+# PDF Editor — UI Redesign: Gap Analysis & Implementation Plan
+
+**Status:** Draft for review
+**Goal:** Replace the current "baby-like" UI with a sophisticated, comprehensive, professional PDF-editor interface that surfaces the full capability already built into the engine + gateway — **without breaking or losing any existing functionality or workflow.**
+**Naming:** Drop "EditQube"/"DocQube" branding. Use plain **"PDF Editor"** for now.
+
+---
+
+## 1. What we have today (baseline inventory)
+
+### 1.1 Architecture (this is solid — we keep it)
+```
+React 19 + Vite 8 + Tailwind v4
+ App.tsx ............ central state owner (zoom, tool, annotations, search, docs)
+ components/ ........ Toolbar, LeftSidebar (tool rail), Sidebar (right tabs), SearchBar, Thumbnail, WasmInspector
+ viewer/ ............ PDFViewer + 6 stacked layers (Canvas, Annotation, Selection, Redaction, Overlay, SearchOverlay)
+ lib/ ............... gatewayService (REST client), coordinateMapping, wasmLoader
+ |
+ v REST
+FastAPI gateway (app/routers: documents, edits, render, info, health)
+ |
+ v pybind11
+C++ engine (PDFium + Skia + FreeType + HarfBuzz)
+```
+The **layered-canvas viewer** and the **typed edit-operation envelope** (`{version, operations[]}`) are genuinely good foundations. The redesign is a UI/UX re-skin + feature-surfacing effort, **not** an architecture rewrite.
+
+### 1.2 Existing tools (left rail) — 8 tools
+Select · Search · Pan · Highlight · Draw (ink) · Comment (sticky) · Signature · Redact
+
+### 1.3 Existing right-sidebar tabs — 4
+Files · Pages (thumbnails + delete/reorder) · Notes (annotation list) · Settings (empty stub)
+
+### 1.4 Existing toolbar controls
+Logo · Gateway-health % · device-preview icons (non-functional) · undo/redo (non-functional) · rotate · zoom ± · Comments · Share (stub) · Export · Save Edits
+
+### 1.5 Workflows that currently work (MUST be preserved)
+1. Upload PDF → list → select → view (with mock fallback when gateway is down)
+2. Virtualized scroll viewer with per-page render + zoom
+3. Highlight via drag-select → persists via `applyEdits` (creates new doc id)
+4. Freehand ink draw → persists
+5. Sticky-note comment (click → popup → save) → persists
+6. Redact area (drag → confirm → permanent removal, full save)
+7. Page rotate / delete / reorder → persists
+8. Live full-text search (debounced) with prev/next + on-page highlight overlay
+9. Export / download edited PDF
+10. WASM inspector (debug panel) — client-side engine probe
+
+---
+
+## 2. The core problems (why it feels "baby-like")
+
+| # | Problem | Evidence |
+|---|---------|----------|
+| P1 | **Two fighting themes.** `index.css` defines a full *dark* "enterprise" component system (`rgba(2,6,23,…)`, white-on-dark) but `App.tsx`/components render a *light* theme with ad-hoc inline Tailwind. Half the CSS classes are unused or visually contradict what's on screen. | `index.css` `.toolbar`, `.sidebar`, `.doc-card` vs. `App.tsx` `bg-gray-50`, `text-gray-900` |
+| P2 | **No design system.** Spacing, radii, colors, font-weights are chosen per-component. Multiple blues (`#3b82f6` called "indigo"), random `rounded-full` pills, heavy drop-shadows. | Toolbar uses `shadow-lg`; CSS uses `0 4px 20px rgba(0,0,0,.25)` |
+| P3 | **Gimmicky chrome.** "Nice work! You completed 2/6 steps!" gamification toast, oversized circular page badge, decorative non-functional device-preview + undo/redo icons. | `App.tsx:470`, Toolbar |
+| P4 | **Thin feature surface.** The engine/gateway expose ~20 endpoints and 10 edit types; the UI wires maybe half. Big capabilities (metadata, fonts, structured text model, free-text, image/stamp, real text selection) are invisible. | §3 gap table |
+| P5 | **Stubs masquerading as features.** Text selection returns *mock* text (`SelectionLayer.tsx:52`); Signature tool only shows a banner; device-preview & undo/redo do nothing. | `SelectionLayer`, `OverlayLayer`, `Toolbar` |
+| P6 | **No empty/error/loading polish.** Single spinner + "No active document." No drag-drop upload zone, no toasts system, no command surface, no keyboard shortcuts. | `App.tsx` |
+| P7 | **Branding leftovers.** "EditQube" logo, "DocQube PDF Engine" baked into mock-page SVG. | `Toolbar.tsx:40`, `gatewayService.ts:361,415` |
+
+---
+
+## 3. Gap analysis — engine readiness vs. UI exposure
+
+> **Reworked against the master engineering roadmap.** Every UI feature is now gated on whether the *engine* actually supports it. We must build the UI to comprehensively surface what's **Done**, gracefully stub what's **In-progress / Not-started**, and never ship UI ahead of the engine.
+
+**Engine status** (from roadmap): 🟢 Done · 🟠 In-progress · 🔴 Not-started/None
+**UI status:** ✅ wired · 🟡 partial/stub · ❌ absent
+
+| Capability | Endpoint / edit type | Engine | UI | Action |
+|---|---|:--:|:--:|---|
+| Render / zoom / pan / nav / thumbnails | `GET …/render` | 🟢 | ✅ | Keep; re-skin |
+| Full-text search w/ rects | `GET …/search` | 🟢 | ✅ | Keep; add results-list panel |
+| **Text extraction + glyph bounds** | `GET …/pages/{i}/text`, `…/model` | 🟢 | 🟡 **mocked** | ⭐⭐ **Wire real selection + copy** ([SelectionLayer.tsx:53](frontend/src/viewer/SelectionLayer.tsx#L53) returns mock; clipboard never wired) |
+| Structured model (para→line→run→glyph) | `GET …/pages/{i}/model` | 🟢 | ❌ (logged only) | Use for line-box selection now; reflow is future |
+| Coordinate transforms (page↔device) | `GET …/transform/*` | 🟢 | ❌ (client math) | Use for pixel-accurate placement |
+| Glyph-width measure | `GET …/glyph-width` | 🟢 | ❌ | Use for free-text/overlay layout |
+| Document metadata (read) | `GET …/metadata` | 🟢 | ❌ | ⭐ **Properties panel (read-only)** |
+| Fonts inventory (embedded/subset/substituted…) | `GET …/fonts` | 🟢 | ❌ (only WASM debug) | ⭐ **Fonts inspector** (differentiator) |
+| **Highlight** | edit `highlight` | 🟢 | ✅ | Add color/opacity picker |
+| **Freehand ink** | edit `freehand` | 🟢 | ✅ | Add color/thickness/eraser |
+| **Comment / sticky (create)** | edit `comment` | 🟢 | ✅ | Keep; create-only (see edit/delete below) |
+| **Free-text box** | edit `free_text` | 🟢 | ❌ | ⭐ **Add "Text box" tool** |
+| **Text overlay / stamp** | edit `text_overlay` | 🟢 | ❌ | ⭐ **Add stamp / text-overlay tool** |
+| **Image overlay** | edit `image_overlay` | 🟢 | ❌ | ⭐⭐ **Visual Signature (draw/type/upload) + image stamp** |
+| **Redaction** | edit `redaction` | 🟠 (in progress) | ✅ | Keep; add multi-area when engine lands |
+| Page rotate / delete / reorder | edits `page_*` | 🟢 | ✅ | Keep; add multi-select |
+| Export / download (full save) | `GET …/export` | 🟠 (in progress) | ✅ | Keep; add print/flatten when ready |
+| Annotation **reader** (existing annots) | `GET …/annotations` | 🟠 (in progress) | ✅ list | Read-only list; refine when reader lands |
+| **Edit / delete existing annotation** | — *(no edit op exists)* | 🔴 | ❌ | 🔧 **Needs new backend op**; UI shows create-only until then |
+| Underline / strikeout / squiggly markup | decoration renderer | 🔴 (Phase 3) | 🟡 render-only | Defer real markup; keep FE strikeout as-is |
+| Glyph-accurate hit-testing (spatial index) | hit-test module | 🔴 (Phase 3) | ❌ | Approximate (line-box) selection only for v1 |
+| **Edit existing text / replace / reflow** | content-stream editor | 🔴 (Phase 3) | ❌ | ❗ **Not a v1 UI feature** — set expectations |
+| Form fields (view / fill) | AcroForm | 🔴 (P2 view + P3 fill) | ❌ | Defer; stub "Forms" as coming-soon |
+| Bookmarks / Outline | — *(no API anywhere)* | 🔴 | ❌ | 🔧 **Needs new engine+gateway**; stub Outline tab |
+| Encrypted / password PDFs | encryption | 🔴 (Phase 3) | ❌ | Defer password prompt until engine supports |
+| **Digital (cryptographic) signature** | cert/PKI | 🔴 (Phase 4, out of v1 scope) | ❌ | Do **visual** sig only; label as not certified |
+| WASM in-browser render + worker | wasm path | 🟠 (in progress) | 🟡 (inspector) | Keep behind dev flag |
+
+### 3.1 What this rework changes vs. the first draft
+- ⭐⭐ **Real text selection/copy is the #1 win and is genuinely ready** — the engine extracts glyphs with bounds; the live viewer just never calls it. This is *wiring*, not new engine work. (Note: glyph-perfect hit-testing is Phase 3, so v1 selection is line-box-accurate, not per-glyph.)
+- ✅ **Signature stays a flagship — but as a *visual* signature** (image_overlay, which is Done), explicitly **not** a cryptographic/certified signature (Phase 4, out of v1 scope). UI copy must not imply legal e-signature.
+- 🔧 **Three features I'd wrongly treated as "easy UI wins" are blocked on backend:** (1) edit/delete of existing annotations (no edit op), (2) Outline/Bookmarks (no API at all), (3) password/encrypted PDFs (Phase 3). These become either small backend tickets or graceful stubs — **not** silent dead buttons.
+- ❗ **"Edit existing text" must be framed as future.** Users hear "PDF editor" and expect to retype existing paragraphs — that's the Phase 3 content-stream editor, **Not-started**. The UI should make the v1 capability set (annotate, overlay, redact, organize pages, fill-via-overlay) clear so it doesn't feel broken.
+
+### 3.2 Small backend tickets that unlock high-value UI (flag to engine devs)
+| Ticket | Unlocks | Size |
+|---|---|---|
+| `delete_annotation` / `update_annotation` edit op | Editable/deletable comments & markup in the inspector | Small |
+| `GET …/outline` (PDFium `FPDFBookmark_*`) | Outline/Bookmarks navigation tab | Small |
+| Surface `password` flow + encryption (Phase 3) | Open protected PDFs | Medium (already roadmapped) |
+
+---
+
+## 4. Target experience (the redesign)
+
+### 4.1 Layout model — professional editor shell
+```
+┌────────────────────────────────────────────────────────────────────┐
+│ TOP BAR: [≡ File ▾] PDF Editor · doc name · ⟲⟳ undo/redo │
+│ · save-state ("All changes saved" / "Saving…") │
+│ · zoom · page X/Y · [Share] [Export ▾] │
+├──┬─────────────────────────────────────────────────────┬───────────┤
+│ │ CONTEXTUAL TOOL STRIP (changes with active tool): │ │
+│T │ e.g. Highlight → color swatches, opacity │ RIGHT │
+│O │ Draw → color, thickness, eraser │ PANEL │
+│O ├───────────────────────────────────────────────────────┤ (tabbed) │
+│L │ │ Pages │
+│ │ PAGE CANVAS (scroll / virtualized) │ Outline │
+│R │ + layered overlays (unchanged) │ Comments │
+│A │ │ Search │
+│I │ │ Props │
+│L │ │ Fonts │
+└──┴───────────────────────────────────────────────────────┴───────────┘
+```
+Key moves vs. today:
+- **Contextual tool strip** under the top bar that swaps controls per active tool (this is what makes it feel like Acrobat/Figma rather than a toy). Replaces hardcoded colors/thicknesses with real pickers — closes several gaps at once.
+- **Tool rail** stays on the left (familiar), but redrawn on the new design system with consistent icons + tooltips + keyboard shortcut hints.
+- **Right panel** becomes a real tabbed inspector. Tabs are gated on engine readiness:
+ - **Ready now (engine Done):** Pages, Comments/Notes (read + create), Search results, **Properties** (metadata, read-only), **Fonts**.
+ - **Stubbed (engine Not-started):** Outline/Bookmarks and Forms render as a clean "coming soon" empty-state, *not* a broken tab — they light up when their backend ticket lands.
+- **Command-driven top bar**: real undo/redo (operation history), explicit save-state indicator, Export dropdown (Download / Print-when-ready / Flatten-when-ready).
+- Remove gamification toast, decorative device-preview icons, oversized page badge.
+
+### 4.2 Design system (single source of truth)
+Define tokens once (CSS variables + a Tailwind theme) and delete the contradictory legacy classes.
+- **Theme:** **light, neutral, low-chroma** workspace (Acrobat/Foxit-like) — locked (§8). Tokens structured so dark mode is a later toggle.
+- **Color:** one primary accent + neutral grays + semantic (success/warn/error). Kill the "indigo that's actually blue" ambiguity.
+- **Type:** keep Outfit (UI) + JetBrains Mono (debug/metadata). Define a type scale (xs–2xl) and stop per-component font-weights.
+- **Spacing/radii/shadow:** 4px grid; 2 radii (control / card); 2 elevations. No more `0 4px 20px rgba(0,0,0,.25)` on a light UI.
+- **Primitives:** small set of reusable components — `Button`, `IconButton`, `Tooltip`, `Panel`, `Tab`, `Toolbar`, `Popover`, `ColorPicker`, `Slider`, `Toast`, `Modal`, `EmptyState`, `Spinner`. Everything else composes these.
+
+### 4.3 Interaction upgrades
+- Keyboard shortcuts (V select, H highlight, etc.; ⌘/Ctrl+Z undo, ⌘F search).
+- Drag-and-drop file upload onto the canvas with a real drop zone.
+- Toast/notification system (replace the gamification + scattered `alert()`/`confirm()` calls with a consistent confirm modal + toasts).
+- Proper unsaved-changes tracking and "Save"/"Saved" affordance.
+
+---
+
+## 5. Implementation plan (phased, non-breaking)
+
+Principle: **re-skin behind stable behavior first, surface only engine-Done features, stub the rest gracefully.** Each phase compiles and ships; no phase removes a working workflow until its replacement is proven; **no phase ships UI for an engine feature that isn't Done.**
+
+**v1 capability framing (set in the UI so it doesn't feel broken):** PDF Editor v1 = *annotate (highlight/ink/comment), overlay (text box / stamp / visual signature), redact, organize pages (rotate/delete/reorder), search, select & copy text, inspect (properties/fonts), export.* It does **not** yet edit existing text, fill forms, or apply certified digital signatures — those are engine Phase 3/4.
+
+### Phase 0 — Foundation & rename (no behavior change) — *small*
+- Establish design tokens: rewrite `index.css` into a coherent token layer + Tailwind theme; remove dead/contradictory classes.
+- Build the primitive component library (§4.2) as empty-but-styled shells.
+- Rename: "EditQube"/"DocQube" → "PDF Editor" (`Toolbar.tsx:40`, `gatewayService.ts:361,415`, `index.html` title, favicon/logo). Remove gamification toast (`App.tsx:470`) and decorative non-functional icons (device-preview).
+- **Exit criteria:** app looks cleaner, every existing workflow still works, zero functional regressions.
+
+### Phase 1 — App shell & top bar — *medium*
+- New top bar: File menu, doc title, **real undo/redo** (operation-history stack in `App.tsx` over the existing `applyEdits` model — each edit returns a `newDocumentId`, so keep a stack of doc ids for instant undo/redo), save-state indicator, zoom, page nav, Export dropdown.
+- **Exit criteria:** every top-bar control is functional or removed; undo/redo works for highlight/ink/comment/redact/page ops.
+
+### Phase 2 — Tool rail + contextual tool strip — *medium*
+- Redraw the tool rail on the new system with tooltips + shortcuts.
+- Add the contextual strip; move color/opacity (highlight) and color/thickness/eraser (draw) into real pickers — closes the hardcoded-color gaps (all engine-Done).
+- **Exit criteria:** highlight + draw fully parameterized; tool switching is keyboard-driven.
+
+### Phase 3 — Right inspector panel — *medium*
+- Rebuild right sidebar as tabbed inspector. **Build the engine-Done tabs for real; stub the rest:**
+ - **Pages** — improved thumbnails + multi-select (Done).
+ - **Search** — results list + on-page sync (Done).
+ - **Properties** — `/metadata`, read-only (Done).
+ - **Fonts** — `/fonts` inventory, the differentiator (Done).
+ - **Comments/Notes** — list + **create** only (annotation reader in-progress; **edit/delete deferred** until the `delete/update_annotation` op exists — show a disabled affordance, not a dead button).
+ - **Outline** + **Forms** — clean "coming soon" empty-states (no engine API yet).
+- **Exit criteria:** metadata + fonts + search-list visible; comments listed; stubbed tabs render an intentional empty-state.
+
+### Phase 4 — Real text layer (replace the mock) — *medium-large* ⭐⭐
+- Replace `SelectionLayer`'s mock ([SelectionLayer.tsx:53](frontend/src/viewer/SelectionLayer.tsx#L53)) with real glyph data from `/pages/{i}/text` / `/model`: selectable text, **copy-to-clipboard** (currently never wired), highlight quad-points snapped to text.
+- v1 selection is **line-box accurate** (uses returned glyph bboxes); per-glyph hit-testing waits on the engine Phase 3 spatial index.
+- **Exit criteria:** user selects and copies real text; highlights align to text runs.
+
+### Phase 5 — New overlay tools: Visual Signature, Text box, Stamp — *medium-large* ⭐
+- **Visual Signature** via `image_overlay` (Done): draw-pad / type / upload → place on page. Replaces the banner-only stub. **Label clearly as a visual signature, not a certified/digital signature.**
+- **Text box** via `free_text` (Done); **Stamp / text overlay** via `text_overlay` / `image_overlay` (Done).
+- **Exit criteria:** signature + text box + stamp produce real edits and survive export.
+
+### Phase 6 — Polish & power features — *ongoing*
+- Drag-drop upload, toast system, confirm modals (replace scattered `alert`/`confirm`), keyboard-shortcut help overlay, search-and-redact, multi-select page ops, dark-mode toggle.
+- **Engine-gated (wire when their tickets land):** edit/delete annotations, Outline navigation, Forms view/fill, encrypted-PDF password prompt, print/flatten export, page insert/duplicate/extract, in-browser WASM worker render.
+
+> Phases 0–3 deliver the sophisticated look + surface every Done capability. Phases 4–5 wire the two flagships (real text, visual signature). Phase 6 polishes and lights up engine features as they land. "Everything (0–6)" = everything the engine currently supports, with graceful forward-compat stubs for the rest.
+
+---
+
+## 6. Preserve-don't-break checklist (regression guardrails)
+
+Each phase must keep these intact (manual smoke + ideally a test):
+- [ ] Upload (real gateway **and** mock fallback when gateway 501/offline)
+- [ ] Document list / select / metadata load
+- [ ] Virtualized scroll + per-page render + zoom (0.5×–3×) + rotate
+- [ ] Highlight → `applyEdits` → new doc id swap
+- [ ] Freehand ink → persists
+- [ ] Comment popup → persists
+- [ ] Redact → confirm → full save → persists
+- [ ] Page rotate / delete / reorder → persists + current-page math
+- [ ] Search debounce + prev/next + on-page overlay + scroll-to-page
+- [ ] Export download
+- [ ] WASM inspector still loads (keep as dev/debug panel, hidden behind a flag)
+- [ ] Coordinate mapping correctness (zoom/rotation/DPR) — the redaction y-flip math in `App.tsx:392` is fragile; cover before refactor.
+
+**Contracts that must not change without backend coordination:** the `EditOperation` envelope shape, `DocumentInfo` shape, and gateway URLs in `gatewayService.ts`. Redesign is presentation-layer; keep `gatewayService` as the stable seam.
+
+---
+
+## 7. Effort & sequencing summary
+
+| Phase | Theme | Rel. size | User-visible payoff |
+|---|---|---|---|
+| 0 | Tokens + rename + de-gimmick | S | Instantly looks less toy-like |
+| 1 | App shell, undo/redo, save-state | M | Feels like a real editor |
+| 2 | Tool rail + contextual strip + pickers | M | Pro tool ergonomics |
+| 3 | Tabbed inspector (props, fonts, comments, search) | M | Surfaces hidden power |
+| 4 | Real text selection/copy (engine Done; wiring) | M–L | Kills the biggest "fake" feeling |
+| 5 | Visual signature / text box / stamp (engine Done) | M–L | Major new capabilities |
+| 6 | Polish + light up engine features as they land | ongoing | Differentiation |
+
+> Every phase touches presentation or wires an **engine-Done** capability. Nothing here waits on unbuilt engine work except the explicitly-stubbed tabs (Outline, Forms) and the Phase-6 engine-gated list.
+
+---
+
+## 8. Locked decisions (confirmed)
+
+1. **Theme:** **Light neutral** workspace (Acrobat/Foxit-like). Tokens architected so a dark theme is a later toggle, but dark mode itself is deferred to Phase 6.
+2. **Layout:** **Left tool-rail + contextual tool strip** under the top bar (per §4.1).
+3. **Scope:** **Full redesign, Phases 0–6** (everything — sophisticated shell *and* flagship features *and* polish).
+4. **Components:** **Hand-built primitives on Tailwind** — zero new dependencies, full control, consistent with the current zero-dep frontend.
+
+**Next step:** detailed component-by-component build spec for Phase 0, then begin implementation phase-by-phase with a smoke-check of the §6 regression list after each phase.
diff --git a/frontend/index.html b/frontend/index.html
index 0fca6f0..5dae465 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
frontend
+ PDF Editor
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d6bbbb3..793aafe 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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"
}
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f90339d..e69de29 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -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);
- }
-}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 541435a..0581f17 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(null);
- const [documents, setDocuments] = useState([]);
- const [selectedDocId, setSelectedDocId] = useState('');
- const [activeDoc, setActiveDoc] = useState(null);
-
- // Settings
- const [zoom, setZoom] = useState(1.0);
- const [activeTool, setActiveTool] = useState('select');
- const [highlightColor, setHighlightColor] = useState('#ffeb3b');
- const [currentPage, setCurrentPage] = useState(0);
-
- // States
- const [annotations, setAnnotations] = useState([]);
- const [backendHealthy, setBackendHealthy] = useState(null);
- const [sidebarTab, setSidebarTab] = useState<'documents' | 'annotations' | 'outline' | 'settings'>('documents');
- const [isLoading, setIsLoading] = useState(true);
- const [wasmInspectorOpen, setWasmInspectorOpen] = useState(false);
- const [showToast, setShowToast] = useState(true);
- // Search State
+ const [documents, setDocuments] = useState([]);
+ const [activeDoc, setActiveDoc] = useState(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('select');
+ const [toolSettings, setToolSettings] = useState(DEFAULT_TOOL_SETTINGS);
+ const [currentPage, setCurrentPage] = useState(0);
+ const [isInspectorOpen, setIsInspectorOpen] = useState(true);
+
+ // Data
+ const [annotations, setAnnotations] = useState([]);
+ const [metadata, setMetadata] = useState(null);
+ const [fonts, setFonts] = useState([]);
+ const [backendHealthy, setBackendHealthy] = useState(null);
+ const [engineReady, setEngineReady] = useState(false);
+ const [inspectorTab, setInspectorTab] = useState('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([]);
- 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 (
-
- {/* Top Navigation / Toolbar */}
-
+ 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}
/>
-
- {/* Left Interactive Sidebar (Tools & Search) */}
-
+ setSignatureModalOpen(true)}
/>
- {/* Main PDF Scroll Viewer Area */}
-
- {showToast && (
-
-
Nice work! You completed 2/6 steps!
-
-
- )}
+
+
setToolSettings((s) => ({ ...s, ...patch }))}
+ onOpenSignature={() => setSignatureModalOpen(true)}
+ hasSignature={!!pendingSignature}
+ activeStamp={activeStamp?.label ?? null}
+ onSelectStamp={(label, color) => setActiveStamp({ label, color })}
+ />
- {isLoading ? (
-
-
-
-
+
+ {isLoading && !activeDoc ? (
+
-
Loading Document...
-
- ) : activeDoc ? (
-
- ) : (
-
-
-
No active document. Please upload a PDF file.
-
- )}
-
+ ) : (
+
+
+
No document open
+
Open a PDF to start editing.
+
+
+ )}
+
- {/* Right Interactive Sidebar (Tabs & Outline) */}
- {
- if (viewerRef.current) {
- viewerRef.current.scrollToPage(pageIndex);
- }
- }}
- onDeletePage={handleDeletePage}
- onReorderPage={handleReorderPage}
- />
+ {isInspectorOpen && (
+ 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}
+ />
+ )}
+
setSignatureModalOpen(false)}
+ onConfirm={(url, aspect) => {
+ setPendingSignature({ url, aspect });
+ setSignatureModalOpen(false);
+ setActiveTool('signature');
+ toast('Signature ready — click on the page to place it', 'info');
+ }}
+ />
+
+ setConfirmState(null)} />
+
);
}
diff --git a/frontend/src/components/InspectorPanel.tsx b/frontend/src/components/InspectorPanel.tsx
new file mode 100644
index 0000000..3e2c3ee
--- /dev/null
+++ b/frontend/src/components/InspectorPanel.tsx
@@ -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: },
+ { id: 'notes', label: 'Notes', icon: },
+ { id: 'search', label: 'Search', icon: },
+ { id: 'properties', label: 'Properties', icon: },
+ { id: 'fonts', label: 'Fonts', icon: },
+ { id: 'outline', label: 'Outline', icon: , stub: true },
+ { id: 'forms', label: 'Forms', icon: , 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 = (p) => {
+ const selectedDoc = p.documents.find((d) => d.id === p.selectedDocumentId);
+ const activeDef = TABS.find((t) => t.id === p.activeTab)!;
+
+ return (
+
+ );
+};
+
+/* ----------------------------------------------------------------- Pages */
+const PagesTab: React.FC = (p) => {
+ if (!p.totalPages) return } title="No pages" hint="Open a PDF to see its pages." />;
+ return (
+
+ {Array.from({ length: p.totalPages }).map((_, idx) => (
+
+ 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}
+ />
+
+ ))}
+
+ );
+};
+
+/* ----------------------------------------------------------------- Notes */
+const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation) => void }> = ({ annotations, onNavigate }) => {
+ if (annotations.length === 0) {
+ return } title="No annotations yet"
+ hint="Highlights, ink, and comments you add appear here." />;
+ }
+ return (
+
+ {annotations.map((a) => (
+
+ ))}
+
+ Editing & deleting saved annotations is coming soon — for now annotations are append-only.
+
+
+ );
+};
+
+/* ---------------------------------------------------------------- Search */
+const SearchTab: React.FC = (p) => {
+ let flatIndex = -1;
+ return (
+
+
+
+
+ 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)]"
+ />
+
+ {p.searchQuery && (
+
+ {p.searchResults.length > 0 ? `${p.searchResults.length} match${p.searchResults.length > 1 ? 'es' : ''}` : 'No matches'}
+
+ )}
+
+
+ {p.searchResults.map((r) => {
+ flatIndex++;
+ const i = flatIndex;
+ return (
+
+ );
+ })}
+ {!p.searchQuery && (
+ } title="Search the document" hint="Type a term to find and jump to matches." />
+ )}
+
+
+ );
+};
+
+/* ------------------------------------------------------------ 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 (
+
+ {rows.map(([k, v]) => (
+
+ {k}
+ {v && v.trim() ? v : —}
+
+ ))}
+
Document properties are read-only.
+
+ );
+};
+
+/* ----------------------------------------------------------------- Fonts */
+const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
+ if (fonts.length === 0) {
+ return } title="No font data" hint="Font inventory appears once a document with embedded fonts is open." />;
+ }
+ // De-duplicate by name
+ const seen = new Set();
+ const unique = fonts.filter((f) => (seen.has(f.name) ? false : (seen.add(f.name), true)));
+ return (
+
+ {unique.map((f, i) => (
+
+
+ {f.name || 'Unknown'}
+ {f.type && {f.type}}
+
+
+
+ {f.isSubset && }
+ {f.substitutedTo && }
+ {f.encoding && }
+ {f.hasToUnicode && }
+
+
+ ))}
+
+ );
+};
+
+const Badge: React.FC<{ label: string; ok?: boolean; warn?: boolean }> = ({ label, ok, warn }) => (
+
+ {label}
+
+);
diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx
deleted file mode 100644
index 2d4abea..0000000
--- a/frontend/src/components/LeftSidebar.tsx
+++ /dev/null
@@ -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 = ({
- activeTool,
- onActiveToolChange,
- onUploadStart,
- searchQuery,
- setSearchQuery,
- searchResultCount,
- searchCurrentMatch,
- onSearchNext,
- onSearchPrev
-}) => {
- const handleFileUpload = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (file && onUploadStart) {
- onUploadStart(file);
- }
- e.target.value = '';
- };
-
- const tools = [
- {
- id: 'select', label: 'Select', icon: (
-
- )
- },
- {
- id: 'search', label: 'Search', icon: (
-
- )
- },
- {
- id: 'pan', label: 'Pan', icon: (
-
- )
- },
- {
- id: 'highlight', label: 'Highlight', icon: (
-
- )
- },
- {
- id: 'draw', label: 'Draw', icon: (
-
- )
- },
- {
- id: 'comment', label: 'Comment', icon: (
-
- )
- },
- {
- id: 'signature', label: 'Signature', icon: (
-
- )
- },
- {
- id: 'redact', label: 'Redact', icon: (
-
- )
- }
- ];
-
- return (
-
- {/* Primary Action Button (Screen/Upload) */}
-
-
-
-
- {tools.map(tool => (
-
- ))}
-
- {/* Slide-out Search Panel Overlay */}
- {activeTool === 'search' && (
-
-
-
Search Document
-
-
-
-
-
-
setSearchQuery(e.target.value)}
- className="bg-transparent border-none outline-none text-sm text-gray-800 placeholder-gray-400 w-full"
- autoFocus
- />
-
-
- {searchQuery && (
-
-
- {searchResultCount > 0 ? `${searchCurrentMatch + 1} of ${searchResultCount} matches` : 'No matches'}
-
-
- {searchResultCount > 0 && (
-
- )}
-
- )}
-
-
- )}
-
- );
-};
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
deleted file mode 100644
index 04fb0e4..0000000
--- a/frontend/src/components/SearchBar.tsx
+++ /dev/null
@@ -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 = ({
- onSearch,
- resultCount,
- currentMatch,
- onNext,
- onPrev,
-}) => {
- const [query, setQuery] = useState('');
-
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') {
- onSearch(query);
- }
- };
-
- return (
-
-
-
setQuery(e.target.value)}
- onKeyDown={handleKeyDown}
- className="bg-transparent border-none outline-none text-slate-100 text-sm w-32 placeholder-slate-500"
- />
- {query && (
-
-
{resultCount > 0 ? `${currentMatch + 1}/${resultCount}` : '0/0'}
-
-
- )}
-
- );
-};
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
deleted file mode 100644
index 4f40ba3..0000000
--- a/frontend/src/components/Sidebar.tsx
+++ /dev/null
@@ -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 = ({
- 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: (
-
- )
- },
- {
- id: 'outline', label: 'Pages', icon: (
-
- )
- },
- {
- id: 'annotations', label: 'Notes', icon: (
-
- )
- },
- {
- id: 'settings', label: 'Settings', icon: (
-
- )
- }
- ];
-
- return (
-
- {/* Progress / Page indicator */}
-
- {currentPage + 1}/{Math.max(1, totalPages)}
-
-
-
-
- {tabs.map(tab => (
-
- ))}
-
-
-
- {/* Help Icon */}
-
-
- {/* Slide-out Panel Overlay */}
- {activeTab !== 'settings' && activeTab !== 'outline' && activeTab !== 'documents' && activeTab !== 'annotations' ? null : (
-
-
-
- {activeTab === 'documents' ? 'Files' : activeTab === 'outline' ? 'Pages' : activeTab === 'annotations' ? 'Notes' : 'Settings'}
-
-
-
-
- {activeTab === 'documents' && (
-
- {documents.map((doc) => (
-
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'}`}>
-
{doc.filename}
-
{formatBytes(doc.sizeBytes)} • {doc.totalPages} Pages
-
- ))}
-
- )}
-
- {activeTab === 'outline' && (
-
- {Array.from({ length: totalPages }).map((_, idx) => (
- 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}
- />
- ))}
-
- )}
-
- {activeTab === 'annotations' && (
-
- {annotations.length === 0 ? (
-
No notes yet.
- ) : (
- annotations.map((anno) => (
-
-
- {anno.type}
- {anno.author}
-
-
"{anno.content}"
-
- ))
- )}
-
- )}
-
- {activeTab === 'settings' && (
-
-
WASM settings can go here.
-
- )}
-
-
- )}
-
- );
-};
diff --git a/frontend/src/components/SignatureModal.tsx b/frontend/src/components/SignatureModal.tsx
new file mode 100644
index 0000000..2e1a33c
--- /dev/null
+++ b/frontend/src/components/SignatureModal.tsx
@@ -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 = ({ open, onClose, onConfirm }) => {
+ const [mode, setMode] = useState('draw');
+ const [typed, setTyped] = useState('');
+ const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
+ const canvasRef = useRef(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) => {
+ 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 (
+
+
+
+ >
+ }
+ >
+
+ {(['draw', 'type', 'upload'] as Mode[]).map((m) => (
+
+ ))}
+
+
+ {mode === 'draw' && (
+
+
+
+ Draw your signature above
+
+
+
+ )}
+
+ {mode === 'type' && (
+
+
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)]"
+ />
+
+
+ {typed || 'Preview'}
+
+
+
+ )}
+
+ {mode === 'upload' && (
+
+ )}
+
+ Visual signature only — not a certified e-signature.
+
+ );
+};
diff --git a/frontend/src/components/Thumbnail.tsx b/frontend/src/components/Thumbnail.tsx
index 759a12a..7a4ecb9 100644
--- a/frontend/src/components/Thumbnail.tsx
+++ b/frontend/src/components/Thumbnail.tsx
@@ -56,11 +56,11 @@ export const Thumbnail: React.FC = ({
return (
-
+
{loading ? (
-
-
-
Loading...
+
) : imageUrl ? (
<>
@@ -94,7 +94,7 @@ export const Thumbnail: React.FC
= ({
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"
>