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