From ef544263a55ffcfcab8d0e253f80aa29be09c26d Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Wed, 10 Jun 2026 11:41:58 +0530 Subject: [PATCH] fix: updated ui and functionalities --- .gitignore | 6 +- docs/ui-redesign-plan.md | 254 ++++++ frontend/index.html | 2 +- frontend/package-lock.json | 33 +- frontend/src/App.css | 184 ---- frontend/src/App.tsx | 866 +++++++++---------- frontend/src/components/InspectorPanel.tsx | 304 +++++++ frontend/src/components/LeftSidebar.tsx | 179 ---- frontend/src/components/SearchBar.tsx | 58 -- frontend/src/components/Sidebar.tsx | 179 ---- frontend/src/components/SignatureModal.tsx | 168 ++++ frontend/src/components/Thumbnail.tsx | 12 +- frontend/src/components/ToolRail.tsx | 92 ++ frontend/src/components/Toolbar.tsx | 268 +++--- frontend/src/components/TopBar.tsx | 200 +++++ frontend/src/components/icons.tsx | 78 ++ frontend/src/components/ui.tsx | 261 ++++++ frontend/src/index.css | 958 ++++----------------- frontend/src/lib/coordinateMapping.ts | 35 +- frontend/src/lib/gatewayService.ts | 76 +- frontend/src/lib/toast.ts | 39 + frontend/src/lib/tools.ts | 49 ++ frontend/src/viewer/AnnotationLayer.tsx | 6 +- frontend/src/viewer/OverlayLayer.tsx | 251 +++--- frontend/src/viewer/PDFViewer.tsx | 68 +- frontend/src/viewer/SelectionLayer.tsx | 130 ++- 26 files changed, 2568 insertions(+), 2188 deletions(-) create mode 100644 docs/ui-redesign-plan.md create mode 100644 frontend/src/components/InspectorPanel.tsx delete mode 100644 frontend/src/components/LeftSidebar.tsx delete mode 100644 frontend/src/components/SearchBar.tsx delete mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/SignatureModal.tsx create mode 100644 frontend/src/components/ToolRail.tsx create mode 100644 frontend/src/components/TopBar.tsx create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/lib/toast.ts create mode 100644 frontend/src/lib/tools.ts 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…

-

Loading Document...

-
- ) : activeDoc ? ( -
+ ) : 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) */} -
- - 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... +
+
+ 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" > @@ -110,7 +110,7 @@ export const Thumbnail: React.FC = ({ 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" > diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx new file mode 100644 index 0000000..8fa07de --- /dev/null +++ b/frontend/src/components/ToolRail.tsx @@ -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: }, + { id: 'pan', label: 'Pan', shortcut: 'H', icon: }, + 'divider', + { id: 'highlight', label: 'Highlight', shortcut: 'K', icon: }, + { id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: }, + { id: 'comment', label: 'Comment', shortcut: 'C', icon: }, + { id: 'textbox', label: 'Text box', shortcut: 'T', icon: }, + { id: 'signature', label: 'Signature', shortcut: 'S', icon: }, + { id: 'stamp', label: 'Stamp', shortcut: 'M', icon: }, + 'divider', + { id: 'redact', label: 'Redact', shortcut: 'R', icon: , 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 }) => ( + } + {React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon} + +); + +export const ToolRail: React.FC = ({ activeTool, onToolChange, hasSignature, onOpenSignature }) => { + const pickTool = (id: ToolId) => { + onToolChange(id); + if (id === 'signature' && !hasSignature) onOpenSignature(); + }; + + return ( + + ); +}; diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index e17bbe4..bf6b208 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -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) => void; + onOpenSignature: () => void; + hasSignature: boolean; + activeStamp: string | null; + onSelectStamp: (label: string, color: string) => void; } +const TOOL_META: Record = { + select: { label: 'Select', icon: }, + pan: { label: 'Pan', icon: }, + highlight: { label: 'Highlight', icon: }, + draw: { label: 'Draw', icon: }, + comment: { label: 'Comment', icon: }, + textbox: { label: 'Text box', icon: }, + signature: { label: 'Signature', icon: }, + stamp: { label: 'Stamp', icon: }, + redact: { label: 'Redact', icon: }, +}; + +const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => ( + {children} +); +const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); +const Divider = () =>
; + export const Toolbar: React.FC = ({ - 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 ( -
- {/* Left Section - Logo & Title */} -
-
-
- EditQube Logo -
-
- Edit - Qube -
-
- - {/* Middle Section - Status & Zoom */} -
- {/* Progress Bar Mock / Status */} -
-
- - {backendHealthy === null - ? 'Checking Gateway...' - : backendHealthy - ? 'Gateway Connected' - : 'Gateway Offline'} - - - {backendHealthy ? '100%' : '0%'} - -
-
-
-
-
- -
- - -
- -
- -
- - -
- -
- -
- - {Math.round(zoom * 100)}% - -
-
- - {/* Right Section - Actions */} -
- - - - - - - - - + {meta.icon} + + {meta.label} +
+ + + {/* Per-tool controls (no overflow clip — would cut off the active-swatch ring) */} +
+ {activeTool === 'select' && Drag to select text — release to copy, or press Ctrl/⌘ + C} + {activeTool === 'pan' && Drag anywhere to move the page.} + + {activeTool === 'highlight' && ( + <> + + onSettingsChange({ highlightColor: c })} /> + + onSettingsChange({ highlightOpacity: v / 100 })} suffix="%" /> + + )} + + {activeTool === 'draw' && ( + <> + + onSettingsChange({ inkColor: c })} + palette={['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#18212e', '#ec4899', '#0891b2']} /> + + onSettingsChange({ inkThickness: v })} suffix="px" /> + + )} + + {activeTool === 'comment' && Click anywhere on the page to drop a sticky note.} + + {activeTool === 'textbox' && ( + <> + + onSettingsChange({ textColor: c })} + palette={['#18212e', '#dc2626', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '#ffffff']} /> + + onSettingsChange({ fontSize: v })} suffix="pt" /> + Click to place a text box. + + )} + + {activeTool === 'signature' && ( + <> + + {hasSignature ? Click on the page to place it. : Draw, type, or upload a signature.} + Visual signature — not certified + + )} + + {activeTool === 'stamp' && ( + <> +
+ {STAMP_PRESETS.map((s) => ( + + ))} +
+ Pick a stamp, then click to place it. + + )} + + {activeTool === 'redact' && ⚠ Drag a box to permanently remove content underneath.}
); }; + +const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx new file mode 100644 index 0000000..f72cd68 --- /dev/null +++ b/frontend/src/components/TopBar.tsx @@ -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 = ({ + documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth, + currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo, + isSaving, isDirtySaved, onRotate, onExport, onUpload, + isInspectorOpen, onToggleInspector, +}) => { + const fileRef = useRef(null); + const handleFile = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) onUpload(f); + e.target.value = ''; + }; + + return ( +
+ {/* Left: brand + file menu + doc name */} +
+
+
+ + + +
+ PDF Editor +
+ + ( + + )} + > +
+ } onClick={() => fileRef.current?.click()}>Open PDF… + } onClick={onExport} disabled={!documentName}>Export / Download +
+
Print — coming soon
+
+ + + fileRef.current?.click()} className="text-[var(--text-muted)] hover:text-[var(--text)]"> + + + + {documentName && ( + + {documentName} + + )} +
+ + {/* Center: history · zoom · rotate · page nav */} +
+
+ + +
+ +
+ onZoomChange(Math.max(0.25, zoom - 0.1))}> + ( + + )} + > +
+ } onClick={onFitWidth}>Fit width +
+ {ZOOM_PRESETS.map((z) => ( + + ))} +
+ + onZoomChange(Math.min(5, zoom + 0.1))}> +
+ + + + {documentName && ( +
+ { + 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)]" + /> + / {Math.max(1, totalPages)} +
+ )} +
+ + {/* Right: save-state + health + export + inspector toggle */} +
+ + + + + + +
+ + +
+ ); +}; + +const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?: boolean; children: React.ReactNode }> = ({ icon, onClick, disabled, children }) => ( + +); + +const SaveState: React.FC<{ isSaving: boolean; saved: boolean }> = ({ isSaving, saved }) => { + if (isSaving) return Saving…; + if (!saved) return null; + return Saved; +}; + +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 ( + + + {label} + + ); +}; diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..b53825b --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -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, +) => ( + + {children} + +); + +type IC = React.FC; +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(); +export const PanIcon: IC = mk(); +export const HighlightIcon: IC = mk(<>); +export const DrawIcon: IC = mk(); +export const CommentIcon: IC = mk(); +export const SignatureIcon: IC = mk(<>); +export const TextBoxIcon: IC = mk(<>); +export const StampIcon: IC = mk(<>); +export const RedactIcon: IC = mk(); +export const ImageIcon: IC = mk(<>); + +// Top bar +export const UndoIcon: IC = mk(); +export const RedoIcon: IC = mk(); +export const ZoomInIcon: IC = mk(<>); +export const ZoomOutIcon: IC = mk(<>); +export const RotateIcon: IC = mk(); +export const DownloadIcon: IC = mk(); +export const ChevronDownIcon: IC = mk(); +export const MenuIcon: IC = mk(); +export const CheckIcon: IC = mk(); +export const XIcon: IC = mk(); +export const ShareIcon: IC = mk(<>); +export const FitIcon: IC = mk(); + +// Inspector +export const PagesIcon: IC = mk(<>); +export const NotesIcon: IC = mk(<>); +export const PropertiesIcon: IC = mk(<>); +export const FontsIcon: IC = mk(); +export const OutlineIcon: IC = mk(); +export const FormsIcon: IC = mk(<>); +export const SearchIcon: IC = mk(<>); +export const TrashIcon: IC = mk(); +export const ArrowUpIcon: IC = mk(); +export const ArrowDownIcon: IC = mk(); +export const CopyIcon: IC = mk(<>); +export const PlusIcon: IC = mk(); +export const UploadIcon: IC = mk(); +export const InfoIcon: IC = mk(<>); +export const SpinnerIcon: IC = ({ size = 18, className }) => + base(size, `${className ?? ''} animate-spin`, 2, <>); diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..5893b95 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -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 { + variant?: ButtonVariant; + size?: 'sm' | 'md'; +} +const buttonVariants: Record = { + 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 = ({ variant = 'outline', size = 'md', className = '', children, ...rest }) => ( + +); + +interface IconButtonProps extends React.ButtonHTMLAttributes { + label: string; + active?: boolean; + size?: number; +} +export const IconButton: React.FC = ({ label, active, className = '', children, size = 34, ...rest }) => ( + +); + +interface PopoverProps { + trigger: (open: boolean) => React.ReactNode; + children: React.ReactNode; + align?: 'left' | 'right'; + width?: number; +} +export const Popover: React.FC = ({ trigger, children, align = 'left', width }) => { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+
setOpen((o) => !o)}>{trigger(open)}
+ {open && ( +
e.stopPropagation()} + > + {children} +
+ )} +
+ ); +}; + +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 = ({ value, onChange, palette = DEFAULT_PALETTE }) => ( +
+ {palette.map((c) => ( +
+); + +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 = ({ value, min, max, step = 1, onChange, label, suffix = '', width = 110 }) => ( +
+ {label && {label}} + onChange(parseFloat(e.target.value))} + style={{ width }} + className="accent-[var(--accent)]" + /> + {value}{suffix} +
+); + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + hint?: string; + badge?: string; +} +export const EmptyState: React.FC = ({ icon, title, hint, badge }) => ( +
+ {icon && ( +
+ {icon} +
+ )} + {badge && ( + + {badge} + + )} +

{title}

+ {hint &&

{hint}

} +
+); + +interface ModalProps { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + width?: number; + footer?: React.ReactNode; +} +export const Modal: React.FC = ({ 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 ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +}; + +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 = ({ state, onClose }) => ( + + + + + ) + } + > +

{state?.message}

+
+); + +const toastStyles: Record = { + 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([]); + useEffect(() => subscribeToasts(setItems), []); + return ( +
+ {items.map((t) => ( +
+ {t.kind === 'success' && } + {t.kind === 'error' && } + {t.kind === 'info' && } + {t.message} + +
+ ))} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 7d308f1..fec3d51 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,36 +1,61 @@ -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap'); @import "tailwindcss"; - :root { - --sans: 'Outfit', system-ui, -apple-system, sans-serif; - --mono: 'JetBrains Mono', monospace; + --font-sans: 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; - /* Color Palette - Light Enterprise Design */ - --bg-main: #f8fafc; /* Light canvas bg */ - --bg-card: #ffffff; - --bg-sidebar: #ffffff; - --bg-accent-indigo: #3b82f6; /* Blue for buttons */ - --bg-accent-indigo-hover: #2563eb; - --border-main: #e2e8f0; - --border-glow: rgba(59, 130, 246, 0.25); + /* Surfaces */ + --canvas: #f1f2f4; /* document workspace background */ + --surface: #ffffff; /* panels, bars */ + --surface-2: #f6f7f9; /* subtle raised / hover */ + --surface-3: #edeff2; /* pressed / track */ - --text-main: #0f172a; - --text-muted: #475569; - --text-dim: #94a3b8; + /* Borders — intentionally faint; rely on surface/canvas contrast for separation */ + --border: #ebedf0; + --border-strong: #dadde2; - /* Status Colors */ - --color-success: #10b981; - --color-success-bg: rgba(16, 185, 129, 0.1); - --color-success-border: rgba(16, 185, 129, 0.2); - --color-warning: #f59e0b; - --color-warning-bg: rgba(245, 158, 11, 0.1); - --color-warning-border: rgba(245, 158, 11, 0.2); - --color-error: #ef4444; - --color-error-bg: rgba(239, 68, 68, 0.1); - --color-error-border: rgba(239, 68, 68, 0.2); + /* Text */ + --text: #18212e; + --text-muted: #5b6573; + --text-dim: #98a1ad; - font-family: var(--sans); + /* Primary accent (professional blue) */ + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-soft: #eef4ff; + --accent-text: #ffffff; + + /* Semantic */ + --success: #16a34a; + --success-soft: #e9f7ee; + --warning: #d97706; + --warning-soft: #fdf3e7; + --danger: #dc2626; + --danger-soft: #fdecec; + + /* Tool accents (used by contextual strip swatches) */ + --tool-highlight: #facc15; + --tool-ink: #2563eb; + --tool-redact: #ef4444; + + /* Radii */ + --r-sm: 6px; + --r-md: 8px; + --r-lg: 12px; + + /* Elevation */ + --shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.10); + --shadow-2: 0 4px 12px rgba(16, 24, 40, 0.10); + --shadow-3: 0 12px 32px rgba(16, 24, 40, 0.16); + + /* Sizing */ + --topbar-h: 56px; + --strip-h: 48px; + --rail-w: 80px; + --inspector-w: 322px; + + font-family: var(--font-sans); color-scheme: light; } @@ -38,610 +63,58 @@ box-sizing: border-box; margin: 0; padding: 0; - transition: background-color 0.2s, border-color 0.2s; +} + +html, body, #root { + width: 100%; + height: 100%; } body { overflow: hidden; - background-color: var(--bg-main); - color: var(--text-main); + background: var(--canvas); + color: var(--text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + font-size: 14px; } #root { - width: 100vw; - height: 100vh; display: flex; flex-direction: column; - overflow: hidden; } -/* Custom Sleek Scrollbar */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: var(--bg-main); -} - -::-webkit-scrollbar-thumb { - background: var(--border-main); +/* Sleek scrollbars */ +.scroll-thin::-webkit-scrollbar, +.custom-scrollbar::-webkit-scrollbar, +.viewer-viewport::-webkit-scrollbar { width: 10px; height: 10px; } +.scroll-thin::-webkit-scrollbar-track, +.custom-scrollbar::-webkit-scrollbar-track, +.viewer-viewport::-webkit-scrollbar-track { background: transparent; } +.scroll-thin::-webkit-scrollbar-thumb, +.custom-scrollbar::-webkit-scrollbar-thumb, +.viewer-viewport::-webkit-scrollbar-thumb { + background: var(--border-strong); border-radius: 9999px; - border: 2px solid var(--bg-main); + border: 2px solid transparent; + background-clip: padding-box; } +.scroll-thin::-webkit-scrollbar-thumb:hover, +.custom-scrollbar::-webkit-scrollbar-thumb:hover, +.viewer-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: padding-box; } -::-webkit-scrollbar-thumb:hover { - background: var(--text-dim); -} +/* Hidden scrollbar (used by horizontal tab/tool strips) */ +.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; } +.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; } /* ========================================================================== - TOOLBAR STYLES - ========================================================================== */ -.toolbar { - height: 64px; - background: var(--bg-card); - border-bottom: 1px solid var(--border-main); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - z-index: 50; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); -} - -.toolbar-section { - display: flex; - align-items: center; - gap: 16px; -} - -.logo-container { - display: flex; - align-items: center; - gap: 10px; -} - -.logo-badge { - width: 36px; - height: 36px; - background: var(--bg-accent-indigo); - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-weight: 800; - color: white; - font-size: 15px; - box-shadow: 0 8px 16px rgba(79, 70, 229, 0.35); - letter-spacing: 0.5px; -} - -.logo-text { - font-weight: 900; - font-size: 18px; - letter-spacing: -0.5px; - display: flex; - align-items: center; -} - -.logo-sub { - color: #a5b4fc; - font-weight: 500; - font-size: 11px; - margin-left: 6px; - padding: 2px 8px; - background: rgba(79, 70, 229, 0.15); - border: 1px solid rgba(79, 70, 229, 0.3); - border-radius: 9999px; - letter-spacing: 1px; - text-transform: uppercase; -} - -.health-badge { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border-radius: 9999px; - font-size: 11px; - font-weight: 600; - border: 1px solid transparent; -} - -.health-badge.status-checking { - background: var(--color-warning-bg); - color: var(--color-warning); - border-color: var(--color-warning-border); -} - -.health-badge.status-healthy { - background: var(--color-success-bg); - color: var(--color-success); - border-color: var(--color-success-border); -} - -.health-badge.status-unhealthy { - background: var(--color-error-bg); - color: var(--color-error); - border-color: var(--color-error-border); -} - -.health-dot { - width: 6px; - height: 6px; - border-radius: 9999px; -} - -.status-checking .health-dot { - background: var(--color-warning); - animation: pulse 1.5s infinite; -} - -.status-healthy .health-dot { - background: var(--color-success); -} - -.status-unhealthy .health-dot { - background: var(--color-error); -} - -.wasm-badge { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border-radius: 9999px; - font-size: 11px; - font-weight: 600; - border: 1px solid rgba(99, 102, 241, 0.2); - background: rgba(99, 102, 241, 0.1); - color: #a5b4fc; -} - -.wasm-badge .wasm-dot { - width: 6px; - height: 6px; - border-radius: 9999px; - background: #6366f1; - box-shadow: 0 0 8px #6366f1; -} - -.toolbar-center { - display: flex; - align-items: center; - gap: 16px; - background: rgba(2, 6, 23, 0.4); - padding: 6px 12px; - border-radius: 12px; - border: 1px solid rgba(30, 41, 59, 0.7); -} - -.page-indicator { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-muted); - font-size: 13px; - font-weight: 600; - padding: 0 8px; -} - -.page-current { - color: white; - font-weight: 800; -} - -.page-separator { - color: var(--text-dim); -} - -.divider-vertical { - width: 1px; - height: 20px; - background: var(--border-main); -} - -.zoom-controls { - display: flex; - align-items: center; - gap: 6px; -} - -.btn-icon { - background: transparent; - border: none; - color: var(--text-muted); - padding: 6px; - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; -} - -.btn-icon:hover { - color: white; - background: rgba(255, 255, 255, 0.05); -} - -.zoom-select { - background: var(--bg-main); - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - padding: 4px 10px; - border-radius: 6px; - border: 1px solid var(--border-main); - outline: none; - cursor: pointer; - appearance: none; - -webkit-appearance: none; - text-align: center; -} - -.zoom-select:focus { - border-color: var(--bg-accent-indigo); -} - -.tool-selector { - display: flex; - background: rgba(2, 6, 23, 0.4); - padding: 4px; - border-radius: 12px; - border: 1px solid rgba(30, 41, 59, 0.7); -} - -.tool-btn { - background: transparent; - border: none; - color: var(--text-muted); - font-weight: 700; - font-size: 12px; - padding: 6px 14px; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -.tool-btn:hover { - color: white; -} - -.tool-btn.active { - background: var(--border-main); - color: white; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); -} - -.tool-btn.highlight.active { - background: rgba(245, 158, 11, 0.2); - color: #fbbf24; - border: 1px solid rgba(245, 158, 11, 0.4); -} - -.tool-btn.draw.active { - background: rgba(59, 130, 246, 0.2); - color: #60a5fa; - border: 1px solid rgba(59, 130, 246, 0.4); -} - -.tool-btn.signature.active { - background: rgba(79, 70, 229, 0.2); - color: #c7d2fe; - border: 1px solid rgba(79, 70, 229, 0.4); -} - -.tool-btn.redact.active { - background: rgba(244, 63, 94, 0.2); - color: #fda4af; - border: 1px solid rgba(244, 63, 94, 0.4); -} - -.upload-btn { - display: flex; - align-items: center; - gap: 8px; - background: var(--bg-accent-indigo); - color: white; - font-weight: 600; - font-size: 12px; - padding: 8px 16px; - border-radius: 10px; - cursor: pointer; - box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2); - border: 1px solid rgba(79, 70, 229, 0.4); - transition: transform 0.2s, background-color 0.2s; -} - -.upload-btn:hover { - background: var(--bg-accent-indigo-hover); - transform: translateY(-1px); -} - -.export-btn { - display: flex; - align-items: center; - gap: 8px; - background: rgba(255, 255, 255, 0.05); - color: white; - font-weight: 600; - font-size: 12px; - padding: 8px 16px; - border-radius: 10px; - cursor: pointer; - border: 1px solid var(--border-main); - transition: transform 0.2s, background-color 0.2s; -} - -.export-btn:hover { - background: rgba(255, 255, 255, 0.1); - transform: translateY(-1px); -} - -.hidden-file-input { - display: none; -} - -/* ========================================================================== - SIDEBAR STYLES - ========================================================================== */ -.sidebar { - width: 320px; - background: var(--bg-sidebar); - border-right: 1px solid var(--border-main); - display: flex; - flex-direction: column; - height: 100%; -} - -.sidebar-tabs { - display: flex; - border-bottom: 1px solid var(--border-main); - padding: 10px; - gap: 6px; - background: rgba(9, 13, 22, 0.8); -} - -.tab-btn { - flex: 1; - background: transparent; - border: none; - color: var(--text-muted); - font-weight: 700; - font-size: 12px; - padding: 8px 0; - border-radius: 8px; - cursor: pointer; - text-align: center; - transition: all 0.2s; -} - -.tab-btn:hover { - color: white; - background: rgba(255, 255, 255, 0.03); -} - -.tab-btn.active { - background: var(--bg-card); - color: white; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); -} - -.relative-tab { - position: relative; -} - -.badge-notification { - position: absolute; - top: 4px; - right: 12px; - background: var(--bg-accent-indigo); - color: white; - font-size: 9px; - font-weight: 900; - width: 16px; - height: 16px; - border-radius: 9999px; - display: flex; - align-items: center; - justify-content: center; - animation: pulse 2s infinite; -} - -.sidebar-content { - flex: 1; - overflow-y: auto; - padding: 18px; -} - -.doc-list-container, -.annotations-list { - display: flex; - flex-direction: column; - gap: 12px; -} - -.tab-section-header { - font-size: 10px; - font-weight: 900; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 1.5px; - padding: 0 4px; -} - -.doc-card { - background: rgba(15, 23, 42, 0.4); - border: 1px solid var(--border-main); - border-radius: 12px; - padding: 12px; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -.doc-card:hover { - background: rgba(15, 23, 42, 0.8); - border-color: rgba(255, 255, 255, 0.1); -} - -.doc-card.active { - background: rgba(79, 70, 229, 0.08); - border-color: rgba(79, 70, 229, 0.5); - box-shadow: inset 0 0 12px rgba(79, 70, 229, 0.05); -} - -.doc-card-body { - display: flex; - align-items: start; - gap: 12px; -} - -.doc-icon-container { - padding: 6px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.03); - color: var(--text-muted); - display: flex; - align-items: center; -} - -.doc-icon-container.active { - background: rgba(79, 70, 229, 0.15); - color: #818cf8; -} - -.doc-details { - flex: 1; - min-width: 0; -} - -.doc-filename { - font-size: 13px; - font-weight: 700; - color: var(--text-muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.doc-card.active .doc-filename { - color: white; -} - -.doc-metadata { - display: flex; - gap: 6px; - font-size: 10px; - font-weight: 500; - color: var(--text-dim); - margin-top: 4px; -} - -.thumbnails-grid { - display: grid; - grid-template-cols: repeat(2, 1fr); - gap: 14px; -} - -.thumbnail-card { - display: flex; - flex-direction: column; - gap: 8px; - cursor: pointer; -} - -.thumbnail-preview { - width: 100%; - aspect-ratio: 3 / 4; - background: var(--bg-card); - border: 1px solid var(--border-main); - border-radius: 10px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: var(--text-muted); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); -} - -.thumbnail-preview:hover { - transform: translateY(-2px); - border-color: rgba(99, 102, 241, 0.4); -} - -.thumbnail-svg-icon { - color: var(--text-dim); - margin-bottom: 8px; -} - -.thumbnail-label { - font-size: 10px; - font-weight: 600; - color: var(--text-muted); -} - -.empty-state { - padding: 32px 16px; - text-align: center; - color: var(--text-dim); - font-size: 12px; - font-weight: 500; - line-height: 1.5; -} - -.annotation-card { - padding: 12px; - background: rgba(15, 23, 42, 0.4); - border: 1px solid var(--border-main); - border-radius: 12px; - display: flex; - flex-direction: column; - gap: 6px; -} - -.annotation-header { - display: flex; - align-items: center; - justify-content: space-between; -} - -.annotation-type-badge { - font-size: 9px; - font-weight: 800; - color: #f59e0b; - text-transform: uppercase; - letter-spacing: 1px; - padding: 2px 8px; - background: rgba(245, 158, 11, 0.1); - border: 1px solid rgba(245, 158, 11, 0.2); - border-radius: 9999px; -} - -.annotation-author { - font-size: 9px; - font-weight: 700; - color: var(--text-dim); -} - -.annotation-content { - font-size: 12px; - color: var(--text-muted); - font-style: italic; - line-height: 1.5; -} - -/* ========================================================================== - VIEWER STYLES + VIEWER (class names consumed by PDFViewer + layers — keep stable) ========================================================================== */ .viewer-viewport { flex: 1; height: 100%; overflow: auto; - background: #f3f4f6; + background: var(--canvas); display: flex; justify-content: center; align-items: start; @@ -654,17 +127,19 @@ body { display: flex; flex-direction: column; align-items: center; - padding: 32px 0; + padding: 28px 0; } .page-container { position: absolute; background: white; - border-radius: 8px; - transition: transform 0.2s; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-radius: 3px; + box-shadow: var(--shadow-2); + outline: 1px solid rgba(16, 24, 40, 0.04); } +.shadow-premium { box-shadow: var(--shadow-2); } + .page-loading-state { width: 100%; height: 100%; @@ -672,235 +147,182 @@ body { flex-direction: column; align-items: center; justify-content: center; - background: #f9fafb; + background: var(--surface-2); color: var(--text-muted); gap: 12px; - border-radius: 8px; + border-radius: 3px; } .spinner { - width: 32px; - height: 32px; - border: 4px solid rgba(79, 70, 229, 0.2); - border-top-color: var(--bg-accent-indigo); + width: 28px; + height: 28px; + border: 3px solid var(--accent-soft); + border-top-color: var(--accent); border-radius: 9999px; - animation: spin 1s linear infinite; + animation: spin 0.8s linear infinite; } .page-loading-label { - font-size: 13px; + font-size: 12px; font-weight: 600; - animation: pulse 1.5s infinite; - letter-spacing: 0.5px; + letter-spacing: 0.2px; + color: var(--text-dim); } /* Layering */ -.selection-layer { - position: absolute; - top: 0; - left: 0; - z-index: 20; - cursor: text; -} - +.selection-layer { position: absolute; top: 0; left: 0; z-index: 20; cursor: text; } .selection-highlight { position: absolute; - border: 1px solid rgba(99, 102, 241, 0.6); - background: rgba(99, 102, 241, 0.2); + background: rgba(37, 99, 235, 0.22); pointer-events: none; - border-radius: 2px; + border-radius: 1px; } - -.annotation-layer { +.selection-glyph { position: absolute; - top: 0; - left: 0; - z-index: 30; - pointer-events: auto; + background: rgba(37, 99, 235, 0.28); + pointer-events: none; } +.annotation-layer { position: absolute; top: 0; left: 0; z-index: 30; } .annotation-box { position: absolute; cursor: pointer; - mix-blend-mode: multiply; - opacity: 0.75; border-radius: 2px; - transition: opacity 0.15s; + transition: opacity 0.15s, box-shadow 0.15s; } - -.annotation-box:hover { - opacity: 0.95; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.annotation-box.type-highlight { - background: #fde047; - /* Yellow 300 */ -} - -.annotation-box.type-comment { - background: #fecdd3; - /* Rose 200 */ - border-bottom: 2px solid #f43f5e; -} - -.annotation-box.type-strikeout { - display: flex; - align-items: center; - justify-content: center; -} - -.strikeout-line { - width: 100%; - height: 2px; - background-color: #ef4444; /* red-500 */ - opacity: 0.8; -} - +.annotation-box:hover { box-shadow: 0 2px 8px rgba(16, 24, 40, 0.18); } +.annotation-box.type-highlight { mix-blend-mode: multiply; } +.annotation-box.type-comment { display: flex; align-items: center; justify-content: center; } +.annotation-box.type-strikeout { display: flex; align-items: center; justify-content: center; } +.strikeout-line { width: 100%; height: 2px; background: var(--danger); opacity: 0.85; } .annotation-box.type-signature { - border: 2px dashed rgba(79, 70, 229, 0.5); /* indigo-600 */ - background: rgba(79, 70, 229, 0.05); + border: 2px dashed rgba(37, 99, 235, 0.5); + background: rgba(37, 99, 235, 0.05); border-radius: 4px; - mix-blend-mode: normal; /* override multiply for signature */ - display: flex; - align-items: center; - justify-content: center; + display: flex; align-items: center; justify-content: center; } - .signature-badge { - color: rgba(79, 70, 229, 0.8); - background: rgba(255, 255, 255, 0.8); + color: rgba(37, 99, 235, 0.85); + background: rgba(255, 255, 255, 0.85); border-radius: 9999px; padding: 4px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); -} - -.overlay-layer { - position: absolute; - top: 0; - left: 0; - z-index: 40; - pointer-events: none; + box-shadow: var(--shadow-1); } +.overlay-layer { position: absolute; top: 0; left: 0; z-index: 40; } .overlay-signature-alert { - position: absolute; - inset: 0; - background: rgba(79, 70, 229, 0.05); - display: flex; - align-items: center; - justify-content: center; - border: 2px dashed rgba(79, 70, 229, 0.4); - border-radius: 8px; + position: absolute; inset: 0; + background: rgba(37, 99, 235, 0.05); + display: flex; align-items: center; justify-content: center; + border: 2px dashed rgba(37, 99, 235, 0.4); + border-radius: 6px; } - .overlay-badge { - background: var(--bg-accent-indigo); + background: var(--accent); color: white; font-size: 11px; font-weight: 700; - padding: 4px 10px; - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); - letter-spacing: 0.5px; + padding: 5px 12px; + border-radius: 9999px; + box-shadow: var(--shadow-2); + letter-spacing: 0.3px; } - .overlay-toast { position: absolute; - top: 16px; - right: 16px; - background: var(--color-success); + top: 14px; left: 50%; + transform: translateX(-50%); + background: var(--text); color: white; font-size: 11px; - font-weight: 700; - padding: 4px 12px; + font-weight: 600; + padding: 5px 12px; border-radius: 9999px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + box-shadow: var(--shadow-2); + white-space: nowrap; } -/* Animations */ -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0.5; - } -} -/* Comment Popup Styles */ +/* Comment popup */ .comment-popup-container { - width: 260px; - background: var(--bg-card); - border: 1px solid var(--border-main); - border-radius: 12px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); + width: 264px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + box-shadow: var(--shadow-3); overflow: hidden; - animation: slideUp 0.2s ease-out; + animation: slideUp 0.18s ease-out; } - .comment-form-header { - display: flex; - justify-content: space-between; - align-items: center; + display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; - background: rgba(15, 23, 42, 0.6); - border-bottom: 1px solid var(--border-main); + background: var(--surface-2); + border-bottom: 1px solid var(--border); } - .comment-textarea { width: 100%; background: transparent; - color: var(--text-main); + color: var(--text); border: none; padding: 12px 14px; font-size: 13px; resize: none; outline: none; + font-family: var(--font-sans); } - -.comment-textarea::placeholder { - color: var(--text-dim); -} - +.comment-textarea::placeholder { color: var(--text-dim); } .comment-form-footer { padding: 10px 14px; - background: rgba(15, 23, 42, 0.4); - border-top: 1px solid var(--border-main); - display: flex; - justify-content: flex-end; + background: var(--surface-2); + border-top: 1px solid var(--border); + display: flex; justify-content: flex-end; } - .comment-submit-btn { - background: var(--bg-accent-indigo); + background: var(--accent); color: white; border: none; - padding: 6px 14px; - border-radius: 6px; + padding: 7px 16px; + border-radius: var(--r-sm); font-size: 12px; font-weight: 600; cursor: pointer; - transition: background 0.2s; +} +.comment-submit-btn:hover { background: var(--accent-hover); } + +/* In-canvas text box editor (free_text tool) */ +.textbox-editor { + position: absolute; + z-index: 50; + background: rgba(255,255,255,0.85); + border: 1.5px solid var(--accent); + border-radius: 4px; + box-shadow: var(--shadow-2); + outline: none; + resize: none; + overflow: hidden; + font-family: var(--font-sans); + line-height: 1.25; + padding: 2px 4px; } -.comment-submit-btn:hover { - background: var(--bg-accent-indigo-hover); -} - -@keyframes slideUp { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } +/* Thumbnails */ +.thumbnail-card { display: flex; flex-direction: column; gap: 6px; cursor: pointer; } +.thumbnail-preview { + width: 100%; + aspect-ratio: 3 / 4; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + display: flex; flex-direction: column; align-items: center; justify-content: center; + color: var(--text-muted); + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + box-shadow: var(--shadow-1); } +.thumbnail-preview:hover { border-color: var(--accent); box-shadow: var(--shadow-2); } +.thumbnail-preview.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); } +.thumbnail-svg-icon { color: var(--text-dim); margin-bottom: 6px; } +.thumbnail-label { font-size: 11px; font-weight: 600; color: var(--text-muted); } +/* Animations */ +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } +@keyframes slideUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes toastIn { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } diff --git a/frontend/src/lib/coordinateMapping.ts b/frontend/src/lib/coordinateMapping.ts index d4cabcf..dd86bf1 100644 --- a/frontend/src/lib/coordinateMapping.ts +++ b/frontend/src/lib/coordinateMapping.ts @@ -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; diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 328fae6..b250ee2 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -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 { - DocQube PDF Engine + PDF Editor PAGE ${pageIndex + 1} OF THE SPECIFICATION @@ -412,13 +450,45 @@ class GatewayService { - DocQube Core Engine (Gate G0a scaffolding) + PDF Editor — preview render PAGE ${pageIndex + 1} `; return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}`; } + async getPageText(documentId: string, pageIndex: number): Promise { + 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 { + try { + const response = await fetch(`${this.baseUrl}/documents/${documentId}/metadata`); + if (!response.ok) return {}; + return response.json(); + } catch { + return {}; + } + } + + async getDocumentFonts(documentId: string): Promise { + 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 { const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`); if (!response.ok) throw new Error(`Export failed: ${response.statusText}`); diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts new file mode 100644 index 0000000..1be1d6c --- /dev/null +++ b/frontend/src/lib/toast.ts @@ -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); + }; +} diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts new file mode 100644 index 0000000..6d1b514 --- /dev/null +++ b/frontend/src/lib/tools.ts @@ -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 = { + 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' }, +]; diff --git a/frontend/src/viewer/AnnotationLayer.tsx b/frontend/src/viewer/AnnotationLayer.tsx index c0c160f..db4bb06 100644 --- a/frontend/src/viewer/AnnotationLayer.tsx +++ b/frontend/src/viewer/AnnotationLayer.tsx @@ -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 = ({ 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 = ({ .filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex) .filter((anno) => anno.type === 'ink' && anno.paths) .map((anno) => ( - + {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(' '); diff --git a/frontend/src/viewer/OverlayLayer.tsx b/frontend/src/viewer/OverlayLayer.tsx index ab357c4..2ba0e1a 100644 --- a/frontend/src/viewer/OverlayLayer.tsx +++ b/frontend/src/viewer/OverlayLayer.tsx @@ -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 = ({ - 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(null); + const rootRef = useRef(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 (
- {/* Signature overlay state indicator */} - {activeTool === 'signature' && ( -
- - READY TO PLACE SIGNATURE (Page {pageIndex + 1}) - -
+ {/* Tool hints */} + {activeTool === 'signature' && !hasSignature && ( +
Create a signature to place it here
+ )} + {activeTool === 'signature' && hasSignature && !commentPopup && ( +
Click to place signature
)} - - {/* Comment Tool Tip */} {activeTool === 'comment' && !commentPopup && ( -
- Click anywhere to add a Sticky Note -
+
Click to add a sticky note
+ )} + {activeTool === 'stamp' && activeStamp && ( +
Click to place “{activeStamp}”
+ )} + {activeTool === 'textbox' && !textBox && ( +
Click to add a text box
)} - {/* Comment Input Popup */} + {/* Comment popup */} {commentPopup && ( -
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()} > -
+
- Add Sticky Note -
-