mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Improve annotations (#5919)
* Text box/notes movement improvements ✅ * Fix the issue where hiding, then showing annotations looses progress ✅ * Fix the issue where hidig/showing annotations jumps you back up to the top of your open document ✅ * Support ctrl+c and ctrl+v and backspace to delete ✅ * Better handling when moving to different tool from annotate ✅ * Added a color picker eyedropper button ✅ * Auto-switch to Select after note/text placement, so users can quickly place and type ✅
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
AnnotationEvent,
|
||||
AnnotationPatch,
|
||||
AnnotationRect,
|
||||
AnnotationSelection,
|
||||
} from '@app/components/viewer/viewerTypes';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
@@ -86,9 +87,13 @@ type AnnotationApiSurface = {
|
||||
setActiveTool: (toolId: AnnotationToolId | null) => void;
|
||||
getActiveTool?: () => { id: AnnotationToolId } | null;
|
||||
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
|
||||
getSelectedAnnotation?: () => unknown | null;
|
||||
getSelectedAnnotation?: () => AnnotationSelection | null;
|
||||
deselectAnnotation?: () => void;
|
||||
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
|
||||
deleteAnnotation?: (pageIndex: number, annotationId: string) => void;
|
||||
deleteAnnotations?: (annotations: Array<{ pageIndex: number; id: string }>) => void;
|
||||
createAnnotation?: (pageIndex: number, annotation: Record<string, unknown>) => void;
|
||||
getSelectedAnnotations?: () => AnnotationSelection[];
|
||||
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
|
||||
purgeAnnotation?: (pageIndex: number, annotationId: string) => void;
|
||||
/** v2.7.0: move annotation without regenerating its appearance stream */
|
||||
@@ -380,6 +385,26 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function Annotation
|
||||
return api?.getActiveTool?.() ?? null;
|
||||
},
|
||||
|
||||
deleteAnnotation: (pageIndex: number, annotationId: string) => {
|
||||
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
|
||||
api?.deleteAnnotation?.(pageIndex, annotationId);
|
||||
},
|
||||
|
||||
deleteAnnotations: (annotations: Array<{ pageIndex: number; id: string }>) => {
|
||||
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
|
||||
api?.deleteAnnotations?.(annotations);
|
||||
},
|
||||
|
||||
createAnnotation: (pageIndex: number, annotation: Record<string, unknown>) => {
|
||||
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
|
||||
api?.createAnnotation?.(pageIndex, annotation);
|
||||
},
|
||||
|
||||
getSelectedAnnotations: () => {
|
||||
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
|
||||
return api?.getSelectedAnnotations?.() ?? [];
|
||||
},
|
||||
|
||||
purgeAnnotation: (pageIndex: number, annotationId: string) => {
|
||||
const api = annotationApi as unknown as AnnotationApiSurface | undefined;
|
||||
api?.purgeAnnotation?.(pageIndex, annotationId);
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
|
||||
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
|
||||
import type { PdfAnnotationObject } from '@embedpdf/models';
|
||||
import type { AnnotationPatch, AnnotationObject } from '@app/components/viewer/viewerTypes';
|
||||
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
|
||||
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
|
||||
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
|
||||
@@ -19,7 +22,7 @@ export interface AnnotationSelectionMenuProps {
|
||||
documentId?: string;
|
||||
context?: {
|
||||
type: 'annotation';
|
||||
annotation: any;
|
||||
annotation: TrackedAnnotation<PdfAnnotationObject>;
|
||||
pageIndex: number;
|
||||
};
|
||||
selected: boolean;
|
||||
@@ -58,11 +61,7 @@ function AnnotationSelectionMenuInner({
|
||||
const { t } = useTranslation();
|
||||
const { provides } = useAnnotation(documentId);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [isTextEditorOpen, setIsTextEditorOpen] = useState(false);
|
||||
const [textDraft, setTextDraft] = useState('');
|
||||
const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null);
|
||||
|
||||
// Merge refs - menuWrapperProps.ref is a callback ref
|
||||
const setRef = useCallback((node: HTMLDivElement | null) => {
|
||||
@@ -74,18 +73,18 @@ function AnnotationSelectionMenuInner({
|
||||
// Type detection
|
||||
const getAnnotationType = useCallback((): AnnotationType => {
|
||||
const type = annotation?.object?.type;
|
||||
const toolId = annotation?.object?.customData?.toolId;
|
||||
const toolId = (annotation?.object as AnnotationObject | undefined)?.customData?.toolId;
|
||||
|
||||
// Map type numbers to categories
|
||||
if ([9, 10, 11, 12].includes(type)) return 'textMarkup';
|
||||
if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup';
|
||||
if (type === 15) {
|
||||
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
|
||||
}
|
||||
if (type === 3) {
|
||||
return toolId === 'note' ? 'note' : 'text';
|
||||
}
|
||||
if ([5, 6, 7].includes(type)) return 'shape';
|
||||
if ([4, 8].includes(type)) return 'line';
|
||||
if (type !== undefined && [5, 6, 7].includes(type)) return 'shape';
|
||||
if (type !== undefined && [4, 8].includes(type)) return 'line';
|
||||
if (type === 13) return 'stamp';
|
||||
|
||||
return 'unknown';
|
||||
@@ -106,7 +105,7 @@ function AnnotationSelectionMenuInner({
|
||||
};
|
||||
|
||||
// Get annotation properties
|
||||
const obj = annotation?.object;
|
||||
const obj = annotation?.object as AnnotationObject | undefined;
|
||||
const annotationType = getAnnotationType();
|
||||
const annotationId = obj?.id;
|
||||
|
||||
@@ -117,7 +116,7 @@ function AnnotationSelectionMenuInner({
|
||||
// Text annotations use textColor
|
||||
if (type === 3) return obj.textColor || obj.color || '#000000';
|
||||
// Shape annotations use strokeColor
|
||||
if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
|
||||
if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
|
||||
// Default to color property
|
||||
return obj.color || obj.strokeColor || '#000000';
|
||||
};
|
||||
@@ -154,82 +153,22 @@ function AnnotationSelectionMenuInner({
|
||||
}
|
||||
}, [provides, annotationId, pageIndex]);
|
||||
|
||||
const handleOpenTextEditor = useCallback(() => {
|
||||
if (!annotation) return;
|
||||
|
||||
// Try to find the annotation element in the DOM
|
||||
const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement;
|
||||
|
||||
let fontSize = (obj?.fontSize || 14) * 1.33;
|
||||
let fontFamily = 'Helvetica';
|
||||
|
||||
if (annotationElement) {
|
||||
const rect = annotationElement.getBoundingClientRect();
|
||||
|
||||
// Try multiple selectors to find the text element
|
||||
const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement;
|
||||
if (textElement) {
|
||||
const computedStyle = window.getComputedStyle(textElement);
|
||||
const computedSize = parseFloat(computedStyle.fontSize);
|
||||
if (computedSize && computedSize > 0) {
|
||||
fontSize = computedSize;
|
||||
}
|
||||
fontFamily = computedStyle.fontFamily || fontFamily;
|
||||
}
|
||||
|
||||
setTextBoxPosition({
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
fontSize: fontSize,
|
||||
fontFamily: fontFamily,
|
||||
});
|
||||
} else if (wrapperRef.current) {
|
||||
// Fallback to wrapper position
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
setTextBoxPosition({
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 200),
|
||||
height: Math.max(rect.height, 50),
|
||||
fontSize: fontSize,
|
||||
fontFamily: fontFamily,
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
setTextDraft(obj?.contents || '');
|
||||
setIsTextEditorOpen(true);
|
||||
|
||||
// Focus the textarea after it renders
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.select();
|
||||
}, 0);
|
||||
}, [obj, annotation, annotationId]);
|
||||
|
||||
const handleSaveText = useCallback(() => {
|
||||
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||
|
||||
provides.updateAnnotation(pageIndex, annotationId, {
|
||||
contents: textDraft,
|
||||
});
|
||||
setIsTextEditorOpen(false);
|
||||
setTextBoxPosition(null);
|
||||
}, [provides, annotationId, pageIndex, textDraft]);
|
||||
|
||||
const handleCloseTextEdit = useCallback(() => {
|
||||
setIsTextEditorOpen(false);
|
||||
setTextBoxPosition(null);
|
||||
// Focus inline text input (same as double-clicking the note/text box). Dispatches dblclick on the
|
||||
// annotation hit-area div (EmbedPDF's inner div with onDoubleClick) so built-in FreeText editing is used.
|
||||
const handleFocusTextEdit = useCallback(() => {
|
||||
const root = wrapperRef.current?.closest('[data-no-interaction]');
|
||||
const main = root?.firstElementChild;
|
||||
// EmbedPDF puts onDoubleClick on the content div. For text/note (no rotation) it's the first child.
|
||||
const hitArea = main?.lastElementChild ?? main?.firstElementChild;
|
||||
if (!hitArea) return;
|
||||
hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }));
|
||||
}, []);
|
||||
|
||||
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
|
||||
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
|
||||
|
||||
const type = obj?.type;
|
||||
const patch: any = {};
|
||||
const patch: AnnotationPatch = {};
|
||||
|
||||
if (target === 'stroke') {
|
||||
// Shape stroke - preserve fill color
|
||||
@@ -262,14 +201,14 @@ function AnnotationSelectionMenuInner({
|
||||
patch.color = color;
|
||||
|
||||
// For text markup annotations (highlight, underline, strikeout, squiggly)
|
||||
if ([9, 10, 11, 12].includes(type)) {
|
||||
if (type !== undefined && [9, 10, 11, 12].includes(type)) {
|
||||
patch.strokeColor = color;
|
||||
patch.fillColor = color;
|
||||
patch.opacity = obj?.opacity ?? 1;
|
||||
}
|
||||
|
||||
// For line annotations (type 4, 8), include stroke properties
|
||||
if ([4, 8].includes(type)) {
|
||||
if (type !== undefined && [4, 8].includes(type)) {
|
||||
patch.strokeColor = color;
|
||||
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
|
||||
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
|
||||
@@ -330,7 +269,7 @@ function AnnotationSelectionMenuInner({
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={handleOpenTextEditor}
|
||||
onClick={handleFocusTextEdit}
|
||||
styles={commonButtonStyles}
|
||||
>
|
||||
<EditIcon style={{ fontSize: 18 }} />
|
||||
@@ -493,9 +432,6 @@ function AnnotationSelectionMenuInner({
|
||||
}
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
// Position menu below the wrapper, centered
|
||||
// Use getBoundingClientRect which gives viewport-relative coordinates
|
||||
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
|
||||
setMenuPosition({
|
||||
top: wrapperRect.bottom + 8,
|
||||
left: wrapperRect.left + wrapperRect.width / 2,
|
||||
@@ -504,11 +440,15 @@ function AnnotationSelectionMenuInner({
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Update position on scroll/resize
|
||||
// MutationObserver catches EmbedPDF updating the wrapper's inline style during drag
|
||||
const observer = new MutationObserver(updatePosition);
|
||||
observer.observe(wrapperRef.current, { attributes: true, attributeFilter: ['style'] });
|
||||
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
@@ -519,6 +459,7 @@ function AnnotationSelectionMenuInner({
|
||||
|
||||
const menuContent = menuPosition ? (
|
||||
<div
|
||||
data-annotation-selection-menu
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
@@ -542,79 +483,22 @@ function AnnotationSelectionMenuInner({
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${textBoxPosition.top}px`,
|
||||
left: `${textBoxPosition.left}px`,
|
||||
width: `${textBoxPosition.width}px`,
|
||||
height: `${textBoxPosition.height}px`,
|
||||
zIndex: 10001,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={textDraft}
|
||||
onChange={(e) => setTextDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCloseTextEdit();
|
||||
} else if (e.key === 'Enter' && e.ctrlKey) {
|
||||
handleSaveText();
|
||||
}
|
||||
}}
|
||||
onBlur={handleSaveText}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '0',
|
||||
minWidth: '0',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
fontSize: `${textBoxPosition.fontSize}px`,
|
||||
fontFamily: textBoxPosition.fontFamily,
|
||||
lineHeight: '1.2',
|
||||
color: '#000000',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '2px solid var(--mantine-color-blue-5)',
|
||||
borderRadius: '0',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
resize: 'none',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
overflow: 'hidden',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */}
|
||||
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps.
|
||||
Must stay pointerEvents:none so EmbedPDF's internal drag handlers receive events.
|
||||
Edit Text button dispatches dblclick on this wrapper's parent to use EmbedPDF's built-in inline editing. */}
|
||||
<div
|
||||
ref={setRef}
|
||||
onClick={canClickToEdit ? handleOpenTextEditor : undefined}
|
||||
style={{
|
||||
// Use EmbedPDF's positioning styles
|
||||
...menuWrapperProps?.style,
|
||||
// Keep the wrapper invisible but still occupying space for positioning
|
||||
opacity: 0,
|
||||
pointerEvents: canClickToEdit ? 'auto' : 'none',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
{typeof document !== 'undefined' && menuContent
|
||||
? createPortal(menuContent, document.body)
|
||||
: null}
|
||||
{typeof document !== 'undefined' && textEditorOverlay
|
||||
? createPortal(textEditorOverlay, document.body)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1066,6 +1066,8 @@ const EmbedPdfViewerContent = ({
|
||||
onDiscardAndContinue={async () => {
|
||||
// Save applied redactions (if any) while discarding pending ones
|
||||
await discardAndSaveApplied();
|
||||
// Reset annotation changes ref so future show/hide doesn't re-prompt
|
||||
hasAnnotationChangesRef.current = false;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import type { PluginRegistry } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
@@ -24,7 +25,9 @@ import { AttachmentPluginPackage } from '@embedpdf/plugin-attachment/react';
|
||||
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
import type { AnnotationTool, AnnotationEvent } from '@embedpdf/plugin-annotation';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import type { PdfAnnotationObject, Rect } from '@embedpdf/models';
|
||||
import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react';
|
||||
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
|
||||
@@ -68,7 +71,7 @@ interface LocalEmbedPDFProps {
|
||||
enableFormFill?: boolean;
|
||||
isManualRedactionMode?: boolean;
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
onSignatureAdded?: (annotation: PdfAnnotationObject) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
@@ -80,7 +83,7 @@ interface LocalEmbedPDFProps {
|
||||
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: Rect}>>([]);
|
||||
|
||||
// Convert File to URL if needed
|
||||
useEffect(() => {
|
||||
@@ -125,7 +128,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
createPluginRegistration(ScrollPluginPackage),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: !enableFormFill,
|
||||
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
||||
withAnnotations: !enableAnnotations, // Show baked annotations only when annotation layer is OFF; live layer visibility is controlled via CSS
|
||||
}),
|
||||
|
||||
// Register interaction manager (required for zoom and selection features)
|
||||
@@ -203,7 +206,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
// Register print plugin for printing PDFs
|
||||
createPluginRegistration(PrintPluginPackage),
|
||||
];
|
||||
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]);
|
||||
}, [pdfUrl, enableAnnotations, fileName, file, url]);
|
||||
|
||||
// Initialize the engine with the React hook - use local WASM for offline support
|
||||
const { engine, isLoading, error } = usePdfiumEngine({
|
||||
@@ -280,7 +283,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={async (registry: any) => {
|
||||
onInitialized={async (registry: PluginRegistry) => {
|
||||
// v2.0: Use registry.getPlugin() to access plugin APIs
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
@@ -289,10 +292,22 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
if (!annotationApi) return;
|
||||
|
||||
if (enableAnnotations) {
|
||||
const ensureTool = (tool: any) => {
|
||||
// LooseAnnotationTool bypasses strict Partial<T> defaults typing from the library —
|
||||
// EmbedPDF accepts extra runtime properties (borderWidth, textColor, finishOnDoubleClick,
|
||||
// etc.) that aren't reflected in the TypeScript model types.
|
||||
type LooseAnnotationTool = {
|
||||
id: string;
|
||||
name: string;
|
||||
interaction?: { exclusive: boolean; cursor: string; textSelection?: boolean; isRotatable?: boolean };
|
||||
matchScore?: (annotation: PdfAnnotationObject) => number;
|
||||
defaults?: Record<string, unknown>;
|
||||
clickBehavior?: Record<string, unknown>;
|
||||
behavior?: { deactivateToolAfterCreate?: boolean; selectAfterCreate?: boolean };
|
||||
};
|
||||
const ensureTool = (tool: LooseAnnotationTool) => {
|
||||
const existing = annotationApi.getTool?.(tool.id);
|
||||
if (!existing) {
|
||||
annotationApi.addTool(tool);
|
||||
annotationApi.addTool(tool as unknown as AnnotationTool);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,7 +315,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'highlight',
|
||||
name: 'Highlight',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
strokeColor: '#ffd54f',
|
||||
@@ -317,7 +332,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'underline',
|
||||
name: 'Underline',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
strokeColor: '#ffb300',
|
||||
@@ -334,7 +349,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'strikeout',
|
||||
name: 'Strikeout',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
strokeColor: '#e53935',
|
||||
@@ -351,7 +366,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'squiggly',
|
||||
name: 'Squiggly',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
strokeColor: '#00acc1',
|
||||
@@ -368,7 +383,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'ink',
|
||||
name: 'Pen',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
strokeColor: '#1f2933',
|
||||
@@ -388,7 +403,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'inkHighlighter',
|
||||
name: 'Ink Highlighter',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && (annotation.strokeColor === '#ffd54f' || annotation.color === '#ffd54f') ? 8 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.INK && (annotation.strokeColor === '#ffd54f' || annotation.color === '#ffd54f') ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
strokeColor: '#ffd54f',
|
||||
@@ -408,7 +423,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'square',
|
||||
name: 'Square',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
@@ -432,7 +447,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'circle',
|
||||
name: 'Circle',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
@@ -456,7 +471,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'line',
|
||||
name: 'Line',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
@@ -480,7 +495,12 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'lineArrow',
|
||||
name: 'Arrow',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => {
|
||||
if (annotation.type !== PdfAnnotationSubtype.LINE) return 0;
|
||||
// EmbedPDF stores endStyle/lineEndingStyles at runtime; library types use lineEndings
|
||||
const ann = annotation as PdfAnnotationObject & { endStyle?: string; lineEndingStyles?: { end?: string } };
|
||||
return (ann.endStyle === 'ClosedArrow' || ann.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0;
|
||||
},
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
@@ -505,7 +525,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'polyline',
|
||||
name: 'Polyline',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: '#1565c0',
|
||||
@@ -526,7 +546,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'polygon',
|
||||
name: 'Polygon',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: '#0000ff', // fill color (blue)
|
||||
@@ -548,8 +568,8 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
ensureTool({
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
interaction: { exclusive: true, cursor: 'text' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
|
||||
interaction: { exclusive: true, cursor: 'text', isRotatable: false },
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#111111',
|
||||
@@ -560,7 +580,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
contents: 'Text',
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
@@ -568,8 +588,8 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
ensureTool({
|
||||
id: 'note',
|
||||
name: 'Note',
|
||||
interaction: { exclusive: true, cursor: 'pointer' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
|
||||
interaction: { exclusive: true, cursor: 'pointer', isRotatable: false },
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#1b1b1b',
|
||||
@@ -584,7 +604,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
defaultSize: { width: 160, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
@@ -593,7 +613,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
id: 'stamp',
|
||||
name: 'Image Stamp',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
|
||||
matchScore: (annotation: PdfAnnotationObject) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
},
|
||||
@@ -627,7 +647,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
},
|
||||
});
|
||||
|
||||
annotationApi.onAnnotationEvent((event: any) => {
|
||||
annotationApi.onAnnotationEvent((event: AnnotationEvent) => {
|
||||
if (event.type === 'create' && event.committed) {
|
||||
setAnnotations(prev => [...prev, {
|
||||
id: event.annotation.id,
|
||||
@@ -635,19 +655,11 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
rect: event.annotation.rect
|
||||
}]);
|
||||
|
||||
|
||||
if (onSignatureAdded) {
|
||||
onSignatureAdded(event.annotation);
|
||||
}
|
||||
} else if (event.type === 'delete' && event.committed) {
|
||||
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
|
||||
} else if (event.type === 'loaded') {
|
||||
const loadedAnnotations = event.annotations || [];
|
||||
setAnnotations(loadedAnnotations.map((ann: any) => ({
|
||||
id: ann.id,
|
||||
pageIndex: ann.pageIndex || 0,
|
||||
rect: ann.rect
|
||||
})));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -750,8 +762,9 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
|
||||
<AnnotationLayer
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
selectionOutlineColor="#007ACC"
|
||||
selectionOutline={{ color: "#007ACC" }}
|
||||
selectionMenu={(props) => <AnnotationSelectionMenu {...props} />}
|
||||
style={!showBakedAnnotations ? { opacity: 0, pointerEvents: 'none' } : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import type { PluginRegistry } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
|
||||
@@ -18,6 +19,8 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { Rotation, PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import type { PdfAnnotationObject } from '@embedpdf/models';
|
||||
import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
@@ -41,7 +44,7 @@ const DOCUMENT_NAME = 'stirling-pdf-signing-viewer';
|
||||
interface LocalEmbedPDFWithAnnotationsProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
onAnnotationChange?: (annotations: any[]) => void;
|
||||
onAnnotationChange?: (annotations: PdfAnnotationObject[]) => void;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDFWithAnnotations({
|
||||
@@ -187,7 +190,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={async (registry: any) => {
|
||||
onInitialized={async (registry: PluginRegistry) => {
|
||||
// v2.0: Use registry.getPlugin() to access plugin APIs
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
@@ -223,10 +226,8 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
|
||||
// Listen for annotation events to notify parent
|
||||
if (onAnnotationChange) {
|
||||
annotationApi.onAnnotationEvent((event: any) => {
|
||||
if (event.committed) {
|
||||
// Get all annotations and notify parent
|
||||
// This is a simplified approach - in reality you'd need to get all annotations
|
||||
annotationApi.onAnnotationEvent((event: AnnotationEvent) => {
|
||||
if (event.type !== 'loaded' && event.committed) {
|
||||
onAnnotationChange([event.annotation]);
|
||||
}
|
||||
});
|
||||
@@ -295,7 +296,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
<AnnotationLayer
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
selectionOutlineColor="#007ACC"
|
||||
selectionOutline={{ color: "#007ACC" }}
|
||||
/>
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
|
||||
@@ -58,9 +58,11 @@ export function useViewerRightRailButtons(
|
||||
useEffect(() => {
|
||||
if (selectedTool === 'annotate') {
|
||||
setIsAnnotationsActive(true);
|
||||
} else if (selectedTool === 'redact') {
|
||||
} else if (selectedTool) {
|
||||
// Any other tool is active — annotate button should not be highlighted
|
||||
setIsAnnotationsActive(false);
|
||||
} else {
|
||||
// No tool selected — fall back to URL path check
|
||||
setIsAnnotationsActive(isAnnotationsPath());
|
||||
}
|
||||
}, [selectedTool, isAnnotationsPath]);
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface AnnotationAPI {
|
||||
getSelectedAnnotation: () => AnnotationSelection | null;
|
||||
deselectAnnotation: () => void;
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
|
||||
deleteAnnotation?: (pageIndex: number, annotationId: string) => void;
|
||||
deleteAnnotations?: (annotations: Array<{ pageIndex: number; id: string }>) => void;
|
||||
createAnnotation?: (pageIndex: number, annotation: Record<string, unknown>) => void;
|
||||
getSelectedAnnotations?: () => AnnotationSelection[];
|
||||
deactivateTools: () => void;
|
||||
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
|
||||
getActiveTool?: () => { id: AnnotationToolId } | null;
|
||||
@@ -77,13 +81,49 @@ export type AnnotationToolId =
|
||||
| 'signatureStamp'
|
||||
| 'signatureInk';
|
||||
|
||||
export interface AnnotationEvent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
// Import for internal use within this file, and re-export for external consumers
|
||||
import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
|
||||
export type { AnnotationEvent } from '@embedpdf/plugin-annotation';
|
||||
export type { PdfAnnotationObject } from '@embedpdf/models';
|
||||
|
||||
export type AnnotationPatch = Record<string, unknown>;
|
||||
export type AnnotationSelection = unknown;
|
||||
|
||||
/** Annotation object as returned by the EmbedPDF annotation API */
|
||||
export interface AnnotationObject {
|
||||
id?: string;
|
||||
uid?: string;
|
||||
pageIndex?: number;
|
||||
type?: number;
|
||||
subtype?: number;
|
||||
inkList?: unknown;
|
||||
color?: string;
|
||||
strokeColor?: string;
|
||||
fillColor?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
opacity?: number;
|
||||
strokeWidth?: number;
|
||||
borderWidth?: number;
|
||||
lineWidth?: number;
|
||||
thickness?: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontColor?: string;
|
||||
interiorColor?: string;
|
||||
textAlign?: number;
|
||||
endStyle?: string;
|
||||
startStyle?: string;
|
||||
lineEndingStyles?: { start?: string; end?: string };
|
||||
customData?: { toolId?: string; annotationToolId?: string };
|
||||
rect?: AnnotationRect;
|
||||
contents?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection returned by getSelectedAnnotation — EmbedPDF may wrap the annotation
|
||||
* in an `.object` property or surface fields directly on the selection.
|
||||
*/
|
||||
export type AnnotationSelection = AnnotationObject & { object?: AnnotationObject };
|
||||
|
||||
export interface AnnotationToolOptions {
|
||||
color?: string;
|
||||
|
||||
Reference in New Issue
Block a user