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