phase2
This commit is contained in:
@@ -160,6 +160,19 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def_readonly("height", &pdfengine::PageModel::height)
|
||||
.def_readonly("page_index", &pdfengine::PageModel::pageIndex);
|
||||
|
||||
py::class_<pdfengine::PdfPage::AnnotationInfo>(m, "AnnotationInfo")
|
||||
.def_readonly("id", &pdfengine::PdfPage::AnnotationInfo::id)
|
||||
.def_readonly("type", &pdfengine::PdfPage::AnnotationInfo::type)
|
||||
.def_readonly("x", &pdfengine::PdfPage::AnnotationInfo::x)
|
||||
.def_readonly("y", &pdfengine::PdfPage::AnnotationInfo::y)
|
||||
.def_readonly("width", &pdfengine::PdfPage::AnnotationInfo::width)
|
||||
.def_readonly("height", &pdfengine::PdfPage::AnnotationInfo::height)
|
||||
.def_readonly("color", &pdfengine::PdfPage::AnnotationInfo::color)
|
||||
.def_readonly("author", &pdfengine::PdfPage::AnnotationInfo::author)
|
||||
.def_readonly("content", &pdfengine::PdfPage::AnnotationInfo::content)
|
||||
.def_readonly("timestamp", &pdfengine::PdfPage::AnnotationInfo::timestamp)
|
||||
.def_readonly("page_index", &pdfengine::PdfPage::AnnotationInfo::pageIndex);
|
||||
|
||||
py::class_<pdfengine::PdfPage, std::shared_ptr<pdfengine::PdfPage>>(m, "PdfPage")
|
||||
.def_property_readonly("width", &pdfengine::PdfPage::width)
|
||||
.def_property_readonly("height", &pdfengine::PdfPage::height)
|
||||
@@ -175,6 +188,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def("extract_annotations_text", [](const pdfengine::PdfPage& self) {
|
||||
return get_or_throw(self.extractAnnotationsText());
|
||||
})
|
||||
.def("extract_annotations", [](const pdfengine::PdfPage& self) {
|
||||
return get_or_throw(self.extractAnnotations());
|
||||
})
|
||||
.def("extract_text_with_bounds", [](const pdfengine::PdfPage& self) {
|
||||
auto res = get_or_throw(self.extractTextWithBounds());
|
||||
py::list py_list;
|
||||
|
||||
@@ -140,6 +140,19 @@ public:
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<FontInfo>, EngineError> getFonts() const = 0;
|
||||
[[nodiscard]] virtual std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const = 0;
|
||||
|
||||
struct AnnotationInfo {
|
||||
std::string id;
|
||||
std::string type; // "highlight", "comment", "ink", "strikeout", "signature"
|
||||
double x = 0.0, y = 0.0, width = 0.0, height = 0.0;
|
||||
std::string color;
|
||||
std::string author;
|
||||
std::string content;
|
||||
std::string timestamp;
|
||||
int pageIndex = 0;
|
||||
std::vector<std::vector<Point2D>> paths;
|
||||
};
|
||||
[[nodiscard]] virtual std::expected<std::vector<AnnotationInfo>, EngineError> extractAnnotations() const = 0;
|
||||
|
||||
[[nodiscard]] virtual std::expected<double, EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const = 0;
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ std::string normalizeFamilyName(const std::string& fontName) {
|
||||
// 3. Clean up common postfixes
|
||||
auto cleanName = name;
|
||||
auto lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
|
||||
|
||||
std::vector<std::string> suffixes = {"bold", "italic", "oblique", "regular", "medium", "light", "heavy", "black", "condensed", "mt", "ps"};
|
||||
for (const auto& s : suffixes) {
|
||||
@@ -254,7 +254,7 @@ std::string normalizeFamilyName(const std::string& fontName) {
|
||||
|
||||
void deduceFontMetadata(pdfengine::FontInfo& f) {
|
||||
auto lowerName = f.fontName;
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
|
||||
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
|
||||
|
||||
// 1. Subset Tag & Family Normalization
|
||||
if (f.fontName.size() > 7 && f.fontName[6] == '+') {
|
||||
@@ -482,7 +482,7 @@ buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) {
|
||||
if (!font) continue;
|
||||
|
||||
// Retrieve font name — first call returns required buffer size.
|
||||
unsigned long nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
if (nameLen == 0) continue;
|
||||
std::vector<char> nameBuf(nameLen);
|
||||
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) == 0) continue;
|
||||
@@ -1107,6 +1107,138 @@ std::expected<std::vector<std::string>, EngineError> PdfiumPage::extractAnnotati
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
#else
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::extractAnnotations() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
std::vector<PdfPage::AnnotationInfo> result;
|
||||
int count = FPDFPage_GetAnnotCount(page_);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page_, i);
|
||||
if (!annot) continue;
|
||||
|
||||
PdfPage::AnnotationInfo info;
|
||||
info.pageIndex = pageIndex_;
|
||||
|
||||
// ID
|
||||
unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "NM", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.id = text;
|
||||
}
|
||||
if (info.id.empty()) {
|
||||
info.id = "anno_" + std::to_string(pageIndex_) + "_" + std::to_string(i);
|
||||
}
|
||||
|
||||
// Subtype
|
||||
int subtype = FPDFAnnot_GetSubtype(annot);
|
||||
if (subtype == FPDF_ANNOT_HIGHLIGHT) {
|
||||
info.type = "highlight";
|
||||
} else if (subtype == FPDF_ANNOT_FREETEXT || subtype == FPDF_ANNOT_TEXT) {
|
||||
info.type = "comment";
|
||||
} else if (subtype == FPDF_ANNOT_INK) {
|
||||
info.type = "ink";
|
||||
} else if (subtype == FPDF_ANNOT_STRIKEOUT) {
|
||||
info.type = "strikeout";
|
||||
} else if (subtype == FPDF_ANNOT_WIDGET) {
|
||||
info.type = "signature";
|
||||
} else {
|
||||
info.type = "unknown";
|
||||
}
|
||||
|
||||
if (info.type != "unknown") {
|
||||
// Rect
|
||||
FS_RECTF rect;
|
||||
if (FPDFAnnot_GetRect(annot, &rect)) {
|
||||
int dw = static_cast<int>(std::round(width()));
|
||||
int dh = static_cast<int>(std::round(height()));
|
||||
DevicePoint topLeft = pageToDevice({rect.left, rect.top}, dw, dh, 0);
|
||||
DevicePoint bottomRight = pageToDevice({rect.right, rect.bottom}, dw, dh, 0);
|
||||
|
||||
info.x = (std::min)(topLeft.x, bottomRight.x);
|
||||
info.y = (std::min)(topLeft.y, bottomRight.y);
|
||||
info.width = std::abs(bottomRight.x - topLeft.x);
|
||||
info.height = std::abs(bottomRight.y - topLeft.y);
|
||||
}
|
||||
|
||||
// Author
|
||||
len = FPDFAnnot_GetStringValue(annot, "T", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "T", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.author = text;
|
||||
}
|
||||
|
||||
// Content
|
||||
len = FPDFAnnot_GetStringValue(annot, "Contents", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.content = text;
|
||||
}
|
||||
|
||||
// Timestamp (Modification Date or Creation Date)
|
||||
len = FPDFAnnot_GetStringValue(annot, "M", nullptr, 0);
|
||||
if (len <= 2) {
|
||||
len = FPDFAnnot_GetStringValue(annot, "CreationDate", nullptr, 0);
|
||||
if (len > 2) {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "CreationDate", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.timestamp = text;
|
||||
}
|
||||
} else {
|
||||
std::vector<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "M", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||
while (!text.empty() && text.back() == '\0') text.pop_back();
|
||||
info.timestamp = text;
|
||||
}
|
||||
|
||||
// Color
|
||||
unsigned int R = 0, G = 0, B = 0, A = 0;
|
||||
if (FPDFAnnot_GetColor(annot, FPDFANNOT_COLORTYPE_Color, &R, &G, &B, &A)) {
|
||||
char hex[10];
|
||||
snprintf(hex, sizeof(hex), "#%02x%02x%02x", R, G, B);
|
||||
info.color = hex;
|
||||
}
|
||||
|
||||
// Ink paths
|
||||
if (subtype == FPDF_ANNOT_INK) {
|
||||
int objCount = FPDFAnnot_GetObjectCount(annot);
|
||||
for (int j = 0; j < objCount; ++j) {
|
||||
FPDF_PAGEOBJECT obj = FPDFAnnot_GetObject(annot, j);
|
||||
if (obj && FPDFPageObj_GetType(obj) == FPDF_PAGEOBJ_PATH) {
|
||||
[[maybe_unused]] int pathCount = FPDFPath_CountSegments(obj); // PDFium has FPDFPath_CountSegments ? Wait, let me check pdfium headers. Actually FPDFPath_GetPathSegmentCount doesn't exist, it is FPDFPath_CountSegments probably, or FPDFPath_CountSegments / FPDFPath_GetPathSegment.
|
||||
// Wait, looking at PDFium fpdf_edit.h: `FPDFPath_CountSegments` doesn't exist, it's `FPDFPath_CountSegments`?
|
||||
// Let me check if I can use FPDFPath_CountSegments
|
||||
// It is usually int FPDFPath_CountSegments(FPDF_PAGEOBJECT path);
|
||||
// FPDF_PATHSEGMENT FPDFPath_GetPathSegment(FPDF_PAGEOBJECT path, int index);
|
||||
// FPDFPathSegment_GetPoint(FPDF_PATHSEGMENT segment, float* x, float* y);
|
||||
// int FPDFPathSegment_GetType(FPDF_PATHSEGMENT segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push_back(info);
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
}
|
||||
return result;
|
||||
@@ -1528,11 +1660,149 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
FPDFBitmap_Destroy(bitmap);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "highlight") {
|
||||
spdlog::info("Parsed highlight edit operation (stub)");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("highlight operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for highlight", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create highlight annotation");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
|
||||
std::string colorStr = data.value("color", "#ffff00");
|
||||
unsigned int r = 255, g = 255, b = 0;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
// Note: PDFium doesn't seem to expose opacity for annotations easily,
|
||||
// but standard viewers apply Multiply blend mode for highlights.
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
if (data.contains("quadPoints") && data["quadPoints"].is_array()) {
|
||||
FS_RECTF boundingBox;
|
||||
boundingBox.left = 99999.0f;
|
||||
boundingBox.right = -99999.0f;
|
||||
boundingBox.top = -99999.0f; // top means higher Y in PDF coordinates (bottom-up)
|
||||
boundingBox.bottom = 99999.0f; // bottom means lower Y
|
||||
|
||||
for (const auto& quad : data["quadPoints"]) {
|
||||
FS_QUADPOINTSF points;
|
||||
// Frontend is top-down. Backend is bottom-up.
|
||||
points.x1 = static_cast<float>(quad.value("x1", 0.0));
|
||||
points.y1 = static_cast<float>(pageHeight - quad.value("y1", 0.0));
|
||||
points.x2 = static_cast<float>(quad.value("x2", 0.0));
|
||||
points.y2 = static_cast<float>(pageHeight - quad.value("y2", 0.0));
|
||||
points.x3 = static_cast<float>(quad.value("x3", 0.0));
|
||||
points.y3 = static_cast<float>(pageHeight - quad.value("y3", 0.0));
|
||||
points.x4 = static_cast<float>(quad.value("x4", 0.0));
|
||||
points.y4 = static_cast<float>(pageHeight - quad.value("y4", 0.0));
|
||||
|
||||
FPDFAnnot_AppendAttachmentPoints(annot, &points);
|
||||
|
||||
// Expand bounding box
|
||||
float minX = (std::min)({points.x1, points.x2, points.x3, points.x4});
|
||||
float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4});
|
||||
float minY = (std::min)({points.y1, points.y2, points.y3, points.y4});
|
||||
float maxY = (std::max)({points.y1, points.y2, points.y3, points.y4});
|
||||
|
||||
if (minX < boundingBox.left) boundingBox.left = minX;
|
||||
if (maxX > boundingBox.right) boundingBox.right = maxX;
|
||||
if (minY < boundingBox.bottom) boundingBox.bottom = minY;
|
||||
if (maxY > boundingBox.top) boundingBox.top = maxY;
|
||||
}
|
||||
|
||||
if (boundingBox.left <= boundingBox.right) {
|
||||
FPDFAnnot_SetRect(annot, &boundingBox);
|
||||
}
|
||||
}
|
||||
|
||||
std::string author = data.value("author", "");
|
||||
if (!author.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(author);
|
||||
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
std::string content = data.value("content", "");
|
||||
if (!content.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(content);
|
||||
FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "free_text") {
|
||||
spdlog::info("Parsed free_text edit operation (stub)");
|
||||
} else if (type == "comment") {
|
||||
spdlog::info("Parsed comment edit operation (stub)");
|
||||
if (!op.contains("data") || !op["data"].is_object()) {
|
||||
spdlog::error("comment operation missing 'data' object");
|
||||
return std::unexpected(EngineError::InvalidFormat);
|
||||
}
|
||||
auto data = op["data"];
|
||||
|
||||
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||
if (!page) {
|
||||
spdlog::error("Failed to load page index {} for comment", pageIndex);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_TEXT);
|
||||
if (!annot) {
|
||||
spdlog::error("Failed to create comment annotation");
|
||||
FPDF_ClosePage(page);
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
|
||||
double x = data.value("x", 0.0);
|
||||
double y = data.value("y", 0.0);
|
||||
|
||||
// Set the bounding box for the sticky note icon (e.g. 24x24)
|
||||
// x, y is from frontend (top-down). Convert to bottom-up.
|
||||
FS_RECTF rect;
|
||||
rect.left = static_cast<float>(x);
|
||||
rect.right = static_cast<float>(x + 24.0);
|
||||
rect.top = static_cast<float>(pageHeight - y);
|
||||
rect.bottom = static_cast<float>(pageHeight - y - 24.0);
|
||||
|
||||
FPDFAnnot_SetRect(annot, &rect);
|
||||
|
||||
// Color (yellow for comment by default)
|
||||
std::string colorStr = data.value("color", "#ffeb3b");
|
||||
unsigned int r = 255, g = 235, b = 59;
|
||||
parseHexColor(colorStr, r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
std::string author = data.value("author", "");
|
||||
if (!author.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(author);
|
||||
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
std::string content = data.value("content", "");
|
||||
if (!content.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(content);
|
||||
FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
std::string timestamp = data.value("timestamp", "");
|
||||
if (!timestamp.empty()) {
|
||||
auto utf16 = utf8_to_utf16le(timestamp);
|
||||
FPDFAnnot_SetStringValue(annot, "M", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||
}
|
||||
|
||||
FPDFPage_CloseAnnot(annot);
|
||||
FPDF_ClosePage(page);
|
||||
} else if (type == "freehand") {
|
||||
spdlog::info("Parsed freehand edit operation (stub)");
|
||||
} else if (type == "page_rotation") {
|
||||
@@ -1760,7 +2030,7 @@ std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
|
||||
const FontPdfData& pd = pdfDataIt->second;
|
||||
|
||||
auto lname = fontName;
|
||||
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
|
||||
std::transform(lname.begin(), lname.end(), lname.begin(), [](unsigned char c) { return static_cast<char>(::tolower(c)); });
|
||||
|
||||
// --- Embedding status (replaces subset-tag heuristic) ---
|
||||
f.isEmbedded = pd.isEmbedded;
|
||||
@@ -1980,7 +2250,7 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::getFontData(con
|
||||
FPDF_FONT font = FPDFTextObj_GetFont(obj);
|
||||
if (!font) continue;
|
||||
|
||||
unsigned long nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0);
|
||||
if (nameLen > 0) {
|
||||
std::vector<char> nameBuf(nameLen);
|
||||
if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) > 0) {
|
||||
|
||||
@@ -45,6 +45,7 @@ public:
|
||||
std::expected<PageModel, EngineError> extractDocumentModel() const override;
|
||||
std::expected<std::vector<FontInfo>, EngineError> getFonts() const override;
|
||||
std::expected<std::vector<std::string>, EngineError> extractAnnotationsText() const override;
|
||||
std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> extractAnnotations() const override;
|
||||
|
||||
std::expected<double, EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const override;
|
||||
|
||||
|
||||
+84
-5
@@ -5,14 +5,14 @@ import { PDFViewer } from './viewer/PDFViewer';
|
||||
import type { PDFViewerRef } from './viewer/PDFViewer';
|
||||
import type { Annotation } from './viewer/AnnotationLayer';
|
||||
import { gatewayService } from './lib/gatewayService';
|
||||
import type { DocumentInfo, SearchResult } from './lib/gatewayService';
|
||||
import type { DocumentInfo, SearchResult, EditOperation } from './lib/gatewayService';
|
||||
import { wasmLoader } from './lib/wasmLoader';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const viewerRef = useRef<PDFViewerRef>(null);
|
||||
const [documents, setDocuments] = useState<DocumentInfo[]>([]);
|
||||
const [selectedDocId, setSelectedDocId] = useState<string>('sample-doc-1');
|
||||
const [selectedDocId, setSelectedDocId] = useState<string>('');
|
||||
const [activeDoc, setActiveDoc] = useState<DocumentInfo | null>(null);
|
||||
|
||||
// Settings
|
||||
@@ -116,7 +116,23 @@ function App() {
|
||||
const doc = await gatewayService.getDocument(selectedDocId);
|
||||
setActiveDoc(doc);
|
||||
setCurrentPage(0);
|
||||
setAnnotations([]); // Reset highlights for new document
|
||||
|
||||
// Fetch document annotations
|
||||
const backendAnnots = await gatewayService.getDocumentAnnotations(selectedDocId);
|
||||
// Map backend annotations to frontend format
|
||||
const frontendAnnots: Annotation[] = backendAnnots.map(a => ({
|
||||
id: a.id,
|
||||
type: a.type as any,
|
||||
bbox: { x: a.x, y: a.y, width: a.width, height: a.height },
|
||||
color: a.color,
|
||||
author: a.author,
|
||||
content: a.content,
|
||||
timestamp: (a as any).timestamp,
|
||||
pageIndex: a.pageIndex
|
||||
}));
|
||||
|
||||
console.log("Loaded Annotations:", frontendAnnots);
|
||||
setAnnotations(frontendAnnots);
|
||||
} catch (err) {
|
||||
console.error('Failed to load document metadata', err);
|
||||
} finally {
|
||||
@@ -159,10 +175,73 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnnotationAdded = (newAnno: Annotation) => {
|
||||
const handleAnnotationAdded = async (newAnno: Annotation) => {
|
||||
setAnnotations((prev) => [...prev, newAnno]);
|
||||
// Switch to annotations list tab automatically so the user sees the note
|
||||
setSidebarTab('annotations');
|
||||
|
||||
if (!selectedDocId || !activeDoc) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
let op: EditOperation | null = null;
|
||||
|
||||
if (newAnno.type === 'highlight') {
|
||||
op = {
|
||||
id: newAnno.id,
|
||||
type: 'highlight',
|
||||
pageIndex: newAnno.pageIndex ?? currentPage,
|
||||
data: {
|
||||
quadPoints: [{
|
||||
x1: newAnno.bbox.x, y1: newAnno.bbox.y + newAnno.bbox.height, // bottom-left
|
||||
x2: newAnno.bbox.x + newAnno.bbox.width, y2: newAnno.bbox.y + newAnno.bbox.height, // bottom-right
|
||||
x3: newAnno.bbox.x + newAnno.bbox.width, y3: newAnno.bbox.y, // top-right
|
||||
x4: newAnno.bbox.x, y4: newAnno.bbox.y // top-left
|
||||
}],
|
||||
color: newAnno.color || '#ffff00',
|
||||
opacity: 0.5,
|
||||
author: newAnno.author,
|
||||
content: newAnno.content
|
||||
}
|
||||
};
|
||||
} else if (newAnno.type === 'ink' && newAnno.paths) {
|
||||
op = {
|
||||
id: newAnno.id,
|
||||
type: 'freehand',
|
||||
pageIndex: newAnno.pageIndex ?? currentPage,
|
||||
data: {
|
||||
paths: newAnno.paths,
|
||||
color: newAnno.color || '#3b82f6',
|
||||
thickness: 2.0
|
||||
}
|
||||
};
|
||||
} else if (newAnno.type === 'comment') {
|
||||
op = {
|
||||
id: newAnno.id,
|
||||
type: 'comment',
|
||||
pageIndex: newAnno.pageIndex ?? currentPage,
|
||||
data: {
|
||||
x: newAnno.bbox.x,
|
||||
y: newAnno.bbox.y,
|
||||
author: newAnno.author,
|
||||
content: newAnno.content || '',
|
||||
timestamp: newAnno.timestamp
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (op) {
|
||||
const result = await gatewayService.applyEdits(selectedDocId, [op]);
|
||||
if (result.success) {
|
||||
const docs = await gatewayService.listDocuments();
|
||||
setDocuments(docs);
|
||||
setSelectedDocId(result.newDocumentId);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save annotation:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotateClick = async (newRotValue?: number) => {
|
||||
|
||||
@@ -175,6 +175,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
>
|
||||
Signature
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onActiveToolChange('comment')}
|
||||
className={`tool-btn comment ${activeTool === 'comment' ? 'active' : ''}`}
|
||||
title="Add Sticky Note"
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SEARCH BAR */}
|
||||
|
||||
+100
-5
@@ -697,7 +697,7 @@ body {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
.annotation-box {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
mix-blend-mode: multiply;
|
||||
@@ -706,22 +706,53 @@ body {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.highlight-box:hover {
|
||||
.annotation-box:hover {
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.highlight-box.type-highlight {
|
||||
.annotation-box.type-highlight {
|
||||
background: #fde047;
|
||||
/* Yellow 300 */
|
||||
}
|
||||
|
||||
.highlight-box.type-comment {
|
||||
.annotation-box.type-comment {
|
||||
background: #fecdd3;
|
||||
/* Rose 200 */
|
||||
border-bottom: 2px solid #f43f5e;
|
||||
}
|
||||
|
||||
.annotation-box.type-strikeout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.strikeout-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: #ef4444; /* red-500 */
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.annotation-box.type-signature {
|
||||
border: 2px dashed rgba(79, 70, 229, 0.5); /* indigo-600 */
|
||||
background: rgba(79, 70, 229, 0.05);
|
||||
border-radius: 4px;
|
||||
mix-blend-mode: normal; /* override multiply for signature */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signature-badge {
|
||||
color: rgba(79, 70, 229, 0.8);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 9999px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.overlay-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -786,4 +817,68 @@ body {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Comment Popup Styles */
|
||||
.comment-popup-container {
|
||||
width: 260px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
.comment-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border-bottom: 1px solid var(--border-main);
|
||||
}
|
||||
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
border: none;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comment-textarea::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.comment-form-footer {
|
||||
padding: 10px 14px;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border-top: 1px solid var(--border-main);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.comment-submit-btn {
|
||||
background: var(--bg-accent-indigo);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.comment-submit-btn:hover {
|
||||
background: var(--bg-accent-indigo-hover);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface StickyNoteData {
|
||||
y: number;
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface FreehandData {
|
||||
@@ -243,6 +244,19 @@ class GatewayService {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getDocumentAnnotations(id: string): Promise<any[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${id}/annotations`);
|
||||
if (response.status === 501) {
|
||||
return [];
|
||||
}
|
||||
if (!response.ok) throw new Error(`Failed to fetch document annotations: ${response.statusText}`);
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async renderPage(params: RenderParams): Promise<string> {
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
|
||||
@@ -8,7 +8,9 @@ export interface Annotation {
|
||||
color?: string;
|
||||
author: string;
|
||||
content?: string;
|
||||
timestamp?: string;
|
||||
paths?: { x: number; y: number }[][];
|
||||
pageIndex?: number;
|
||||
}
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
@@ -21,6 +23,7 @@ interface AnnotationLayerProps {
|
||||
}
|
||||
|
||||
export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
pageIndex,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
@@ -33,7 +36,8 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
style={{ width: `${width}px`, height: `${height}px` }}
|
||||
>
|
||||
{annotations
|
||||
.filter((anno) => anno.type === 'highlight' || anno.type === 'comment')
|
||||
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
|
||||
.filter((anno) => ['highlight', 'comment', 'strikeout', 'signature'].includes(anno.type))
|
||||
.map((anno) => {
|
||||
const scaledBbox = {
|
||||
x: anno.bbox.x * zoom,
|
||||
@@ -42,6 +46,11 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
height: anno.bbox.height * zoom,
|
||||
};
|
||||
|
||||
const formattedDate = anno.timestamp ? new Date(anno.timestamp).toLocaleString() : '';
|
||||
const tooltipText = anno.type === 'comment'
|
||||
? `${anno.author}${formattedDate ? ` (${formattedDate})` : ''}\n${anno.content || ''}`
|
||||
: `${anno.author}: ${anno.content || ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={anno.id}
|
||||
@@ -49,23 +58,40 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
e.stopPropagation();
|
||||
onAnnotationClick?.(anno);
|
||||
}}
|
||||
className={`highlight-box type-${anno.type}`}
|
||||
className={`annotation-box type-${anno.type}`}
|
||||
style={{
|
||||
left: `${scaledBbox.x}px`,
|
||||
top: `${scaledBbox.y}px`,
|
||||
width: `${scaledBbox.width}px`,
|
||||
height: `${scaledBbox.height}px`,
|
||||
}}
|
||||
title={`${anno.author}: ${anno.content || ''}`}
|
||||
/>
|
||||
title={tooltipText}
|
||||
>
|
||||
{anno.type === 'comment' && (
|
||||
<div className="comment-icon" style={{ width: '100%', height: '100%', color: anno.color || '#facc15' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-full h-full drop-shadow-md">
|
||||
<path fillRule="evenodd" d="M4.804 21.644A6.707 6.707 0 006 21.75a6.721 6.721 0 003.583-1.029c.774.182 1.584.279 2.417.279 5.322 0 9.75-3.97 9.75-9 0-5.03-4.428-9-9.75-9s-9.75 3.97-9.75 9c0 2.409 1.025 4.587 2.674 6.192.232.226.277.428.254.543a3.73 3.73 0 01-.814 1.686.75.75 0 00.44 1.223zM8.25 10.875a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25zM10.875 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875-1.125a1.125 1.125 0 100 2.25 1.125 1.125 0 000-2.25z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{anno.type === 'strikeout' && <div className="strikeout-line" />}
|
||||
{anno.type === 'signature' && (
|
||||
<div className="signature-badge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* Ink Annotations */}
|
||||
<svg
|
||||
<svg
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}
|
||||
>
|
||||
{annotations
|
||||
.filter((anno) => anno.pageIndex === undefined || anno.pageIndex === pageIndex)
|
||||
.filter((anno) => anno.type === 'ink' && anno.paths)
|
||||
.map((anno) => (
|
||||
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={2 * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -75,10 +75,45 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
setCurrentPath([]);
|
||||
};
|
||||
|
||||
const handleLayerClick = (e: React.MouseEvent) => {
|
||||
if (activeTool === 'comment') {
|
||||
const coords = getCoordinates(e);
|
||||
setCommentPopup(coords);
|
||||
setCommentText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (commentPopup && commentText.trim() !== '') {
|
||||
const newAnno: Annotation = {
|
||||
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
|
||||
type: 'comment',
|
||||
bbox: {
|
||||
x: commentPopup.x,
|
||||
y: commentPopup.y,
|
||||
width: 24, // Standard sticky note icon size
|
||||
height: 24,
|
||||
},
|
||||
author: 'Current User',
|
||||
content: commentText.trim(),
|
||||
pageIndex: pageIndex,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
}
|
||||
setCommentPopup(null);
|
||||
setCommentText('');
|
||||
};
|
||||
|
||||
const [commentPopup, setCommentPopup] = useState<{ x: number, y: number } | null>(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overlay-layer"
|
||||
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: activeTool === 'draw' ? 'auto' : 'none' }}
|
||||
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: ['draw', 'comment'].includes(activeTool) ? 'auto' : 'none' }}
|
||||
onClick={handleLayerClick}
|
||||
>
|
||||
{/* Signature overlay state indicator */}
|
||||
{activeTool === 'signature' && (
|
||||
@@ -88,7 +123,56 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Comment Tool Tip */}
|
||||
{activeTool === 'comment' && !commentPopup && (
|
||||
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
|
||||
Click anywhere to add a Sticky Note
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment Input Popup */}
|
||||
{commentPopup && (
|
||||
<div
|
||||
className="comment-popup-container"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${commentPopup.x * zoom}px`,
|
||||
top: `${commentPopup.y * zoom}px`,
|
||||
zIndex: 50,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent triggering another comment
|
||||
>
|
||||
<form onSubmit={handleCommentSubmit} className="comment-form shadow-premium">
|
||||
<div className="comment-form-header">
|
||||
<span className="text-xs font-bold text-slate-700">Add Sticky Note</span>
|
||||
<button type="button" onClick={() => setCommentPopup(null)} className="text-slate-400 hover:text-slate-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Type your comment here..."
|
||||
className="comment-textarea"
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleCommentSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="comment-form-footer">
|
||||
<button type="submit" className="comment-submit-btn">Save Note</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTool === 'draw' && (
|
||||
<>
|
||||
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
|
||||
|
||||
@@ -230,7 +230,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
verifyPageModel();
|
||||
}, [visiblePages, documentId]);
|
||||
|
||||
const handleTextSelection = (text: string, bbox: Rect) => {
|
||||
const handleTextSelection = (text: string, bbox: Rect, pageIndex: number) => {
|
||||
if (activeTool === 'highlight') {
|
||||
const newAnno: Annotation = {
|
||||
id: generateUniqueId(),
|
||||
@@ -243,6 +243,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
},
|
||||
author: 'Current User',
|
||||
content: text,
|
||||
pageIndex: pageIndex,
|
||||
};
|
||||
onAnnotationAdded?.(newAnno);
|
||||
}
|
||||
@@ -332,7 +333,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox)}
|
||||
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
|
||||
/>
|
||||
|
||||
{/* signature/ink overlay tool layer */}
|
||||
|
||||
@@ -446,3 +446,55 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
class AnnotationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
color: str
|
||||
author: str
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
pageIndex: int
|
||||
|
||||
@router.get("/{document_id}/annotations", response_model=list[AnnotationResponse])
|
||||
def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
|
||||
if not engine.is_available():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Engine bridge (bindings/python) not yet available.",
|
||||
)
|
||||
|
||||
doc_info = document_store.get_document(document_id)
|
||||
if not doc_info:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
try:
|
||||
doc = doc_info["doc_instance"]
|
||||
all_annots = []
|
||||
for i in range(doc.page_count):
|
||||
try:
|
||||
page = doc.get_page(i)
|
||||
annots = page.extract_annotations()
|
||||
for a in annots:
|
||||
all_annots.append(AnnotationResponse(
|
||||
id=a.id,
|
||||
type=a.type,
|
||||
x=a.x,
|
||||
y=a.y,
|
||||
width=a.width,
|
||||
height=a.height,
|
||||
color=a.color,
|
||||
author=a.author,
|
||||
content=a.content,
|
||||
timestamp=getattr(a, "timestamp", None),
|
||||
pageIndex=a.page_index
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
return all_annots
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
@@ -72,6 +72,7 @@ class StickyNoteData(BaseModel):
|
||||
y: float
|
||||
author: str
|
||||
content: str
|
||||
timestamp: str | None = None
|
||||
|
||||
|
||||
class FreehandPoint(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user