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);
|
||||
}
|
||||
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 {
|
||||
spdlog::warn("Unsupported edit operation type: {}", type);
|
||||
}
|
||||
|
||||
@@ -425,6 +425,24 @@ function App() {
|
||||
}], '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 */
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-[#f1f2f4] text-[#18212e] antialiased">
|
||||
@@ -504,6 +522,11 @@ function App() {
|
||||
searchResults={searchResults}
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationUpdate={handleUpdateAnnotation}
|
||||
onAnnotationClick={(a) => {
|
||||
setInspectorTab('notes');
|
||||
if (!isInspectorOpen) setIsInspectorOpen(true);
|
||||
}}
|
||||
onPageVisible={setCurrentPage}
|
||||
onRedactArea={handleRedactArea}
|
||||
onPlaceText={handlePlaceText}
|
||||
@@ -546,6 +569,7 @@ function App() {
|
||||
annotations={annotations}
|
||||
onNavigateAnnotation={navigateToAnnotation}
|
||||
onDeleteAnnotation={handleDeleteAnnotation}
|
||||
onUpdateAnnotation={handleUpdateAnnotation}
|
||||
outline={outline}
|
||||
onNavigateOutline={(p) => viewerRef.current?.scrollToPage(p)}
|
||||
searchQuery={searchQuery}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface InspectorPanelProps {
|
||||
annotations: Annotation[];
|
||||
onNavigateAnnotation: (a: Annotation) => void;
|
||||
onDeleteAnnotation: (a: Annotation) => void;
|
||||
onUpdateAnnotation?: (a: Annotation) => void;
|
||||
|
||||
outline: OutlineItem[];
|
||||
onNavigateOutline: (pageIndex: number) => void;
|
||||
@@ -137,7 +138,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
|
||||
{/* Body */}
|
||||
<div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
{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 === 'properties' && <PropertiesTab metadata={p.metadata} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
|
||||
{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) {
|
||||
return <EmptyState icon={<NotesIcon size={30} />} title="No annotations yet"
|
||||
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>}
|
||||
<span className="text-[10px] font-medium text-[#98a1ad]">{a.author}</span>
|
||||
</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
|
||||
title="Delete annotation"
|
||||
aria-label="Delete annotation"
|
||||
|
||||
@@ -184,12 +184,24 @@ export type EditOperationDataMap = {
|
||||
page_deletion: PageDeletionData;
|
||||
page_reorder: PageReorderData;
|
||||
delete_annotation: DeleteAnnotationData;
|
||||
update_annotation: UpdateAnnotationData;
|
||||
};
|
||||
|
||||
export interface DeleteAnnotationData {
|
||||
annotationId: string;
|
||||
}
|
||||
|
||||
export interface UpdateAnnotationData {
|
||||
annotationId: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface OutlineItem {
|
||||
title: string;
|
||||
pageIndex: number;
|
||||
|
||||
@@ -29,6 +29,7 @@ interface AnnotationLayerProps {
|
||||
zoom: number;
|
||||
annotations: Annotation[];
|
||||
onAnnotationClick?: (annotation: Annotation) => void;
|
||||
onAnnotationUpdate?: (annotation: Annotation) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
}
|
||||
|
||||
@@ -39,8 +40,47 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
|
||||
zoom,
|
||||
annotations,
|
||||
onAnnotationClick,
|
||||
onAnnotationUpdate,
|
||||
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 (
|
||||
<div
|
||||
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}: ${anno.content || ''}`;
|
||||
|
||||
const isDragging = draggingAnno === anno.id;
|
||||
const currentOffset = isDragging ? dragOffset : { x: 0, y: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={anno.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAnnotationClick?.(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}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, anno.id)}
|
||||
onPointerMove={(e) => handlePointerMove(e, anno.id)}
|
||||
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' : ''}`}
|
||||
style={{
|
||||
left: `${scaledBbox.x}px`,
|
||||
top: `${scaledBbox.y}px`,
|
||||
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
|
||||
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
|
||||
width: `${scaledBbox.width}px`,
|
||||
height: `${scaledBbox.height}px`,
|
||||
backgroundColor: anno.type === 'highlight' ? (anno.color || '#ffeb3b') : undefined,
|
||||
|
||||
@@ -28,6 +28,8 @@ interface PDFViewerProps {
|
||||
searchResults?: SearchResult[];
|
||||
searchCurrentMatch?: number;
|
||||
onAnnotationAdded?: (anno: Annotation) => void;
|
||||
onAnnotationUpdate?: (anno: Annotation) => void;
|
||||
onAnnotationClick?: (anno: Annotation) => void;
|
||||
onPageVisible?: (pageIndex: number) => void;
|
||||
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
@@ -65,6 +67,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
searchResults,
|
||||
searchCurrentMatch,
|
||||
onAnnotationAdded,
|
||||
onAnnotationUpdate,
|
||||
onAnnotationClick,
|
||||
onPageVisible,
|
||||
onRedactArea,
|
||||
onPlaceText,
|
||||
@@ -79,7 +83,6 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
const [containerHeight, setContainerHeight] = useState(800);
|
||||
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
||||
const verifiedPagesRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Reset cached page renders when switching documents
|
||||
useEffect(() => {
|
||||
setRenderedPages([]);
|
||||
@@ -387,6 +390,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
annotations={annotations}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onAnnotationUpdate={onAnnotationUpdate}
|
||||
onFieldChange={onFieldChange}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user