mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
Annotations set up
This commit is contained in:
parent
f3cc30d0c2
commit
9f54df290d
@ -708,6 +708,11 @@ tags = "signature,autograph"
|
||||
title = "Sign"
|
||||
desc = "Adds signature to PDF by drawing, text or image"
|
||||
|
||||
[home.annotate]
|
||||
tags = "annotate,highlight,draw"
|
||||
title = "Annotate"
|
||||
desc = "Highlight, draw, add notes and shapes in the viewer"
|
||||
|
||||
[home.flatten]
|
||||
tags = "simplify,remove,interactive"
|
||||
title = "Flatten"
|
||||
@ -3904,6 +3909,21 @@ draw = "Draw"
|
||||
save = "Save"
|
||||
saveChanges = "Save Changes"
|
||||
|
||||
[annotation]
|
||||
title = "Annotate"
|
||||
highlight = "Highlight"
|
||||
pen = "Pen"
|
||||
text = "Text box"
|
||||
note = "Note"
|
||||
rectangle = "Rectangle"
|
||||
ellipse = "Ellipse"
|
||||
select = "Select"
|
||||
exit = "Exit annotation mode"
|
||||
strokeWidth = "Width"
|
||||
opacity = "Opacity"
|
||||
fontSize = "Font size"
|
||||
chooseColor = "Choose colour"
|
||||
|
||||
[search]
|
||||
title = "Search PDF"
|
||||
placeholder = "Enter search term..."
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { useFileState, useFileContext } from '@app/contexts/FileContext';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useNavigation, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
|
||||
@ -23,16 +22,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const { t } = useTranslation();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef, isPlacementMode } = useSignature();
|
||||
|
||||
const { setToolAndWorkbench } = useNavigation();
|
||||
|
||||
// File state for save functionality
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
@ -41,6 +38,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
// Check if we're in sign mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
const isAnnotateMode = selectedTool === 'annotate';
|
||||
|
||||
// When leaving viewer, turn off annotation overlay
|
||||
useEffect(() => {
|
||||
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
@ -75,89 +80,19 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
{/* Launch Annotate tool in the left panel */}
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => setToolAndWorkbench('annotate', 'viewer')}
|
||||
disabled={disabled}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
@ -213,25 +148,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -84,9 +84,10 @@ const EmbedPdfViewerContent = ({
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that use the stamp/signature placement system with hover preview
|
||||
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
const isAnnotateTool = selectedTool === 'annotate';
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotateTool || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
@ -121,9 +121,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
selectAfterCreate: true,
|
||||
}),
|
||||
|
||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||
defaultMode: 'disabled',
|
||||
}),
|
||||
|
||||
// Register zoom plugin with configuration
|
||||
@ -248,7 +248,305 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
if (!annotationApi) return;
|
||||
|
||||
if (enableAnnotations) {
|
||||
annotationApi.addTool({
|
||||
const ensureTool = (tool: any) => {
|
||||
const existing = annotationApi.getTool?.(tool.id);
|
||||
if (!existing) {
|
||||
annotationApi.addTool(tool);
|
||||
}
|
||||
};
|
||||
|
||||
ensureTool({
|
||||
id: 'highlight',
|
||||
name: 'Highlight',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: '#ffd54f',
|
||||
opacity: 0.6,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'underline',
|
||||
name: 'Underline',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: '#ffb300',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'strikeout',
|
||||
name: 'Strikeout',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: '#e53935',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'squiggly',
|
||||
name: 'Squiggly',
|
||||
interaction: { exclusive: true, cursor: 'text', textSelection: true },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: '#00acc1',
|
||||
opacity: 1,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'ink',
|
||||
name: 'Pen',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#1f2933',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'inkHighlighter',
|
||||
name: 'Ink Highlighter',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#ffd54f',
|
||||
opacity: 0.5,
|
||||
borderWidth: 6,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'square',
|
||||
name: 'Square',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: '#1565c0',
|
||||
interiorColor: '#e3f2fd',
|
||||
opacity: 0.35,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 120, height: 90 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'circle',
|
||||
name: 'Circle',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: '#1565c0',
|
||||
interiorColor: '#e3f2fd',
|
||||
opacity: 0.35,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 100, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'line',
|
||||
name: 'Line',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultLength: 120,
|
||||
defaultAngle: 0,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'lineArrow',
|
||||
name: 'Arrow',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultLength: 120,
|
||||
defaultAngle: 0,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'polyline',
|
||||
name: 'Polyline',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: '#1565c0',
|
||||
opacity: 1,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
finishOnDoubleClick: true,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'polygon',
|
||||
name: 'Polygon',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: '#1565c0',
|
||||
interiorColor: '#e3f2fd',
|
||||
opacity: 0.35,
|
||||
borderWidth: 2,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
finishOnDoubleClick: true,
|
||||
defaultSize: { width: 140, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
interaction: { exclusive: true, cursor: 'text' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#111111',
|
||||
fontSize: 14,
|
||||
fontFamily: 'Helvetica',
|
||||
opacity: 1,
|
||||
interiorColor: '#fffef7',
|
||||
contents: 'Text',
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'note',
|
||||
name: 'Note',
|
||||
interaction: { exclusive: true, cursor: 'pointer' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: '#1b1b1b',
|
||||
color: '#ffa000',
|
||||
interiorColor: '#fff8e1',
|
||||
opacity: 1,
|
||||
contents: 'Note',
|
||||
fontSize: 12,
|
||||
},
|
||||
clickBehavior: {
|
||||
enabled: true,
|
||||
defaultSize: { width: 160, height: 100 },
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'stamp',
|
||||
name: 'Image Stamp',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: (annotation) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
},
|
||||
behavior: {
|
||||
deactivateToolAfterCreate: true,
|
||||
selectAfterCreate: true,
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
@ -258,7 +556,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
},
|
||||
});
|
||||
|
||||
annotationApi.addTool({
|
||||
ensureTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
|
||||
@ -2,7 +2,7 @@ import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useSta
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { AnnotationToolId, AnnotationToolOptions, SignatureAPI } from '@app/components/viewer/viewerTypes';
|
||||
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
|
||||
@ -199,12 +199,161 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
|
||||
const buildAnnotationDefaults = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
switch (toolId) {
|
||||
case 'highlight':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.HIGHLIGHT,
|
||||
color: options?.color ?? '#ffd54f',
|
||||
opacity: options?.opacity ?? 0.6,
|
||||
};
|
||||
case 'underline':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.UNDERLINE,
|
||||
color: options?.color ?? '#ffb300',
|
||||
opacity: options?.opacity ?? 1,
|
||||
};
|
||||
case 'strikeout':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.STRIKEOUT,
|
||||
color: options?.color ?? '#e53935',
|
||||
opacity: options?.opacity ?? 1,
|
||||
};
|
||||
case 'squiggly':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.SQUIGGLY,
|
||||
color: options?.color ?? '#00acc1',
|
||||
opacity: options?.opacity ?? 1,
|
||||
};
|
||||
case 'ink':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: options?.color ?? '#1f2933',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
lineWidth: options?.thickness ?? 2,
|
||||
strokeWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'inkHighlighter':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: options?.color ?? '#ffd54f',
|
||||
opacity: options?.opacity ?? 0.5,
|
||||
borderWidth: options?.thickness ?? 6,
|
||||
lineWidth: options?.thickness ?? 6,
|
||||
strokeWidth: options?.thickness ?? 6,
|
||||
};
|
||||
case 'text':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: options?.color ?? '#111111',
|
||||
fontSize: options?.fontSize ?? 14,
|
||||
fontFamily: options?.fontFamily ?? 'Helvetica',
|
||||
opacity: options?.opacity ?? 1,
|
||||
interiorColor: options?.fillColor ?? '#fffef7',
|
||||
borderWidth: options?.thickness ?? 1,
|
||||
};
|
||||
case 'note':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
textColor: options?.color ?? '#1b1b1b',
|
||||
color: options?.color ?? '#ffa000',
|
||||
interiorColor: options?.fillColor ?? '#fff8e1',
|
||||
opacity: options?.opacity ?? 1,
|
||||
fontSize: options?.fontSize ?? 12,
|
||||
contents: 'Note',
|
||||
};
|
||||
case 'square':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.SQUARE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
interiorColor: options?.fillColor ?? '#e3f2fd',
|
||||
opacity: options?.opacity ?? 0.35,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'circle':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.CIRCLE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
interiorColor: options?.fillColor ?? '#e3f2fd',
|
||||
opacity: options?.opacity ?? 0.35,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'line':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'lineArrow':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.LINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
startStyle: 'None',
|
||||
endStyle: 'ClosedArrow',
|
||||
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
|
||||
};
|
||||
case 'polyline':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.POLYLINE,
|
||||
color: options?.color ?? '#1565c0',
|
||||
opacity: options?.opacity ?? 1,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'polygon':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.POLYGON,
|
||||
color: options?.color ?? '#1565c0',
|
||||
interiorColor: options?.fillColor ?? '#e3f2fd',
|
||||
opacity: options?.opacity ?? 0.35,
|
||||
borderWidth: options?.thickness ?? 2,
|
||||
};
|
||||
case 'stamp':
|
||||
return {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
};
|
||||
case 'select':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const configureAnnotationTool = useCallback(
|
||||
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
const api = annotationApi as any;
|
||||
|
||||
if (defaults) {
|
||||
api.setToolDefaults?.(toolId, defaults);
|
||||
}
|
||||
|
||||
api.setActiveTool?.(toolId === 'select' ? null : toolId);
|
||||
},
|
||||
[annotationApi, buildAnnotationDefaults]
|
||||
);
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
const editable = target?.getAttribute?.('contenteditable');
|
||||
if (tag === 'input' || tag === 'textarea' || editable === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
@ -371,7 +520,23 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
|
||||
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
configureAnnotationTool(toolId, options);
|
||||
},
|
||||
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
|
||||
const defaults = buildAnnotationDefaults(toolId, options);
|
||||
const api = annotationApi as any;
|
||||
if (defaults && api?.setToolDefaults) {
|
||||
api.setToolDefaults(toolId, defaults);
|
||||
}
|
||||
},
|
||||
getSelectedAnnotation: () => {
|
||||
return annotationApi?.getSelectedAnnotation?.() ?? null;
|
||||
},
|
||||
updateAnnotation: (pageIndex: number, annotationId: string, patch: Partial<any>) => {
|
||||
annotationApi?.updateAnnotation?.(pageIndex, annotationId, patch);
|
||||
},
|
||||
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, configureAnnotationTool, buildAnnotationDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotationApi?.onAnnotationEvent) {
|
||||
|
||||
@ -14,6 +14,10 @@ export interface SignatureAPI {
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
deactivateTools: () => void;
|
||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||
activateAnnotationTool?: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
setAnnotationStyle?: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
|
||||
getSelectedAnnotation?: () => any | null;
|
||||
updateAnnotation?: (pageIndex: number, annotationId: string, patch: Partial<any>) => void;
|
||||
}
|
||||
|
||||
export interface HistoryAPI {
|
||||
@ -23,3 +27,33 @@ export interface HistoryAPI {
|
||||
canRedo: () => boolean;
|
||||
subscribe?: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export type AnnotationToolId =
|
||||
| 'select'
|
||||
| 'highlight'
|
||||
| 'underline'
|
||||
| 'strikeout'
|
||||
| 'squiggly'
|
||||
| 'ink'
|
||||
| 'inkHighlighter'
|
||||
| 'text'
|
||||
| 'note'
|
||||
| 'square'
|
||||
| 'circle'
|
||||
| 'line'
|
||||
| 'lineArrow'
|
||||
| 'polyline'
|
||||
| 'polygon'
|
||||
| 'stamp'
|
||||
| 'signatureStamp'
|
||||
| 'signatureInk';
|
||||
|
||||
export interface AnnotationToolOptions {
|
||||
color?: string;
|
||||
fillColor?: string;
|
||||
opacity?: number;
|
||||
thickness?: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
imageSrc?: string;
|
||||
}
|
||||
|
||||
@ -48,6 +48,7 @@ import Crop from "@app/tools/Crop";
|
||||
import Sign from "@app/tools/Sign";
|
||||
import AddText from "@app/tools/AddText";
|
||||
import AddImage from "@app/tools/AddImage";
|
||||
import Annotate from "@app/tools/Annotate";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@ -226,6 +227,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, 'addImage'),
|
||||
supportsAutomate: false,
|
||||
},
|
||||
annotate: {
|
||||
icon: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
|
||||
name: t('home.annotate.title', 'Annotate'),
|
||||
component: Annotate,
|
||||
description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EDIT,
|
||||
workbench: 'viewer',
|
||||
operationConfig: signOperationConfig,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, 'annotate'),
|
||||
supportsAutomate: false,
|
||||
},
|
||||
|
||||
// Document Security
|
||||
|
||||
|
||||
589
frontend/src/core/tools/Annotate.tsx
Normal file
589
frontend/src/core/tools/Annotate.tsx
Normal file
@ -0,0 +1,589 @@
|
||||
import { useEffect, useMemo, useState, useContext, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Text, Group, ActionIcon, Stack, Divider, Slider, Box, Tooltip as MantineTooltip, Button, TextInput, NumberInput } from '@mantine/core';
|
||||
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import { useNavigation } from '@app/contexts/NavigationContext';
|
||||
import { useFileSelection, useFileContext } from '@app/contexts/FileContext';
|
||||
import { BaseToolProps } from '@app/types/tool';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
const Annotate = (_props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setToolAndWorkbench } = useNavigation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const { selectors } = useFileContext();
|
||||
const { signatureApiRef } = useSignature();
|
||||
const viewerContext = useContext(ViewerContext);
|
||||
|
||||
const [activeTool, setActiveTool] = useState<AnnotationToolId>('highlight');
|
||||
const [inkColor, setInkColor] = useState('#1f2933');
|
||||
const [inkWidth, setInkWidth] = useState(2);
|
||||
const [highlightColor, setHighlightColor] = useState('#ffd54f');
|
||||
const [highlightOpacity, setHighlightOpacity] = useState(60);
|
||||
const [underlineColor, setUnderlineColor] = useState('#ffb300');
|
||||
const [strikeoutColor, setStrikeoutColor] = useState('#e53935');
|
||||
const [squigglyColor, setSquigglyColor] = useState('#00acc1');
|
||||
const [textColor, setTextColor] = useState('#111111');
|
||||
const [textSize, setTextSize] = useState(14);
|
||||
const [shapeStrokeColor, setShapeStrokeColor] = useState('#1565c0');
|
||||
const [shapeFillColor, setShapeFillColor] = useState('#e3f2fd');
|
||||
const [shapeOpacity, setShapeOpacity] = useState(35);
|
||||
const [shapeThickness, setShapeThickness] = useState(2);
|
||||
const [colorPickerTarget, setColorPickerTarget] = useState<'ink' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'text' | 'shapeStroke' | 'shapeFill' | null>(null);
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
|
||||
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
|
||||
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
|
||||
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
|
||||
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const stampInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const buildToolOptions = useCallback((toolId: AnnotationToolId) => {
|
||||
switch (toolId) {
|
||||
case 'ink':
|
||||
return { color: inkColor, thickness: inkWidth };
|
||||
case 'inkHighlighter':
|
||||
return { color: highlightColor, opacity: highlightOpacity / 100, thickness: 6 };
|
||||
case 'highlight':
|
||||
return { color: highlightColor, opacity: highlightOpacity / 100 };
|
||||
case 'underline':
|
||||
return { color: underlineColor, opacity: 1 };
|
||||
case 'strikeout':
|
||||
return { color: strikeoutColor, opacity: 1 };
|
||||
case 'squiggly':
|
||||
return { color: squigglyColor, opacity: 1 };
|
||||
case 'text':
|
||||
return { color: textColor, fontSize: textSize };
|
||||
case 'note':
|
||||
return { color: textColor };
|
||||
case 'square':
|
||||
case 'circle':
|
||||
case 'polygon':
|
||||
return {
|
||||
color: shapeStrokeColor,
|
||||
interiorColor: shapeFillColor,
|
||||
opacity: shapeOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
};
|
||||
case 'line':
|
||||
case 'polyline':
|
||||
case 'lineArrow':
|
||||
return {
|
||||
color: shapeStrokeColor,
|
||||
opacity: shapeOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [highlightColor, highlightOpacity, inkColor, inkWidth, underlineColor, strikeoutColor, squigglyColor, textColor, textSize, shapeStrokeColor, shapeFillColor, shapeOpacity, shapeThickness]);
|
||||
|
||||
useEffect(() => {
|
||||
setToolAndWorkbench('annotate', 'viewer');
|
||||
}, [setToolAndWorkbench]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerContext) return;
|
||||
if (viewerContext.isAnnotationMode) return;
|
||||
|
||||
viewerContext.setAnnotationMode(true);
|
||||
signatureApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
|
||||
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions]);
|
||||
|
||||
const activateAnnotationTool = (toolId: AnnotationToolId) => {
|
||||
viewerContext?.setAnnotationMode(true);
|
||||
setActiveTool(toolId);
|
||||
const options = buildToolOptions(toolId);
|
||||
signatureApiRef?.current?.activateAnnotationTool?.(toolId, options);
|
||||
|
||||
if (toolId === 'stamp') {
|
||||
// Use existing add image flow for stamp assets
|
||||
if (stampInputRef.current) {
|
||||
stampInputRef.current.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// push style updates to EmbedPDF when sliders/colors change
|
||||
signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
}, [activeTool, buildToolOptions, signatureApiRef]);
|
||||
|
||||
// Allow exiting multi-point tools with Escape (e.g., polyline)
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (['polyline', 'polygon'].includes(activeTool)) {
|
||||
signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
signatureApiRef?.current?.activateAnnotationTool?.(null as any);
|
||||
setTimeout(() => {
|
||||
signatureApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [activeTool, buildToolOptions, signatureApiRef]);
|
||||
|
||||
// Poll selected annotation to allow editing existing highlights/text
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const ann = signatureApiRef?.current?.getSelectedAnnotation?.();
|
||||
const annId = ann?.object?.id ?? null;
|
||||
setSelectedAnn(ann || null);
|
||||
// Only reset drafts when selection changes
|
||||
if (annId !== selectedAnnId) {
|
||||
setSelectedAnnId(annId);
|
||||
if (ann?.object?.contents !== undefined) {
|
||||
setSelectedTextDraft(ann.object.contents ?? '');
|
||||
}
|
||||
if (ann?.object?.fontSize !== undefined) {
|
||||
setSelectedFontSize(ann.object.fontSize ?? 14);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
return () => clearInterval(interval);
|
||||
}, [signatureApiRef, selectedAnnId]);
|
||||
|
||||
const annotationTools: { id: AnnotationToolId; label: string; icon: string }[] = [
|
||||
{ id: 'highlight', label: t('annotation.highlight', 'Highlight'), icon: 'highlight' },
|
||||
{ id: 'underline', label: t('annotation.underline', 'Underline'), icon: 'format-underlined' },
|
||||
{ id: 'strikeout', label: t('annotation.strikeout', 'Strikeout'), icon: 'strikethrough-s' },
|
||||
{ id: 'squiggly', label: t('annotation.squiggly', 'Squiggly'), icon: 'show-chart' },
|
||||
{ id: 'ink', label: t('annotation.pen', 'Pen'), icon: 'edit' },
|
||||
{ id: 'inkHighlighter', label: t('annotation.inkHighlighter', 'Ink Highlighter'), icon: 'brush' },
|
||||
{ id: 'text', label: t('annotation.text', 'Text box'), icon: 'text-fields' },
|
||||
{ id: 'note', label: t('annotation.note', 'Note'), icon: 'sticky-note-2' },
|
||||
{ id: 'square', label: t('annotation.square', 'Square'), icon: 'crop-square' },
|
||||
{ id: 'circle', label: t('annotation.circle', 'Circle'), icon: 'radio-button-unchecked' },
|
||||
{ id: 'line', label: t('annotation.line', 'Line'), icon: 'show-chart' },
|
||||
{ id: 'lineArrow', label: t('annotation.arrow', 'Arrow'), icon: 'trending-flat' },
|
||||
{ id: 'polyline', label: t('annotation.polyline', 'Polyline'), icon: 'polyline' },
|
||||
{ id: 'polygon', label: t('annotation.polygon', 'Polygon'), icon: 'change-history' },
|
||||
{ id: 'stamp', label: t('annotation.stamp', 'Stamp'), icon: 'image' },
|
||||
];
|
||||
|
||||
const activeColor =
|
||||
colorPickerTarget === 'ink'
|
||||
? inkColor
|
||||
: colorPickerTarget === 'highlight' || colorPickerTarget === 'inkHighlighter'
|
||||
? highlightColor
|
||||
: colorPickerTarget === 'underline'
|
||||
? underlineColor
|
||||
: colorPickerTarget === 'strikeout'
|
||||
? strikeoutColor
|
||||
: colorPickerTarget === 'squiggly'
|
||||
? squigglyColor
|
||||
: colorPickerTarget === 'shapeStroke'
|
||||
? shapeStrokeColor
|
||||
: colorPickerTarget === 'shapeFill'
|
||||
? shapeFillColor
|
||||
: textColor;
|
||||
|
||||
const steps = useMemo(() => {
|
||||
if (selectedFiles.length === 0) return [];
|
||||
|
||||
const toolButtons = (
|
||||
<Group gap="xs">
|
||||
{annotationTools.map((tool) => (
|
||||
<MantineTooltip key={tool.id} label={tool.label} withArrow>
|
||||
<ActionIcon
|
||||
variant={activeTool === tool.id ? 'filled' : 'subtle'}
|
||||
color={activeTool === tool.id ? 'blue' : undefined}
|
||||
radius="md"
|
||||
onClick={() => activateAnnotationTool(tool.id)}
|
||||
aria-label={tool.label}
|
||||
>
|
||||
<LocalIcon icon={tool.icon} width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</MantineTooltip>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const controls = (
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" align="center">
|
||||
<input
|
||||
ref={stampInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const dataUrl: string = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
// push into stamp defaults and activate stamp tool
|
||||
signatureApiRef?.current?.setAnnotationStyle?.('stamp', { imageSrc: dataUrl as string });
|
||||
signatureApiRef?.current?.activateAnnotationTool?.('stamp', { imageSrc: dataUrl as string });
|
||||
setActiveTool('stamp');
|
||||
} catch (err) {
|
||||
console.error('Failed to load stamp image', err);
|
||||
} finally {
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ColorSwatchButton
|
||||
color={
|
||||
activeTool === 'ink'
|
||||
? inkColor
|
||||
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
|
||||
? highlightColor
|
||||
: activeTool === 'underline'
|
||||
? underlineColor
|
||||
: activeTool === 'strikeout'
|
||||
? strikeoutColor
|
||||
: activeTool === 'squiggly'
|
||||
? squigglyColor
|
||||
: shapeStrokeColor
|
||||
}
|
||||
size={30}
|
||||
onClick={() => {
|
||||
const target =
|
||||
activeTool === 'ink'
|
||||
? 'ink'
|
||||
: activeTool === 'highlight' || activeTool === 'inkHighlighter'
|
||||
? 'highlight'
|
||||
: activeTool === 'underline'
|
||||
? 'underline'
|
||||
: activeTool === 'strikeout'
|
||||
? 'strikeout'
|
||||
: activeTool === 'squiggly'
|
||||
? 'squiggly'
|
||||
: ['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool)
|
||||
? 'shapeStroke'
|
||||
: 'text';
|
||||
setColorPickerTarget(target);
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
{['square', 'circle', 'polygon'].includes(activeTool) && (
|
||||
<ColorSwatchButton
|
||||
color={shapeFillColor}
|
||||
size={30}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeFill');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTool === 'ink' && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">{t('annotation.strokeWidth', 'Width')}</Text>
|
||||
<Slider min={1} max={12} value={inkWidth} onChange={setInkWidth} w={140} />
|
||||
</>
|
||||
)}
|
||||
{(activeTool === 'highlight' || activeTool === 'inkHighlighter') && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider min={10} max={100} value={highlightOpacity} onChange={setHighlightOpacity} w={140} />
|
||||
</>
|
||||
)}
|
||||
{activeTool === 'text' && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">{t('annotation.fontSize', 'Font size')}</Text>
|
||||
<Slider min={8} max={32} value={textSize} onChange={setTextSize} w={140} />
|
||||
</>
|
||||
)}
|
||||
{['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool) && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider min={10} max={100} value={shapeOpacity} onChange={setShapeOpacity} w={140} />
|
||||
<Text size="sm" c="dimmed">{t('annotation.strokeWidth', 'Stroke')}</Text>
|
||||
<Slider min={1} max={12} value={shapeThickness} onChange={setShapeThickness} w={140} />
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Divider />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('annotation.tipPlace', 'Click anywhere on the PDF to place highlights, drawings, notes, or text.')}
|
||||
</Text>
|
||||
{selectedAnn && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>{t('annotation.editSelected', 'Edit selected annotation')}</Text>
|
||||
{(selectedAnn.object?.type === 9 || selectedAnn.object?.type === 1 || selectedAnn.object?.type === 3 || selectedAnn.object?.type === 15) && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
|
||||
onChange={(value) => {
|
||||
signatureApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ opacity: value / 100 }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(selectedAnn.object?.type === 9 || selectedAnn.object?.type === 15 || selectedAnn.object?.type === 3 || selectedAnn.object?.type === 1) && (
|
||||
<ColorSwatchButton
|
||||
color={selectedAnn.object?.color ?? selectedAnn.object?.textColor ?? highlightColor}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('highlight');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(selectedAnn.object?.type === 3 || selectedAnn.object?.type === 1) && (
|
||||
<>
|
||||
<TextInput
|
||||
label={t('annotation.text', 'Text')}
|
||||
value={selectedTextDraft}
|
||||
onChange={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
setSelectedTextDraft(val);
|
||||
if (selectedUpdateTimer.current) {
|
||||
clearTimeout(selectedUpdateTimer.current);
|
||||
}
|
||||
selectedUpdateTimer.current = setTimeout(() => {
|
||||
signatureApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ contents: val, textColor: selectedAnn.object?.textColor ?? textColor }
|
||||
);
|
||||
}, 120);
|
||||
}}
|
||||
/>
|
||||
{selectedAnn.object?.type === 3 && (
|
||||
<NumberInput
|
||||
label={t('annotation.fontSize', 'Font size')}
|
||||
min={6}
|
||||
max={72}
|
||||
value={selectedFontSize}
|
||||
onChange={(val) => {
|
||||
const size = typeof val === 'number' ? val : 14;
|
||||
setSelectedFontSize(size);
|
||||
signatureApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ fontSize: size }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{['4','5','7','8','12','15'].includes(String(selectedAnn.object?.type)) && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">{t('annotation.opacity', 'Opacity')}</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
|
||||
onChange={(value) => {
|
||||
signatureApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ opacity: value / 100 }
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">{t('annotation.strokeWidth', 'Stroke')}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={12}
|
||||
value={selectedAnn.object?.borderWidth ?? shapeThickness}
|
||||
onChange={(value) => {
|
||||
signatureApiRef?.current?.updateAnnotation?.(
|
||||
selectedAnn.object?.pageIndex ?? 0,
|
||||
selectedAnn.object?.id,
|
||||
{ borderWidth: value }
|
||||
);
|
||||
setShapeThickness(value);
|
||||
}}
|
||||
/>
|
||||
<Group gap="xs">
|
||||
<ColorSwatchButton
|
||||
color={selectedAnn.object?.color ?? shapeStrokeColor}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeStroke');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
{['4','5','7','8','12'].includes(String(selectedAnn.object?.type)) && (
|
||||
<ColorSwatchButton
|
||||
color={selectedAnn.object?.interiorColor ?? shapeFillColor}
|
||||
size={28}
|
||||
onClick={() => {
|
||||
setColorPickerTarget('shapeFill');
|
||||
setIsColorPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<LocalIcon icon="save" width="1rem" height="1rem" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext?.exportActions?.saveAsCopy?.();
|
||||
if (!pdfArrayBuffer) return;
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
const fileName = selectors.getFiles()[0]?.name || 'annotated.pdf';
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
} catch (error) {
|
||||
console.error('Failed to save annotated PDF', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('rightRail.save', 'Save')}
|
||||
</Button>
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={activeColor}
|
||||
onColorChange={(color) => {
|
||||
if (colorPickerTarget === 'ink') {
|
||||
setInkColor(color);
|
||||
if (activeTool === 'ink') {
|
||||
signatureApiRef?.current?.setAnnotationStyle?.('ink', buildToolOptions('ink'));
|
||||
}
|
||||
if (selectedAnn?.object?.type === 15) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color });
|
||||
}
|
||||
} else if (colorPickerTarget === 'highlight') {
|
||||
setHighlightColor(color);
|
||||
if (activeTool === 'highlight' || activeTool === 'inkHighlighter') {
|
||||
signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
}
|
||||
if (selectedAnn?.object?.type === 9 || selectedAnn?.object?.type === 15) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color });
|
||||
}
|
||||
} else if (colorPickerTarget === 'underline') {
|
||||
setUnderlineColor(color);
|
||||
signatureApiRef?.current?.setAnnotationStyle?.('underline', buildToolOptions('underline'));
|
||||
if (selectedAnn?.object?.id) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color });
|
||||
}
|
||||
} else if (colorPickerTarget === 'strikeout') {
|
||||
setStrikeoutColor(color);
|
||||
signatureApiRef?.current?.setAnnotationStyle?.('strikeout', buildToolOptions('strikeout'));
|
||||
if (selectedAnn?.object?.id) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color });
|
||||
}
|
||||
} else if (colorPickerTarget === 'squiggly') {
|
||||
setSquigglyColor(color);
|
||||
signatureApiRef?.current?.setAnnotationStyle?.('squiggly', buildToolOptions('squiggly'));
|
||||
if (selectedAnn?.object?.id) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, { color });
|
||||
}
|
||||
} else {
|
||||
setTextColor(color);
|
||||
if (activeTool === 'text') {
|
||||
signatureApiRef?.current?.setAnnotationStyle?.('text', buildToolOptions('text'));
|
||||
}
|
||||
if (selectedAnn?.object?.type === 3 || selectedAnn?.object?.type === 1) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, {
|
||||
textColor: color,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (colorPickerTarget === 'shapeStroke' && ['square', 'circle', 'line', 'lineArrow', 'polyline', 'polygon'].includes(activeTool)) {
|
||||
setShapeStrokeColor(color);
|
||||
signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
if (selectedAnn?.object?.id) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, {
|
||||
color,
|
||||
strokeColor: color,
|
||||
borderWidth: shapeThickness,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (colorPickerTarget === 'shapeFill' && ['square', 'circle', 'polygon'].includes(activeTool)) {
|
||||
setShapeFillColor(color);
|
||||
signatureApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
|
||||
if (selectedAnn && (selectedAnn.object?.interiorColor !== undefined || ['4','5','7','8','12'].includes(String(selectedAnn.object?.type)))) {
|
||||
signatureApiRef?.current?.updateAnnotation?.(selectedAnn.object.pageIndex ?? 0, selectedAnn.object.id, {
|
||||
interiorColor: color,
|
||||
fillColor: color,
|
||||
borderWidth: shapeThickness,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t('annotation.chooseColor', 'Choose color')}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
title: t('annotation.title', 'Annotate'),
|
||||
isCollapsed: false,
|
||||
onCollapsedClick: undefined,
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" radius="md">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('annotation.desc', 'Use highlight, pen, text, and notes. Changes stay live—no flattening required.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
<Box>
|
||||
<Text size="sm" mb="xs" fw={600}>{t('annotation.title', 'Annotate')}</Text>
|
||||
{toolButtons}
|
||||
</Box>
|
||||
{controls}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [
|
||||
activeTool,
|
||||
annotationTools,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
selectedFiles.length,
|
||||
t,
|
||||
selectedAnn,
|
||||
textColor,
|
||||
textSize,
|
||||
viewerContext?.exportActions,
|
||||
selectors,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: false,
|
||||
},
|
||||
steps,
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: { files: [], downloadUrl: null },
|
||||
title: '',
|
||||
onFileClick: () => {},
|
||||
onUndo: () => {},
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default Annotate;
|
||||
@ -25,6 +25,7 @@ export const CORE_REGULAR_TOOL_IDS = [
|
||||
'ocr',
|
||||
'addImage',
|
||||
'rotate',
|
||||
'annotate',
|
||||
'scannerImageSplit',
|
||||
'editTableOfContents',
|
||||
'scannerEffect',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user