17 Commits
Author SHA1 Message Date
maskantech 66f7cd345c docker 2026-07-17 15:13:26 +05:30
furqan 3775359ab2 Merge pull request 'feat: implement PDF viewer components and scaffolding for interactive document editing' (#78) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/78
2026-07-10 06:53:32 +00:00
azeeee05 27538a9489 feat: implement PDF viewer components and scaffolding for interactive document editing 2026-07-10 12:22:09 +05:30
furqan b15aed60d1 Merge pull request 'azeem' (#77) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/77
2026-07-10 05:49:09 +00:00
azeeee05 ad51b4b270 feat: implement PDF viewer, floating text toolbar, and document editing gateway router 2026-07-09 12:39:47 +05:30
azeeee05 f9ec4e1680 feat: implement redaction UI and backend engine support for area-based PDF redactions 2026-07-09 11:51:28 +05:30
furqan f8af0aa611 Merge pull request 'azeem' (#75) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/75
2026-07-09 05:10:37 +00:00
azeeee05 82a07d2e6e feat: implement PDF annotation extraction and inspector API endpoints via C++ bindings 2026-07-09 10:38:26 +05:30
azeeee05 62ad42b116 feat: implement backend and frontend support for PDF text overlays and annotations 2026-07-08 15:53:17 +05:30
furqan 46ec78e9b4 Merge pull request 'feat: implement PDF page parsing, text extraction, and document modeling logic' (#74) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/74
2026-07-08 09:47:32 +00:00
azeeee05 99f87eb7cf feat: implement PDF page parsing, text extraction, and document modeling logic 2026-07-07 19:03:11 +05:30
furqan a383bd8704 Merge pull request 'azeem' (#72) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/72
2026-07-01 09:39:29 +00:00
azeeee05 90027823e4 chore: remove obsolete feature inventory documentationl 2026-07-01 15:07:38 +05:30
azeeee05 5b034c97b8 Merge branch 'dev' of https://gitea.maskantech.in/gitea_admin/pdf into azeem 2026-07-01 15:06:08 +05:30
azeeee05 bc3ed20a8b done the signature module 2026-07-01 15:05:37 +05:30
furqan abcd407c35 Merge pull request 'furqan' (#71) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/71
2026-06-30 15:49:48 +00:00
azeeee05 afa8c0a702 feat: implement PDF toolbar, viewer components, and redaction support layer 2026-06-30 11:32:15 +05:30
35 changed files with 2247 additions and 286 deletions
+30
View File
@@ -0,0 +1,30 @@
**/.git
**/.github
**/.venv
**/node_modules
**/dist
**/build
**/out
**/__pycache__
**/*.pyc
**/*.pyo
**/*.pyd
**/*.log
**/.DS_Store
**/Thumbs.db
**/.vscode
**/.idea
**/coverage
**/tmp
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/CMakeUserPresets.json
**/compile_commands.json
**/vcpkg
**/third_party/pdfium/depot_tools
**/third_party/pdfium/checkout
**/third_party/pdfium/install
**/third_party/skia/depot_tools
**/third_party/skia/checkout
**/third_party/skia/install
+16 -1
View File
@@ -341,12 +341,27 @@ PYBIND11_MODULE(pdfengine, m) {
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content) .def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp) .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("thickness", &pdfengine::PdfPage::AnnotationInfo::thickness)
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths) .def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName) .def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue) .def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType) .def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
.def_readonly("field_flags", &pdfengine::PdfPage::AnnotationInfo::fieldFlags) .def_readonly("field_flags", &pdfengine::PdfPage::AnnotationInfo::fieldFlags)
.def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions); .def_readonly("field_options", &pdfengine::PdfPage::AnnotationInfo::fieldOptions)
.def_property_readonly("quad_points", [](const pdfengine::PdfPage::AnnotationInfo& self) {
py::list out;
for (const auto& quad : self.quadPoints) {
py::list quad_list;
for (const auto& pt : quad) {
py::dict d;
d["x"] = pt.x;
d["y"] = pt.y;
quad_list.append(d);
}
out.append(quad_list);
}
return out;
});
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage") py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
.def_property_readonly("width", &pdfengine::PdfPage::width) .def_property_readonly("width", &pdfengine::PdfPage::width)
+40
View File
@@ -0,0 +1,40 @@
services:
gateway:
build:
context: ./gateway
dockerfile: Dockerfile
image: pdf-engine-gateway:dev
container_name: pdf-engine-gateway
environment:
PDFENGINE_ENVIRONMENT: dev
PDFENGINE_ENGINE_AVAILABLE: "false"
PORT: 8000
ports:
- "8000:8000"
volumes:
- ./gateway:/home/app
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()" ]
interval: 20s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
image: pdf-engine-frontend:dev
container_name: pdf-engine-frontend
environment:
VITE_GATEWAY_URL: http://gateway:8000
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on:
- gateway
restart: unless-stopped
@@ -5,6 +5,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <cstdint> #include <cstdint>
#include <array>
namespace pdfengine { namespace pdfengine {
@@ -197,7 +198,9 @@ public:
std::string content; std::string content;
std::string timestamp; std::string timestamp;
int pageIndex = 0; int pageIndex = 0;
double thickness = 0.0;
std::vector<std::vector<Point2D>> paths; std::vector<std::vector<Point2D>> paths;
std::vector<std::array<Point2D, 4>> quadPoints;
std::string fieldName; std::string fieldName;
std::string fieldValue; std::string fieldValue;
+1
View File
@@ -164,6 +164,7 @@ private:
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex); std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex); std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex); std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_stamp(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex); std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex); std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex); std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
+3 -1
View File
@@ -42,7 +42,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
} }
static const std::set<std::string> kContentOps = { static const std::set<std::string> kContentOps = {
"replace_text", "reflow_paragraph", "text_overlay", "add_text", "replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp",
"underline", "strikeout", "squiggly", "redaction", "underline", "strikeout", "squiggly", "redaction",
"image_overlay", "highlight", "free_text", "comment", "freehand"}; "image_overlay", "highlight", "free_text", "comment", "freehand"};
if (kContentOps.count(type)) markEdited(pageIndex); if (kContentOps.count(type)) markEdited(pageIndex);
@@ -54,6 +54,8 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
r = applyOp_reflow(op, pageIndex); r = applyOp_reflow(op, pageIndex);
} else if (type == "text_overlay" || type == "add_text") { } else if (type == "text_overlay" || type == "add_text") {
r = applyOp_textOverlay(op, pageIndex); r = applyOp_textOverlay(op, pageIndex);
} else if (type == "stamp") {
r = applyOp_stamp(op, pageIndex);
} else if (type == "underline" || type == "strikeout" || type == "squiggly") { } else if (type == "underline" || type == "strikeout" || type == "squiggly") {
r = applyOp_decoration(op, pageIndex); r = applyOp_decoration(op, pageIndex);
} else if (type == "redaction") { } else if (type == "redaction") {
@@ -1,4 +1,7 @@
#include "parser/pdfium_internal.hpp" #include "parser/pdfium_internal.hpp"
#include <chrono>
#include <iomanip>
#include <sstream>
namespace pdfengine::parser { namespace pdfengine::parser {
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
#endif #endif
} }
std::expected<void, EngineError> PdfiumDocument::applyOp_stamp(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("stamp operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
std::string text = data.value("text", "");
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double width = data.value("width", 100.0);
double height = data.value("height", 30.0);
double fontSize = data.value("fontSize", 18.0);
std::string textColor = data.value("textColor", "#000000");
std::string bgColor = data.value("backgroundColor", "#ffffff");
std::string borderColor = data.value("borderColor", "#000000");
bool includeDate = data.value("includeDate", false);
if (includeDate) {
auto now = std::chrono::system_clock::now();
auto in_time_t = std::chrono::system_clock::to_time_t(now);
std::stringstream ss;
ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M");
text += "\n" + ss.str();
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for stamp", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// Background rect
FPDF_PAGEOBJECT bgRect = FPDFPageObj_CreateNewRect(
static_cast<float>(x), static_cast<float>(y),
static_cast<float>(width), static_cast<float>(height)
);
unsigned int r=0, g=0, b=0;
parseHexColor(bgColor, r, g, b);
FPDFPageObj_SetFillColor(bgRect, r, g, b, 255);
FPDFPath_SetDrawMode(bgRect, FPDF_FILLMODE_WINDING, 0);
FPDFPage_InsertObject(page, bgRect);
// Border rect
FPDF_PAGEOBJECT borderRect = FPDFPageObj_CreateNewRect(
static_cast<float>(x), static_cast<float>(y),
static_cast<float>(width), static_cast<float>(height)
);
parseHexColor(borderColor, r, g, b);
FPDFPageObj_SetStrokeColor(borderRect, r, g, b, 255);
FPDFPageObj_SetStrokeWidth(borderRect, 2.5f);
FPDFPath_SetDrawMode(borderRect, 0, 1);
FPDFPage_InsertObject(page, borderRect);
FPDF_FONT font = FPDFText_LoadStandardFont(doc_, "Helvetica-Bold");
std::vector<std::string> lines;
std::stringstream textStream(text);
std::string line;
while(std::getline(textStream, line, '\n')) {
lines.push_back(line);
}
float startY = static_cast<float>(y + height - fontSize * 1.1);
parseHexColor(textColor, r, g, b);
for (size_t i = 0; i < lines.size(); i++) {
float currentFontSize = static_cast<float>(i == 0 ? fontSize : fontSize * 0.5);
FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, currentFontSize);
FPDFPageObj_SetFillColor(textObj, r, g, b, 255);
auto utf16 = utf8_to_utf16le(lines[i]);
FPDFText_SetText(textObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
float left=0, bottom=0, right=0, top=0;
FPDFPageObj_GetBounds(textObj, &left, &bottom, &right, &top);
float textWidth = right - left;
float textX = static_cast<float>(x + width / 2.0 - textWidth / 2.0);
float textY = startY - (i * fontSize * 0.7f);
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, textX, textY);
FPDFPage_InsertObject(page, textObj);
}
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after stamp");
}
FPDF_ClosePage(page);
return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) { std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
std::string type = op.value("type", ""); std::string type = op.value("type", "");
@@ -129,6 +227,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohma
int subtype = FPDF_ANNOT_UNDERLINE; int subtype = FPDF_ANNOT_UNDERLINE;
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT; if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY; else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
else if (type == "highlight") subtype = FPDF_ANNOT_HIGHLIGHT;
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype); FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
if (!annot) { if (!annot) {
@@ -383,6 +482,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_freehand(const nlohmann
float thickness = static_cast<float>(data.value("thickness", 2.0)); float thickness = static_cast<float>(data.value("thickness", 2.0));
FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness); FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness);
std::string tStr = std::to_string(thickness);
auto tUtf16 = utf8_to_utf16le(tStr);
FPDFAnnot_SetStringValue(annot, "CustomThickness", reinterpret_cast<FPDF_WIDESTRING>(tUtf16.data()));
float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f; float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f;
bool anyPoints = false; bool anyPoints = false;
@@ -549,6 +652,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const
if (data.contains("thickness")) { if (data.contains("thickness")) {
float thickness = static_cast<float>(data["thickness"].get<double>()); float thickness = static_cast<float>(data["thickness"].get<double>());
FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness); FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness);
std::string tStr = std::to_string(thickness);
auto tUtf16 = utf8_to_utf16le(tStr);
FPDFAnnot_SetStringValue(targetAnnot, "CustomThickness", reinterpret_cast<FPDF_WIDESTRING>(tUtf16.data()));
} }
if (data.contains("text")) { if (data.contains("text")) {
+5 -11
View File
@@ -133,22 +133,19 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
#endif #endif
} }
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
if (pageCount() <= 1) { if (pageCount() <= 1) {
spdlog::error("Cannot delete the only page in the document"); spdlog::error("Cannot delete the only page in the document");
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
FPDFPage_Delete(doc_, pageIndex); FPDFPage_Delete(doc_, pageIndex);
return {}; return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
} }
#endif
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
if (!op.contains("data") || !op["data"].is_object()) { if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("page_reorder operation missing 'data' object"); spdlog::error("page_reorder operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat); return std::unexpected(EngineError::InvalidFormat);
@@ -170,10 +167,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
return {}; return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
} }
#endif
} }
+30
View File
@@ -600,6 +600,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
} }
if (subtype == FPDF_ANNOT_INK) { if (subtype == FPDF_ANNOT_INK) {
float h_radius = 0, v_radius = 0, border_width = 1.0f;
info.thickness = 2.0; // default
unsigned long ctLen = FPDFAnnot_GetStringValue(annot, "CustomThickness", nullptr, 0);
if (ctLen > 2) {
std::vector<char16_t> ctBuf(ctLen / 2);
FPDFAnnot_GetStringValue(annot, "CustomThickness", reinterpret_cast<FPDF_WCHAR*>(ctBuf.data()), ctLen);
std::string ctStr = utf16le_to_utf8(ctBuf.data(), ctBuf.size());
try {
info.thickness = std::stod(ctStr);
} catch (...) {}
} else if (FPDFAnnot_GetBorder(annot, &h_radius, &v_radius, &border_width)) {
info.thickness = border_width;
}
const double pageH = height(); const double pageH = height();
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot); unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
for (unsigned long s = 0; s < strokeCount; ++s) { for (unsigned long s = 0; s < strokeCount; ++s) {
@@ -614,6 +629,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
} }
if (!stroke.empty()) info.paths.push_back(std::move(stroke)); if (!stroke.empty()) info.paths.push_back(std::move(stroke));
} }
} else if (subtype == FPDF_ANNOT_HIGHLIGHT || subtype == FPDF_ANNOT_STRIKEOUT || subtype == FPDF_ANNOT_UNDERLINE || subtype == FPDF_ANNOT_SQUIGGLY) {
const double pageH = height();
size_t quadCount = FPDFAnnot_CountAttachmentPoints(annot);
for (size_t q = 0; q < quadCount; ++q) {
FS_QUADPOINTSF quad;
if (FPDFAnnot_GetAttachmentPoints(annot, q, &quad)) {
std::array<Point2D, 4> pts = {{
{static_cast<double>(quad.x1), pageH - static_cast<double>(quad.y1)},
{static_cast<double>(quad.x2), pageH - static_cast<double>(quad.y2)},
{static_cast<double>(quad.x3), pageH - static_cast<double>(quad.y3)},
{static_cast<double>(quad.x4), pageH - static_cast<double>(quad.y4)}
}};
info.quadPoints.push_back(pts);
}
}
} }
result.push_back(info); result.push_back(info);
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.vite/
.git/
.gitignore
*.log
.DS_Store
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine AS base
ENV NODE_ENV=development
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS development
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
FROM base AS build
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+163 -50
View File
@@ -5,8 +5,9 @@ import { Toolbar } from './components/Toolbar';
import { InspectorPanel } from './components/InspectorPanel'; import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel'; import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal'; import { SignatureModal } from './components/SignatureModal';
import { RedactPagesModal } from './components/RedactPagesModal';
import { AboutModal } from './components/AboutModal'; import { AboutModal } from './components/AboutModal';
import { ToastViewport } from './components/ui';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal'; import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal'; import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
import { PDFViewer } from './viewer/PDFViewer'; import { PDFViewer } from './viewer/PDFViewer';
@@ -18,9 +19,9 @@ import { PasswordModal } from './components/PasswordModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService'; import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping'; import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping'; import type { Rect } from './lib/coordinateMapping';
import { toast } from './lib/toast';
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools'; import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
import type { ToolId, ToolSettings } from './lib/tools'; import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`; const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
@@ -38,8 +39,7 @@ function App() {
const permissions = activeDoc?.permissions ?? null; const permissions = activeDoc?.permissions ?? null;
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false; const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (label: string) => const denyToast = (_label: string) => {};
toast(`${label} is not permitted by this document's restrictions`, 'error');
const disabledTools = new Set<ToolId>(); const disabledTools = new Set<ToolId>();
if (!can('canAnnotate')) if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t)); (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
@@ -65,9 +65,11 @@ function App() {
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null); const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false); const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false); const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null); const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null); const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null); const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -83,15 +85,21 @@ function App() {
if (!canUndo) return; if (!canUndo) return;
preservePageRef.current = true; preservePageRef.current = true;
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) })); setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
toast('Undo', 'info', 1200);
}; };
const redo = () => { const redo = () => {
if (!canRedo) return; if (!canRedo) return;
preservePageRef.current = true; preservePageRef.current = true;
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) })); setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
toast('Redo', 'info', 1200);
}; };
useEffect(() => {
if (hist.stack.length > 0) {
localStorage.setItem('pdf_hist', JSON.stringify(hist));
}
}, [hist]);
useEffect(() => { useEffect(() => {
gatewayService.getHealth() gatewayService.getHealth()
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); }) .then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
@@ -104,7 +112,23 @@ function App() {
setIsLoading(true); setIsLoading(true);
const docs = await gatewayService.listDocuments(); const docs = await gatewayService.listDocuments();
setDocuments(docs); setDocuments(docs);
if (docs.length > 0) openDocument(docs[0].id); if (docs.length > 0) {
// Attempt to load from localStorage, otherwise fallback to the most recent document
const savedHist = localStorage.getItem('pdf_hist');
if (savedHist) {
try {
const parsedHist = JSON.parse(savedHist);
if (parsedHist.stack && parsedHist.stack.length > 0 && docs.some((d: any) => d.id === parsedHist.stack[parsedHist.index])) {
setHist(parsedHist);
return;
}
} catch (e) {
console.error('Failed to parse history', e);
}
}
// Default to the most recent document (last in list)
openDocument(docs[docs.length - 1].id);
}
} catch (e) { } catch (e) {
console.error('Failed to load documents', e); console.error('Failed to load documents', e);
} finally { } finally {
@@ -140,6 +164,7 @@ function App() {
content: a.content, content: a.content,
timestamp: a.timestamp, timestamp: a.timestamp,
pageIndex: a.pageIndex, pageIndex: a.pageIndex,
quadPoints: a.quad_points || a.quadPoints,
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined, paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
fieldName: a.fieldName, fieldName: a.fieldName,
fieldValue: a.fieldValue, fieldValue: a.fieldValue,
@@ -213,18 +238,16 @@ function App() {
gatewayService.listDocuments().then(setDocuments).catch(() => {}); gatewayService.listDocuments().then(setDocuments).catch(() => {});
}; };
const applyOps = async (ops: EditOperation[], successMsg?: string) => { const applyOps = async (ops: EditOperation[], _successMsg?: string) => {
if (!selectedDocId) return; if (!selectedDocId) return;
setIsSaving(true); setIsSaving(true);
try { try {
const result = await gatewayService.applyEdits(selectedDocId, ops); const result = await gatewayService.applyEdits(selectedDocId, ops);
if (result.success) { if (result.success) {
adoptNewDocument(result.newDocumentId); adoptNewDocument(result.newDocumentId);
if (successMsg) toast(successMsg, 'success');
} }
} catch (e) { } catch (e) {
console.error('Edit failed', e); console.error('Edit failed', e);
toast('Edit failed — check the gateway connection', 'error');
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -267,22 +290,33 @@ function App() {
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => { const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
if (!can('canAnnotate')) { denyToast('Text decorations'); return; } if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
const quadPoints = lines.map((line) => { const quadPointsBackend = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom; const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly }; return { x1: lx, y1: ly, x2: lx + lw, y2: ly, x3: lx, y3: ly + lh, x4: lx + lw, y4: ly + lh };
}); });
const newAnnos = lines.map(line => ({ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const qPointsFront = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
if (lx < minX) minX = lx;
if (ly < minY) minY = ly;
if (lx + lw > maxX) maxX = lx + lw;
if (ly + lh > maxY) maxY = ly + lh;
return [{ x: lx, y: ly }, { x: lx + lw, y: ly }, { x: lx, y: ly + lh }, { x: lx + lw, y: ly + lh }];
});
const newAnno = {
id: rid('locdec'), id: rid('locdec'),
type, type,
pageIndex, pageIndex,
bbox: { x: line.x / zoom, y: line.y / zoom, width: line.width / zoom, height: line.height / zoom }, bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
quadPoints: qPointsFront,
color, color,
author: 'Current User', author: 'Current User',
} as Annotation)); } as Annotation;
setAnnotations(prev => [...prev, ...newAnnos]); setAnnotations(prev => [...prev, newAnno]);
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints, color, author: 'Current User' } }]); applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints: quadPointsBackend, color, author: 'Current User' } }]);
}; };
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => { const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
@@ -320,24 +354,80 @@ function App() {
if (!activeStamp) return; if (!activeStamp) return;
if (!can('canAnnotate')) { denyToast('Stamping'); return; } if (!can('canAnnotate')) { denyToast('Stamping'); return; }
const fontSize = 22; const fontSize = 22;
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62); const padding = 12; // visual padding
const height = fontSize * 1.5; const dateWidth = 16 * (fontSize * 0.5) * 0.7; // date string approx length
const labelWidth = activeStamp.label.length * fontSize * 0.7;
const contentWidth = Math.max(labelWidth, dateWidth);
const width = Math.max(80, contentWidth) + (padding * 2);
const height = fontSize * 1.5 + (padding * 2);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex)); const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{ applyOps([{
id: rid('stamp'), type: 'text_overlay', pageIndex, id: rid('stamp'), type: 'stamp', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color }, data: {
x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height,
text: activeStamp.label,
textColor: activeStamp.textColor,
backgroundColor: activeStamp.backgroundColor,
borderColor: activeStamp.borderColor,
fontSize,
includeDate: true // We can toggle this later, true by default for now
},
}], `Stamp “${activeStamp.label}” placed`); }], `Stamp “${activeStamp.label}” placed`);
}; };
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => { const handleRedactPages = (pagesString: string) => {
if (!activeDoc) return;
const ranges = pagesString.split(',').map(s => s.trim());
const pagesToRedact = new Set<number>();
for (const r of ranges) {
if (r.includes('-')) {
const parts = r.split('-');
if (parts.length === 2) {
const start = parseInt(parts[0], 10);
const end = parseInt(parts[1], 10);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
if (i >= 1 && i <= activeDoc.totalPages) pagesToRedact.add(i - 1);
}
}
}
} else {
const val = parseInt(r, 10);
if (!isNaN(val) && val >= 1 && val <= activeDoc.totalPages) {
pagesToRedact.add(val - 1);
}
}
}
const newRedactions: { id: string, pageIndex: number, bounds: Rect }[] = [];
pagesToRedact.forEach(pageIndex => {
const pInfo = activeDoc.pages?.[pageIndex];
if (!pInfo) return;
newRedactions.push({
id: rid('redmark'),
pageIndex,
bounds: {
x: 0,
y: 0,
width: pInfo.width,
height: pInfo.height
}
});
});
if (newRedactions.length > 0) {
setPendingRedactions(p => [...p, ...newRedactions]);
} else {
}
setRedactPagesModalOpen(false);
};
const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => {
if (!pendingSignature) return; if (!pendingSignature) return;
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; } if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
const width = 160;
const height = width / (pendingSignature.aspect || 3);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{ applyOps([{
id: rid('sig'), type: 'image_overlay', pageIndex, id: rid('sig'), type: 'image_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, imageData: pendingSignature.url }, data: { x: pdfRect.x, y: pdfRect.y, width: pdfRect.width, height: pdfRect.height, imageData: pendingSignature.url },
}], 'Signature placed'); }], 'Signature placed');
setActiveTool('select'); setActiveTool('select');
}; };
@@ -351,7 +441,7 @@ function App() {
const handleDeletePage = (pageIndex: number) => { const handleDeletePage = (pageIndex: number) => {
if (!activeDoc) return; if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Deleting pages'); return; } if (!can('canAssemble')) { denyToast('Deleting pages'); return; }
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; } if (activeDoc.totalPages <= 1) { return; }
setConfirmState({ setConfirmState({
title: 'Delete page?', title: 'Delete page?',
message: `Page ${pageIndex + 1} will be removed from this document.`, message: `Page ${pageIndex + 1} will be removed from this document.`,
@@ -370,19 +460,31 @@ function App() {
setCurrentPage(to); setCurrentPage(to);
}; };
const handleRedactArea = (pageIndex: number, bounds: Rect) => { const [pendingRedactions, setPendingRedactions] = useState<{ id: string, pageIndex: number, bounds: Rect }[]>([]);
const handleMarkRedaction = (pageIndex: number, bounds: Rect) => {
setPendingRedactions(prev => [...prev, { id: rid('redmark'), pageIndex, bounds }]);
};
const handleApplyRedactions = () => {
if (!activeDoc) return; if (!activeDoc) return;
if (!can('canModify')) { denyToast('Redaction'); return; } if (!can('canModify')) { denyToast('Redaction'); return; }
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex)); if (pendingRedactions.length === 0) return;
setConfirmState({ setConfirmState({
title: 'Redact area?', title: 'Apply Redactions?',
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.', message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
confirmLabel: 'Redact', danger: true, confirmLabel: 'Apply', danger: true,
onConfirm: () => { onConfirm: () => {
applyOps([{ const ops = pendingRedactions.map(mark => {
id: rid('redact'), type: 'redaction', pageIndex, const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' }, return {
}], 'Area redacted'); id: mark.id, type: 'redaction' as const, pageIndex: mark.pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#000000' },
};
});
applyOps(ops, 'Redactions applied');
setPendingRedactions([]);
setActiveTool('select'); setActiveTool('select');
}, },
}); });
@@ -394,14 +496,13 @@ function App() {
const newDoc = await gatewayService.uploadDocument(file, password); const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]); setDocuments((prev) => [newDoc, ...prev]);
openDocument(newDoc.id); openDocument(newDoc.id);
toast(`Opened ${newDoc.filename}`, 'success');
setPasswordPrompt(null); setPasswordPrompt(null);
} catch (e) { } catch (e) {
if (e instanceof PasswordError) { if (e instanceof PasswordError) {
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined }); setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else { } else {
console.error('Upload failed', e); console.error('Upload failed', e);
toast('Upload failed', 'error');
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -413,10 +514,8 @@ function App() {
if (!can('canCopy')) { denyToast('Exporting'); return; } if (!can('canCopy')) { denyToast('Exporting'); return; }
try { try {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename); await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
toast('Exported', 'success');
} catch (e) { } catch (e) {
console.error('Export failed', e); console.error('Export failed', e);
toast('Export failed', 'error');
} }
}; };
@@ -424,7 +523,7 @@ function App() {
if (!activeDoc) return; if (!activeDoc) return;
if (!can('canPrint')) { denyToast('Printing'); return; } if (!can('canPrint')) { denyToast('Printing'); return; }
try { try {
toast('Preparing print...', 'info');
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId); const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
const blob = new Blob([bytes], { type: 'application/pdf' }); const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -442,7 +541,6 @@ function App() {
document.body.appendChild(iframe); document.body.appendChild(iframe);
} catch (e) { } catch (e) {
console.error('Print failed', e); console.error('Print failed', e);
toast('Print failed', 'error');
} }
}; };
@@ -541,8 +639,14 @@ function App() {
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))} onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)} onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature} hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null} activeStamp={activeStamp}
onSelectStamp={(label, color) => setActiveStamp({ label, color })} onSelectStamp={setActiveStamp}
redactionMode={redactionMode}
onRedactionModeChange={setRedactionMode}
pendingRedactionCount={pendingRedactions.length}
onApplyRedactions={handleApplyRedactions}
onClearRedactions={() => setPendingRedactions([])}
onRedactPages={() => setRedactPagesModalOpen(true)}
/> />
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
@@ -562,8 +666,11 @@ function App() {
pagesInfo={activeDoc.pages} pagesInfo={activeDoc.pages}
activeTool={activeTool} activeTool={activeTool}
toolSettings={toolSettings} toolSettings={toolSettings}
redactionMode={redactionMode}
hasSignature={!!pendingSignature} hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null} signatureImageUrl={pendingSignature?.url}
signatureAspect={pendingSignature?.aspect}
activeStamp={activeStamp}
annotations={annotations} annotations={annotations}
canCopy={can('canCopy')} canCopy={can('canCopy')}
onFieldChange={(id, value, i) => { onFieldChange={(id, value, i) => {
@@ -585,7 +692,9 @@ function App() {
if (!isInspectorOpen) setIsInspectorOpen(true); if (!isInspectorOpen) setIsInspectorOpen(true);
}} }}
onPageVisible={setCurrentPage} onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea} onMarkRedaction={handleMarkRedaction}
pendingRedactions={pendingRedactions}
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
onPlaceText={handlePlaceText} onPlaceText={handlePlaceText}
onEditText={handleEditText} onEditText={handleEditText}
onReflowParagraph={handleReflowParagraph} onReflowParagraph={handleReflowParagraph}
@@ -656,10 +765,15 @@ function App() {
setPendingSignature({ url, aspect }); setPendingSignature({ url, aspect });
setSignatureModalOpen(false); setSignatureModalOpen(false);
setActiveTool('signature'); setActiveTool('signature');
toast('Signature ready — click on the page to place it', 'info');
}} }}
/> />
<RedactPagesModal
open={redactPagesModalOpen}
onClose={() => setRedactPagesModalOpen(false)}
onConfirm={handleRedactPages}
/>
<AboutModal <AboutModal
open={aboutModalOpen} open={aboutModalOpen}
onClose={() => setAboutModalOpen(false)} onClose={() => setAboutModalOpen(false)}
@@ -672,7 +786,6 @@ function App() {
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }} onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
onClose={() => setPasswordPrompt(null)} onClose={() => setPasswordPrompt(null)}
/> />
<ToastViewport />
</div> </div>
); );
} }
+1 -1
View File
@@ -208,7 +208,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span> <span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
</CustomButton> </CustomButton>
{onUpdate && (a.type === 'highlight' || a.type === 'ink' || a.type === 'comment') && ( {onUpdate && (['highlight', 'ink', 'comment', 'strikeout', 'underline', 'squiggly'].includes(a.type)) && (
<div className="flex items-center gap-2 mt-1 px-1" onClick={e => e.stopPropagation()}> <div className="flex items-center gap-2 mt-1 px-1" onClick={e => e.stopPropagation()}>
<input <input
type="color" type="color"
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Modal } from './ui';
import { CustomButton } from './custom/CustomButton';
interface RedactPagesModalProps {
open: boolean;
onClose: () => void;
onConfirm: (pagesString: string) => void;
}
export const RedactPagesModal: React.FC<RedactPagesModalProps> = ({ open, onClose, onConfirm }) => {
const [pagesString, setPagesString] = useState('');
return (
<Modal open={open} onClose={onClose} title="Redact Pages">
<div className="flex w-[400px] flex-col gap-4 p-5">
<p className="text-[13px] text-[#4b5563]">
Enter the pages or page ranges to mark for redaction (e.g. "1, 3-5"). This will mark the entire page area for redaction.
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[11.5px] font-semibold text-[#18212e]">Pages</label>
<input
type="text"
className="h-[36px] w-full rounded-[8px] border border-[#d1d5db] bg-white px-3 text-[13.5px] shadow-sm outline-none transition-colors focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb]"
placeholder="e.g. 1, 3-5"
value={pagesString}
onChange={(e) => setPagesString(e.target.value)}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2 border-t border-[#ebedf0] pt-4">
<CustomButton variant="outline" onClick={onClose}>
Cancel
</CustomButton>
<CustomButton
variant="primary"
onClick={() => {
if (pagesString.trim()) {
onConfirm(pagesString.trim());
setPagesString('');
}
}}
disabled={!pagesString.trim()}
>
Mark for Redaction
</CustomButton>
</div>
</div>
</Modal>
);
};
+766 -113
View File
@@ -1,82 +1,276 @@
import { CustomButton } from './custom/CustomButton'; import { CustomButton } from './custom/CustomButton';
import React, { useRef, useState } from 'react'; import React, { useRef, useState, useEffect, useCallback } from 'react';
import { Modal } from './ui';
import { toast } from '../lib/toast';
/* ─── Types ─────────────────────────────────────────────── */
interface SignatureModalProps { interface SignatureModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (dataUrl: string, aspect: number) => void; onConfirm: (dataUrl: string, aspect: number) => void;
} }
type Mode = 'draw' | 'type' | 'upload'; type Mode = 'draw' | 'type' | 'upload' | 'saved';
interface SavedSig {
id: string;
dataUrl: string;
aspect: number;
label: string;
createdAt: number;
}
/* ─── Constants ──────────────────────────────────────────── */
const STORAGE_KEY = 'pdf_saved_signatures';
const CANVAS_W = 960;
const CANVAS_H = 320;
const SIGNATURE_FONTS: { label: string; family: string; css: string }[] = [
{ label: 'Script', family: 'Great Vibes', css: '"Great Vibes", cursive' },
{ label: 'Elegant', family: 'Pacifico', css: '"Pacifico", cursive' },
{ label: 'Classic', family: 'Dancing Script', css: '"Dancing Script", cursive' },
{ label: 'Formal', family: 'Pinyon Script', css: '"Pinyon Script", cursive' },
{ label: 'Bold', family: 'Satisfy', css: '"Satisfy", cursive' },
{ label: 'Handwrite', family: 'Caveat', css: '"Caveat", cursive' },
];
const INK_COLORS = [
{ label: 'Black', value: '#0f172a' },
{ label: 'Navy', value: '#1e3a8a' },
{ label: 'Blue', value: '#2563eb' },
{ label: 'Ink', value: '#312e81' },
];
const THICKNESS_OPTIONS = [
{ label: 'Thin', value: 1.5 },
{ label: 'Medium', value: 2.8 },
{ label: 'Thick', value: 4.5 },
];
/* ─── Google Fonts loader ────────────────────────────────── */
function useGoogleFonts() {
useEffect(() => {
const id = 'sig-google-fonts';
if (document.getElementById(id)) return;
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href =
'https://fonts.googleapis.com/css2?family=Great+Vibes&family=Pacifico&family=Dancing+Script:wght@700&family=Pinyon+Script&family=Satisfy&family=Caveat:wght@700&display=swap';
document.head.appendChild(link);
}, []);
}
/* ─── Saved signatures helpers ───────────────────────────── */
function loadSaved(): SavedSig[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
catch { return []; }
}
function persistSaved(sigs: SavedSig[]) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sigs.slice(0, 8))); } catch {}
}
/* ─── Point smoothing (Catmull-Rom) ─────────────────────── */
type Pt = { x: number; y: number; p: number };
function midPt(a: Pt, b: Pt): Pt {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, p: (a.p + b.p) / 2 };
}
function strokePoints(ctx: CanvasRenderingContext2D, pts: Pt[], color: string, baseThickness: number) {
if (pts.length < 2) return;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = color;
for (let i = 1; i < pts.length; i++) {
const prev = pts[i - 1];
const curr = pts[i];
const mid = midPt(prev, curr);
const pressure = (prev.p + curr.p) / 2;
ctx.lineWidth = baseThickness * (0.6 + pressure * 0.8);
ctx.beginPath();
if (i === 1) {
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(mid.x, mid.y);
} else {
const prevMid = midPt(pts[i - 2], prev);
ctx.moveTo(prevMid.x, prevMid.y);
ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y);
}
ctx.stroke();
}
}
/* ────────────────────────────────────────────────────────── */
export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => { export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => {
useGoogleFonts();
/* tabs */
const [mode, setMode] = useState<Mode>('draw'); const [mode, setMode] = useState<Mode>('draw');
const [typed, setTyped] = useState('');
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null); /* draw */
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const strokes = useRef<Pt[][]>([]);
const currentStroke = useRef<Pt[]>([]);
const drawing = useRef(false); const drawing = useRef(false);
const hasInk = useRef(false); const hasInk = useRef(false);
const [inkColor, setInkColor] = useState(INK_COLORS[0].value);
const [thickness, setThickness] = useState(THICKNESS_OPTIONS[1].value);
const resetLocal = () => { setMode('draw'); setTyped(''); setUploaded(null); hasInk.current = false; }; /* type */
const handleClose = () => { resetLocal(); onClose(); }; const [typed, setTyped] = useState('');
const [fontIdx, setFontIdx] = useState(0);
const [typeColor, setTypeColor] = useState(INK_COLORS[0].value);
const clearCanvas = () => { /* upload */
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
const [dragging, setDragging] = useState(false);
/* saved */
const [savedSigs, setSavedSigs] = useState<SavedSig[]>([]);
/* load saved on open */
useEffect(() => { if (open) setSavedSigs(loadSaved()); }, [open]);
/* redraw canvas after color/thickness change */
const redrawAll = useCallback(() => {
const c = canvasRef.current; const c = canvasRef.current;
if (!c) return; if (!c) return;
const ctx = c.getContext('2d')!; const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height); ctx.clearRect(0, 0, c.width, c.height);
for (const stroke of strokes.current) {
strokePoints(ctx, stroke, inkColor, thickness);
}
}, [inkColor, thickness]);
useEffect(() => { if (mode === 'draw') redrawAll(); }, [inkColor, thickness, mode, redrawAll]);
/* ── reset on close ── */
const resetLocal = () => {
setMode('draw');
setTyped('');
setUploaded(null);
hasInk.current = false; hasInk.current = false;
strokes.current = [];
currentStroke.current = [];
const c = canvasRef.current;
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
};
const handleClose = () => { resetLocal(); onClose(); };
/* ── canvas clear ── */
const clearCanvas = () => {
strokes.current = [];
currentStroke.current = [];
hasInk.current = false;
const c = canvasRef.current;
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
}; };
const pos = (e: React.PointerEvent) => { /* ── pointer helpers ── */
const canvasPos = (e: React.PointerEvent): Pt => {
const c = canvasRef.current!; const c = canvasRef.current!;
const r = c.getBoundingClientRect(); const r = c.getBoundingClientRect();
return { x: (e.clientX - r.left) * (c.width / r.width), y: (e.clientY - r.top) * (c.height / r.height) }; return {
x: (e.clientX - r.left) * (c.width / r.width),
y: (e.clientY - r.top) * (c.height / r.height),
p: e.pressure > 0 ? e.pressure : 0.5,
};
}; };
const onDown = (e: React.PointerEvent) => { const onDown = (e: React.PointerEvent) => {
drawing.current = true; drawing.current = true;
const ctx = canvasRef.current!.getContext('2d')!; const pt = canvasPos(e);
const { x, y } = pos(e); currentStroke.current = [pt];
ctx.beginPath(); ctx.moveTo(x, y);
(e.target as Element).setPointerCapture(e.pointerId); (e.target as Element).setPointerCapture(e.pointerId);
}; };
const onMove = (e: React.PointerEvent) => { const onMove = (e: React.PointerEvent) => {
if (!drawing.current) return; if (!drawing.current) return;
const pt = canvasPos(e);
currentStroke.current.push(pt);
const ctx = canvasRef.current!.getContext('2d')!; const ctx = canvasRef.current!.getContext('2d')!;
const { x, y } = pos(e); const stroke = currentStroke.current;
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
hasInk.current = true; hasInk.current = true;
}; };
const onUp = () => { drawing.current = false; };
const handleConfirm = () => { const onUp = () => {
if (mode === 'draw') { if (drawing.current && currentStroke.current.length > 0) {
if (!hasInk.current) { toast('Draw a signature first', 'error'); return; } strokes.current.push([...currentStroke.current]);
const c = canvasRef.current!; currentStroke.current = [];
onConfirm(c.toDataURL('image/png'), c.width / c.height);
} else if (mode === 'type') {
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
const c = document.createElement('canvas');
c.width = 600; c.height = 200;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = '#1b2430';
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
onConfirm(c.toDataURL('image/png'), c.width / c.height);
} else if (mode === 'upload') {
if (!uploaded) { toast('Upload an image first', 'error'); return; }
onConfirm(uploaded.url, uploaded.aspect);
} }
drawing.current = false;
};
/* ── build final dataUrl from typed ── */
const buildTypedCanvas = () => {
const c = document.createElement('canvas');
c.width = 900; c.height = 240;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = typeColor;
ctx.font = `bold 100px ${SIGNATURE_FONTS[fontIdx].css}`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText(typed.trim() || 'Preview', c.width / 2, c.height / 2);
return { dataUrl: c.toDataURL('image/png'), aspect: c.width / c.height };
};
/* ── confirm ── */
const handleConfirm = () => {
let dataUrl = '';
let aspect = 3;
if (mode === 'draw') {
if (!hasInk.current) return;
const c = canvasRef.current!;
dataUrl = c.toDataURL('image/png');
aspect = c.width / c.height;
} else if (mode === 'type') {
if (!typed.trim()) return;
const res = buildTypedCanvas();
dataUrl = res.dataUrl; aspect = res.aspect;
} else if (mode === 'upload') {
if (!uploaded) return;
dataUrl = uploaded.url; aspect = uploaded.aspect;
} else if (mode === 'saved') {
return;
}
saveToStorage(dataUrl, aspect);
onConfirm(dataUrl, aspect);
resetLocal(); resetLocal();
}; };
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const saveToStorage = (dataUrl: string, aspect: number) => {
const f = e.target.files?.[0]; const prev = loadSaved();
if (!f) return; const entry: SavedSig = {
id: `sig_${Date.now()}`,
dataUrl,
aspect,
label: mode === 'type' ? (typed.trim() || 'Signature') : 'Signature',
createdAt: Date.now(),
};
const updated = [entry, ...prev.filter((s) => s.dataUrl !== dataUrl)];
persistSaved(updated);
};
const handleUseSaved = (s: SavedSig) => {
saveToStorage(s.dataUrl, s.aspect); // bump to top
onConfirm(s.dataUrl, s.aspect);
resetLocal();
};
const handleDeleteSaved = (id: string) => {
const updated = savedSigs.filter((s) => s.id !== id);
setSavedSigs(updated);
persistSaved(updated);
};
/* ── upload handlers ── */
const processFile = (f: File) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const url = reader.result as string; const url = reader.result as string;
@@ -85,85 +279,544 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
img.src = url; img.src = url;
}; };
reader.readAsDataURL(f); reader.readAsDataURL(f);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) processFile(f);
e.target.value = ''; e.target.value = '';
}; };
const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); setDragging(false);
const f = e.dataTransfer.files[0];
if (f && f.type.startsWith('image/')) processFile(f);
};
/* ── tab labels ── */
const tabs: { id: Mode; label: string; icon: React.ReactNode }[] = [
{
id: 'draw', label: 'Draw',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
},
{
id: 'type', label: 'Type',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>,
},
{
id: 'upload', label: 'Upload',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>,
},
{
id: 'saved', label: 'Saved',
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>,
},
];
/* ── Baseline guide on canvas ── */
useEffect(() => {
if (!open || mode !== 'draw') return;
const c = canvasRef.current;
if (!c || hasInk.current || strokes.current.length > 0) return;
const ctx = c.getContext('2d')!;
ctx.clearRect(0, 0, c.width, c.height);
// baseline guide
ctx.beginPath();
ctx.setLineDash([8, 10]);
ctx.strokeStyle = '#c7d2e0';
ctx.lineWidth = 1.5;
ctx.moveTo(60, c.height * 0.72);
ctx.lineTo(c.width - 60, c.height * 0.72);
ctx.stroke();
ctx.setLineDash([]);
}, [open, mode]);
/* ── Render ── */
if (!open) return null;
return ( return (
<Modal <div
open={open} className="fixed inset-0 z-[200] flex items-center justify-center p-4"
onClose={handleClose} style={{ background: 'rgba(10,15,25,0.55)', backdropFilter: 'blur(4px)' }}
title="Create signature" onMouseDown={handleClose}
width={580}
footer={
<>
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
</>
}
> >
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5"> <div
{(['draw', 'type', 'upload'] as Mode[]).map((m) => ( className="relative flex flex-col overflow-hidden"
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)} style={{
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]'}`}> width: 660,
{m} maxHeight: '92vh',
</CustomButton> borderRadius: 16,
))} background: '#ffffff',
boxShadow: '0 32px 80px rgba(10,15,30,0.28), 0 0 0 1px rgba(0,0,0,0.07)',
animation: 'sigModalIn 0.22s cubic-bezier(0.34,1.4,0.64,1)',
}}
onMouseDown={(e) => e.stopPropagation()}
>
{/* ── Header ── */}
<div
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '18px 24px 16px',
borderBottom: '1px solid #edf0f5',
background: '#ffffff',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 34, height: 34, borderRadius: 8,
background: '#eef4ff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
</div>
<div>
<h2 style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', margin: 0, lineHeight: 1.2 }}>
Add Signature
</h2>
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0, marginTop: 1 }}>
Draw, type, or upload your signature
</p>
</div>
</div>
<button
onClick={handleClose}
style={{
width: 30, height: 30, borderRadius: 8, border: '1px solid #edf0f5',
background: '#f8fafc', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'center', color: '#64748b',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f1f5f9'; (e.currentTarget as HTMLButtonElement).style.color = '#0f172a'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f8fafc'; (e.currentTarget as HTMLButtonElement).style.color = '#64748b'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/* ── Tab bar ── */}
<div style={{ display: 'flex', gap: 4, padding: '12px 20px 0', background: '#f8fafc', borderBottom: '1px solid #edf0f5' }}>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setMode(t.id)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: '8px 8px 0 0',
border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
transition: 'all 0.15s',
background: mode === t.id ? '#ffffff' : 'transparent',
color: mode === t.id ? '#2563eb' : '#64748b',
borderBottom: mode === t.id ? '2px solid #2563eb' : '2px solid transparent',
boxShadow: mode === t.id ? '0 -2px 12px rgba(37,99,235,0.07), inset 0 0 0 1px rgba(37,99,235,0.08)' : 'none',
position: 'relative', bottom: -1,
}}
>
{t.icon}
{t.label}
{t.id === 'saved' && savedSigs.length > 0 && (
<span style={{
background: '#2563eb', color: '#fff', borderRadius: 20,
fontSize: 9, fontWeight: 700, padding: '1px 5px', lineHeight: 1.6,
}}>
{savedSigs.length}
</span>
)}
</button>
))}
</div>
{/* ── Body ── */}
<div style={{ padding: '20px 24px', overflowY: 'auto', flex: 1, minHeight: 0 }}>
{/* ══ DRAW ══ */}
{mode === 'draw' && (
<div>
{/* Controls row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
{/* Ink color */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Ink</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{INK_COLORS.map((c) => (
<button
key={c.value}
title={c.label}
onClick={() => setInkColor(c.value)}
style={{
width: 24, height: 24, borderRadius: '50%',
background: c.value, border: 'none', cursor: 'pointer',
outline: inkColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
outlineOffset: 3, transition: 'all 0.12s',
}}
/>
))}
</div>
</div>
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
{/* Thickness */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Thickness</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{THICKNESS_OPTIONS.map((o) => (
<button
key={o.value}
title={o.label}
onClick={() => setThickness(o.value)}
style={{
width: 32, height: 30, border: 'none', borderRadius: 6, cursor: 'pointer',
background: thickness === o.value ? '#eff6ff' : '#ffffff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: thickness === o.value ? 'inset 0 0 0 1.5px #2563eb' : 'inset 0 0 0 1px #e2e8f0',
transition: 'all 0.12s',
}}
>
<div style={{
width: 18, height: o.value * 0.9,
borderRadius: 99,
background: thickness === o.value ? '#2563eb' : '#64748b',
transition: 'all 0.12s',
}} />
</button>
))}
</div>
</div>
</div>
{/* Clear */}
<CustomButton variant="outline" size="sm" onClick={clearCanvas}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
Clear
</CustomButton>
</div>
{/* Canvas */}
<div style={{ position: 'relative', borderRadius: 12, overflow: 'hidden', boxShadow: '0 0 0 1.5px #e2e8f0, 0 4px 20px rgba(0,0,0,0.05)' }}>
<canvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
style={{
width: '100%', height: 200,
display: 'block', cursor: 'crosshair',
background: '#f8fafc',
touchAction: 'none',
}}
/>
{!hasInk.current && strokes.current.length === 0 && (
<div style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 6,
}}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#c7d2e0" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<p style={{ fontSize: 12, color: '#b8c5d6', fontWeight: 500, margin: 0 }}>Sign here</p>
</div>
)}
</div>
<p style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, textAlign: 'center' }}>
Use mouse, stylus, or finger pressure sensitivity supported
</p>
</div>
)}
{/* ══ TYPE ══ */}
{mode === 'type' && (
<div>
{/* Name input */}
<input
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder="Type your full name"
maxLength={60}
style={{
width: '100%', boxSizing: 'border-box',
padding: '11px 14px', borderRadius: 9,
border: '1.5px solid #e2e8f0', background: '#f8fafc',
fontSize: 14, fontWeight: 500, color: '#0f172a', outline: 'none',
transition: 'border-color 0.15s',
}}
onFocus={(e) => (e.currentTarget.style.borderColor = '#2563eb')}
onBlur={(e) => (e.currentTarget.style.borderColor = '#e2e8f0')}
/>
{/* Font picker */}
<div style={{ marginTop: 14 }}>
<p style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
Style
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{SIGNATURE_FONTS.map((f, i) => (
<button
key={f.family}
onClick={() => setFontIdx(i)}
style={{
padding: '10px 14px', borderRadius: 9,
border: fontIdx === i ? '1.5px solid #2563eb' : '1.5px solid #e2e8f0',
background: fontIdx === i ? '#eff6ff' : '#f8fafc',
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s',
display: 'flex', flexDirection: 'column', gap: 3,
}}
>
<span style={{ fontSize: 10, fontWeight: 600, color: fontIdx === i ? '#2563eb' : '#94a3b8', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{f.label}
</span>
<span style={{
fontFamily: f.css, fontSize: 26, color: typeColor,
lineHeight: 1.2, display: 'block', maxWidth: '100%',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{typed.trim() || 'Preview'}
</span>
</button>
))}
</div>
</div>
{/* Ink color */}
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Color</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{INK_COLORS.map((c) => (
<button
key={c.value}
title={c.label}
onClick={() => setTypeColor(c.value)}
style={{
width: 24, height: 24, borderRadius: '50%',
background: c.value, border: 'none', cursor: 'pointer',
outline: typeColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
outlineOffset: 3, transition: 'all 0.12s',
}}
/>
))}
</div>
</div>
{/* Live preview */}
<div style={{
marginTop: 16, height: 90, borderRadius: 10,
background: 'linear-gradient(180deg,#f8faff 0%,#eef2fb 100%)',
border: '1.5px dashed #c7d7f5',
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
}}>
<span style={{
fontFamily: SIGNATURE_FONTS[fontIdx].css,
fontSize: 54, color: typeColor, lineHeight: 1,
maxWidth: '90%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{typed.trim() || 'Your Signature'}
</span>
</div>
</div>
)}
{/* ══ UPLOAD ══ */}
{mode === 'upload' && (
<div>
<label
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
minHeight: 220, borderRadius: 12, cursor: 'pointer',
border: `2px dashed ${dragging ? '#2563eb' : uploaded ? '#c7d7f5' : '#cbd5e1'}`,
background: dragging ? '#eff6ff' : uploaded ? '#f8faff' : '#f8fafc',
transition: 'all 0.18s', gap: 10,
}}
>
{uploaded ? (
<>
<img
src={uploaded.url}
alt="signature preview"
style={{ maxHeight: 160, maxWidth: '85%', objectFit: 'contain', borderRadius: 6 }}
/>
<span style={{ fontSize: 12, color: '#2563eb', fontWeight: 600, marginTop: 4 }}>
Click to replace
</span>
</>
) : (
<>
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'linear-gradient(135deg,#eff6ff,#e0e7ff)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/>
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
</svg>
</div>
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>
Drop image here or click to browse
</p>
<p style={{ fontSize: 11, color: '#94a3b8', margin: '4px 0 0' }}>
PNG with transparent background gives the best result
</p>
</div>
<span style={{
fontSize: 11, fontWeight: 600, color: '#2563eb',
padding: '6px 14px', borderRadius: 7, background: '#eff6ff',
border: '1px solid #bfdbfe',
}}>
Choose file
</span>
</>
)}
<input type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
</label>
{uploaded && (
<button
onClick={() => setUploaded(null)}
style={{
marginTop: 10, width: '100%', padding: '7px 0', borderRadius: 8,
border: '1px solid #fee2e2', background: '#fff5f5', cursor: 'pointer',
fontSize: 12, fontWeight: 600, color: '#dc2626',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
transition: 'all 0.15s',
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
Remove image
</button>
)}
</div>
)}
{/* ══ SAVED ══ */}
{mode === 'saved' && (
<div>
{savedSigs.length === 0 ? (
<div style={{
minHeight: 200, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 10,
}}>
<div style={{
width: 52, height: 52, borderRadius: 14, background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
</div>
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>No saved signatures</p>
<p style={{ fontSize: 12, color: '#94a3b8', margin: 0 }}>Your signatures will appear here after use</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{savedSigs.map((s) => (
<div
key={s.id}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 10,
border: '1.5px solid #edf0f5', background: '#f8fafc',
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#bfdbfe')}
onMouseLeave={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#edf0f5')}
>
{/* Thumbnail */}
<div style={{
width: 120, height: 52, borderRadius: 8, overflow: 'hidden',
background: '#fff', border: '1px solid #e2e8f0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<img
src={s.dataUrl}
alt={s.label}
style={{ maxWidth: '95%', maxHeight: '90%', objectFit: 'contain' }}
/>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', margin: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s.label}
</p>
<p style={{ fontSize: 11, color: '#94a3b8', margin: '2px 0 0' }}>
{new Date(s.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<CustomButton
variant="primary"
size="sm"
onClick={() => handleUseSaved(s)}
>
Use
</CustomButton>
<CustomButton
variant="outline"
size="sm"
onClick={() => handleDeleteSaved(s.id)}
title="Delete"
style={{ padding: '0 8px' }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
</CustomButton>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* ── Footer ── */}
{mode !== 'saved' && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 24px', borderTop: '1px solid #edf0f5',
background: '#f8fafc',
}}>
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0 }}>
Visual signature placed as image overlay
</p>
<div style={{ display: 'flex', gap: 8 }}>
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
<CustomButton
variant="primary"
onClick={handleConfirm}
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Use Signature
</CustomButton>
</div>
</div>
)}
{/* Keyframe animation */}
<style>{`
@keyframes sigModalIn {
from { opacity: 0; transform: scale(0.94) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div> </div>
</div>
{mode === 'draw' && (
<div>
<canvas
ref={canvasRef}
width={920}
height={300}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
style={{ cursor: 'crosshair' }}
/>
<div className="mt-2 flex justify-between">
<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>
)}
{mode === 'type' && (
<div>
<input
autoFocus
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder="Type your name"
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-[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>
</div>
)}
{mode === 'upload' && (
<div>
<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-[#98a1ad]">PNG with transparent background works best</span>
</>
)}
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
</label>
</div>
)}
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only not a certified e-signature.</p>
</Modal>
); );
}; };
+16 -14
View File
@@ -1,7 +1,7 @@
import { CustomButton } from './custom/CustomButton'; import { CustomButton } from './custom/CustomButton';
import React from 'react'; import React from 'react';
import type { ToolId } from '../lib/tools'; import type { ToolId } from '../lib/tools';
import { toast } from '../lib/toast';
import { Popover } from './ui'; import { Popover } from './ui';
import { import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon, SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
@@ -9,22 +9,23 @@ import {
UnderlineIcon, StrikeoutIcon, SquigglyIcon UnderlineIcon, StrikeoutIcon, SquigglyIcon
} from './icons'; } from './icons';
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean } interface ToolDef { id: ToolId; label: string; shortLabel?: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
const TOOLS: (ToolDef | 'divider')[] = [ const TOOLS: (ToolDef | 'divider')[] = [
{ id: 'select', label: 'Select & copy text', shortcut: 'V', icon: <SelectIcon /> }, { id: 'select', label: 'Select & copy text', shortLabel: 'Select', shortcut: 'V', icon: <SelectIcon /> },
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> }, { id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
'divider', 'divider',
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> }, { id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> }, { id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> }, { id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> }, { id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> }, { id: 'draw', label: 'Draw (ink)', shortLabel: 'Draw', shortcut: 'D', icon: <DrawIcon /> },
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> }, { id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> }, { id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
{ {
id: 'edit_text', id: 'edit_text',
label: 'Edit text', label: 'Edit text',
shortLabel: 'Edit',
shortcut: 'E', shortcut: 'E',
icon: ( icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
@@ -36,6 +37,7 @@ const TOOLS: (ToolDef | 'divider')[] = [
{ {
id: 'stream_edit', id: 'stream_edit',
label: 'Raw Text (beta)', label: 'Raw Text (beta)',
shortLabel: 'Raw',
shortcut: 'Q', shortcut: 'Q',
icon: ( icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
@@ -66,7 +68,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
aria-pressed={active} aria-pressed={active}
aria-disabled={disabled} aria-disabled={disabled}
onClick={onClick} onClick={onClick}
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${ className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${
disabled disabled
? 'cursor-not-allowed text-[#c5cad1]' ? 'cursor-not-allowed text-[#c5cad1]'
: active : active
@@ -75,15 +77,15 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]' : 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
}`} }`}
> >
{active && !disabled && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />} {active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon} {React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
</CustomButton> </CustomButton>
); );
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => { export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
const pickTool = (id: ToolId) => { const pickTool = (id: ToolId) => {
if (disabledTools?.has(id)) { if (disabledTools?.has(id)) {
toast("This tool is not permitted by this document's restrictions", 'error');
return; return;
} }
onToolChange(id); onToolChange(id);
@@ -91,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
}; };
return ( return (
<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' }}> <nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
{TOOLS.map((t, i) => {TOOLS.map((t, i) =>
t === 'divider' t === 'divider'
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" /> ? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-[#ebedf0]" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />, : <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
)} )}
<div className="flex-1" /> <div className="flex-1 shrink-0 min-h-[16px]" />
<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]"> <CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
<InfoIcon size={19} /> <InfoIcon size={19} />
</CustomButton> </CustomButton>
@@ -108,7 +110,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
align="left" align="left"
width={210} width={210}
trigger={(open) => ( trigger={(open) => (
<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]'}`}> <CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
<HelpIcon size={19} /> <HelpIcon size={19} />
</CustomButton> </CustomButton>
)} )}
+48 -7
View File
@@ -1,6 +1,6 @@
import { CustomButton } from './custom/CustomButton'; import { CustomButton } from './custom/CustomButton';
import React from 'react'; import React from 'react';
import type { ToolId, ToolSettings } from '../lib/tools'; import type { ToolId, ToolSettings, StampPreset } from '../lib/tools';
import { STAMP_PRESETS } from '../lib/tools'; import { STAMP_PRESETS } from '../lib/tools';
import { ColorSwatches, Slider } from './ui'; import { ColorSwatches, Slider } from './ui';
import { import {
@@ -15,8 +15,14 @@ interface ToolbarProps {
onSettingsChange: (patch: Partial<ToolSettings>) => void; onSettingsChange: (patch: Partial<ToolSettings>) => void;
onOpenSignature: () => void; onOpenSignature: () => void;
hasSignature: boolean; hasSignature: boolean;
activeStamp: string | null; activeStamp?: StampPreset | null;
onSelectStamp: (label: string, color: string) => void; onSelectStamp: (stamp: StampPreset) => void;
redactionMode?: 'area' | 'text';
onRedactionModeChange?: (mode: 'area' | 'text') => void;
pendingRedactionCount?: number;
onApplyRedactions?: () => void;
onClearRedactions?: () => void;
onRedactPages?: () => void;
} }
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = { const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
@@ -54,6 +60,8 @@ const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
export const Toolbar: React.FC<ToolbarProps> = ({ export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp, activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
redactionMode = 'area', onRedactionModeChange,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
}) => { }) => {
const meta = TOOL_META[activeTool]; const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact'; const isRedact = activeTool === 'redact';
@@ -150,9 +158,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{STAMP_PRESETS.map((s) => ( {STAMP_PRESETS.map((s) => (
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)} <CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s)}
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]' : ''}`} 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?.label === 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)` }}> style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
{s.label} {s.label}
</CustomButton> </CustomButton>
))} ))}
@@ -161,7 +169,40 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</> </>
)} )}
{activeTool === 'redact' && <Hint tone="warn"> Drag a box to permanently remove content underneath.</Hint>} {activeTool === 'redact' && (
<>
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
<button
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
onClick={() => onRedactionModeChange?.('area')}
>
Area
</button>
<button
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
onClick={() => onRedactionModeChange?.('text')}
>
Text
</button>
</div>
<CustomButton variant="outline" size="sm" onClick={onRedactPages}>
Redact Pages
</CustomButton>
<Divider />
{pendingRedactionCount > 0 ? (
<>
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
Apply {pendingRedactionCount} Redaction{pendingRedactionCount > 1 ? 's' : ''}
</CustomButton>
<CustomButton variant="outline" size="sm" onClick={onClearRedactions}>
Clear
</CustomButton>
</>
) : (
<Hint tone="warn"> Select text or drag a box to permanently remove content.</Hint>
)}
</>
)}
</div> </div>
</div> </div>
); );
+2 -30
View File
@@ -1,7 +1,5 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { subscribeToasts, dismissToast } from '../lib/toast'; import { XIcon } from './icons';
import type { ToastItem } from '../lib/toast';
import { XIcon, CheckIcon, InfoIcon } from './icons';
import { CustomButton } from './custom/CustomButton'; import { CustomButton } from './custom/CustomButton';
interface PopoverProps { interface PopoverProps {
@@ -189,30 +187,4 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ state, onClose })
<p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p> <p className="text-[13px] leading-relaxed text-[#5b6573]">{state?.message}</p>
</Modal> </Modal>
); );
const toastStyles: Record<ToastItem['kind'], string> = {
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[]>([]);
useEffect(() => subscribeToasts(setItems), []);
return (
<div className="pointer-events-none fixed bottom-5 left-1/2 z-[300] flex -translate-x-1/2 flex-col items-center gap-2">
{items.map((t) => (
<div
key={t.id}
className={`pointer-events-auto flex items-center gap-2 rounded-full border px-4 py-2 text-[12.5px] font-semibold shadow-[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>
<CustomButton variant="unstyled" className="ml-1 opacity-60 hover:opacity-100" onClick={() => dismissToast(t.id)}><XIcon size={13} /></CustomButton>
</div>
))}
</div>
);
};
+7
View File
@@ -24,6 +24,13 @@
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; } .scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; } .scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; }
/* Micro scrollbar (used by narrow sidebars) */
.scroll-micro { scrollbar-width: thin; }
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
/* Animations */ /* Animations */
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
+15
View File
@@ -136,6 +136,20 @@ export interface TextOverlayData {
color: string; color: string;
} }
export interface StampData {
text: string;
x: number;
y: number;
width: number;
height: number;
textColor: string;
backgroundColor: string;
borderColor: string;
fontSize: number;
includeDate: boolean;
timestamp?: string;
}
export interface RedactionData { export interface RedactionData {
x: number; x: number;
y: number; y: number;
@@ -207,6 +221,7 @@ export interface PageReorderData {
export type EditOperationDataMap = { export type EditOperationDataMap = {
text_overlay: TextOverlayData; text_overlay: TextOverlayData;
stamp: StampData;
redaction: RedactionData; redaction: RedactionData;
image_overlay: ImageOverlayData; image_overlay: ImageOverlayData;
highlight: HighlightData; highlight: HighlightData;
+4 -2
View File
@@ -194,8 +194,10 @@ export class TextSelectionModel {
} }
const rects: SelRect[] = []; const rects: SelRect[] = [];
for (const arr of byLine.values()) { for (const arr of byLine.values()) {
const x = Math.min(...arr.map((g) => g.x)); const nonSpace = arr.filter(g => !/^\s+$/.test(g.text));
const right = Math.max(...arr.map((g) => g.right)); const measureArr = nonSpace.length > 0 ? nonSpace : arr;
const x = Math.min(...measureArr.map((g) => g.x));
const right = Math.max(...measureArr.map((g) => g.right));
const band = this.lines[arr[0].line]; const band = this.lines[arr[0].line];
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top }); rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
} }
+14 -7
View File
@@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
q: 'stream_edit', q: 'stream_edit',
}; };
export const STAMP_PRESETS = [ export interface StampPreset {
{ label: 'APPROVED', color: '#16a34a' }, label: string;
{ label: 'DRAFT', color: '#6b7280' }, textColor: string;
{ label: 'CONFIDENTIAL', color: '#dc2626' }, backgroundColor: string;
{ label: 'REVIEWED', color: '#2563eb' }, borderColor: string;
{ label: 'FINAL', color: '#7c3aed' }, }
{ label: 'VOID', color: '#dc2626' },
export const STAMP_PRESETS: StampPreset[] = [
{ label: 'APPROVED', textColor: '#16a34a', backgroundColor: '#dcfce7', borderColor: '#16a34a' },
{ label: 'DRAFT', textColor: '#6b7280', backgroundColor: '#f3f4f6', borderColor: '#6b7280' },
{ label: 'CONFIDENTIAL', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
{ label: 'REVIEWED', textColor: '#2563eb', backgroundColor: '#dbeafe', borderColor: '#2563eb' },
{ label: 'FINAL', textColor: '#7c3aed', backgroundColor: '#ede9fe', borderColor: '#7c3aed' },
{ label: 'VOID', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
]; ];
+53 -7
View File
@@ -12,6 +12,7 @@ export interface Annotation {
content?: string; content?: string;
timestamp?: string; timestamp?: string;
paths?: { x: number; y: number }[][]; paths?: { x: number; y: number }[][];
quadPoints?: { x: number; y: number }[][];
pageIndex?: number; pageIndex?: number;
fieldName?: string; fieldName?: string;
@@ -114,19 +115,28 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onPointerDown={(e) => handlePointerDown(e, anno)} onPointerDown={(e) => handlePointerDown(e, anno)}
onPointerMove={(e) => handlePointerMove(e, anno.id)} onPointerMove={(e) => handlePointerMove(e, anno.id)}
onPointerUp={(e) => handlePointerUp(e, anno)} onPointerUp={(e) => handlePointerUp(e, anno)}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`} className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
style={{ style={{
left: `${scaledBbox.x + currentOffset.x * zoom}px`, left: `${scaledBbox.x + currentOffset.x * zoom}px`,
top: `${scaledBbox.y + currentOffset.y * zoom}px`, top: `${scaledBbox.y + currentOffset.y * zoom}px`,
width: `${scaledBbox.width}px`, width: `${scaledBbox.width}px`,
height: `${scaledBbox.height}px`, height: `${scaledBbox.height}px`,
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined, backgroundColor: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.color || '#ffeb3b') : undefined,
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined, opacity: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.opacity ?? 0.4) : undefined,
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined, mixBlendMode: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? 'multiply' : undefined,
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
title={tooltipText} title={tooltipText}
> >
{anno.type === 'highlight' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMin * zoom - scaledBbox.y;
return <div key={i} className="absolute" style={{ left, top, width: (xMax - xMin) * zoom, height: (yMax - yMin) * zoom, backgroundColor: anno.color || '#ffeb3b', opacity: anno.opacity ?? 0.4, mixBlendMode: 'multiply' }} />
})}
{anno.type === 'comment' && ( {anno.type === 'comment' && (
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}> <div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md">
@@ -134,9 +144,27 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
</svg> </svg>
</div> </div>
)} )}
{anno.type === 'strikeout' && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />} {anno.type === 'strikeout' && (!anno.quadPoints || anno.quadPoints.length === 0) && <div className="w-full h-[1.5px] opacity-[0.85] absolute top-1/2 -translate-y-1/2" style={{ backgroundColor: anno.color || '#dc2626' }} />}
{anno.type === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />} {anno.type === 'strikeout' && anno.quadPoints && anno.quadPoints.map((q, i) => {
{anno.type === 'squiggly' && ( const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMin * zoom - scaledBbox.y + ((yMax - yMin) * zoom / 2);
return <div key={i} className="absolute h-[1.5px] opacity-[0.85] -translate-y-1/2" style={{ left, top, width: (xMax - xMin) * zoom, backgroundColor: anno.color || '#dc2626' }} />
})}
{anno.type === 'underline' && (!anno.quadPoints || anno.quadPoints.length === 0) && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
{anno.type === 'underline' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMax * zoom - scaledBbox.y;
return <div key={i} className="absolute h-[1.5px] opacity-[0.85]" style={{ left, top, width: (xMax - xMin) * zoom, backgroundColor: anno.color || '#2563eb' }} />
})}
{anno.type === 'squiggly' && (!anno.quadPoints || anno.quadPoints.length === 0) && (
<svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}> <svg width="100%" height="4" xmlns="http://www.w3.org/2000/svg" style={{position: 'absolute', bottom: 0, left: 0, opacity: 0.85}}>
<defs> <defs>
<pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse"> <pattern id={`sq-${anno.id}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
@@ -146,6 +174,24 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} /> <rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id})`} />
</svg> </svg>
)} )}
{anno.type === 'squiggly' && anno.quadPoints && anno.quadPoints.map((q, i) => {
const xMin = Math.min(q[0].x, q[1].x, q[2].x, q[3].x);
const xMax = Math.max(q[0].x, q[1].x, q[2].x, q[3].x);
const yMin = Math.min(q[0].y, q[1].y, q[2].y, q[3].y);
const yMax = Math.max(q[0].y, q[1].y, q[2].y, q[3].y);
const left = xMin * zoom - scaledBbox.x;
const top = yMax * zoom - scaledBbox.y;
return (
<svg key={i} style={{position: 'absolute', left, top, width: (xMax - xMin) * zoom, height: 4, opacity: 0.85}} xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`sq-${anno.id}-${i}`} x="0" y="0" width="6" height="4" patternUnits="userSpaceOnUse">
<path d="M 0 2 Q 1.5 0 3 2 T 6 2" fill="none" stroke={anno.color || '#16a34a'} strokeWidth="1.2" />
</pattern>
</defs>
<rect x="0" y="0" width="100%" height="4" fill={`url(#sq-${anno.id}-${i})`} />
</svg>
)
})}
{anno.type === 'signature' && ( {anno.type === 'signature' && (
<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)]"> <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"> <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">
+206
View File
@@ -0,0 +1,206 @@
import React, { useState, useRef, useEffect } from 'react';
import type { Rect } from '../lib/coordinateMapping';
interface FloatingTextToolbarProps {
selection: { text: string; bbox: Rect; lines: Rect[] };
onAction: (action: 'copy' | 'comment' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'redact' | 'edit', overrideColor?: string) => void;
}
const COLORS = [
'#facc15', '#4ade80', '#2dd4bf', '#a78bfa', '#e879f9',
'#fb923c', '#60a5fa', '#f472b6', '#22d3ee', '#34d399',
'#16a34a', '#a855f7', '#2563eb', '#fef08a', '#ef4444',
'#ffffff', '#e5e5e5', '#a3a3a3', '#52525b', '#000000',
];
export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ selection, onAction }) => {
const { bbox } = selection;
// Calculate position: just above the bounding box
const top = bbox.y - 48; // 48px above
const left = bbox.x;
const [openDropdown, setOpenDropdown] = useState<'highlight' | 'underline' | 'strikeout' | null>(null);
const [toolColors, setToolColors] = useState({
highlight: '#facc15',
underline: '#f43f5e',
strikeout: '#ef4444',
});
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenDropdown(null);
}
};
if (openDropdown) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [openDropdown]);
const handleAction = (action: 'highlight' | 'underline' | 'strikeout') => {
onAction(action, toolColors[action]);
setOpenDropdown(null);
};
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
<div className="grid grid-cols-5 gap-1">
{COLORS.map((c) => (
<button
key={c}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
style={{ backgroundColor: c }}
onClick={() => {
setToolColors(prev => ({ ...prev, [action]: c }));
onAction(action, c);
setOpenDropdown(null);
}}
/>
))}
</div>
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
<span>Opacity</span>
<div className="flex items-center gap-2">
<button className="hover:text-white"></button>
<span>100%</span>
<button className="hover:text-white">+</button>
</div>
</div>
</div>
);
return (
<div
ref={menuRef}
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
style={{ top, left, pointerEvents: 'auto' }}
onMouseDown={(e) => {
// Prevent clearing the selection layer when clicking the toolbar
e.stopPropagation();
}}
>
<button
onClick={() => onAction('copy')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Copy"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
onClick={() => onAction('comment')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Add Comment"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
<line x1="9" y1="12" x2="15" y2="12" />
<line x1="12" y1="9" x2="12" y2="15" />
</svg>
</button>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="relative flex items-center group">
<button
onClick={() => handleAction('highlight')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Highlight"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 11-6 6v3h9l3-3"/>
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>
<path d="M12 21h10" strokeWidth="3" stroke={toolColors.highlight} />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'highlight' && <ColorPicker action="highlight" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('underline')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Underline"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4v6a6 6 0 0 0 12 0V4"/>
<line x1="4" y1="20" x2="20" y2="20" stroke={toolColors.underline} strokeWidth="3" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'underline' && <ColorPicker action="underline" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('strikeout')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Strikeout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4H9a3 3 0 0 0-2.83 4"/>
<path d="M14 12a4 4 0 0 1 0 8H6"/>
<line x1="4" y1="12" x2="20" y2="12" stroke={toolColors.strikeout} strokeWidth="2.5" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
</div>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<button
onClick={() => onAction('redact')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Redact Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3z"/>
<path d="M3 3l18 18"/>
</svg>
Redact Text
</button>
<button
onClick={() => onAction('edit')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Edit Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
<path d="M15 5l4 4"/>
</svg>
Edit Text
</button>
</div>
);
};
+14 -5
View File
@@ -2,6 +2,7 @@ import { CustomButton } from '../components/custom/CustomButton';
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer'; import type { Annotation } from './AnnotationLayer';
import type { Rect } from '../lib/coordinateMapping'; import type { Rect } from '../lib/coordinateMapping';
import type { StampPreset } from '../lib/tools';
interface OverlayLayerProps { interface OverlayLayerProps {
pageIndex: number; pageIndex: number;
@@ -14,11 +15,12 @@ interface OverlayLayerProps {
textColor: string; textColor: string;
fontSize: number; fontSize: number;
hasSignature: boolean; hasSignature: boolean;
activeStamp: string | null; activeStamp: StampPreset | null;
onAnnotationAdded?: (anno: Annotation) => void; onAnnotationAdded?: (anno: Annotation) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void; onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void; onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void; /** Called when user clicks to begin interactive signature placement */
onBeginPlacement?: (pageIndex: number, viewportPt: { x: number; y: number }) => void;
} }
const TEXTBOX_WIDTH_PTS = 200; const TEXTBOX_WIDTH_PTS = 200;
@@ -28,7 +30,7 @@ const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
export const OverlayLayer: React.FC<OverlayLayerProps> = ({ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
pageIndex, width, height, activeTool, zoom, pageIndex, width, height, activeTool, zoom,
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp, inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature, onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
}) => { }) => {
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]); const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
@@ -88,7 +90,14 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
} else if (activeTool === 'stamp' && activeStamp) { } else if (activeTool === 'stamp' && activeStamp) {
onPlaceStamp?.(pageIndex, coords); onPlaceStamp?.(pageIndex, coords);
} else if (activeTool === 'signature' && hasSignature) { } else if (activeTool === 'signature' && hasSignature) {
onPlaceSignature?.(pageIndex, coords); // Pass viewport coordinates (relative to page) to begin interactive placement
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
onBeginPlacement?.(pageIndex, {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
} }
}; };
@@ -135,7 +144,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
<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> <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 && ( {activeTool === 'stamp' && activeStamp && (
<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> <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.label}</div>
)} )}
{activeTool === 'textbox' && !textBox && ( {activeTool === 'textbox' && !textBox && (
<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> <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>
+176 -26
View File
@@ -11,11 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping'; import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService'; import { gatewayService } from '../lib/gatewayService';
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService'; import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
import type { ToolSettings } from '../lib/tools'; import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
import { toast } from '../lib/toast';
import { RedactionLayer } from './RedactionLayer'; import { RedactionLayer } from './RedactionLayer';
import { StreamEditLayer } from './StreamEditLayer'; import { StreamEditLayer } from './StreamEditLayer';
import { wasmFreeDocument } from '../lib/pdfiumEngine'; import { wasmFreeDocument } from '../lib/pdfiumEngine';
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
import type { PlacementRect } from './SignaturePlacementOverlay';
import { FloatingTextToolbar } from './FloatingTextToolbar';
interface PDFViewerProps { interface PDFViewerProps {
documentId: string; documentId: string;
@@ -24,10 +27,14 @@ interface PDFViewerProps {
pageHeight: number; pageHeight: number;
zoom: number; zoom: number;
pagesInfo?: PageInfo[]; pagesInfo?: PageInfo[];
activeTool: string; activeTool: ToolId;
toolSettings: ToolSettings; toolSettings: ToolSettings;
redactionMode?: 'area' | 'text';
hasSignature: boolean; hasSignature: boolean;
activeStamp: string | null; /** The data-URL of the pending signature image (for the placement preview) */
signatureImageUrl?: string;
signatureAspect?: number;
activeStamp: StampPreset | null;
annotations: Annotation[]; annotations: Annotation[];
searchQuery?: string; searchQuery?: string;
searchResults?: SearchResult[]; searchResults?: SearchResult[];
@@ -36,13 +43,16 @@ interface PDFViewerProps {
onAnnotationUpdate?: (anno: Annotation) => void; onAnnotationUpdate?: (anno: Annotation) => void;
onAnnotationClick?: (anno: Annotation) => void; onAnnotationClick?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void; onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void; onMarkRedaction?: (pageIndex: number, bounds: Rect) => void;
pendingRedactions?: { id: string, pageIndex: number, bounds: Rect }[];
onRemoveRedaction?: (id: string) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void; onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void; onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void; onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
onStreamDocumentChanged?: (newDocumentId: string) => void; onStreamDocumentChanged?: (newDocumentId: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void; onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void; /** Called with final PDF-space rect after user commits interactive placement */
onPlaceSignature?: (pageIndex: number, pdfRect: Rect, rotation: number) => void;
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void; onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void; onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
canCopy?: boolean; canCopy?: boolean;
@@ -70,7 +80,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
pagesInfo, pagesInfo,
activeTool, activeTool,
toolSettings, toolSettings,
redactionMode = 'area',
hasSignature, hasSignature,
signatureImageUrl,
signatureAspect = 3,
activeStamp, activeStamp,
annotations, annotations,
searchQuery, searchQuery,
@@ -80,7 +93,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onAnnotationUpdate, onAnnotationUpdate,
onAnnotationClick, onAnnotationClick,
onPageVisible, onPageVisible,
onRedactArea, onMarkRedaction,
pendingRedactions = [],
onRemoveRedaction,
onPlaceText, onPlaceText,
onEditText, onEditText,
onReflowParagraph, onReflowParagraph,
@@ -100,6 +115,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const prevDocumentIdRef = useRef<string>(documentId); const prevDocumentIdRef = useRef<string>(documentId);
const inFlightRenderRef = useRef<Set<number>>(new Set()); const inFlightRenderRef = useRef<Set<number>>(new Set());
const inFlightTextRef = useRef<Set<number>>(new Set()); const inFlightTextRef = useRef<Set<number>>(new Set());
/** Active interactive signature placement state */
const [activePlacement, setActivePlacement] = useState<{
pageIndex: number;
rect: PlacementRect;
} | null>(null);
const [textSelection, setTextSelection] = useState<{
pageIndex: number;
text: string;
bbox: Rect;
lines: Rect[];
} | null>(null);
useEffect(() => { useEffect(() => {
setPageTexts({}); setPageTexts({});
const prev = prevDocumentIdRef.current; const prev = prevDocumentIdRef.current;
@@ -294,18 +323,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
}, [textToolActive, visiblePages, documentId, pageTexts]); }, [textToolActive, visiblePages, documentId, pageTexts]);
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => { const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
// This is still called on Ctrl+C for select mode
if (activeTool === 'select') { if (activeTool === 'select') {
if (!canCopy) { if (!canCopy) return;
toast("Copying is not permitted by this document's restrictions", 'error');
return;
}
if (text.trim()) { if (text.trim()) {
navigator.clipboard?.writeText(text).then( navigator.clipboard?.writeText(text).catch(() => {});
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
() => toast('Copy failed — clipboard unavailable', 'error'),
);
} else {
toast('No selectable text in that area', 'info');
} }
return; return;
} }
@@ -334,7 +356,90 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
activeTool === 'strikeout' ? toolSettings.strikeoutColor : activeTool === 'strikeout' ? toolSettings.strikeoutColor :
toolSettings.squigglyColor; toolSettings.squigglyColor;
onDecorateText?.(pageIndex, lines, activeTool, color); onDecorateText?.(pageIndex, lines, activeTool, color);
return;
} }
if (activeTool === 'redact') {
if (lines.length > 0) {
lines.forEach(line => {
onMarkRedaction?.(pageIndex, {
x: line.x / zoom,
y: line.y / zoom,
width: line.width / zoom,
height: line.height / zoom,
});
});
}
return;
}
};
const handleToolbarAction = (action: string, overrideColor?: string) => {
if (!textSelection) return;
const { pageIndex, text, bbox, lines } = textSelection;
switch (action) {
case 'copy':
if (!canCopy) break;
navigator.clipboard?.writeText(text).catch(() => {});
break;
case 'highlight': {
const newAnno: Annotation = {
id: generateUniqueId(),
type: 'highlight',
pageIndex,
bbox: {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: bbox.width / zoom,
height: bbox.height / zoom,
},
color: overrideColor || toolSettings.highlightColor,
opacity: toolSettings.highlightOpacity,
author: 'Current User',
content: text,
};
onAnnotationAdded?.(newAnno);
break;
}
case 'underline':
onDecorateText?.(pageIndex, lines, 'underline', overrideColor || toolSettings.underlineColor);
break;
case 'strikeout':
onDecorateText?.(pageIndex, lines, 'strikeout', overrideColor || toolSettings.strikeoutColor);
break;
case 'squiggly':
onDecorateText?.(pageIndex, lines, 'squiggly', overrideColor || toolSettings.squigglyColor);
break;
case 'redact':
onMarkRedaction?.(pageIndex, {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: bbox.width / zoom,
height: bbox.height / zoom
});
break;
case 'edit':
break;
case 'comment': {
const newAnno: Annotation = {
id: generateUniqueId(),
type: 'comment',
pageIndex,
bbox: {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: 24,
height: 24,
},
color: '#facc15',
author: 'Current User',
content: 'New Comment\n\n' + text,
};
onAnnotationAdded?.(newAnno);
break;
}
}
setTextSelection(null);
}; };
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
@@ -452,27 +557,36 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onFieldChange={onFieldChange} onFieldChange={onFieldChange}
/> />
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && ( {(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || (activeTool === 'redact' && redactionMode === 'text')) && (
<SelectionLayer <SelectionLayer
pageIndex={page.index} pageIndex={page.index}
width={page.width} width={page.width}
height={page.height} height={page.height}
zoom={zoom} zoom={zoom}
glyphs={pageTexts[page.index] || []} glyphs={pageTexts[page.index] || []}
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') ? 'highlight' : 'select'} mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)} onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
/> />
)} )}
{activeTool === 'redact' && ( {textSelection && textSelection.pageIndex === page.index && (
<RedactionLayer <FloatingTextToolbar
pageIndex={page.index} selection={textSelection}
width={page.width} onAction={(action, color) => handleToolbarAction(action as any, color)}
height={page.height}
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
/> />
)} )}
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
onRemoveRedaction={onRemoveRedaction}
mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'}
/>
<OverlayLayer <OverlayLayer
pageIndex={page.index} pageIndex={page.index}
width={page.width} width={page.width}
@@ -488,7 +602,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onAnnotationAdded={onAnnotationAdded} onAnnotationAdded={onAnnotationAdded}
onPlaceText={onPlaceText} onPlaceText={onPlaceText}
onPlaceStamp={onPlaceStamp} onPlaceStamp={onPlaceStamp}
onPlaceSignature={onPlaceSignature} onBeginPlacement={(pageIdx, viewportPt) => {
// Compute a sensible initial rect centred on the click point
const pageInfo = pagesInfo?.[pageIdx];
const pgW = pageInfo ? pageInfo.width * zoom : page.width;
const pgH = pageInfo ? pageInfo.height * zoom : page.height;
const sigW = Math.min(180 * zoom, pgW * 0.5);
const sigH = sigW / (signatureAspect || 3);
const x = Math.max(0, Math.min(pgW - sigW, viewportPt.x - sigW / 2));
const y = Math.max(0, Math.min(pgH - sigH, viewportPt.y - sigH / 2));
setActivePlacement({
pageIndex: pageIdx,
rect: { x, y, width: sigW, height: sigH, rotation: 0 },
});
}}
/> />
{activeTool === 'edit_text' && ( {activeTool === 'edit_text' && (
@@ -540,6 +667,29 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
searchResults={searchResults} searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch} searchCurrentMatch={searchCurrentMatch}
/> />
{/* Interactive signature placement overlay */}
{activePlacement && activePlacement.pageIndex === page.index && signatureImageUrl && (
<SignaturePlacementOverlay
imageUrl={signatureImageUrl}
aspect={signatureAspect || 3}
initialRect={activePlacement.rect}
pageWidth={page.width}
pageHeight={page.height}
onCommit={(finalRect) => {
setActivePlacement(null);
// Convert viewport rect to PDF-space coordinates
const pageInfo = pagesInfo?.[page.index];
const pageHPts = pageInfo ? pageInfo.height : (pageHeight || 792);
const pdfX = finalRect.x / zoom;
const pdfW = finalRect.width / zoom;
const pdfH = finalRect.height / zoom;
const pdfY = pageHPts - (finalRect.y / zoom) - pdfH;
onPlaceSignature?.(page.index, { x: pdfX, y: pdfY, width: pdfW, height: pdfH }, finalRect.rotation);
}}
onCancel={() => setActivePlacement(null)}
/>
)}
</> </>
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]"> <div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]">
+28 -1
View File
@@ -6,12 +6,18 @@ interface RedactionLayerProps {
width: number; width: number;
height: number; height: number;
onRedactionSelected: (bounds: Rect) => void; onRedactionSelected: (bounds: Rect) => void;
pendingRedactions?: { id: string, bounds: Rect }[];
onRemoveRedaction?: (id: string) => void;
mode?: 'area' | 'text';
} }
export const RedactionLayer: React.FC<RedactionLayerProps> = ({ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
width, width,
height, height,
onRedactionSelected, onRedactionSelected,
pendingRedactions = [],
onRemoveRedaction,
mode = 'area',
}) => { }) => {
const [dragStart, setDragStart] = useState<Point | null>(null); const [dragStart, setDragStart] = useState<Point | null>(null);
const [redactBox, setRedactBox] = useState<Rect | null>(null); const [redactBox, setRedactBox] = useState<Rect | null>(null);
@@ -62,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
left: 0, left: 0,
width: `${width}px`, width: `${width}px`,
height: `${height}px`, height: `${height}px`,
cursor: 'crosshair', cursor: mode === 'area' ? 'crosshair' : 'default',
zIndex: 25, zIndex: 25,
pointerEvents: mode === 'area' ? 'auto' : 'none',
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
@@ -86,6 +93,26 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
}} }}
/> />
)} )}
{pendingRedactions.map((redaction) => (
<div
key={redaction.id}
className="group absolute"
style={{
left: `${redaction.bounds.x}px`,
top: `${redaction.bounds.y}px`,
width: `${redaction.bounds.width}px`,
height: `${redaction.bounds.height}px`,
border: '2px solid #ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.2)',
zIndex: 30,
cursor: 'pointer',
pointerEvents: 'auto',
}}
>
<div className="absolute inset-0 hidden bg-black group-hover:block" title="Click to remove" onClick={(e) => { e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} />
</div>
))}
</div> </div>
); );
}; };
+13 -4
View File
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
glyphs: Glyph[]; glyphs: Glyph[];
mode?: 'select' | 'highlight'; mode?: 'select' | 'highlight';
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void; onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void;
} }
const SEL_START_EVT = 'pdf-selection-start'; const SEL_START_EVT = 'pdf-selection-start';
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
glyphs, glyphs,
mode = 'select', mode = 'select',
onTextSelected, onTextSelected,
onSelectionChange,
}) => { }) => {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]); const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
setBox(null); setBox(null);
drag.current = null; drag.current = null;
boxStart.current = null; boxStart.current = null;
}, []); onSelectionChange?.(null);
}, [onSelectionChange]);
useEffect(() => { useEffect(() => {
const onOther = (e: Event) => { const onOther = (e: Event) => {
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
const cur = selRef.current; const cur = selRef.current;
if (!cur || cur.start === cur.end) { if (!cur || cur.start === cur.end) {
setSel(null); setSel(null);
onSelectionChange?.(null);
return; return;
} }
const text = model.textOfRange(cur);
const u = model.unionRect(cur);
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
if (mode === 'highlight') { if (mode === 'highlight') {
const text = model.textOfRange(cur);
const u = model.unionRect(cur);
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines); if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
setSel(null); setSel(null);
onSelectionChange?.(null);
} else if (mode === 'select') {
if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines });
} }
}; };
@@ -0,0 +1,308 @@
/**
* SignaturePlacementOverlay
* Adobe-style interactive signature placement: drag to reposition,
* corner/edge handles to resize, rotation handle to rotate.
* Sits as an absolute child inside the page div.
*/
import React, { useRef, useState, useCallback, useEffect } from 'react';
import { CustomButton } from '../components/custom/CustomButton';
export interface PlacementRect {
/** All values in viewport-pixels relative to the page div */
x: number;
y: number;
width: number;
height: number;
rotation: number; // degrees
}
interface Props {
imageUrl: string;
aspect: number;
initialRect: PlacementRect;
pageWidth: number; // viewport px
pageHeight: number; // viewport px
onCommit: (rect: PlacementRect) => void;
onCancel: () => void;
}
type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'move';
const MIN_SIZE = 40;
const HANDLE_CURSORS: Record<Handle, string> = {
nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize',
e: 'ew-resize', se: 'nwse-resize', s: 'ns-resize',
sw: 'nesw-resize', w: 'ew-resize',
rotate: 'grab', move: 'move',
};
const HANDLE_POSITIONS: Record<Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }> = {
nw: { left: '-5px', top: '-5px' },
n: { left: 'calc(50% - 5px)', top: '-5px' },
ne: { left: 'calc(100% - 5px)', top: '-5px' },
e: { left: 'calc(100% - 5px)', top: 'calc(50% - 5px)' },
se: { left: 'calc(100% - 5px)', top: 'calc(100% - 5px)' },
s: { left: 'calc(50% - 5px)', top: 'calc(100% - 5px)' },
sw: { left: '-5px', top: 'calc(100% - 5px)' },
w: { left: '-5px', top: 'calc(50% - 5px)' },
};
export const SignaturePlacementOverlay: React.FC<Props> = ({
imageUrl, aspect, initialRect, pageWidth, pageHeight, onCommit, onCancel,
}) => {
const [rect, setRect] = useState<PlacementRect>(initialRect);
const rectRef = useRef(rect);
rectRef.current = rect;
const dragRef = useRef<{
handle: Handle;
startX: number; startY: number;
startRect: PlacementRect;
centerX: number; centerY: number;
} | null>(null);
/* Clamp rect to page bounds */
const clamp = useCallback((r: PlacementRect): PlacementRect => {
const w = Math.max(MIN_SIZE, r.width);
const h = Math.max(MIN_SIZE, r.height);
const x = Math.max(0, Math.min(pageWidth - w, r.x));
const y = Math.max(0, Math.min(pageHeight - h, r.y));
return { ...r, x, y, width: w, height: h };
}, [pageWidth, pageHeight]);
const onPointerDown = useCallback((e: React.PointerEvent, handle: Handle) => {
e.stopPropagation();
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const r = rectRef.current;
dragRef.current = {
handle,
startX: e.clientX,
startY: e.clientY,
startRect: { ...r },
centerX: r.x + r.width / 2,
centerY: r.y + r.height / 2,
};
}, []);
useEffect(() => {
const onMove = (e: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
const { startRect, handle } = d;
setRect(() => {
let { x, y, width, height, rotation } = startRect;
if (handle === 'move') {
x = startRect.x + dx;
y = startRect.y + dy;
} else if (handle === 'rotate') {
// Angle from center to current mouse (uses dragRef center, not local cx/cy)
const angle = Math.atan2(
(e.clientY - d.centerY),
(e.clientX - d.centerX),
) * 180 / Math.PI + 90;
rotation = angle;
} else {
// Resize — maintain aspect ratio on corners if Shift pressed (we do it always for corners)
const isCorner = ['nw', 'ne', 'se', 'sw'].includes(handle);
switch (handle) {
case 'n': y = startRect.y + dy; height = startRect.height - dy; break;
case 's': height = startRect.height + dy; break;
case 'e': width = startRect.width + dx; break;
case 'w': x = startRect.x + dx; width = startRect.width - dx; break;
case 'nw': x = startRect.x + dx; y = startRect.y + dy; width = startRect.width - dx; height = startRect.height - dy; break;
case 'ne': y = startRect.y + dy; width = startRect.width + dx; height = startRect.height - dy; break;
case 'se': width = startRect.width + dx; height = startRect.height + dy; break;
case 'sw': x = startRect.x + dx; width = startRect.width - dx; height = startRect.height + dy; break;
}
if (isCorner && e.shiftKey) {
// Lock aspect ratio
const newAspect = width / height;
if (newAspect > aspect) width = height * aspect;
else height = width / aspect;
}
}
return clamp({ x, y, width, height, rotation });
});
};
const onUp = () => { dragRef.current = null; };
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [clamp, aspect]);
/* Keyboard: Escape = cancel, Enter = commit */
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter') { e.preventDefault(); onCommit(rectRef.current); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onCancel, onCommit]);
const { x, y, width, height, rotation } = rect;
const cx = x + width / 2;
return (
<>
{/* Dim everything outside the signature */}
<div
style={{
position: 'absolute', inset: 0, zIndex: 60,
cursor: 'default',
background: 'rgba(15,23,42,0.18)',
pointerEvents: 'auto',
}}
onPointerDown={(e) => { e.stopPropagation(); onCancel(); }}
/>
{/* Signature overlay box */}
<div
style={{
position: 'absolute',
left: x, top: y, width, height,
zIndex: 61,
transform: `rotate(${rotation}deg)`,
transformOrigin: `${width / 2}px ${height / 2}px`,
boxSizing: 'border-box',
outline: '2px solid #2563eb',
outlineOffset: '-1px',
borderRadius: 3,
cursor: 'move',
pointerEvents: 'auto',
userSelect: 'none',
}}
onPointerDown={(e) => onPointerDown(e, 'move')}
>
{/* Signature image */}
<img
src={imageUrl}
alt="signature"
draggable={false}
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', userSelect: 'none', pointerEvents: 'none' }}
/>
{/* Corner + edge handles */}
{(Object.entries(HANDLE_POSITIONS) as [Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }][]).map(([h, pos]) => (
<div
key={h}
onPointerDown={(e) => onPointerDown(e, h)}
style={{
position: 'absolute',
left: pos.left, top: pos.top,
width: 10, height: 10,
background: '#ffffff',
border: '2px solid #2563eb',
borderRadius: 2,
cursor: HANDLE_CURSORS[h],
zIndex: 2,
boxShadow: '0 1px 4px rgba(0,0,0,0.18)',
touchAction: 'none',
}}
/>
))}
{/* Rotation handle — sits above the top center */}
<div
onPointerDown={(e) => onPointerDown(e, 'rotate')}
title="Rotate"
style={{
position: 'absolute',
left: 'calc(50% - 9px)',
top: -34,
width: 18, height: 18,
background: '#2563eb',
borderRadius: '50%',
cursor: 'grab',
zIndex: 3,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(37,99,235,0.4)',
touchAction: 'none',
}}
>
{/* Rotation stem */}
<div style={{
position: 'absolute', left: '50%', top: '100%',
width: 1.5, height: 14,
background: '#2563eb',
transform: 'translateX(-50%)',
}} />
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'relative', zIndex: 1 }}>
<path d="M21.5 2v6h-6"/><path d="M21.34 15.57a10 10 0 1 1-.57-8.38"/>
</svg>
</div>
{/* Size badge */}
<div style={{
position: 'absolute',
bottom: -22, left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(15,23,42,0.75)',
color: '#fff',
fontSize: 10,
fontWeight: 600,
padding: '2px 7px',
borderRadius: 99,
whiteSpace: 'nowrap',
pointerEvents: 'none',
fontFamily: 'monospace',
}}>
{Math.round(width)} × {Math.round(height)}
</div>
</div>
{/* Action bar — centered below the signature box */}
<div
style={{
position: 'absolute',
left: cx - 130,
top: y + height + 48,
zIndex: 62,
display: 'flex',
gap: 8,
alignItems: 'center',
background: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: 12,
padding: '8px 12px',
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
pointerEvents: 'auto',
whiteSpace: 'nowrap',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{/* Keyboard hint */}
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>
Drag · Resize · <kbd style={{ background: '#f1f5f9', padding: '1px 4px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 10 }}>Shift</kbd> lock ratio
</span>
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
{/* Cancel */}
<CustomButton variant="outline" size="sm" onClick={onCancel}>
Cancel <span style={{ opacity: 0.5, fontSize: 10 }}>Esc</span>
</CustomButton>
{/* Confirm */}
<CustomButton variant="primary" size="sm" onClick={() => onCommit(rect)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
Apply Signature
<span style={{ opacity: 0.6, fontSize: 10 }}></span>
</CustomButton>
</div>
</>
);
};
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { gatewayService } from '../lib/gatewayService'; import { gatewayService } from '../lib/gatewayService';
import type { TextObjectResponse } from '../lib/gatewayService'; import type { TextObjectResponse } from '../lib/gatewayService';
import { toast } from '../lib/toast';
import { loadPdfFont } from '../lib/fontFaceLoader'; import { loadPdfFont } from '../lib/fontFaceLoader';
@@ -90,7 +90,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
if (newText === obj.text) return; if (newText === obj.text) return;
try { try {
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText); const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
toast('Text updated successfully', 'success');
if (res.newDocumentId && onDocumentChanged) { if (res.newDocumentId && onDocumentChanged) {
onDocumentChanged(res.newDocumentId); onDocumentChanged(res.newDocumentId);
} else { } else {
@@ -102,7 +102,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
onEditSuccess(); onEditSuccess();
} }
} catch (e: any) { } catch (e: any) {
toast(`Failed to update text: ${e.message}`, 'error'); console.error('Failed to update text:', e.message);
} }
}; };
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.pyo
*.pyd
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
*.log
.DS_Store
.git/
.gitignore
+38
View File
@@ -0,0 +1,38 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PORT=8000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
ninja-build \
pkg-config \
git \
libjpeg-dev \
zlib1g-dev \
libpng-dev \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system app \
&& useradd --system --gid app --create-home --home-dir /home/app app
WORKDIR /home/app
COPY pyproject.toml README.md ./
RUN python -m pip install --upgrade pip \
&& pip install --no-cache-dir -e ".[dev]"
COPY app ./app
COPY tests ./tests
RUN chown -R app:app /home/app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+2
View File
@@ -111,7 +111,9 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
content=a.content, content=a.content,
timestamp=getattr(a, "timestamp", None), timestamp=getattr(a, "timestamp", None),
pageIndex=a.page_index, pageIndex=a.page_index,
thickness=getattr(a, "thickness", None),
paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])], paths=[[{"x": p.x, "y": p.y} for p in stroke] for stroke in getattr(a, "paths", [])],
quadPoints=getattr(a, "quad_points", []),
fieldName=getattr(a, "field_name", None), fieldName=getattr(a, "field_name", None),
fieldValue=getattr(a, "field_value", None), fieldValue=getattr(a, "field_value", None),
fieldType=getattr(a, "field_type", None), fieldType=getattr(a, "field_type", None),
+41 -3
View File
@@ -21,6 +21,19 @@ class TextOverlayData(BaseModel):
fontFamily: str fontFamily: str
color: str color: str
class StampData(BaseModel):
text: str
x: float
y: float
width: float
height: float
textColor: str
backgroundColor: str
borderColor: str
fontSize: float = Field(..., gt=0)
includeDate: bool = False
timestamp: str | None = None
class RedactionData(BaseModel): class RedactionData(BaseModel):
x: float x: float
@@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel):
pageIndex: int = Field(..., ge=0) pageIndex: int = Field(..., ge=0)
data: TextOverlayData data: TextOverlayData
class StampOperation(BaseModel):
id: str
type: Literal["stamp"]
pageIndex: int = Field(..., ge=0)
data: StampData
class RedactionOperation(BaseModel): class RedactionOperation(BaseModel):
id: str id: str
@@ -283,8 +302,23 @@ class SquigglyOperation(BaseModel):
pageIndex: int = Field(..., ge=0) pageIndex: int = Field(..., ge=0)
data: DecorationData data: DecorationData
class SignatureData(BaseModel):
x: float
y: float
width: float
height: float
image_data: str | None = None
author: str = "Signer"
class SignatureOperation(BaseModel):
id: str
type: Literal["signature"]
pageIndex: int = Field(..., ge=0)
data: SignatureData
EditOperation = Annotated[ EditOperation = Annotated[
TextOverlayOperation TextOverlayOperation
| StampOperation
| RedactionOperation | RedactionOperation
| ImageOverlayOperation | ImageOverlayOperation
| HighlightOperation | HighlightOperation
@@ -301,7 +335,9 @@ EditOperation = Annotated[
| ReflowParagraphOperation | ReflowParagraphOperation
| UnderlineOperation | UnderlineOperation
| StrikeoutOperation | StrikeoutOperation
| SquigglyOperation, | SquigglyOperation
| SignatureOperation
,
Field(discriminator="type"), Field(discriminator="type"),
] ]
@@ -313,8 +349,8 @@ class EditsRequest(BaseModel):
_OP_PERMISSION = { _OP_PERMISSION = {
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate", "highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate", "squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate", "free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate",
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate", "image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify", "replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
"update_field": "canFillForms", "update_field": "canFillForms",
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble", "page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
@@ -361,6 +397,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
if "," in img_data_str: if "," in img_data_str:
img_data_str = img_data_str.split(",", 1)[1] img_data_str = img_data_str.split(",", 1)[1]
img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4)
raw_bytes = base64.b64decode(img_data_str) raw_bytes = base64.b64decode(img_data_str)
img = Image.open(io.BytesIO(raw_bytes)) img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA") img_rgba = img.convert("RGBA")
@@ -371,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
bgra_bytes = img_bgra.tobytes() bgra_bytes = img_bgra.tobytes()
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_") fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
temp_path = temp_path.replace("\\", "/")
created_temp_files.append(temp_path) created_temp_files.append(temp_path)
try: try:
with os.fdopen(fd, "wb") as tmp: with os.fdopen(fd, "wb") as tmp:
+2
View File
@@ -13,7 +13,9 @@ class AnnotationResponse(BaseModel):
content: str content: str
timestamp: str | None = None timestamp: str | None = None
pageIndex: int pageIndex: int
thickness: float | None = None
paths: list[list[dict[str, float]]] = [] paths: list[list[dict[str, float]]] = []
quadPoints: list[list[dict[str, float]]] = []
fieldName: str | None = None fieldName: str | None = None
fieldValue: str | None = None fieldValue: str | None = None