Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66f7cd345c | ||
|
|
3775359ab2 | ||
|
|
27538a9489 | ||
|
|
b15aed60d1 | ||
|
|
ad51b4b270 | ||
|
|
f9ec4e1680 | ||
|
|
f8af0aa611 | ||
|
|
82a07d2e6e | ||
|
|
62ad42b116 | ||
|
|
46ec78e9b4 | ||
|
|
99f87eb7cf | ||
|
|
a383bd8704 | ||
|
|
90027823e4 | ||
|
|
5b034c97b8 | ||
|
|
bc3ed20a8b | ||
|
|
abcd407c35 | ||
|
|
afa8c0a702 |
@@ -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
|
||||
@@ -341,12 +341,27 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
|
||||
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
|
||||
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex)
|
||||
.def_readonly("thickness", &pdfengine::PdfPage::AnnotationInfo::thickness)
|
||||
.def_readonly("paths", &pdfengine::PdfPage::AnnotationInfo::paths)
|
||||
.def_readonly("field_name", &pdfengine::PdfPage::AnnotationInfo::fieldName)
|
||||
.def_readonly("field_value", &pdfengine::PdfPage::AnnotationInfo::fieldValue)
|
||||
.def_readonly("field_type", &pdfengine::PdfPage::AnnotationInfo::fieldType)
|
||||
.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")
|
||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||
|
||||
@@ -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 <vector>
|
||||
#include <cstdint>
|
||||
#include <array>
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
@@ -197,7 +198,9 @@ public:
|
||||
std::string content;
|
||||
std::string timestamp;
|
||||
int pageIndex = 0;
|
||||
double thickness = 0.0;
|
||||
std::vector<std::vector<Point2D>> paths;
|
||||
std::vector<std::array<Point2D, 4>> quadPoints;
|
||||
|
||||
std::string fieldName;
|
||||
std::string fieldValue;
|
||||
|
||||
@@ -164,6 +164,7 @@ private:
|
||||
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_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_redaction(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
|
||||
|
||||
@@ -42,7 +42,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
}
|
||||
|
||||
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",
|
||||
"image_overlay", "highlight", "free_text", "comment", "freehand"};
|
||||
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);
|
||||
} else if (type == "text_overlay" || type == "add_text") {
|
||||
r = applyOp_textOverlay(op, pageIndex);
|
||||
} else if (type == "stamp") {
|
||||
r = applyOp_stamp(op, pageIndex);
|
||||
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
|
||||
r = applyOp_decoration(op, pageIndex);
|
||||
} else if (type == "redaction") {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
|
||||
#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) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::string type = op.value("type", "");
|
||||
@@ -129,6 +227,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohma
|
||||
int subtype = FPDF_ANNOT_UNDERLINE;
|
||||
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
|
||||
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
|
||||
else if (type == "highlight") subtype = FPDF_ANNOT_HIGHLIGHT;
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
|
||||
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));
|
||||
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;
|
||||
bool anyPoints = false;
|
||||
|
||||
@@ -549,6 +652,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const
|
||||
if (data.contains("thickness")) {
|
||||
float thickness = static_cast<float>(data["thickness"].get<double>());
|
||||
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")) {
|
||||
|
||||
@@ -133,22 +133,19 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageRotation(const nloh
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageDeletion(const nlohmann::json& op, int pageIndex) {
|
||||
if (pageCount() <= 1) {
|
||||
spdlog::error("Cannot delete the only page in the document");
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
FPDFPage_Delete(doc_, pageIndex);
|
||||
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
|
||||
std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohmann::json& op, int pageIndex) {
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("page_reorder operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
@@ -170,10 +167,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_pageReorder(const nlohm
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
return {};
|
||||
#else
|
||||
(void)op; (void)pageIndex;
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
}
|
||||
@@ -600,6 +600,21 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
|
||||
}
|
||||
|
||||
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();
|
||||
unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot);
|
||||
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));
|
||||
}
|
||||
} 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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.git/
|
||||
.gitignore
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
@@ -5,8 +5,9 @@ import { Toolbar } from './components/Toolbar';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import type { InspectorTab } from './components/InspectorPanel';
|
||||
import { SignatureModal } from './components/SignatureModal';
|
||||
import { RedactPagesModal } from './components/RedactPagesModal';
|
||||
import { AboutModal } from './components/AboutModal';
|
||||
import { ToastViewport } from './components/ui';
|
||||
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
import { PDFViewer } from './viewer/PDFViewer';
|
||||
@@ -18,9 +19,9 @@ import { PasswordModal } from './components/PasswordModal';
|
||||
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
|
||||
import { viewportRectToPdf } from './lib/coordinateMapping';
|
||||
import type { Rect } from './lib/coordinateMapping';
|
||||
import { toast } from './lib/toast';
|
||||
|
||||
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)}`;
|
||||
|
||||
@@ -38,8 +39,7 @@ function App() {
|
||||
|
||||
const permissions = activeDoc?.permissions ?? null;
|
||||
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
|
||||
const denyToast = (label: string) =>
|
||||
toast(`${label} is not permitted by this document's restrictions`, 'error');
|
||||
const denyToast = (_label: string) => {};
|
||||
const disabledTools = new Set<ToolId>();
|
||||
if (!can('canAnnotate'))
|
||||
(['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 [signatureModalOpen, setSignatureModalOpen] = useState(false);
|
||||
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
|
||||
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
||||
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 [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -83,15 +85,21 @@ function App() {
|
||||
if (!canUndo) return;
|
||||
preservePageRef.current = true;
|
||||
setHist((h) => ({ ...h, index: Math.max(0, h.index - 1) }));
|
||||
toast('Undo', 'info', 1200);
|
||||
|
||||
};
|
||||
const redo = () => {
|
||||
if (!canRedo) return;
|
||||
preservePageRef.current = true;
|
||||
setHist((h) => ({ ...h, index: Math.min(h.stack.length - 1, h.index + 1) }));
|
||||
toast('Redo', 'info', 1200);
|
||||
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hist.stack.length > 0) {
|
||||
localStorage.setItem('pdf_hist', JSON.stringify(hist));
|
||||
}
|
||||
}, [hist]);
|
||||
|
||||
useEffect(() => {
|
||||
gatewayService.getHealth()
|
||||
.then((h) => { setBackendHealthy(true); setEngineReady(!!h.engine_available); })
|
||||
@@ -104,7 +112,23 @@ function App() {
|
||||
setIsLoading(true);
|
||||
const docs = await gatewayService.listDocuments();
|
||||
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) {
|
||||
console.error('Failed to load documents', e);
|
||||
} finally {
|
||||
@@ -140,6 +164,7 @@ function App() {
|
||||
content: a.content,
|
||||
timestamp: a.timestamp,
|
||||
pageIndex: a.pageIndex,
|
||||
quadPoints: a.quad_points || a.quadPoints,
|
||||
paths: Array.isArray(a.paths) && a.paths.length > 0 ? a.paths : undefined,
|
||||
fieldName: a.fieldName,
|
||||
fieldValue: a.fieldValue,
|
||||
@@ -213,18 +238,16 @@ function App() {
|
||||
gatewayService.listDocuments().then(setDocuments).catch(() => {});
|
||||
};
|
||||
|
||||
const applyOps = async (ops: EditOperation[], successMsg?: string) => {
|
||||
const applyOps = async (ops: EditOperation[], _successMsg?: string) => {
|
||||
if (!selectedDocId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await gatewayService.applyEdits(selectedDocId, ops);
|
||||
if (result.success) {
|
||||
adoptNewDocument(result.newDocumentId);
|
||||
if (successMsg) toast(successMsg, 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Edit failed', e);
|
||||
toast('Edit failed — check the gateway connection', 'error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -267,22 +290,33 @@ function App() {
|
||||
|
||||
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
|
||||
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;
|
||||
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'),
|
||||
type,
|
||||
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,
|
||||
author: 'Current User',
|
||||
} as Annotation));
|
||||
setAnnotations(prev => [...prev, ...newAnnos]);
|
||||
} as Annotation;
|
||||
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) => {
|
||||
@@ -320,24 +354,80 @@ function App() {
|
||||
if (!activeStamp) return;
|
||||
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
|
||||
const fontSize = 22;
|
||||
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
|
||||
const height = fontSize * 1.5;
|
||||
const padding = 12; // visual padding
|
||||
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));
|
||||
applyOps([{
|
||||
id: rid('stamp'), type: 'text_overlay', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
|
||||
id: rid('stamp'), type: 'stamp', pageIndex,
|
||||
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`);
|
||||
};
|
||||
|
||||
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 (!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([{
|
||||
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');
|
||||
setActiveTool('select');
|
||||
};
|
||||
@@ -351,7 +441,7 @@ function App() {
|
||||
const handleDeletePage = (pageIndex: number) => {
|
||||
if (!activeDoc) 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({
|
||||
title: 'Delete page?',
|
||||
message: `Page ${pageIndex + 1} will be removed from this document.`,
|
||||
@@ -370,19 +460,31 @@ function App() {
|
||||
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 (!can('canModify')) { denyToast('Redaction'); return; }
|
||||
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
|
||||
if (pendingRedactions.length === 0) return;
|
||||
|
||||
setConfirmState({
|
||||
title: 'Redact area?',
|
||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone after export.',
|
||||
confirmLabel: 'Redact', danger: true,
|
||||
title: 'Apply Redactions?',
|
||||
message: 'All text, images, and vectors underneath will be permanently removed from the file. This cannot be undone.',
|
||||
confirmLabel: 'Apply', danger: true,
|
||||
onConfirm: () => {
|
||||
applyOps([{
|
||||
id: rid('redact'), type: 'redaction', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, fillColor: '#ffffff' },
|
||||
}], 'Area redacted');
|
||||
const ops = pendingRedactions.map(mark => {
|
||||
const pdf = viewportRectToPdf(mark.bounds, zoom, pageHeightPts(mark.pageIndex));
|
||||
return {
|
||||
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');
|
||||
},
|
||||
});
|
||||
@@ -394,14 +496,13 @@ function App() {
|
||||
const newDoc = await gatewayService.uploadDocument(file, password);
|
||||
setDocuments((prev) => [newDoc, ...prev]);
|
||||
openDocument(newDoc.id);
|
||||
toast(`Opened ${newDoc.filename}`, 'success');
|
||||
|
||||
setPasswordPrompt(null);
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordError) {
|
||||
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
|
||||
} else {
|
||||
console.error('Upload failed', e);
|
||||
toast('Upload failed', 'error');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -413,10 +514,8 @@ function App() {
|
||||
if (!can('canCopy')) { denyToast('Exporting'); return; }
|
||||
try {
|
||||
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
|
||||
toast('Exported', 'success');
|
||||
} catch (e) {
|
||||
console.error('Export failed', e);
|
||||
toast('Export failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -424,7 +523,7 @@ function App() {
|
||||
if (!activeDoc) return;
|
||||
if (!can('canPrint')) { denyToast('Printing'); return; }
|
||||
try {
|
||||
toast('Preparing print...', 'info');
|
||||
|
||||
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
|
||||
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -442,7 +541,6 @@ function App() {
|
||||
document.body.appendChild(iframe);
|
||||
} catch (e) {
|
||||
console.error('Print failed', e);
|
||||
toast('Print failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -541,8 +639,14 @@ function App() {
|
||||
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
|
||||
onOpenSignature={() => setSignatureModalOpen(true)}
|
||||
hasSignature={!!pendingSignature}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
|
||||
activeStamp={activeStamp}
|
||||
onSelectStamp={setActiveStamp}
|
||||
redactionMode={redactionMode}
|
||||
onRedactionModeChange={setRedactionMode}
|
||||
pendingRedactionCount={pendingRedactions.length}
|
||||
onApplyRedactions={handleApplyRedactions}
|
||||
onClearRedactions={() => setPendingRedactions([])}
|
||||
onRedactPages={() => setRedactPagesModalOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
@@ -562,8 +666,11 @@ function App() {
|
||||
pagesInfo={activeDoc.pages}
|
||||
activeTool={activeTool}
|
||||
toolSettings={toolSettings}
|
||||
redactionMode={redactionMode}
|
||||
hasSignature={!!pendingSignature}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
signatureImageUrl={pendingSignature?.url}
|
||||
signatureAspect={pendingSignature?.aspect}
|
||||
activeStamp={activeStamp}
|
||||
annotations={annotations}
|
||||
canCopy={can('canCopy')}
|
||||
onFieldChange={(id, value, i) => {
|
||||
@@ -585,7 +692,9 @@ function App() {
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onRedactArea={handleRedactArea}
|
||||
onMarkRedaction={handleMarkRedaction}
|
||||
pendingRedactions={pendingRedactions}
|
||||
onRemoveRedaction={(id) => setPendingRedactions(p => p.filter(x => x.id !== id))}
|
||||
onPlaceText={handlePlaceText}
|
||||
onEditText={handleEditText}
|
||||
onReflowParagraph={handleReflowParagraph}
|
||||
@@ -656,10 +765,15 @@ function App() {
|
||||
setPendingSignature({ url, aspect });
|
||||
setSignatureModalOpen(false);
|
||||
setActiveTool('signature');
|
||||
toast('Signature ready — click on the page to place it', 'info');
|
||||
}}
|
||||
/>
|
||||
|
||||
<RedactPagesModal
|
||||
open={redactPagesModalOpen}
|
||||
onClose={() => setRedactPagesModalOpen(false)}
|
||||
onConfirm={handleRedactPages}
|
||||
/>
|
||||
|
||||
<AboutModal
|
||||
open={aboutModalOpen}
|
||||
onClose={() => setAboutModalOpen(false)}
|
||||
@@ -672,7 +786,6 @@ function App() {
|
||||
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
|
||||
onClose={() => setPasswordPrompt(null)}
|
||||
/>
|
||||
<ToastViewport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
||||
</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()}>
|
||||
<input
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +1,276 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Modal } from './ui';
|
||||
import { toast } from '../lib/toast';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
|
||||
/* ─── Types ─────────────────────────────────────────────── */
|
||||
interface SignatureModalProps {
|
||||
open: boolean;
|
||||
onClose: () => 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 }) => {
|
||||
useGoogleFonts();
|
||||
|
||||
/* tabs */
|
||||
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 strokes = useRef<Pt[][]>([]);
|
||||
const currentStroke = useRef<Pt[]>([]);
|
||||
const drawing = 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; };
|
||||
const handleClose = () => { resetLocal(); onClose(); };
|
||||
/* type */
|
||||
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;
|
||||
if (!c) return;
|
||||
const ctx = c.getContext('2d')!;
|
||||
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;
|
||||
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 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) => {
|
||||
drawing.current = true;
|
||||
const ctx = canvasRef.current!.getContext('2d')!;
|
||||
const { x, y } = pos(e);
|
||||
ctx.beginPath(); ctx.moveTo(x, y);
|
||||
const pt = canvasPos(e);
|
||||
currentStroke.current = [pt];
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onMove = (e: React.PointerEvent) => {
|
||||
if (!drawing.current) return;
|
||||
const pt = canvasPos(e);
|
||||
currentStroke.current.push(pt);
|
||||
const ctx = canvasRef.current!.getContext('2d')!;
|
||||
const { x, y } = pos(e);
|
||||
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
|
||||
const stroke = currentStroke.current;
|
||||
strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
|
||||
hasInk.current = true;
|
||||
};
|
||||
const onUp = () => { drawing.current = false; };
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (mode === 'draw') {
|
||||
if (!hasInk.current) { toast('Draw a signature first', 'error'); return; }
|
||||
const c = canvasRef.current!;
|
||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
||||
} else if (mode === 'type') {
|
||||
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 600; c.height = 200;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.fillStyle = '#1b2430';
|
||||
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
|
||||
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
|
||||
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
|
||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
||||
} else if (mode === 'upload') {
|
||||
if (!uploaded) { toast('Upload an image first', 'error'); return; }
|
||||
onConfirm(uploaded.url, uploaded.aspect);
|
||||
const onUp = () => {
|
||||
if (drawing.current && currentStroke.current.length > 0) {
|
||||
strokes.current.push([...currentStroke.current]);
|
||||
currentStroke.current = [];
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const saveToStorage = (dataUrl: string, aspect: number) => {
|
||||
const prev = loadSaved();
|
||||
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();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
@@ -85,85 +279,544 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
img.src = url;
|
||||
};
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) processFile(f);
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Create signature"
|
||||
width={580}
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
||||
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
|
||||
</>
|
||||
}
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(10,15,25,0.55)', backdropFilter: 'blur(4px)' }}
|
||||
onMouseDown={handleClose}
|
||||
>
|
||||
<div className="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5">
|
||||
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
|
||||
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-[6px] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[#ffffff] text-[#2563eb] shadow-sm' : 'text-[#5b6573] hover:text-[#18212e]'}`}>
|
||||
{m}
|
||||
</CustomButton>
|
||||
))}
|
||||
<div
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
style={{
|
||||
width: 660,
|
||||
maxHeight: '92vh',
|
||||
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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React from 'react';
|
||||
import type { ToolId } from '../lib/tools';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
import { Popover } from './ui';
|
||||
import {
|
||||
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
|
||||
@@ -9,22 +9,23 @@ import {
|
||||
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
||||
} 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')[] = [
|
||||
{ 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 /> },
|
||||
'divider',
|
||||
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
|
||||
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
|
||||
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
||||
{ 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: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{
|
||||
id: 'edit_text',
|
||||
label: 'Edit text',
|
||||
shortLabel: 'Edit',
|
||||
shortcut: 'E',
|
||||
icon: (
|
||||
<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',
|
||||
label: 'Raw Text (beta)',
|
||||
shortLabel: 'Raw',
|
||||
shortcut: 'Q',
|
||||
icon: (
|
||||
<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-disabled={disabled}
|
||||
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
|
||||
? 'cursor-not-allowed text-[#c5cad1]'
|
||||
: active
|
||||
@@ -75,15 +77,15 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
: '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' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
|
||||
{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: 20 }) : t.icon}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
|
||||
const pickTool = (id: ToolId) => {
|
||||
if (disabledTools?.has(id)) {
|
||||
toast("This tool is not permitted by this document's restrictions", 'error');
|
||||
return;
|
||||
}
|
||||
onToolChange(id);
|
||||
@@ -91,16 +93,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
};
|
||||
|
||||
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) =>
|
||||
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)} />,
|
||||
)}
|
||||
|
||||
<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} />
|
||||
</CustomButton>
|
||||
|
||||
@@ -108,7 +110,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
align="left"
|
||||
width={210}
|
||||
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} />
|
||||
</CustomButton>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
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 { ColorSwatches, Slider } from './ui';
|
||||
import {
|
||||
@@ -15,8 +15,14 @@ interface ToolbarProps {
|
||||
onSettingsChange: (patch: Partial<ToolSettings>) => void;
|
||||
onOpenSignature: () => void;
|
||||
hasSignature: boolean;
|
||||
activeStamp: string | null;
|
||||
onSelectStamp: (label: string, color: string) => void;
|
||||
activeStamp?: StampPreset | null;
|
||||
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 }> = {
|
||||
@@ -54,6 +60,8 @@ const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
|
||||
|
||||
export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
|
||||
redactionMode = 'area', onRedactionModeChange,
|
||||
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
|
||||
}) => {
|
||||
const meta = TOOL_META[activeTool];
|
||||
const isRedact = activeTool === 'redact';
|
||||
@@ -150,9 +158,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{STAMP_PRESETS.map((s) => (
|
||||
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
|
||||
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
|
||||
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
|
||||
<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?.label === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
|
||||
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
|
||||
{s.label}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { subscribeToasts, dismissToast } from '../lib/toast';
|
||||
import type { ToastItem } from '../lib/toast';
|
||||
import { XIcon, CheckIcon, InfoIcon } from './icons';
|
||||
import { XIcon } from './icons';
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
␍
|
||||
@@ -24,6 +24,13 @@
|
||||
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: 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 */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
@@ -136,6 +136,20 @@ export interface TextOverlayData {
|
||||
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 {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -207,6 +221,7 @@ export interface PageReorderData {
|
||||
|
||||
export type EditOperationDataMap = {
|
||||
text_overlay: TextOverlayData;
|
||||
stamp: StampData;
|
||||
redaction: RedactionData;
|
||||
image_overlay: ImageOverlayData;
|
||||
highlight: HighlightData;
|
||||
|
||||
@@ -194,8 +194,10 @@ export class TextSelectionModel {
|
||||
}
|
||||
const rects: SelRect[] = [];
|
||||
for (const arr of byLine.values()) {
|
||||
const x = Math.min(...arr.map((g) => g.x));
|
||||
const right = Math.max(...arr.map((g) => g.right));
|
||||
const nonSpace = arr.filter(g => !/^\s+$/.test(g.text));
|
||||
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];
|
||||
rects.push({ x, y: band.top, w: right - x, h: band.bottom - band.top });
|
||||
}
|
||||
|
||||
@@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
q: 'stream_edit',
|
||||
};
|
||||
|
||||
export const STAMP_PRESETS = [
|
||||
{ label: 'APPROVED', color: '#16a34a' },
|
||||
{ label: 'DRAFT', color: '#6b7280' },
|
||||
{ label: 'CONFIDENTIAL', color: '#dc2626' },
|
||||
{ label: 'REVIEWED', color: '#2563eb' },
|
||||
{ label: 'FINAL', color: '#7c3aed' },
|
||||
{ label: 'VOID', color: '#dc2626' },
|
||||
export interface StampPreset {
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Annotation {
|
||||
content?: string;
|
||||
timestamp?: string;
|
||||
paths?: { x: number; y: number }[][];
|
||||
quadPoints?: { x: number; y: number }[][];
|
||||
pageIndex?: number;
|
||||
|
||||
fieldName?: string;
|
||||
@@ -114,19 +115,28 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
onPointerDown={(e) => handlePointerDown(e, anno)}
|
||||
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||
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={{
|
||||
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
||||
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
|
||||
width: `${scaledBbox.width}px`,
|
||||
height: `${scaledBbox.height}px`,
|
||||
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
|
||||
opacity: anno.type === 'highlight' ? (anno.opacity ?? 0.4) : undefined,
|
||||
mixBlendMode: anno.type === 'highlight' ? 'multiply' : undefined,
|
||||
backgroundColor: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.color || '#ffeb3b') : undefined,
|
||||
opacity: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.opacity ?? 0.4) : undefined,
|
||||
mixBlendMode: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? 'multiply' : undefined,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
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' && (
|
||||
<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">
|
||||
@@ -134,9 +144,27 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
</svg>
|
||||
</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 === 'underline' && <div className="w-full h-[1.5px] opacity-[0.85] absolute bottom-0" style={{ backgroundColor: anno.color || '#2563eb' }} />}
|
||||
{anno.type === 'squiggly' && (
|
||||
{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 === 'strikeout' && 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 + ((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}}>
|
||||
<defs>
|
||||
<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})`} />
|
||||
</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' && (
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { CustomButton } from '../components/custom/CustomButton';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import type { Annotation } from './AnnotationLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import type { StampPreset } from '../lib/tools';
|
||||
|
||||
interface OverlayLayerProps {
|
||||
pageIndex: number;
|
||||
@@ -14,11 +15,12 @@ interface OverlayLayerProps {
|
||||
textColor: string;
|
||||
fontSize: number;
|
||||
hasSignature: boolean;
|
||||
activeStamp: string | null;
|
||||
activeStamp: StampPreset | null;
|
||||
onAnnotationAdded?: (anno: Annotation) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
/** Called when user clicks to begin interactive signature placement */
|
||||
onBeginPlacement?: (pageIndex: number, viewportPt: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
const TEXTBOX_WIDTH_PTS = 200;
|
||||
@@ -28,7 +30,7 @@ const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
|
||||
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
pageIndex, width, height, activeTool, zoom,
|
||||
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
|
||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature,
|
||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
|
||||
}) => {
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
|
||||
@@ -88,7 +90,14 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
} else if (activeTool === 'stamp' && activeStamp) {
|
||||
onPlaceStamp?.(pageIndex, coords);
|
||||
} 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>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
|
||||
@@ -11,11 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
|
||||
import type { Rect } from '../lib/coordinateMapping';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
|
||||
import type { ToolSettings } from '../lib/tools';
|
||||
import { toast } from '../lib/toast';
|
||||
import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
|
||||
|
||||
import { RedactionLayer } from './RedactionLayer';
|
||||
import { StreamEditLayer } from './StreamEditLayer';
|
||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
||||
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
|
||||
import type { PlacementRect } from './SignaturePlacementOverlay';
|
||||
import { FloatingTextToolbar } from './FloatingTextToolbar';
|
||||
|
||||
interface PDFViewerProps {
|
||||
documentId: string;
|
||||
@@ -24,10 +27,14 @@ interface PDFViewerProps {
|
||||
pageHeight: number;
|
||||
zoom: number;
|
||||
pagesInfo?: PageInfo[];
|
||||
activeTool: string;
|
||||
activeTool: ToolId;
|
||||
toolSettings: ToolSettings;
|
||||
redactionMode?: 'area' | 'text';
|
||||
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[];
|
||||
searchQuery?: string;
|
||||
searchResults?: SearchResult[];
|
||||
@@ -36,13 +43,16 @@ interface PDFViewerProps {
|
||||
onAnnotationUpdate?: (anno: Annotation) => void;
|
||||
onAnnotationClick?: (anno: Annotation) => 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;
|
||||
onEditText?: (pageIndex: number, run: EditableRun, newText: string, disableJustify?: boolean) => void;
|
||||
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||
onStreamDocumentChanged?: (newDocumentId: string) => 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;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
canCopy?: boolean;
|
||||
@@ -70,7 +80,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
pagesInfo,
|
||||
activeTool,
|
||||
toolSettings,
|
||||
redactionMode = 'area',
|
||||
hasSignature,
|
||||
signatureImageUrl,
|
||||
signatureAspect = 3,
|
||||
activeStamp,
|
||||
annotations,
|
||||
searchQuery,
|
||||
@@ -80,7 +93,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onAnnotationUpdate,
|
||||
onAnnotationClick,
|
||||
onPageVisible,
|
||||
onRedactArea,
|
||||
onMarkRedaction,
|
||||
pendingRedactions = [],
|
||||
onRemoveRedaction,
|
||||
onPlaceText,
|
||||
onEditText,
|
||||
onReflowParagraph,
|
||||
@@ -100,6 +115,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
const prevDocumentIdRef = useRef<string>(documentId);
|
||||
const inFlightRenderRef = 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(() => {
|
||||
setPageTexts({});
|
||||
const prev = prevDocumentIdRef.current;
|
||||
@@ -294,18 +323,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
}, [textToolActive, visiblePages, documentId, pageTexts]);
|
||||
|
||||
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
|
||||
// This is still called on Ctrl+C for select mode
|
||||
if (activeTool === 'select') {
|
||||
if (!canCopy) {
|
||||
toast("Copying is not permitted by this document's restrictions", 'error');
|
||||
return;
|
||||
}
|
||||
if (!canCopy) return;
|
||||
if (text.trim()) {
|
||||
navigator.clipboard?.writeText(text).then(
|
||||
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
|
||||
() => toast('Copy failed — clipboard unavailable', 'error'),
|
||||
);
|
||||
} else {
|
||||
toast('No selectable text in that area', 'info');
|
||||
navigator.clipboard?.writeText(text).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -334,7 +356,90 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
activeTool === 'strikeout' ? toolSettings.strikeoutColor :
|
||||
toolSettings.squigglyColor;
|
||||
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);
|
||||
@@ -452,27 +557,36 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
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
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
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)}
|
||||
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTool === 'redact' && (
|
||||
<RedactionLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
|
||||
{textSelection && textSelection.pageIndex === page.index && (
|
||||
<FloatingTextToolbar
|
||||
selection={textSelection}
|
||||
onAction={(action, color) => handleToolbarAction(action as any, color)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
@@ -488,7 +602,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onAnnotationAdded={onAnnotationAdded}
|
||||
onPlaceText={onPlaceText}
|
||||
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' && (
|
||||
@@ -540,6 +667,29 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
searchResults={searchResults}
|
||||
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]">
|
||||
|
||||
@@ -6,12 +6,18 @@ interface RedactionLayerProps {
|
||||
width: number;
|
||||
height: number;
|
||||
onRedactionSelected: (bounds: Rect) => void;
|
||||
pendingRedactions?: { id: string, bounds: Rect }[];
|
||||
onRemoveRedaction?: (id: string) => void;
|
||||
mode?: 'area' | 'text';
|
||||
}
|
||||
|
||||
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
width,
|
||||
height,
|
||||
onRedactionSelected,
|
||||
pendingRedactions = [],
|
||||
onRemoveRedaction,
|
||||
mode = 'area',
|
||||
}) => {
|
||||
const [dragStart, setDragStart] = useState<Point | null>(null);
|
||||
const [redactBox, setRedactBox] = useState<Rect | null>(null);
|
||||
@@ -62,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
|
||||
left: 0,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
cursor: 'crosshair',
|
||||
cursor: mode === 'area' ? 'crosshair' : 'default',
|
||||
zIndex: 25,
|
||||
pointerEvents: mode === 'area' ? 'auto' : 'none',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
|
||||
glyphs: Glyph[];
|
||||
mode?: 'select' | 'highlight';
|
||||
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';
|
||||
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
glyphs,
|
||||
mode = 'select',
|
||||
onTextSelected,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
||||
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
setBox(null);
|
||||
drag.current = null;
|
||||
boxStart.current = null;
|
||||
}, []);
|
||||
onSelectionChange?.(null);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOther = (e: Event) => {
|
||||
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
||||
const cur = selRef.current;
|
||||
if (!cur || cur.start === cur.end) {
|
||||
setSel(null);
|
||||
onSelectionChange?.(null);
|
||||
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') {
|
||||
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);
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import type { TextObjectResponse } from '../lib/gatewayService';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
|
||||
import { loadPdfFont } from '../lib/fontFaceLoader';
|
||||
|
||||
@@ -90,7 +90,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
||||
if (newText === obj.text) return;
|
||||
try {
|
||||
const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText);
|
||||
toast('Text updated successfully', 'success');
|
||||
|
||||
if (res.newDocumentId && onDocumentChanged) {
|
||||
onDocumentChanged(res.newDocumentId);
|
||||
} else {
|
||||
@@ -102,7 +102,7 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
|
||||
onEditSuccess();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(`Failed to update text: ${e.message}`, 'error');
|
||||
console.error('Failed to update text:', e.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
*.log
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
@@ -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"]
|
||||
@@ -111,7 +111,9 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
content=a.content,
|
||||
timestamp=getattr(a, "timestamp", None),
|
||||
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", [])],
|
||||
quadPoints=getattr(a, "quad_points", []),
|
||||
fieldName=getattr(a, "field_name", None),
|
||||
fieldValue=getattr(a, "field_value", None),
|
||||
fieldType=getattr(a, "field_type", None),
|
||||
|
||||
@@ -21,6 +21,19 @@ class TextOverlayData(BaseModel):
|
||||
fontFamily: 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):
|
||||
x: float
|
||||
@@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel):
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: TextOverlayData
|
||||
|
||||
class StampOperation(BaseModel):
|
||||
id: str
|
||||
type: Literal["stamp"]
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
data: StampData
|
||||
|
||||
|
||||
class RedactionOperation(BaseModel):
|
||||
id: str
|
||||
@@ -283,8 +302,23 @@ class SquigglyOperation(BaseModel):
|
||||
pageIndex: int = Field(..., ge=0)
|
||||
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[
|
||||
TextOverlayOperation
|
||||
| StampOperation
|
||||
| RedactionOperation
|
||||
| ImageOverlayOperation
|
||||
| HighlightOperation
|
||||
@@ -301,7 +335,9 @@ EditOperation = Annotated[
|
||||
| ReflowParagraphOperation
|
||||
| UnderlineOperation
|
||||
| StrikeoutOperation
|
||||
| SquigglyOperation,
|
||||
| SquigglyOperation
|
||||
| SignatureOperation
|
||||
,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -313,8 +349,8 @@ class EditsRequest(BaseModel):
|
||||
_OP_PERMISSION = {
|
||||
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
|
||||
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
|
||||
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
|
||||
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
||||
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate",
|
||||
"image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
|
||||
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
|
||||
"update_field": "canFillForms",
|
||||
"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:
|
||||
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)
|
||||
img = Image.open(io.BytesIO(raw_bytes))
|
||||
img_rgba = img.convert("RGBA")
|
||||
@@ -371,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
|
||||
bgra_bytes = img_bgra.tobytes()
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
|
||||
temp_path = temp_path.replace("\\", "/")
|
||||
created_temp_files.append(temp_path)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
|
||||
@@ -13,7 +13,9 @@ class AnnotationResponse(BaseModel):
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
pageIndex: int
|
||||
thickness: float | None = None
|
||||
paths: list[list[dict[str, float]]] = []
|
||||
quadPoints: list[list[dict[str, float]]] = []
|
||||
|
||||
fieldName: str | None = None
|
||||
fieldValue: str | None = None
|
||||
|
||||
Reference in New Issue
Block a user