Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into saqib
This commit is contained in:
@@ -69,6 +69,3 @@ gateway/*.dylib
|
||||
|
||||
PDF Editor Timeline.xlsx
|
||||
# Local environment config / secrets
|
||||
.env
|
||||
*.local.env
|
||||
gateway/.env
|
||||
|
||||
@@ -171,7 +171,8 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def_readonly("author", &pdfengine::PdfPage::AnnotationInfo::author)
|
||||
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
|
||||
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
|
||||
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex);
|
||||
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
|
||||
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths);
|
||||
|
||||
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
|
||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Engine Roadmap — Reality Audit & Gap Analysis
|
||||
|
||||
**Status:** Review-only. No code changed to produce this.
|
||||
**Question answered:** Of the roadmap items marked *Done*, which are **real** and which are **mocks/stubs**? And do the stubs belong to a later phase?
|
||||
|
||||
## How this was verified (not taken on faith)
|
||||
1. Read the C++ engine dispatch in [pdfium_document.cpp](engine/src/parser/pdfium_document.cpp) — specifically `applyEdits` (line 1432) and every edit-type handler.
|
||||
2. **Runtime-tested each edit op through the real engine bindings** (gateway venv): apply → `save` → reload → assert the change actually persisted (annotation count, text length, page count).
|
||||
3. Read the gateway edit pipeline [edits.py](gateway/app/routers/edits.py) (incl. the image preprocessing).
|
||||
4. Confirmed deps + artifacts: Pillow 10.4.0 present (needed for images), `pdfengine.wasm` (60 KB) built, engine `pdfium=on, skia=off`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Headline findings (the important part)
|
||||
|
||||
| # | Finding | Impact |
|
||||
|---|---|---|
|
||||
| **F1** | **`freehand` (ink) is an engine STUB.** Handler at [line 1867](engine/src/parser/pdfium_document.cpp#L1867) is literally `spdlog::info("Parsed freehand edit operation (stub)")` — it parses and does nothing. | Roadmap says **"Freehand / Ink Annotations — Done 100%"** — that is **false end-to-end**. Ink never persists to the saved PDF. |
|
||||
| **F2** | **`free_text` is an engine STUB.** Handler at [line 1804](engine/src/parser/pdfium_document.cpp#L1804) is `spdlog::info("Parsed free_text edit operation (stub)")`. | The **Text-box tool** (which sends `free_text`) won't persist. (Note: `text_overlay` *is* real — see F3.) |
|
||||
| **F3** | Several items marked **"Done — 0%"** (contradictory) are in fact **REAL and working**: `highlight`, `comment`, Edit-Layer schema. The 0% column is just wrong. | Good news — these are done. |
|
||||
| **F4** | **`redaction`** (roadmap: *In Progress*) is **functionally complete** — it removes overlapping page objects, covers, and regenerates content ([line 1566](engine/src/parser/pdfium_document.cpp#L1566)). Verified: page text dropped 312→101 chars. | Under-reported; it's effectively done. |
|
||||
| **F5** | **Frontend "text selection + copy — Done"** was a **mock** (`SelectionLayer` returned fake text) until it was wired to real glyph data **this session**. | Now real; flagging the roadmap was wrong. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Edit operations — verified truth table
|
||||
|
||||
Tested by applying each op to a real corpus PDF and reloading the result.
|
||||
|
||||
| Edit op | Roadmap | Engine handler | Runtime result | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| `highlight` | Done (0%) | Real (`FPDF_ANNOT_HIGHLIGHT`, quadpoints, color, author) | annots 0→1 | ✅ **REAL** |
|
||||
| `comment` | Done (0%) | Real (`FPDF_ANNOT_TEXT` sticky) | annots 0→1 | ✅ **REAL** |
|
||||
| `text_overlay` | Done (100%) | Real (draws text objects, font mapping) | text 312→321 | ✅ **REAL** |
|
||||
| `image_overlay` | Done (100%) | Real engine **+ gateway** converts data-URI→BGRA→temp file | needs gateway path (Pillow ✓) | ✅ **REAL (via gateway)** |
|
||||
| `redaction` | In Progress | Real content removal + cover + regen | text 312→101 | ✅ **REAL** |
|
||||
| `page_rotation` | Done (100%) | Real (`FPDFPage_SetRotation`) | saved, bytes changed | ✅ **REAL** |
|
||||
| `page_deletion` | Done (100%) | Real (`FPDFPage_Delete`, guards last page) | pages 100→99 | ✅ **REAL** |
|
||||
| `page_reorder` | Done (100%) | Real (`FPDF_MovePages`) | saved ok | ✅ **REAL** |
|
||||
| **`free_text`** | (in schema) | **STUB — no-op** | no change | ❌ **STUB** |
|
||||
| **`freehand` (ink)** | **Done (100%)** | **STUB — no-op** | no change | ❌ **STUB** |
|
||||
|
||||
**8 of 10 edit ops are genuinely real and verified. 2 (`freehand`, `free_text`) are stubs.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Why the stubs matter right now (frontend consequence)
|
||||
|
||||
Both stubs are wired into the live UI, so they *look* like they work but silently fail on save:
|
||||
|
||||
- **Draw / Ink tool** → emits `freehand` → engine ignores it. In live (non-mock) mode the stroke renders locally, then **disappears after the save round-trip** (the reloaded annotation list from `extract_annotations` won't contain it). It will **not** be in the exported PDF.
|
||||
- **Text-box tool** → emits `free_text` → same: text vanishes after reload; not in export.
|
||||
|
||||
> These didn't show up earlier because we've been in **mock mode** (engine flag off), where edits don't actually round-trip. They'll surface the moment the engine is enabled.
|
||||
|
||||
**Cheap mitigations (for later, your call):**
|
||||
- Text-box: re-point it from `free_text` → **`text_overlay`** (already real). ~1-line change in the frontend handler.
|
||||
- Ink: needs a real engine `freehand` handler (write a PDF **Ink annotation**, `FPDF_ANNOT_INK`, from the `paths`) — a small, well-scoped C++ addition, *not* a future-phase feature.
|
||||
|
||||
---
|
||||
|
||||
## 4. Roadmap status, corrected
|
||||
|
||||
### Phase 0 — Infra (8 items, all *Done*)
|
||||
✅ All real and consistent. CMake/vcpkg/CI, PDFium/Skia/FreeType/HarfBuzz builds, FastAPI + React scaffold, WASM hello-world, frozen contracts. No issues.
|
||||
|
||||
### Phase 1 — Core engine + viewer (13 items, all *Done*)
|
||||
Mostly real. Corrections:
|
||||
- **"React: text selection + copy — Done"** → was a **mock** in the live viewer; wired to real glyphs this session. Engine-side glyph extraction was real all along.
|
||||
- `render`, `text extraction w/ bounds`, DPI/coordinate transforms, font load/substitution, thumbnails, incremental save — **verified real**.
|
||||
- **"C++ Engine → WASM Facade — Done"** → `pdfengine.wasm` is built (60 KB) and `wasmLoader` loads it, **but falls back to a JS mock on failure**, and the live app renders **server-side** (`/render`), not via WASM. So the facade exists; it isn't the primary render path.
|
||||
|
||||
### Phase 2 — Editing (status mixed)
|
||||
- **Real & done:** `text_overlay`, `highlight`, `image_overlay`, `comment`, `page_rotation`, `page_deletion`, `page_reorder`, Edit-Layer schema. (Several mislabeled 0%.)
|
||||
- **Marked Done but STUB:** **`freehand`/ink (F1)**. ← the one genuinely wrong "Done".
|
||||
- **Schema-only / STUB:** `free_text` (F2).
|
||||
- **In Progress but effectively real:** `redaction`.
|
||||
- **Genuinely In Progress / Not Started (correctly labeled):**
|
||||
- Annotation reader (read exists via `extract_annotations`; "write/edit existing" not there).
|
||||
- React annotation toolbar/editing UI (built this session, but **edit/delete of existing annots has no engine op**).
|
||||
- Form field viewing — **Not Started** (no engine API).
|
||||
- Export full-save — engine `save_full` works; gateway endpoint exists; treat as **functionally real**.
|
||||
- WASM render path + Web Worker — **In Progress / Not Started** (secondary).
|
||||
|
||||
### Phase 3 — Advanced (16 items, all *Not Started*) ✅ accurate
|
||||
Content-stream analyzer, **edit existing text / replace / reflow**, underline/decoration, font embedding for edits, custom path/Skia rasterizer, **glyph-accurate hit-testing**, **form filling**, encryption/password, regression suite, fuzzing, native SDK packages. **None started — correctly labeled.** This is where "true PDF editing" (retype existing text), forms, and password PDFs live.
|
||||
|
||||
### Phase 4 — Perf/enterprise (gated, *Not Started*) ✅ accurate
|
||||
Custom glyph pipeline, tiling, GPU, reflow, accessibility, **digital (cryptographic) signatures**, advanced forms. Out of v1 scope, correctly labeled.
|
||||
|
||||
---
|
||||
|
||||
## 5. Are the mocks/stubs "part of upcoming phases"?
|
||||
|
||||
Two different cases — important distinction:
|
||||
|
||||
| Stub | Belongs to a future phase? | Reality |
|
||||
|---|---|---|
|
||||
| `freehand` (ink) | **No.** It's a **Phase 2** item *marked done*. The real impl (write an Ink annotation) is small and overdue, not future work. | Incomplete Phase-2 task mislabeled "Done". |
|
||||
| `free_text` | **Partly.** A *simple* FreeText annotation is small Phase-2-grade work. *True editable/reflowing text* is **Phase 3** (`Text replacement engine`, Not Started). | The basic annotation is a quick win; full text editing is correctly future. |
|
||||
| Edit existing annotations (delete/update) | **Not on any phase.** No edit op exists. | Net-new small backend ticket. |
|
||||
| Outline/bookmarks, forms, password PDFs, edit-existing-text | **Yes — Phase 2/3, Not Started.** | Correctly future; the UI already stubs these as "coming soon". |
|
||||
|
||||
---
|
||||
|
||||
## 6. Bottom line
|
||||
|
||||
- **Engine completion is genuinely high** for v1-style editing: **8/10 edit ops real and verified**, render/text/search/pages/redaction/export all real.
|
||||
- **The roadmap over-claims exactly two things:** `freehand`/ink is "Done" but a **stub**, and the **"Done 0%"** rows are mislabeled (they're actually real). It also under-claims `redaction`.
|
||||
- **The only end-to-end-broken user-facing features** are **Ink** and **Text-box** (both ride the two stubs). Both have cheap fixes; neither requires Phase 3.
|
||||
- **Everything labeled Phase 3/4 "Not Started" is accurate** — that's the real remaining work (edit existing text, forms, encryption, perf, certified signatures).
|
||||
|
||||
## 7. Suggested next steps (for review — nothing changed yet)
|
||||
1. **Correct the roadmap**: `freehand` = In Progress (stub); `highlight`/`comment`/schema = Done (fix the 0%); `redaction` = Done.
|
||||
2. **Decide the two cheap fixes**: (a) re-point Text-box tool to `text_overlay` now; (b) implement the engine `freehand` → Ink-annotation handler.
|
||||
3. **Add two small backend tickets** already flagged by the UI: `delete/update_annotation` op, and an outline endpoint.
|
||||
4. Leave Phase 3/4 as-is — correctly scoped future work.
|
||||
@@ -1284,20 +1284,24 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
|
||||
info.color = hex;
|
||||
}
|
||||
|
||||
// Ink paths
|
||||
// Ink geometry: read the /InkList strokes so the frontend can redraw
|
||||
// them as an interactive overlay (Adobe-style, non-destructive).
|
||||
// Points are converted from PDF (bottom-up) to top-left page-point space
|
||||
// to match the frontend's coordinate convention (same as the rect above).
|
||||
if (subtype == FPDF_ANNOT_INK) {
|
||||
int objCount = FPDFAnnot_GetObjectCount(annot);
|
||||
for (int j = 0; j < objCount; ++j) {
|
||||
FPDF_PAGEOBJECT obj = FPDFAnnot_GetObject(annot, j);
|
||||
if (obj && FPDFPageObj_GetType(obj) == FPDF_PAGEOBJ_PATH) {
|
||||
[[maybe_unused]] int pathCount = FPDFPath_CountSegments(obj); // PDFium has FPDFPath_CountSegments ? Wait, let me check pdfium headers. Actually FPDFPath_GetPathSegmentCount doesn't exist, it is FPDFPath_CountSegments probably, or FPDFPath_CountSegments / FPDFPath_GetPathSegment.
|
||||
// Wait, looking at PDFium fpdf_edit.h: `FPDFPath_CountSegments` doesn't exist, it's `FPDFPath_CountSegments`?
|
||||
// Let me check if I can use FPDFPath_CountSegments
|
||||
// It is usually int FPDFPath_CountSegments(FPDF_PAGEOBJECT path);
|
||||
// FPDF_PATHSEGMENT FPDFPath_GetPathSegment(FPDF_PAGEOBJECT path, int index);
|
||||
// FPDFPathSegment_GetPoint(FPDF_PATHSEGMENT segment, float* x, float* y);
|
||||
// int FPDFPathSegment_GetType(FPDF_PATHSEGMENT segment);
|
||||
const double pageH = height();
|
||||
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
|
||||
for (unsigned long s = 0; s < strokeCount; ++s) {
|
||||
unsigned long ptCount = FPDFAnnot_GetInkListPath(annot, s, nullptr, 0);
|
||||
if (ptCount == 0) continue;
|
||||
std::vector<FS_POINTF> pts(ptCount);
|
||||
FPDFAnnot_GetInkListPath(annot, s, pts.data(), ptCount);
|
||||
std::vector<Point2D> stroke;
|
||||
stroke.reserve(ptCount);
|
||||
for (const auto& p : pts) {
|
||||
stroke.push_back(Point2D{static_cast<double>(p.x), pageH - static_cast<double>(p.y)});
|
||||
}
|
||||
if (!stroke.empty()) info.paths.push_back(std::move(stroke));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1929,7 +1933,71 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "freehand") {
|
||||
spdlog::info("Parsed freehand edit operation (stub)");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("freehand operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for freehand", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create ink annotation");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
|
||||
std::string colorStr = data.value("color", "#000000");
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
float thickness = static_cast<float>(data.value("thickness", 2.0));
|
||||
FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness);
|
||||
|
||||
// Accumulate bounding box in PDF (bottom-up) space.
|
||||
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
|
||||
bool anyPoints = false;
|
||||
|
||||
if (data.contains("paths") && data["paths"].is_array()) {
|
||||
for (const auto& path : data["paths"]) {
|
||||
if (!path.is_array() || path.size() < 2) continue;
|
||||
std::vector<FS_POINTF> pts;
|
||||
pts.reserve(path.size());
|
||||
for (const auto& pt : path) {
|
||||
float px = static_cast<float>(pt.value("x", 0.0));
|
||||
// Frontend stores ink points top-down in page points; flip to PDF bottom-up.
|
||||
float py = static_cast<float>(pageHeight - pt.value("y", 0.0));
|
||||
pts.push_back(FS_POINTF{px, py});
|
||||
anyPoints = true;
|
||||
minX = (std::min)(minX, px); maxX = (std::max)(maxX, px);
|
||||
minY = (std::min)(minY, py); maxY = (std::max)(maxY, py);
|
||||
}
|
||||
if (pts.size() >= 2) {
|
||||
FPDFAnnot_AddInkStroke(annot, pts.data(), pts.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyPoints) {
|
||||
float pad = thickness + 1.0f;
|
||||
FS_RECTF rect;
|
||||
rect.left = minX - pad;
|
||||
rect.bottom = minY - pad;
|
||||
rect.right = maxX + pad;
|
||||
rect.top = maxY + pad;
|
||||
FPDFAnnot_SetRect(annot, &rect);
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "page_rotation") {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_rotation operation missing 'data' object");
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="h-full w-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PDF Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<body class="h-full w-full overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
||||
<div id="root" class="flex h-full w-full flex-col"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+59
-14
@@ -5,8 +5,10 @@ import { Toolbar } from './components/Toolbar';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import type { InspectorTab } from './components/InspectorPanel';
|
||||
import { SignatureModal } from './components/SignatureModal';
|
||||
import { ToastViewport, ConfirmDialog } from './components/ui';
|
||||
import type { ConfirmOptions } from './components/ui';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { ToastViewport } from './components/ui';
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
import { PDFViewer } from './viewer/PDFViewer';
|
||||
import type { PDFViewerRef } from './viewer/PDFViewer';
|
||||
import type { Annotation } from './viewer/AnnotationLayer';
|
||||
@@ -54,11 +56,14 @@ function App() {
|
||||
// Tool aux state
|
||||
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
|
||||
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
|
||||
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
||||
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
|
||||
const [confirmState, setConfirmState] = useState<(ConfirmOptions & { onConfirm: () => void }) | null>(null);
|
||||
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
|
||||
|
||||
// Search
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchCaseSensitive, setSearchCaseSensitive] = useState(false);
|
||||
const [searchWholeWords, setSearchWholeWords] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
|
||||
|
||||
@@ -133,6 +138,8 @@ function App() {
|
||||
content: a.content,
|
||||
timestamp: a.timestamp,
|
||||
pageIndex: a.pageIndex,
|
||||
// Ink stroke geometry (top-left page points) so the overlay can redraw it interactively.
|
||||
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
|
||||
})));
|
||||
if (preservePageRef.current) preservePageRef.current = false;
|
||||
else setCurrentPage(0);
|
||||
@@ -154,7 +161,7 @@ function App() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await gatewayService.searchDocument(selectedDocId, searchQuery);
|
||||
const results = await gatewayService.searchDocument(selectedDocId, searchQuery, searchCaseSensitive, searchWholeWords);
|
||||
setSearchResults(results);
|
||||
setSearchCurrentMatch(0);
|
||||
if (results.length > 0) viewerRef.current?.scrollToPage(results[0].pageIndex);
|
||||
@@ -163,7 +170,7 @@ function App() {
|
||||
}
|
||||
}, searchQuery ? 300 : 0);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchQuery, selectedDocId]);
|
||||
}, [searchQuery, selectedDocId, searchCaseSensitive, searchWholeWords]);
|
||||
|
||||
const selectSearchMatch = (i: number) => {
|
||||
if (i < 0 || i >= searchResults.length) return;
|
||||
@@ -250,8 +257,8 @@ function App() {
|
||||
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 },
|
||||
id: rid('txt'), type: 'text_overlay', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text, fontSize: toolSettings.fontSize, fontFamily: 'Helvetica', color: toolSettings.textColor },
|
||||
}], 'Text box added');
|
||||
setActiveTool('select');
|
||||
};
|
||||
@@ -350,6 +357,33 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (!activeDoc) return;
|
||||
try {
|
||||
toast('Preparing print...', 'info');
|
||||
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = url;
|
||||
|
||||
iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
iframe.contentWindow?.focus();
|
||||
iframe.contentWindow?.print();
|
||||
// Optional: cleanup after a delay
|
||||
// setTimeout(() => document.body.removeChild(iframe), 10000);
|
||||
}, 100);
|
||||
};
|
||||
document.body.appendChild(iframe);
|
||||
} catch (e) {
|
||||
console.error('Print failed', e);
|
||||
toast('Print failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInspector = () => {
|
||||
setIsInspectorOpen((prev) => {
|
||||
const next = !prev;
|
||||
@@ -376,7 +410,7 @@ function App() {
|
||||
|
||||
/* -------------------------------------------------------------- render */
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[var(--canvas)] text-[var(--text)] antialiased">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
||||
<TopBar
|
||||
documentName={activeDoc?.filename}
|
||||
backendHealthy={backendHealthy}
|
||||
@@ -395,6 +429,7 @@ function App() {
|
||||
isDirtySaved={hist.stack.length > 1}
|
||||
onRotate={handleRotate}
|
||||
onExport={handleExport}
|
||||
onPrint={handlePrint}
|
||||
onUpload={handleUpload}
|
||||
isInspectorOpen={isInspectorOpen}
|
||||
onToggleInspector={toggleInspector}
|
||||
@@ -406,6 +441,7 @@ function App() {
|
||||
onToolChange={setActiveTool}
|
||||
hasSignature={!!pendingSignature}
|
||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||
onOpenAbout={() => setAboutModalOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
@@ -423,7 +459,7 @@ function App() {
|
||||
{isLoading && !activeDoc ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<div className="spinner" />
|
||||
<p className="text-[12px] font-semibold uppercase tracking-wide text-[var(--text-dim)]">Loading…</p>
|
||||
<p className="text-[12px] font-semibold uppercase tracking-wide text-[#98a1ad]">Loading…</p>
|
||||
</div>
|
||||
) : activeDoc ? (
|
||||
<PDFViewer
|
||||
@@ -450,15 +486,15 @@ function App() {
|
||||
onPlaceSignature={handlePlaceSignature}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[var(--text-dim)]">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--surface-3)]">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
|
||||
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.4}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[14px] font-semibold text-[var(--text)]">No document open</p>
|
||||
<p className="text-[14px] font-semibold text-[#18212e]">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)]">
|
||||
<label className="mt-2 inline-flex h-10 cursor-pointer items-center gap-2 rounded-[8px] bg-[#2563eb] px-5 text-[13.5px] font-semibold text-white shadow-sm transition-colors hover:bg-[#1d4ed8]">
|
||||
<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 = ''; }} />
|
||||
@@ -486,6 +522,10 @@ function App() {
|
||||
onNavigateAnnotation={navigateToAnnotation}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
searchCaseSensitive={searchCaseSensitive}
|
||||
onSearchCaseSensitiveChange={setSearchCaseSensitive}
|
||||
searchWholeWords={searchWholeWords}
|
||||
onSearchWholeWordsChange={setSearchWholeWords}
|
||||
searchResults={searchResults}
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
onSelectSearchMatch={selectSearchMatch}
|
||||
@@ -506,7 +546,12 @@ function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog state={confirmState} onClose={() => setConfirmState(null)} />
|
||||
<AboutModal
|
||||
open={aboutModalOpen}
|
||||
onClose={() => setAboutModalOpen(false)}
|
||||
/>
|
||||
|
||||
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { XIcon } from './icons';
|
||||
|
||||
interface AboutModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AboutModal: React.FC<AboutModalProps> = ({ open, onClose }) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
modalRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
className="relative flex w-[480px] max-w-[90vw] flex-col items-center overflow-hidden rounded-[8px] bg-[#222222] p-8 text-center text-white shadow-2xl outline-none"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-[4px] p-1 text-white/50 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[9px] bg-[#2563eb] text-white shadow-sm">
|
||||
<svg width="22" height="22" 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-[26px] font-bold tracking-tight">MASKAN PDF</span>
|
||||
</div>
|
||||
<div className="mt-4 text-[14px] text-white/80">
|
||||
PDF EDITOR
|
||||
</div>
|
||||
<div className="text-[14px] text-white/60">
|
||||
Version 1.0.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 border-t border-white/10 pt-6">
|
||||
<div className="text-[18px] font-bold">
|
||||
Maskan Technologies
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[14px] leading-relaxed text-white/80">
|
||||
<p>address: Bangalore, Karnataka, India</p>
|
||||
<p>email: contact@maskantechnologies.com</p>
|
||||
<p>tel.: +91 000-000-0000</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-[14px] font-medium text-white/80">
|
||||
www.maskantechnologies.com
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo } from '../lib/gatewayService';
|
||||
import type { Annotation } from '../viewer/AnnotationLayer';
|
||||
@@ -5,7 +6,7 @@ import { Thumbnail } from './Thumbnail';
|
||||
import { EmptyState, Popover } from './ui';
|
||||
import {
|
||||
PagesIcon, NotesIcon, SearchIcon, PropertiesIcon, FontsIcon, OutlineIcon, FormsIcon,
|
||||
ChevronDownIcon, SearchIcon as SearchGlyph,
|
||||
ChevronDownIcon, ChevronUpIcon, SearchIcon as SearchGlyph,
|
||||
} from './icons';
|
||||
|
||||
export type InspectorTab = 'pages' | 'notes' | 'search' | 'properties' | 'fonts' | 'outline' | 'forms';
|
||||
@@ -31,6 +32,10 @@ interface InspectorPanelProps {
|
||||
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (q: string) => void;
|
||||
searchCaseSensitive: boolean;
|
||||
onSearchCaseSensitiveChange: (c: boolean) => void;
|
||||
searchWholeWords: boolean;
|
||||
onSearchWholeWordsChange: (w: boolean) => void;
|
||||
searchResults: SearchResult[];
|
||||
searchCurrentMatch: number;
|
||||
onSelectSearchMatch: (i: number) => void;
|
||||
@@ -61,63 +66,63 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
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)' }}>
|
||||
<aside className="flex h-full shrink-0 border-l border-[#ebedf0] bg-[#ffffff]" style={{ width: '322px' }}>
|
||||
{/* 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>
|
||||
<div className="flex h-[48px] shrink-0 items-center justify-between gap-2 border-b border-[#ebedf0]" style={{ paddingLeft: '14px', paddingRight: '12px' }}>
|
||||
<span className="shrink-0 text-[13px] font-bold text-[#18212e]">{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>
|
||||
<CustomButton variant="unstyled" className={`flex h-7 min-w-0 max-w-[180px] items-center gap-1.5 rounded-[8px] px-2 text-left transition-colors ${open ? 'bg-[#edeff2]' : 'hover:bg-[#f6f7f9]'}`}>
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#5b6573]">{selectedDoc?.filename ?? 'Document'}</span>
|
||||
<ChevronDownIcon size={13} className="shrink-0 text-[#98a1ad]" />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<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 className="px-1 pb-1 text-[10px] font-bold uppercase tracking-wide text-[#98a1ad]">Open documents</p>
|
||||
{p.documents.map((d) => (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
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)]' : ''}`}
|
||||
className={`flex flex-col rounded-[6px] px-2 py-1.5 text-left transition-colors hover:bg-[#f6f7f9] ${d.id === p.selectedDocumentId ? 'bg-[#eef4ff]' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<span className={`truncate text-[12.5px] font-semibold ${d.id === p.selectedDocumentId ? 'text-[#2563eb]' : 'text-[#18212e]'}`}>{d.filename}</span>
|
||||
<span className="text-[10.5px] text-[#98a1ad]">{formatBytes(d.sizeBytes)} · {d.totalPages} pages</span>
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<span className="text-[11.5px] font-medium text-[var(--text-dim)]">No document</span>
|
||||
<span className="text-[11.5px] font-medium text-[#98a1ad]">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' }}>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-[#ebedf0] bg-[#f6f7f9]" style={{ paddingLeft: '10px', paddingRight: '10px', paddingTop: '7px', paddingBottom: '7px' }}>
|
||||
{TABS.map((t) => {
|
||||
const active = p.activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
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 ${
|
||||
className={`relative flex h-8 w-8 items-center justify-center rounded-[8px] 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)]'
|
||||
? 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
}`}
|
||||
>
|
||||
{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)]" />
|
||||
<span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#2563eb]" />
|
||||
)}
|
||||
</button>
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -145,11 +150,32 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
|
||||
/* ----------------------------------------------------------------- Pages */
|
||||
const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
||||
const [draggedIdx, setDraggedIdx] = React.useState<number | null>(null);
|
||||
|
||||
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)]' : ''}>
|
||||
<div key={idx}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
setDraggedIdx(idx);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedIdx !== null && draggedIdx !== idx) {
|
||||
p.onReorderPage(draggedIdx, idx);
|
||||
}
|
||||
setDraggedIdx(null);
|
||||
}}
|
||||
onDragEnd={() => setDraggedIdx(null)}
|
||||
className={`cursor-grab active:cursor-grabbing ${draggedIdx === idx ? 'opacity-50' : ''} ${idx === p.currentPage ? '[&_.w-full.aspect-\\[3\\/4\\]]:!border-[#2563eb] [&>div>div]:!ring-2 [&_.w-full.aspect-\\[3\\/4\\]]:!ring-[#eef4ff]' : ''}`}
|
||||
>
|
||||
<Thumbnail
|
||||
documentId={p.documentId}
|
||||
pageIndex={idx}
|
||||
@@ -174,18 +200,18 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
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)]">
|
||||
<CustomButton variant="unstyled" key={a.id} onClick={() => onNavigate(a)}
|
||||
className="flex flex-col gap-1.5 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] p-2.5 text-left transition-colors hover:border-[#dadde2]">
|
||||
<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>
|
||||
style={{ background: '#eef4ff', color: '#2563eb' }}>{a.type}</span>
|
||||
<span className="text-[10px] text-[#98a1ad]">{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>
|
||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">“{a.content}”</p>}
|
||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
||||
</CustomButton>
|
||||
))}
|
||||
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[var(--text-dim)]">
|
||||
<p className="px-1 pt-1 text-[10.5px] leading-relaxed text-[#98a1ad]">
|
||||
Editing & deleting saved annotations is coming soon — for now annotations are append-only.
|
||||
</p>
|
||||
</div>
|
||||
@@ -194,38 +220,87 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
|
||||
/* ---------------------------------------------------------------- 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)]" />
|
||||
<div className="border-b border-[#ebedf0] p-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[13px] font-bold text-[#18212e]">Find</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-2.5 py-1.5 focus-within:border-[#2563eb]">
|
||||
<SearchGlyph size={15} className="text-[#98a1ad]" />
|
||||
<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)]"
|
||||
className="w-full bg-transparent text-[13px] text-[#18212e] outline-none placeholder:text-[#98a1ad]"
|
||||
/>
|
||||
</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 className="mt-3 flex items-center justify-between px-0.5">
|
||||
<span className="text-[11.5px] font-medium text-[#5b6573]">
|
||||
{p.searchResults.length > 0 ? `Search results: ${p.searchCurrentMatch + 1}/${p.searchResults.length}` : (p.searchQuery ? 'No matches' : '')}
|
||||
</span>
|
||||
{p.searchResults.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-[#5b6573]">
|
||||
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch - 1 + p.searchResults.length) % p.searchResults.length)}><ChevronUpIcon size={14} /></CustomButton>
|
||||
<CustomButton variant="icon" size={24} onClick={() => p.onSelectSearchMatch((p.searchCurrentMatch + 1) % p.searchResults.length)}><ChevronDownIcon size={14} /></CustomButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-2 px-0.5">
|
||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchCaseSensitive} onChange={(e) => p.onSearchCaseSensitiveChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
Case sensitive
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12px] text-[#5b6573] cursor-pointer">
|
||||
<input type="checkbox" checked={p.searchWholeWords} onChange={(e) => p.onSearchWholeWordsChange(e.target.checked)} className="rounded border-[#dadde2] text-[#2563eb] focus:ring-[#2563eb]" />
|
||||
Whole words only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{p.searchResults.map((r) => {
|
||||
flatIndex++;
|
||||
const i = flatIndex;
|
||||
{p.searchResults.map((r, i) => {
|
||||
const isSelected = i === p.searchCurrentMatch;
|
||||
|
||||
const highlightText = (text: string, query: string) => {
|
||||
if (!query) return text;
|
||||
const flags = p.searchCaseSensitive ? 'g' : 'gi';
|
||||
let regexStr = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
if (p.searchWholeWords) regexStr = `\\b${regexStr}\\b`;
|
||||
|
||||
try {
|
||||
const regex = new RegExp(`(${regexStr})`, flags);
|
||||
const parts = text.split(regex);
|
||||
return (
|
||||
<span>
|
||||
{parts.map((part, index) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={index} className={`font-bold rounded-[2px] px-0.5 ${isSelected ? 'bg-[#fef08a] text-black' : 'bg-[#eef4ff] text-[#2563eb]'}`}>{part}</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
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)]'
|
||||
<CustomButton variant="unstyled" key={i} onClick={() => p.onSelectSearchMatch(i)}
|
||||
className={`mb-1 flex w-full flex-col gap-1.5 rounded-[6px] px-2.5 py-2 text-left transition-colors ${
|
||||
isSelected ? 'bg-[#2563eb] text-white' : 'hover:bg-[#f6f7f9] text-[#5b6573]'
|
||||
}`}>
|
||||
<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>
|
||||
<span className={`text-[12.5px] leading-relaxed ${isSelected ? 'text-white' : 'text-[#18212e]'}`}>
|
||||
{highlightText(r.text || p.searchQuery, p.searchQuery)}
|
||||
</span>
|
||||
<span className={`self-start rounded px-1.5 py-0.5 text-[10px] font-semibold ${isSelected ? 'bg-[#1d4ed8] text-white' : 'bg-[#edeff2] text-[#98a1ad]'}`}>
|
||||
Page {r.pageIndex + 1}
|
||||
</span>
|
||||
</CustomButton>
|
||||
);
|
||||
})}
|
||||
{!p.searchQuery && (
|
||||
@@ -254,12 +329,12 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: n
|
||||
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 key={k} className="grid grid-cols-[88px_1fr] gap-2 border-b border-[#ebedf0] py-2 last:border-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{k}</span>
|
||||
<span className="break-words text-[12.5px] text-[#18212e]">{v && v.trim() ? v : <span className="text-[#98a1ad]">—</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="pt-2 text-[10.5px] text-[var(--text-dim)]">Document properties are read-only.</p>
|
||||
<p className="pt-2 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -275,10 +350,10 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
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 key={i} className="rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] 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>}
|
||||
<span className="truncate font-mono text-[12px] font-semibold text-[#18212e]" title={f.name}>{f.name || 'Unknown'}</span>
|
||||
{f.type && <span className="shrink-0 rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">{f.type}</span>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
<Badge ok={f.isEmbedded} label={f.isEmbedded ? 'Embedded' : 'Not embedded'} />
|
||||
@@ -296,8 +371,8 @@ const FontsTab: React.FC<{ fonts: FontInfo[] }> = ({ fonts }) => {
|
||||
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)',
|
||||
background: warn ? '#fdf3e7' : ok ? '#e9f7ee' : '#edeff2',
|
||||
color: warn ? '#d97706' : ok ? '#16a34a' : '#98a1ad',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Modal, Button } from './ui';
|
||||
import { Modal } from './ui';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
interface SignatureModalProps {
|
||||
@@ -95,17 +96,17 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
width={580}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleConfirm}>Use signature</Button>
|
||||
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
||||
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="mb-5 flex gap-1.5 rounded-[var(--r-md)] bg-[var(--surface-3)] p-1.5">
|
||||
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] 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)]'}`}>
|
||||
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-[6px] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[#ffffff] text-[#2563eb] shadow-sm' : 'text-[#5b6573] hover:text-[#18212e]'}`}>
|
||||
{m}
|
||||
</button>
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -119,12 +120,12 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
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)]"
|
||||
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
|
||||
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>
|
||||
<span className="text-[11px] text-[#98a1ad]">Draw your signature above</span>
|
||||
<CustomButton variant="unstyled" className="text-[12px] font-semibold text-[#2563eb] hover:underline" onClick={clearCanvas}>Clear</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -136,10 +137,10 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
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)]"
|
||||
className="w-full rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-3 py-2.5 text-[14px] outline-none focus:border-[#2563eb]"
|
||||
/>
|
||||
<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)' }}>
|
||||
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9]">
|
||||
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: '#18212e' }}>
|
||||
{typed || 'Preview'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -148,13 +149,13 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
|
||||
{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)]">
|
||||
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9] text-[#5b6573] hover:border-[#2563eb]">
|
||||
{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>
|
||||
<span className="text-[11px] text-[#98a1ad]">PNG with transparent background works best</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
@@ -162,7 +163,7 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] text-[var(--text-dim)]">Visual signature only — not a certified e-signature.</p>
|
||||
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only — not a certified e-signature.</p>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
|
||||
@@ -55,12 +56,12 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
}, [documentId, pageIndex]);
|
||||
|
||||
return (
|
||||
<div className="thumbnail-card" onClick={onClick}>
|
||||
<div className="thumbnail-preview relative group overflow-hidden bg-white transition-colors">
|
||||
<div className="flex flex-col gap-[6px] cursor-pointer" onClick={onClick}>
|
||||
<div className="w-full aspect-[3/4] bg-[#ffffff] border border-[#ebedf0] rounded-[8px] flex flex-col items-center justify-center text-[#5b6573] transition-[border-color,box-shadow,transform] duration-150 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)] hover:border-[#2563eb] hover:shadow-[0_4px_12px_rgba(16,24,40,0.10)] relative group overflow-hidden bg-white transition-colors">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full bg-[var(--surface-2)]">
|
||||
<div className="w-5 h-5 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin mb-2" />
|
||||
<span className="thumbnail-label">Loading…</span>
|
||||
<div className="flex flex-col items-center justify-center h-full w-full bg-[#f6f7f9]">
|
||||
<div className="w-5 h-5 border-2 border-[#2563eb] border-t-transparent rounded-full animate-spin mb-2" />
|
||||
<span className="text-[11px] font-semibold text-[#5b6573]">Loading…</span>
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<>
|
||||
@@ -73,7 +74,7 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
<div className="absolute inset-0 bg-slate-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2 pointer-events-auto">
|
||||
<div className="flex justify-end">
|
||||
{onDelete && totalPages && totalPages > 1 && (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
@@ -84,39 +85,39 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</CustomButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between w-full">
|
||||
{onMoveUp ? (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveUp();
|
||||
}}
|
||||
className="p-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded shadow transition-colors cursor-pointer"
|
||||
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
|
||||
title="Move Page Up"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</button>
|
||||
</CustomButton>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{onMoveDown ? (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveDown();
|
||||
}}
|
||||
className="p-1 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white rounded shadow transition-colors cursor-pointer"
|
||||
className="p-1 bg-[#2563eb] hover:bg-[#1d4ed8] text-white rounded shadow transition-colors cursor-pointer"
|
||||
title="Move Page Down"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
</CustomButton>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
@@ -125,14 +126,14 @@ export const Thumbnail: React.FC<ThumbnailProps> = ({
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full w-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="thumbnail-svg-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className="text-[#98a1ad] mb-[6px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="thumbnail-label text-rose-400">Error</span>
|
||||
<span className="text-[11px] font-semibold text-[#5b6573] text-rose-400">Error</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="thumbnail-label text-center mt-1">Page {pageIndex + 1}</span>
|
||||
<span className="text-[11px] font-semibold text-[#5b6573] text-center mt-1">Page {pageIndex + 1}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
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,
|
||||
SignatureIcon, StampIcon, RedactIcon, InfoIcon, HelpIcon,
|
||||
} from './icons';
|
||||
|
||||
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
|
||||
@@ -27,63 +28,68 @@ interface ToolRailProps {
|
||||
onToolChange: (t: ToolId) => void;
|
||||
hasSignature: boolean;
|
||||
onOpenSignature: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
const RailButton: React.FC<{ t: ToolDef; active: boolean; onClick: () => void }> = ({ t, active, onClick }) => (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
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)]'
|
||||
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
}`}
|
||||
>
|
||||
{active && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? 'var(--danger)' : 'var(--accent)' }} />}
|
||||
{active && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
|
||||
</button>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature }) => {
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout }) => {
|
||||
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' }}>
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[#ebedf0] bg-[#ffffff]" style={{ width: '80px', 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)]" />
|
||||
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" />
|
||||
: <RailButton key={t.id} t={t} active={activeTool === t.id} onClick={() => pickTool(t.id)} />,
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
|
||||
<InfoIcon size={19} />
|
||||
</CustomButton>
|
||||
|
||||
<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>
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
<HelpIcon size={19} />
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<div className="text-[12px]">
|
||||
<p className="mb-1.5 px-1 font-bold text-[var(--text)]">Shortcuts</p>
|
||||
<p className="mb-1.5 px-1 font-bold text-[#18212e]">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>
|
||||
<span className="text-[#5b6573]">{t.label}</span>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">{t.shortcut}</kbd>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1 h-px bg-[var(--border)]" />
|
||||
<div className="my-1 h-px bg-[#ebedf0]" />
|
||||
<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>
|
||||
<span className="text-[#5b6573]">Undo / Redo</span>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 text-[10px] font-semibold">Ctrl+Z / Y</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { ToolId, ToolSettings } from '../lib/tools';
|
||||
import { STAMP_PRESETS } from '../lib/tools';
|
||||
import { ColorSwatches, Slider, Button } from './ui';
|
||||
import { ColorSwatches, Slider } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||
SignatureIcon, StampIcon, RedactIcon,
|
||||
@@ -30,12 +31,12 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
};
|
||||
|
||||
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>
|
||||
<span className={`text-[12px] ${tone === 'warn' ? 'font-semibold text-[#dc2626]' : 'text-[#98a1ad]'}`}>{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>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">{children}</span>
|
||||
);
|
||||
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[var(--border)]" />;
|
||||
const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
|
||||
|
||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||
@@ -44,21 +45,21 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
const isRedact = activeTool === 'redact';
|
||||
return (
|
||||
<div
|
||||
className="flex h-[var(--strip-h)] shrink-0 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)]"
|
||||
className="flex h-[48px] shrink-0 items-center gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
|
||||
style={{ paddingLeft: '16px', paddingRight: '16px' }}
|
||||
>
|
||||
{/* Active tool identity */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--r-md)]"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
|
||||
style={{
|
||||
background: isRedact ? 'var(--danger-soft)' : 'var(--accent-soft)',
|
||||
color: isRedact ? 'var(--danger)' : 'var(--accent)',
|
||||
background: isRedact ? '#fdecec' : '#eef4ff',
|
||||
color: isRedact ? '#dc2626' : '#2563eb',
|
||||
}}
|
||||
>
|
||||
{meta.icon}
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-[var(--text)]">{meta.label}</span>
|
||||
<span className="text-[13px] font-bold text-[#18212e]">{meta.label}</span>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
@@ -101,11 +102,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
|
||||
{activeTool === 'signature' && (
|
||||
<>
|
||||
<Button variant={hasSignature ? 'outline' : 'primary'} size="sm" onClick={onOpenSignature}>
|
||||
<CustomButton variant={hasSignature ? 'outline' : 'primary'} size="sm" onClick={onOpenSignature}>
|
||||
<SignatureIcon size={16} /> {hasSignature ? 'Change signature' : 'Create signature'}
|
||||
</Button>
|
||||
</CustomButton>
|
||||
{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>
|
||||
<span className="ml-auto shrink-0 rounded-full bg-[#f6f7f9] px-2 py-0.5 text-[10px] font-semibold text-[#98a1ad]">Visual signature — not certified</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -113,11 +114,11 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<>
|
||||
<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)]' : ''}`}
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
|
||||
className={`shrink-0 rounded-[6px] 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-[#2563eb]' : ''}`}
|
||||
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
|
||||
{s.label}
|
||||
</button>
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
<Hint>Pick a stamp, then click to place it.</Hint>
|
||||
@@ -131,5 +132,5 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
};
|
||||
|
||||
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>
|
||||
<kbd className="rounded border border-[#ebedf0] bg-[#f6f7f9] px-1.5 py-0.5 text-[10px] font-semibold text-[#18212e]">{children}</kbd>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useRef } from 'react';
|
||||
import { Button, IconButton, Popover } from './ui';
|
||||
import { Popover } from './ui';
|
||||
import {
|
||||
UndoIcon, RedoIcon, ZoomInIcon, ZoomOutIcon, RotateIcon, DownloadIcon,
|
||||
ChevronDownIcon, CheckIcon, SpinnerIcon, UploadIcon, FitIcon, PagesIcon,
|
||||
@@ -23,6 +24,7 @@ interface TopBarProps {
|
||||
isDirtySaved: boolean;
|
||||
onRotate: () => void;
|
||||
onExport: () => void;
|
||||
onPrint: () => void;
|
||||
onUpload: (file: File) => void;
|
||||
isInspectorOpen: boolean;
|
||||
onToggleInspector: () => void;
|
||||
@@ -33,7 +35,7 @@ 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,
|
||||
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
|
||||
isInspectorOpen, onToggleInspector,
|
||||
}) => {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -45,43 +47,42 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
|
||||
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' }}
|
||||
className="flex shrink-0 items-center justify-between gap-3 border-b border-[#ebedf0] bg-[#ffffff]"
|
||||
style={{ height: '56px', 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">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-[#2563eb] 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>
|
||||
<span className="text-[14px] font-extrabold tracking-tight text-[#18212e]">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)]'}`}>
|
||||
<CustomButton variant="unstyled" className={`flex h-8 items-center gap-1 rounded-[8px] px-2.5 text-[13px] font-semibold transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
File <ChevronDownIcon size={14} />
|
||||
</button>
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName}>Print</MenuItem>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<IconButton label="Open PDF file" size={34} onClick={() => fileRef.current?.click()} className="text-[var(--text-muted)] hover:text-[var(--text)]">
|
||||
<CustomButton variant="icon" label="Open PDF file" size={32} onClick={() => fileRef.current?.click()} className="text-[#5b6573] hover:text-[#18212e]">
|
||||
<UploadIcon size={18} />
|
||||
</IconButton>
|
||||
</CustomButton>
|
||||
|
||||
{documentName && (
|
||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[var(--text-muted)]" title={documentName}>
|
||||
<span className="ml-1 max-w-[150px] truncate text-[12.5px] font-medium text-[#5b6573]" title={documentName}>
|
||||
{documentName}
|
||||
</span>
|
||||
)}
|
||||
@@ -90,38 +91,38 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
{/* 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>
|
||||
<CustomButton variant="icon" label="Undo (Ctrl+Z)" size={34} onClick={onUndo} disabled={!canUndo}><UndoIcon size={18} /></CustomButton>
|
||||
<CustomButton variant="icon" label="Redo (Ctrl+Y)" size={34} onClick={onRedo} disabled={!canRedo}><RedoIcon size={18} /></CustomButton>
|
||||
</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>
|
||||
<div className="flex items-center gap-0.5 rounded-[8px] bg-[#f6f7f9] p-0.5">
|
||||
<CustomButton variant="icon" label="Zoom out" size={30} onClick={() => onZoomChange(Math.max(0.25, zoom - 0.1))}><ZoomOutIcon size={17} /></CustomButton>
|
||||
<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)]'}`}>
|
||||
<CustomButton variant="unstyled" className={`h-7 w-[52px] rounded-[5px] text-[12px] font-bold tabular-nums transition-colors ${open ? 'bg-[#edeff2]' : 'text-[#18212e] hover:bg-[#edeff2]'}`}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
</CustomButton>
|
||||
)}
|
||||
>
|
||||
<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)]" />
|
||||
<div className="my-1 h-px bg-[#ebedf0]" />
|
||||
{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)}>
|
||||
<CustomButton variant="unstyled" key={z} className="rounded-[6px] px-2 py-1 text-left tabular-nums hover:bg-[#f6f7f9]" onClick={() => onZoomChange(z)}>
|
||||
{Math.round(z * 100)}%
|
||||
</button>
|
||||
</CustomButton>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
<IconButton label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></IconButton>
|
||||
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></CustomButton>
|
||||
</div>
|
||||
|
||||
<IconButton label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName}><RotateIcon size={18} /></IconButton>
|
||||
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName}><RotateIcon size={18} /></CustomButton>
|
||||
|
||||
{documentName && (
|
||||
<div className="flex items-center gap-1 text-[12px] font-semibold text-[var(--text-muted)]">
|
||||
<div className="flex items-center gap-1 text-[12px] font-semibold text-[#5b6573]">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -131,9 +132,9 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
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)]"
|
||||
className="h-7 w-9 rounded-[6px] border border-[#dadde2] bg-[#ffffff] text-center tabular-nums outline-none focus:border-[#2563eb]"
|
||||
/>
|
||||
<span className="text-[var(--text-dim)]">/ {Math.max(1, totalPages)}</span>
|
||||
<span className="text-[#98a1ad]">/ {Math.max(1, totalPages)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,16 +143,16 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
<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
|
||||
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName}><DownloadIcon size={15} /> Export</CustomButton>
|
||||
<CustomButton variant="icon"
|
||||
label={isInspectorOpen ? "Hide panel" : "Show panel"}
|
||||
size={34}
|
||||
active={isInspectorOpen}
|
||||
onClick={onToggleInspector}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text)]"
|
||||
className="text-[#5b6573] hover:text-[#18212e]"
|
||||
>
|
||||
<PagesIcon size={18} />
|
||||
</IconButton>
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<input ref={fileRef} type="file" accept=".pdf" onChange={handleFile} className="hidden" />
|
||||
@@ -160,37 +161,37 @@ export const TopBar: React.FC<TopBarProps> = ({
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<{ icon: React.ReactNode; onClick: () => void; disabled?: boolean; children: React.ReactNode }> = ({ icon, onClick, disabled, children }) => (
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
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"
|
||||
className="flex items-center gap-2 rounded-[6px] px-2.5 py-2 text-left transition-colors hover:bg-[#f6f7f9] disabled:opacity-40"
|
||||
>
|
||||
{icon} {children}
|
||||
</button>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
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 (isSaving) return <span className="flex items-center gap-1.5 text-[12px] font-medium text-[#5b6573]"><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>;
|
||||
return <span className="hidden items-center gap-1.5 text-[12px] font-medium text-[#16a34a] 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 color = '#98a1ad';
|
||||
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…';
|
||||
color = '#d97706'; label = 'Connecting…'; tip = 'Connecting to gateway…';
|
||||
} else if (healthy && !engineReady) {
|
||||
color = 'var(--warning)'; label = 'Engine off · mock data';
|
||||
color = '#d97706'; 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)]"
|
||||
className="flex items-center gap-1.5 rounded-full border border-[#ebedf0] bg-[#f6f7f9] px-2 py-1 text-[11px] font-medium text-[#5b6573]"
|
||||
title={tip}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: color }} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { wasmLoader } from '../lib/wasmLoader';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
@@ -159,7 +160,7 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-indigo-500 animate-pulse shadow-[0_0_8px_#6366f1]" />
|
||||
<h3 className="font-bold text-sm tracking-wider uppercase text-indigo-400">WASM Compiler Inspect</h3>
|
||||
</div>
|
||||
<button
|
||||
<CustomButton variant="unstyled"
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
title="Close Inspector"
|
||||
@@ -167,7 +168,7 @@ export const WasmInspector: React.FC<WasmInspectorProps> = ({
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'danger' | 'subtle' | 'icon' | 'unstyled';
|
||||
|
||||
export interface CustomButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: 'sm' | 'md' | number;
|
||||
active?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const buttonVariants: Record<Exclude<ButtonVariant, 'icon' | 'unstyled'>, string> = {
|
||||
primary: 'bg-[#2563eb] text-white hover:bg-[#1d4ed8] shadow-sm',
|
||||
ghost: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]',
|
||||
outline: 'border border-[#dadde2] bg-[#ffffff] text-[#18212e] hover:bg-[#f6f7f9]',
|
||||
danger: 'bg-[#dc2626] text-white hover:brightness-95 shadow-sm',
|
||||
subtle: 'bg-[#edeff2] text-[#18212e] hover:bg-[#ebedf0]',
|
||||
};
|
||||
|
||||
export const CustomButton: React.FC<CustomButtonProps> = ({
|
||||
variant = 'outline',
|
||||
size = 'md',
|
||||
active,
|
||||
label,
|
||||
className = '',
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
if (variant === 'unstyled') {
|
||||
return (
|
||||
<button className={className} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'icon') {
|
||||
const s = typeof size === 'number' ? size : 34;
|
||||
return (
|
||||
<button
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
style={{ width: s, height: s, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
className={`shrink-0 rounded-[8px] transition-colors disabled:opacity-35 disabled:cursor-not-allowed ${
|
||||
active
|
||||
? 'bg-[#eef4ff] text-[#2563eb]'
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-[8px] font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
size === 'sm' ? 'h-[34px] px-4 text-[13px]' : 'h-10 px-5 text-[13.5px]'
|
||||
} ${buttonVariants[variant]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { CustomButton } from './CustomButton';
|
||||
|
||||
export interface CustomConfirmationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomConfirmationModalProps {
|
||||
state: (CustomConfirmationOptions & { onConfirm: () => void }) | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CustomConfirmationModal: React.FC<CustomConfirmationModalProps> = ({ state, onClose }) => {
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [state, onClose]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" onMouseDown={onClose}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[#0f172a]/30 backdrop-blur-[2px]"
|
||||
style={{ animation: 'toastIn 0.2s ease-out' }}
|
||||
/>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
|
||||
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-7 pb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{state.danger ? (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#fdecec] text-[#dc2626]">
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#2563eb]">
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">{state.title}</h2>
|
||||
</div>
|
||||
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">{state.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 bg-[#f6f7f9] px-7 py-5 border-t border-[#ebedf0]">
|
||||
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] font-semibold px-4 text-[#5b6573]">
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant={state.danger ? 'danger' : 'primary'}
|
||||
onClick={() => { state.onConfirm(); onClose(); }}
|
||||
className={`rounded-[8px] px-5 font-semibold text-white shadow-sm transition-colors ${
|
||||
state.danger
|
||||
? 'bg-[#dc2626] hover:bg-[#b91c1c]'
|
||||
: 'bg-[#2563eb] hover:bg-[#1d4ed8]'
|
||||
}`}
|
||||
>
|
||||
{state.confirmLabel ?? 'Confirm'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,7 @@ export const ZoomOutIcon: IC = mk(<><circle cx="11" cy="11" r="7" /><path d="M8
|
||||
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 ChevronUpIcon: IC = mk(<path d="M18 15l-6-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" />);
|
||||
@@ -74,5 +75,6 @@ export const CopyIcon: IC = mk(<><rect x="9" y="9" width="11" height="11" rx="2"
|
||||
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 HelpIcon: IC = mk(<><circle cx="12" cy="12" r="9" /><path d="M9 10a3 3 0 116 0c0 1.5-2 2-2 3M12 17h.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" /></>);
|
||||
|
||||
@@ -2,51 +2,7 @@ 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>
|
||||
);
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
interface PopoverProps {
|
||||
trigger: (open: boolean) => React.ReactNode;
|
||||
@@ -76,7 +32,7 @@ export const Popover: React.FC<PopoverProps> = ({ trigger, children, align = 'le
|
||||
{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)] ${
|
||||
className={`absolute top-[calc(100%+6px)] z-50 rounded-[12px] border border-[#ebedf0] bg-[#ffffff] p-2.5 shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -97,17 +53,18 @@ const DEFAULT_PALETTE = ['#facc15', '#fb923c', '#f87171', '#f472b6', '#a78bfa',
|
||||
export const ColorSwatches: React.FC<ColorSwatchesProps> = ({ value, onChange, palette = DEFAULT_PALETTE }) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{palette.map((c) => (
|
||||
<button
|
||||
<CustomButton
|
||||
variant="unstyled"
|
||||
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)]'
|
||||
value.toLowerCase() === c.toLowerCase() ? 'ring-2 ring-[#2563eb] ring-offset-1' : 'border-[#dadde2]'
|
||||
}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[var(--border-strong)]"
|
||||
<label className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-[#dadde2]"
|
||||
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" />
|
||||
@@ -127,7 +84,7 @@ interface SliderProps {
|
||||
}
|
||||
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>}
|
||||
{label && <span className="text-[11px] font-medium text-[#5b6573]">{label}</span>}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
@@ -136,9 +93,9 @@ export const Slider: React.FC<SliderProps> = ({ value, min, max, step = 1, onCha
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||
style={{ width }}
|
||||
className="accent-[var(--accent)]"
|
||||
className="accent-[#2563eb]"
|
||||
/>
|
||||
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[var(--text)]">{value}{suffix}</span>
|
||||
<span className="w-9 text-right text-[11px] font-semibold tabular-nums text-[#18212e]">{value}{suffix}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -151,17 +108,17 @@ interface EmptyStateProps {
|
||||
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)]">
|
||||
<div className="mb-1 flex h-14 w-14 items-center justify-center rounded-2xl bg-[#edeff2] text-[#98a1ad]">
|
||||
{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)]">
|
||||
<span className="rounded-full bg-[#eef4ff] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#2563eb]">
|
||||
{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>}
|
||||
<p className="text-[13.5px] font-semibold text-[#18212e]">{title}</p>
|
||||
{hint && <p className="max-w-[210px] text-[11.5px] leading-relaxed text-[#98a1ad]">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -185,15 +142,15 @@ export const Modal: React.FC<ModalProps> = ({ open, onClose, title, children, wi
|
||||
<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)]"
|
||||
className="max-h-[88vh] overflow-hidden rounded-[12px] border border-[#ebedf0] bg-[#ffffff] shadow-[0_12px_32px_rgba(16,24,40,0.16)]"
|
||||
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 className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
|
||||
<h2 className="text-[15px] font-bold text-[#18212e]">{title}</h2>
|
||||
<CustomButton variant="icon" label="Close" size={30} onClick={onClose}><XIcon size={18} /></CustomButton>
|
||||
</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>}
|
||||
{footer && <div className="flex justify-end gap-2.5 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -218,25 +175,25 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
|
||||
footer={
|
||||
state && (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
<CustomButton variant="outline" onClick={onClose}>Cancel</CustomButton>
|
||||
<CustomButton
|
||||
variant={state.danger ? 'danger' : 'primary'}
|
||||
onClick={() => { state.onConfirm(); onClose(); }}
|
||||
>
|
||||
{state.confirmLabel ?? 'Confirm'}
|
||||
</Button>
|
||||
</CustomButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">{state?.message}</p>
|
||||
<p className="text-[13px] leading-relaxed text-[#5b6573]">{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',
|
||||
info: 'border-[#ebedf0] bg-[#18212e] text-white',
|
||||
success: 'border-transparent bg-[#16a34a] text-white',
|
||||
error: 'border-transparent bg-[#dc2626] text-white',
|
||||
};
|
||||
export const ToastViewport: React.FC = () => {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
@@ -246,14 +203,14 @@ export const ToastViewport: React.FC = () => {
|
||||
{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]}`}
|
||||
className={`pointer-events-auto flex items-center gap-2 rounded-full border px-4 py-2 text-[12.5px] font-semibold shadow-[0_12px_32px_rgba(16,24,40,0.16)] ${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>
|
||||
<CustomButton variant="unstyled" className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></CustomButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+2
-299
@@ -1,89 +1,6 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
/* Surfaces */
|
||||
--canvas: #f1f2f4; /* document workspace background */
|
||||
--surface: #ffffff; /* panels, bars */
|
||||
--surface-2: #f6f7f9; /* subtle raised / hover */
|
||||
--surface-3: #edeff2; /* pressed / track */
|
||||
|
||||
/* Borders — intentionally faint; rely on surface/canvas contrast for separation */
|
||||
--border: #ebedf0;
|
||||
--border-strong: #dadde2;
|
||||
|
||||
/* Text */
|
||||
--text: #18212e;
|
||||
--text-muted: #5b6573;
|
||||
--text-dim: #98a1ad;
|
||||
|
||||
/* Primary accent (professional blue) */
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-soft: #eef4ff;
|
||||
--accent-text: #ffffff;
|
||||
|
||||
/* Semantic */
|
||||
--success: #16a34a;
|
||||
--success-soft: #e9f7ee;
|
||||
--warning: #d97706;
|
||||
--warning-soft: #fdf3e7;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fdecec;
|
||||
|
||||
/* Tool accents (used by contextual strip swatches) */
|
||||
--tool-highlight: #facc15;
|
||||
--tool-ink: #2563eb;
|
||||
--tool-redact: #ef4444;
|
||||
|
||||
/* Radii */
|
||||
--r-sm: 6px;
|
||||
--r-md: 8px;
|
||||
--r-lg: 12px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-1: 0 1px 2px rgba(16, 24, 40, 0.06), 0 1px 3px rgba(16, 24, 40, 0.10);
|
||||
--shadow-2: 0 4px 12px rgba(16, 24, 40, 0.10);
|
||||
--shadow-3: 0 12px 32px rgba(16, 24, 40, 0.16);
|
||||
|
||||
/* Sizing */
|
||||
--topbar-h: 56px;
|
||||
--strip-h: 48px;
|
||||
--rail-w: 80px;
|
||||
--inspector-w: 322px;
|
||||
|
||||
font-family: var(--font-sans);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: var(--canvas);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Sleek scrollbars */
|
||||
.scroll-thin::-webkit-scrollbar,
|
||||
.custom-scrollbar::-webkit-scrollbar,
|
||||
@@ -94,233 +11,19 @@ body {
|
||||
.scroll-thin::-webkit-scrollbar-thumb,
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
.viewer-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
background: #dadde2;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.scroll-thin::-webkit-scrollbar-thumb:hover,
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: padding-box; }
|
||||
.viewer-viewport::-webkit-scrollbar-thumb:hover { background: #98a1ad; background-clip: padding-box; }
|
||||
|
||||
/* Hidden scrollbar (used by horizontal tab/tool strips) */
|
||||
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
|
||||
/* ==========================================================================
|
||||
VIEWER (class names consumed by PDFViewer + layers — keep stable)
|
||||
========================================================================== */
|
||||
.viewer-viewport {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--canvas);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.viewer-content-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 28px 0;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
position: absolute;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
box-shadow: var(--shadow-2);
|
||||
outline: 1px solid rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.shadow-premium { box-shadow: var(--shadow-2); }
|
||||
|
||||
.page-loading-state {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
gap: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--accent-soft);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 9999px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.page-loading-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Layering */
|
||||
.selection-layer { position: absolute; top: 0; left: 0; z-index: 20; cursor: text; }
|
||||
.selection-highlight {
|
||||
position: absolute;
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
pointer-events: none;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.selection-glyph {
|
||||
position: absolute;
|
||||
background: rgba(37, 99, 235, 0.28);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.annotation-layer { position: absolute; top: 0; left: 0; z-index: 30; }
|
||||
.annotation-box {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: opacity 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.annotation-box:hover { box-shadow: 0 2px 8px rgba(16, 24, 40, 0.18); }
|
||||
.annotation-box.type-highlight { mix-blend-mode: multiply; }
|
||||
.annotation-box.type-comment { display: flex; align-items: center; justify-content: center; }
|
||||
.annotation-box.type-strikeout { display: flex; align-items: center; justify-content: center; }
|
||||
.strikeout-line { width: 100%; height: 2px; background: var(--danger); opacity: 0.85; }
|
||||
.annotation-box.type-signature {
|
||||
border: 2px dashed rgba(37, 99, 235, 0.5);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.signature-badge {
|
||||
color: rgba(37, 99, 235, 0.85);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 9999px;
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.overlay-layer { position: absolute; top: 0; left: 0; z-index: 40; }
|
||||
.overlay-signature-alert {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 2px dashed rgba(37, 99, 235, 0.4);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.overlay-badge {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 5px 12px;
|
||||
border-radius: 9999px;
|
||||
box-shadow: var(--shadow-2);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.overlay-toast {
|
||||
position: absolute;
|
||||
top: 14px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--text);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 5px 12px;
|
||||
border-radius: 9999px;
|
||||
box-shadow: var(--shadow-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Comment popup */
|
||||
.comment-popup-container {
|
||||
width: 264px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-3);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.18s ease-out;
|
||||
}
|
||||
.comment-form-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.comment-textarea::placeholder { color: var(--text-dim); }
|
||||
.comment-form-footer {
|
||||
padding: 10px 14px;
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
.comment-submit-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-submit-btn:hover { background: var(--accent-hover); }
|
||||
|
||||
/* In-canvas text box editor (free_text tool) */
|
||||
.textbox-editor {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
background: rgba(255,255,255,0.85);
|
||||
border: 1.5px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow-2);
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.25;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
/* Thumbnails */
|
||||
.thumbnail-card { display: flex; flex-direction: column; gap: 6px; cursor: pointer; }
|
||||
.thumbnail-preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: var(--text-muted);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
.thumbnail-preview:hover { border-color: var(--accent); box-shadow: var(--shadow-2); }
|
||||
.thumbnail-preview.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
.thumbnail-svg-icon { color: var(--text-dim); margin-bottom: 6px; }
|
||||
.thumbnail-label { font-size: 11px; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
@@ -333,10 +333,14 @@ class GatewayService {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async searchDocument(documentId: string, query: string): Promise<SearchResult[]> {
|
||||
async searchDocument(documentId: string, query: string, caseSensitive: boolean = false, wholeWords: boolean = false): Promise<SearchResult[]> {
|
||||
if (!query) return [];
|
||||
|
||||
const urlParams = new URLSearchParams({ q: query });
|
||||
const urlParams = new URLSearchParams({
|
||||
q: query,
|
||||
...(caseSensitive && { case_sensitive: 'true' }),
|
||||
...(wholeWords && { whole_words: 'true' })
|
||||
});
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/search?${urlParams.toString()}`);
|
||||
|
||||
if (response.status === 501) {
|
||||
|
||||
@@ -34,7 +34,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="annotation-layer"
|
||||
className="absolute top-0 left-0 z-30"
|
||||
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: 'none' }}
|
||||
>
|
||||
{annotations
|
||||
@@ -60,7 +60,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
e.stopPropagation();
|
||||
onAnnotationClick?.(anno);
|
||||
}}
|
||||
className={`annotation-box type-${anno.type}`}
|
||||
className={`absolute cursor-pointer rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type}`}
|
||||
style={{
|
||||
left: `${scaledBbox.x}px`,
|
||||
top: `${scaledBbox.y}px`,
|
||||
@@ -80,9 +80,9 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{anno.type === 'strikeout' && <div className="strikeout-line" />}
|
||||
{anno.type === 'strikeout' && <div className="w-full h-[2px] bg-[#dc2626] opacity-[0.85]" />}
|
||||
{anno.type === 'signature' && (
|
||||
<div className="signature-badge">
|
||||
<div className="text-[rgba(37,99,235,0.85)] bg-[rgba(255,255,255,0.85)] rounded-full p-1 shadow-[0_1px_2px_rgba(16,24,40,0.06),0_1px_3px_rgba(16,24,40,0.10)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
|
||||
</svg>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomButton } from '../components/custom/CustomButton';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import type { Annotation } from './AnnotationLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
@@ -125,47 +126,47 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="overlay-layer"
|
||||
className="absolute top-0 left-0 z-40"
|
||||
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: POINTER_TOOLS.includes(activeTool) ? 'auto' : 'none' }}
|
||||
onClick={handleLayerClick}
|
||||
>
|
||||
{/* Tool hints */}
|
||||
{activeTool === 'signature' && !hasSignature && (
|
||||
<div className="overlay-signature-alert"><span className="overlay-badge">Create a signature to place it here</span></div>
|
||||
<div className="absolute inset-0 bg-[rgba(37,99,235,0.05)] flex items-center justify-center border-2 border-dashed border-[rgba(37,99,235,0.4)] rounded-[6px]"><span className="bg-[#2563eb] text-white text-[11px] font-bold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] tracking-[0.3px]">Create a signature to place it here</span></div>
|
||||
)}
|
||||
{activeTool === 'signature' && hasSignature && !commentPopup && (
|
||||
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to place signature</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place signature</div>
|
||||
)}
|
||||
{activeTool === 'comment' && !commentPopup && (
|
||||
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
|
||||
)}
|
||||
{activeTool === 'stamp' && activeStamp && (
|
||||
<div className="overlay-toast" style={{ pointerEvents: 'none' }}>Click to place “{activeStamp}”</div>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" 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>
|
||||
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
|
||||
)}
|
||||
|
||||
{/* Comment popup */}
|
||||
{commentPopup && (
|
||||
<div
|
||||
className="comment-popup-container"
|
||||
className="w-[264px] bg-[#ffffff] border border-[#ebedf0] rounded-[12px] shadow-[0_12px_32px_rgba(16,24,40,0.16)] overflow-hidden animate-[slideUp_0.18s_ease-out]"
|
||||
style={{ position: 'absolute', left: `${commentPopup.x * zoom}px`, top: `${commentPopup.y * zoom}px`, zIndex: 50 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<form onSubmit={handleCommentSubmit}>
|
||||
<div className="comment-form-header">
|
||||
<div className="flex justify-between items-center py-[10px] px-[14px] bg-[#f6f7f9] border-b border-[#ebedf0]">
|
||||
<span className="text-xs font-bold">Add sticky note</span>
|
||||
<button type="button" onClick={() => setCommentPopup(null)} className="text-[var(--text-dim)] hover:text-[var(--text)]">
|
||||
<CustomButton variant="unstyled" type="button" onClick={() => setCommentPopup(null)} className="text-[#98a1ad] hover:text-[#18212e]">
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</CustomButton>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus value={commentText} onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Type your comment here…" className="comment-textarea" rows={3}
|
||||
placeholder="Type your comment here…" className="w-full bg-transparent text-[#18212e] border-none py-[12px] px-[14px] text-[13px] resize-none outline-none font-sans placeholder:text-[#98a1ad]" rows={3}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCommentSubmit(e); } }}
|
||||
/>
|
||||
<div className="comment-form-footer"><button type="submit" className="comment-submit-btn">Save note</button></div>
|
||||
<div className="py-[10px] px-[14px] bg-[#f6f7f9] border-t border-[#ebedf0] flex justify-end"><CustomButton variant="unstyled" type="submit" className="bg-[#2563eb] text-white border-none py-[7px] px-[16px] rounded-[6px] text-[12px] font-semibold cursor-pointer hover:bg-[#1d4ed8]">Save note</CustomButton></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
@@ -182,7 +183,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commitTextBox(); }
|
||||
if (e.key === 'Escape') { setTextBox(null); setTextValue(''); }
|
||||
}}
|
||||
className="textbox-editor"
|
||||
className="absolute z-50 bg-[rgba(255,255,255,0.85)] border-[1.5px] border-[#2563eb] rounded-[4px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline-none resize-none overflow-hidden font-sans leading-[1.25] py-[2px] px-[4px]"
|
||||
placeholder="Type…"
|
||||
style={{
|
||||
left: `${textBox.x * zoom}px`,
|
||||
|
||||
@@ -347,10 +347,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={`viewer-viewport ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
|
||||
className={`flex-1 h-full overflow-auto bg-[#f1f2f4] flex justify-center items-start outline-none ${activeTool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
|
||||
>
|
||||
<div
|
||||
className="viewer-content-container"
|
||||
className="relative w-full flex flex-col items-center py-7"
|
||||
style={{ height: `${totalContentHeight}px` }}
|
||||
>
|
||||
{visiblePages.map((page) => {
|
||||
@@ -359,7 +359,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
return (
|
||||
<div
|
||||
key={page.index}
|
||||
className="page-container shadow-premium"
|
||||
className="absolute bg-white rounded-[3px] shadow-[0_4px_12px_rgba(16,24,40,0.10)] outline outline-1 outline-[rgba(16,24,40,0.04)] shadow-[0_4px_12px_rgba(16,24,40,0.10)]"
|
||||
style={{
|
||||
top: `${page.top}px`,
|
||||
width: `${page.width}px`,
|
||||
@@ -440,9 +440,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="page-loading-state">
|
||||
<div className="spinner" />
|
||||
<span className="page-loading-label">Loading Page {page.index + 1}...</span>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]">
|
||||
<div className="w-7 h-7 border-[3px] border-[#eef4ff] border-t-[#2563eb] rounded-full animate-spin" />
|
||||
<span className="text-[12px] font-semibold tracking-[0.2px] text-[#98a1ad]">Loading Page {page.index + 1}...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
top: `${redactBox.y}px`,
|
||||
width: `${redactBox.width}px`,
|
||||
height: `${redactBox.height}px`,
|
||||
border: '2px dashed var(--color-error, #f43f5e)',
|
||||
border: '2px dashed #f43f5e',
|
||||
backgroundColor: 'rgba(244, 63, 94, 0.15)',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: '0 0 12px rgba(244, 63, 94, 0.25)',
|
||||
|
||||
@@ -41,10 +41,10 @@ export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={rectIdx}
|
||||
className={`absolute rounded-sm border ${
|
||||
className={`absolute ${
|
||||
isActive
|
||||
? 'bg-orange-500/50 border-orange-600/80 shadow-[0_0_8px_rgba(249,115,22,0.6)] z-30'
|
||||
: 'bg-yellow-400/40 border-yellow-500/60'
|
||||
? 'bg-[#f97316]/40 shadow-[0_0_4px_3px_rgba(249,115,22,0.3)] rounded-[4px] z-30'
|
||||
: 'bg-[#fef08a]/50 rounded-[3px]'
|
||||
}`}
|
||||
style={{
|
||||
left: `${rect.x * zoom}px`,
|
||||
|
||||
@@ -106,7 +106,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="selection-layer"
|
||||
className="absolute top-0 left-0 z-20 cursor-text"
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -115,11 +115,11 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({ width, height, z
|
||||
>
|
||||
{/* Live per-glyph highlight */}
|
||||
{hitGlyphs.map((g, i) => (
|
||||
<div key={i} className="selection-glyph" style={{ left: g.gx, top: g.gy, width: g.gw, height: g.gh }} />
|
||||
<div key={i} className="absolute bg-[rgba(37,99,235,0.28)] pointer-events-none" style={{ left: g.gx, top: g.gy, width: g.gw, height: g.gh }} />
|
||||
))}
|
||||
{/* Drag rectangle (only when nothing is being hit, e.g. mock mode) */}
|
||||
{selectionBox && hitGlyphs.length === 0 && (
|
||||
<div className="selection-highlight" style={{ left: selectionBox.x, top: selectionBox.y, width: selectionBox.width, height: selectionBox.height }} />
|
||||
<div className="absolute bg-[rgba(37,99,235,0.22)] pointer-events-none rounded-[1px]" style={{ left: selectionBox.x, top: selectionBox.y, width: selectionBox.width, height: selectionBox.height }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
PDFENGINE_ENGINE_AVAILABLE=true
|
||||
PDFENGINE_ENVIRONMENT=dev
|
||||
@@ -470,6 +470,9 @@ class AnnotationResponse(BaseModel):
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
pageIndex: int
|
||||
# Stroke geometry for ink annotations (top-left page-point space), so the
|
||||
# frontend can redraw them as an interactive overlay rather than a flat image.
|
||||
paths: list[list[dict[str, float]]] = []
|
||||
|
||||
@router.get("/{document_id}/annotations", response_model=list[AnnotationResponse])
|
||||
def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
@@ -502,7 +505,8 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
author=a.author,
|
||||
content=a.content,
|
||||
timestamp=getattr(a, "timestamp", None),
|
||||
pageIndex=a.page_index
|
||||
pageIndex=a.page_index,
|
||||
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user