done anotations update
This commit is contained in:
@@ -2250,6 +2250,92 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
spdlog::warn("delete_annotation: annotation '{}' not found on page {}", targetId, pageIndex);
|
spdlog::warn("delete_annotation: annotation '{}' not found on page {}", targetId, pageIndex);
|
||||||
}
|
}
|
||||||
FPDF_ClosePage(page);
|
FPDF_ClosePage(page);
|
||||||
|
} else if (type == "update_annotation") {
|
||||||
|
if (!op.contains("data") || !op["data"].is_object()) {
|
||||||
|
spdlog::error("update_annotation operation missing 'data' object");
|
||||||
|
return std::unexpected(EngineError::InvalidFormat);
|
||||||
|
}
|
||||||
|
auto data = op["data"];
|
||||||
|
std::string targetId = data.value("annotationId", "");
|
||||||
|
if (targetId.empty()) {
|
||||||
|
spdlog::error("update_annotation missing 'annotationId'");
|
||||||
|
return std::unexpected(EngineError::InvalidFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
|
||||||
|
if (!page) {
|
||||||
|
spdlog::error("Failed to load page index {} for update_annotation", pageIndex);
|
||||||
|
return std::unexpected(EngineError::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = FPDFPage_GetAnnotCount(page);
|
||||||
|
FPDF_ANNOTATION targetAnnot = nullptr;
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, i);
|
||||||
|
if (!annot) continue;
|
||||||
|
|
||||||
|
std::string 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);
|
||||||
|
id = utf16le_to_utf8(reinterpret_cast<const char16_t*>(buf.data()), len / sizeof(char16_t));
|
||||||
|
while (!id.empty() && id.back() == '\0') id.pop_back();
|
||||||
|
}
|
||||||
|
if (id.empty()) {
|
||||||
|
id = "anno_" + std::to_string(pageIndex) + "_" + std::to_string(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id == targetId) {
|
||||||
|
targetAnnot = annot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
FPDFPage_CloseAnnot(annot);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetAnnot) {
|
||||||
|
// Update Rect
|
||||||
|
if (data.contains("x") && data.contains("y") && data.contains("width") && data.contains("height")) {
|
||||||
|
double pageHeight = FPDF_GetPageHeightF(page);
|
||||||
|
float x = static_cast<float>(data["x"].get<double>());
|
||||||
|
float y = static_cast<float>(data["y"].get<double>());
|
||||||
|
float width = static_cast<float>(data["width"].get<double>());
|
||||||
|
float height = static_cast<float>(data["height"].get<double>());
|
||||||
|
|
||||||
|
FS_RECTF rect;
|
||||||
|
rect.left = x;
|
||||||
|
rect.right = x + width;
|
||||||
|
rect.top = static_cast<float>(pageHeight - y);
|
||||||
|
rect.bottom = static_cast<float>(pageHeight - (y + height));
|
||||||
|
FPDFAnnot_SetRect(targetAnnot, &rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Color
|
||||||
|
if (data.contains("color")) {
|
||||||
|
std::string colorStr = data["color"].get<std::string>();
|
||||||
|
unsigned int r = 0, g = 0, b = 0;
|
||||||
|
parseHexColor(colorStr, r, g, b);
|
||||||
|
FPDFAnnot_SetColor(targetAnnot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Thickness
|
||||||
|
if (data.contains("thickness")) {
|
||||||
|
float thickness = static_cast<float>(data["thickness"].get<double>());
|
||||||
|
FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Text
|
||||||
|
if (data.contains("text")) {
|
||||||
|
std::string text = data["text"].get<std::string>();
|
||||||
|
auto utf16 = utf8_to_utf16le(text);
|
||||||
|
FPDFAnnot_SetStringValue(targetAnnot, "Contents", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
|
||||||
|
}
|
||||||
|
|
||||||
|
FPDFPage_CloseAnnot(targetAnnot);
|
||||||
|
} else {
|
||||||
|
spdlog::warn("update_annotation: annotation '{}' not found on page {}", targetId, pageIndex);
|
||||||
|
}
|
||||||
|
FPDF_ClosePage(page);
|
||||||
} else {
|
} else {
|
||||||
spdlog::warn("Unsupported edit operation type: {}", type);
|
spdlog::warn("Unsupported edit operation type: {}", type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,24 @@ function App() {
|
|||||||
}], 'Annotation deleted');
|
}], 'Annotation deleted');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateAnnotation = (a: Annotation) => {
|
||||||
|
// Optimistically update
|
||||||
|
setAnnotations((prev) => prev.map((x) => x.id === a.id ? a : x));
|
||||||
|
applyOps([{
|
||||||
|
id: rid('updanno'), type: 'update_annotation', pageIndex: a.pageIndex ?? currentPage,
|
||||||
|
data: {
|
||||||
|
annotationId: a.id,
|
||||||
|
x: a.bbox.x,
|
||||||
|
y: a.bbox.y,
|
||||||
|
width: a.bbox.width,
|
||||||
|
height: a.bbox.height,
|
||||||
|
color: a.color,
|
||||||
|
thickness: a.thickness,
|
||||||
|
text: a.content,
|
||||||
|
},
|
||||||
|
}], 'Annotation updated');
|
||||||
|
};
|
||||||
|
|
||||||
/* -------------------------------------------------------------- render */
|
/* -------------------------------------------------------------- render */
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
||||||
@@ -504,6 +522,11 @@ function App() {
|
|||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
searchCurrentMatch={searchCurrentMatch}
|
searchCurrentMatch={searchCurrentMatch}
|
||||||
onAnnotationAdded={handleAnnotationAdded}
|
onAnnotationAdded={handleAnnotationAdded}
|
||||||
|
onAnnotationUpdate={handleUpdateAnnotation}
|
||||||
|
onAnnotationClick={(a) => {
|
||||||
|
setInspectorTab('notes');
|
||||||
|
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||||
|
}}
|
||||||
onPageVisible={setCurrentPage}
|
onPageVisible={setCurrentPage}
|
||||||
onRedactArea={handleRedactArea}
|
onRedactArea={handleRedactArea}
|
||||||
onPlaceText={handlePlaceText}
|
onPlaceText={handlePlaceText}
|
||||||
@@ -546,6 +569,7 @@ function App() {
|
|||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
onNavigateAnnotation={navigateToAnnotation}
|
onNavigateAnnotation={navigateToAnnotation}
|
||||||
onDeleteAnnotation={handleDeleteAnnotation}
|
onDeleteAnnotation={handleDeleteAnnotation}
|
||||||
|
onUpdateAnnotation={handleUpdateAnnotation}
|
||||||
outline={outline}
|
outline={outline}
|
||||||
onNavigateOutline={(p) => viewerRef.current?.scrollToPage(p)}
|
onNavigateOutline={(p) => viewerRef.current?.scrollToPage(p)}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface InspectorPanelProps {
|
|||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
onNavigateAnnotation: (a: Annotation) => void;
|
onNavigateAnnotation: (a: Annotation) => void;
|
||||||
onDeleteAnnotation: (a: Annotation) => void;
|
onDeleteAnnotation: (a: Annotation) => void;
|
||||||
|
onUpdateAnnotation?: (a: Annotation) => void;
|
||||||
|
|
||||||
outline: OutlineItem[];
|
outline: OutlineItem[];
|
||||||
onNavigateOutline: (pageIndex: number) => void;
|
onNavigateOutline: (pageIndex: number) => void;
|
||||||
@@ -137,7 +138,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||||
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
{p.activeTab === 'pages' && <PagesTab {...p} />}
|
||||||
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} />}
|
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
|
||||||
{p.activeTab === 'search' && <SearchTab {...p} />}
|
{p.activeTab === 'search' && <SearchTab {...p} />}
|
||||||
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
||||||
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
|
||||||
@@ -191,7 +192,7 @@ const PagesTab: React.FC<InspectorPanelProps> = (p) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation) => void; onDelete: (a: Annotation) => void }> = ({ annotations, onNavigate, onDelete }) => {
|
const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation) => void; onDelete: (a: Annotation) => void; onUpdate?: (a: Annotation) => void }> = ({ annotations, onNavigate, onDelete, onUpdate }) => {
|
||||||
if (annotations.length === 0) {
|
if (annotations.length === 0) {
|
||||||
return <EmptyState icon={<NotesIcon size={30} />} title="No annotations yet"
|
return <EmptyState icon={<NotesIcon size={30} />} title="No annotations yet"
|
||||||
hint="Highlights, ink, and comments you add appear here." />;
|
hint="Highlights, ink, and comments you add appear here." />;
|
||||||
@@ -209,6 +210,30 @@ const NotesTab: React.FC<{ annotations: Annotation[]; onNavigate: (a: Annotation
|
|||||||
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">“{a.content}”</p>}
|
{a.content && <p className="line-clamp-3 text-[12px] italic text-[#5b6573]">“{a.content}”</p>}
|
||||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
|
|
||||||
|
{onUpdate && (a.type === 'highlight' || a.type === 'ink' || a.type === 'comment') && (
|
||||||
|
<div className="flex items-center gap-2 mt-1 px-1" onClick={e => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={a.color || (a.type === 'highlight' ? '#ffeb3b' : '#000000')}
|
||||||
|
onChange={e => onUpdate({ ...a, color: e.target.value })}
|
||||||
|
className="h-5 w-5 cursor-pointer rounded border border-gray-300 p-0"
|
||||||
|
title="Change Color"
|
||||||
|
/>
|
||||||
|
{a.type === 'ink' && (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={a.thickness || 2}
|
||||||
|
onChange={e => onUpdate({ ...a, thickness: parseFloat(e.target.value) })}
|
||||||
|
className="w-16"
|
||||||
|
title="Change Thickness"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
title="Delete annotation"
|
title="Delete annotation"
|
||||||
aria-label="Delete annotation"
|
aria-label="Delete annotation"
|
||||||
|
|||||||
@@ -184,12 +184,24 @@ export type EditOperationDataMap = {
|
|||||||
page_deletion: PageDeletionData;
|
page_deletion: PageDeletionData;
|
||||||
page_reorder: PageReorderData;
|
page_reorder: PageReorderData;
|
||||||
delete_annotation: DeleteAnnotationData;
|
delete_annotation: DeleteAnnotationData;
|
||||||
|
update_annotation: UpdateAnnotationData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DeleteAnnotationData {
|
export interface DeleteAnnotationData {
|
||||||
annotationId: string;
|
annotationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateAnnotationData {
|
||||||
|
annotationId: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
thickness?: number;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutlineItem {
|
export interface OutlineItem {
|
||||||
title: string;
|
title: string;
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface AnnotationLayerProps {
|
|||||||
zoom: number;
|
zoom: number;
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
onAnnotationClick?: (annotation: Annotation) => void;
|
onAnnotationClick?: (annotation: Annotation) => void;
|
||||||
|
onAnnotationUpdate?: (annotation: Annotation) => void;
|
||||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +40,47 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
zoom,
|
zoom,
|
||||||
annotations,
|
annotations,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
|
onAnnotationUpdate,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [draggingAnno, setDraggingAnno] = React.useState<string | null>(null);
|
||||||
|
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
|
||||||
|
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent, id: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraggingAnno(id);
|
||||||
|
setDragStartPos({ x: e.clientX, y: e.clientY });
|
||||||
|
setDragOffset({ x: 0, y: 0 });
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent, id: string) => {
|
||||||
|
if (draggingAnno !== id) return;
|
||||||
|
setDragOffset({
|
||||||
|
x: (e.clientX - dragStartPos.x) / zoom,
|
||||||
|
y: (e.clientY - dragStartPos.y) / zoom,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent, anno: Annotation) => {
|
||||||
|
if (draggingAnno !== anno.id) return;
|
||||||
|
setDraggingAnno(null);
|
||||||
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
if (dragOffset.x !== 0 || dragOffset.y !== 0) {
|
||||||
|
onAnnotationUpdate?.({
|
||||||
|
...anno,
|
||||||
|
bbox: {
|
||||||
|
...anno.bbox,
|
||||||
|
x: anno.bbox.x + dragOffset.x,
|
||||||
|
y: anno.bbox.y + dragOffset.y,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onAnnotationClick?.(anno);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 z-30"
|
className="absolute top-0 left-0 z-30"
|
||||||
@@ -62,17 +102,19 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
|||||||
? `${anno.author}${formattedDate ? ` (${formattedDate})` : ''}\n${anno.content || ''}`
|
? `${anno.author}${formattedDate ? ` (${formattedDate})` : ''}\n${anno.content || ''}`
|
||||||
: `${anno.author}: ${anno.content || ''}`;
|
: `${anno.author}: ${anno.content || ''}`;
|
||||||
|
|
||||||
|
const isDragging = draggingAnno === anno.id;
|
||||||
|
const currentOffset = isDragging ? dragOffset : { x: 0, y: 0 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={anno.id}
|
key={anno.id}
|
||||||
onClick={(e) => {
|
onPointerDown={(e) => handlePointerDown(e, anno.id)}
|
||||||
e.stopPropagation();
|
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||||
onAnnotationClick?.(anno);
|
onPointerUp={(e) => handlePointerUp(e, anno)}
|
||||||
}}
|
className={`absolute cursor-pointer rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
|
||||||
className={`absolute cursor-pointer rounded-[2px] transition-[opacity,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)] type-${anno.type}`}
|
|
||||||
style={{
|
style={{
|
||||||
left: `${scaledBbox.x}px`,
|
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
||||||
top: `${scaledBbox.y}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.color || '#ffeb3b') : undefined,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface PDFViewerProps {
|
|||||||
searchResults?: SearchResult[];
|
searchResults?: SearchResult[];
|
||||||
searchCurrentMatch?: number;
|
searchCurrentMatch?: number;
|
||||||
onAnnotationAdded?: (anno: Annotation) => void;
|
onAnnotationAdded?: (anno: Annotation) => void;
|
||||||
|
onAnnotationUpdate?: (anno: Annotation) => void;
|
||||||
|
onAnnotationClick?: (anno: Annotation) => void;
|
||||||
onPageVisible?: (pageIndex: number) => void;
|
onPageVisible?: (pageIndex: number) => void;
|
||||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||||
@@ -65,6 +67,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
searchResults,
|
searchResults,
|
||||||
searchCurrentMatch,
|
searchCurrentMatch,
|
||||||
onAnnotationAdded,
|
onAnnotationAdded,
|
||||||
|
onAnnotationUpdate,
|
||||||
|
onAnnotationClick,
|
||||||
onPageVisible,
|
onPageVisible,
|
||||||
onRedactArea,
|
onRedactArea,
|
||||||
onPlaceText,
|
onPlaceText,
|
||||||
@@ -79,7 +83,6 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
const [containerHeight, setContainerHeight] = useState(800);
|
const [containerHeight, setContainerHeight] = useState(800);
|
||||||
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
||||||
const verifiedPagesRef = useRef<Set<number>>(new Set());
|
const verifiedPagesRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
// Reset cached page renders when switching documents
|
// Reset cached page renders when switching documents
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRenderedPages([]);
|
setRenderedPages([]);
|
||||||
@@ -387,6 +390,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
|||||||
height={page.height}
|
height={page.height}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onAnnotationClick={onAnnotationClick}
|
||||||
|
onAnnotationUpdate={onAnnotationUpdate}
|
||||||
onFieldChange={onFieldChange}
|
onFieldChange={onFieldChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user