mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
e20fd97be7
* feat(ui): AnnotationPanel renderCardFooter + readOnly host props Per-card footer slot for host reply/resolve UI (clicks inside don't select the card); readOnly hides delete/edit on all card kinds. Both optional, both no-op by default — Plannotator unchanged. * feat(ui): bless 6 exports into the strict-consumer surface TableOfContents, ResizeHandle, useResizablePanel, useActiveSection, useScrollViewport, utils/annotationHelpers — verified strict-clean and backend-free (useResizablePanel persists via the storageBackend seam); added to the gate and the HANDOFF supported-imports table. * feat(ui): Viewer allowImages + readOnly host props allowImages threads to both CommentPopover sites (popover already gated its attach affordance; Viewer never exposed the knob). readOnly suppresses every composer entry point — selection toolbar (highlighter 'enabled'), pinpoint, global comment, attachments, checkbox toggles — while existing annotations still render and select. Both default to today's behavior. * fix(ui): lazy-import Viewer/CommentPopover in the consumer test (DOM-less bun test crashed on web-highlighter) * feat(ui): strict-consumer gate gains verbatimModuleSyntax + noUnusedLocals/Parameters Fixed the 24 violations the flags surfaced across the supported-import graph: type-only imports (verbatimModuleSyntax) and dead imports/locals. Consumers with stricter tsconfigs no longer have to relax them. * feat(ui): opt-in content-verifying annotation restore verifyRestoredContent on useAnnotationHighlighter: after a meta-based fromStore restore, the painted text is checked against originalText (whitespace-normalized). Mismatch -> highlight removed, text-search fallback re-anchors; if that fails too, onRestoreMismatch(annotation, restoredText) fires and nothing is painted. Default off — today's trust-the-positions behavior. Workspaces hit this live after document drift; correctness upgrade for every consumer. * chore(ui): 0.24.0 — HANDOFF consumer-enablement notes + version bump
273 lines
7.6 KiB
TypeScript
273 lines
7.6 KiB
TypeScript
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
import { Canvas } from './Canvas';
|
|
import { Toolbar } from './Toolbar';
|
|
import { renderStroke } from './utils';
|
|
import type { Point, AnnotatorState } from './types';
|
|
import { DEFAULT_STATE } from './types';
|
|
|
|
interface ImageAnnotatorProps {
|
|
imageSrc: string;
|
|
isOpen: boolean;
|
|
onAccept: (blob: Blob, hasDrawings: boolean, name: string) => Promise<void>;
|
|
onClose: () => void;
|
|
/** Pre-populated image name (derived from filename) */
|
|
initialName?: string;
|
|
}
|
|
|
|
export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
|
|
imageSrc,
|
|
isOpen,
|
|
onAccept,
|
|
onClose,
|
|
initialName = '',
|
|
}) => {
|
|
const [state, setState] = useState<AnnotatorState>(DEFAULT_STATE);
|
|
const [saving, setSaving] = useState(false);
|
|
const [name, setName] = useState(initialName);
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Reset state when dialog opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setState(DEFAULT_STATE);
|
|
setName(initialName);
|
|
}
|
|
}, [isOpen, initialName]);
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Don't intercept when typing in the name input
|
|
const target = e.target as HTMLElement;
|
|
if (target.tagName === 'INPUT') {
|
|
if (e.key === 'Escape') {
|
|
// Blur and let the next Escape close
|
|
target.blur();
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Escape or Enter to accept
|
|
if (e.key === 'Escape' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleAccept();
|
|
return;
|
|
}
|
|
|
|
// Cmd+Z to undo
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
e.preventDefault();
|
|
handleUndo();
|
|
return;
|
|
}
|
|
|
|
// 1/2/3 to switch tools
|
|
if (e.key === '1') setState(s => ({ ...s, tool: 'pen' }));
|
|
if (e.key === '2') setState(s => ({ ...s, tool: 'arrow' }));
|
|
if (e.key === '3') setState(s => ({ ...s, tool: 'circle' }));
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, state.strokes]);
|
|
|
|
const handleStrokeStart = useCallback((point: Point) => {
|
|
const id = crypto.randomUUID();
|
|
setState(s => ({
|
|
...s,
|
|
currentStroke: {
|
|
id,
|
|
tool: s.tool,
|
|
points: [point],
|
|
color: s.color,
|
|
size: s.strokeSize,
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const handleStrokeMove = useCallback((point: Point) => {
|
|
setState(s => {
|
|
if (!s.currentStroke) return s;
|
|
return {
|
|
...s,
|
|
currentStroke: {
|
|
...s.currentStroke,
|
|
points: [...s.currentStroke.points, point],
|
|
},
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const handleStrokeEnd = useCallback(() => {
|
|
setState(s => {
|
|
if (!s.currentStroke || s.currentStroke.points.length < 2) {
|
|
return { ...s, currentStroke: null };
|
|
}
|
|
return {
|
|
...s,
|
|
strokes: [...s.strokes, s.currentStroke],
|
|
currentStroke: null,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const handleUndo = useCallback(() => {
|
|
setState(s => ({
|
|
...s,
|
|
strokes: s.strokes.slice(0, -1),
|
|
}));
|
|
}, []);
|
|
|
|
const handleClear = useCallback(() => {
|
|
setState(s => ({
|
|
...s,
|
|
strokes: [],
|
|
currentStroke: null,
|
|
}));
|
|
}, []);
|
|
|
|
const handleImageLoad = useCallback((img: HTMLImageElement) => {
|
|
imageRef.current = img;
|
|
}, []);
|
|
|
|
const handleAccept = async () => {
|
|
if (saving) return;
|
|
setSaving(true);
|
|
|
|
try {
|
|
const img = imageRef.current;
|
|
if (!img) {
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
const hasDrawings = state.strokes.length > 0;
|
|
const finalName = name.trim() || initialName || 'image';
|
|
|
|
// If no drawings, just pass through original image
|
|
if (!hasDrawings) {
|
|
const response = await fetch(imageSrc);
|
|
const blob = await response.blob();
|
|
await onAccept(blob, false, finalName);
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
// Composite image + drawings
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d')!;
|
|
|
|
canvas.width = img.naturalWidth;
|
|
canvas.height = img.naturalHeight;
|
|
|
|
// Draw original image
|
|
ctx.drawImage(img, 0, 0);
|
|
|
|
// Scale factor from display size to natural size
|
|
const scale = img.naturalWidth / img.clientWidth;
|
|
|
|
// Draw all strokes at full resolution
|
|
state.strokes.forEach(stroke => {
|
|
renderStroke(ctx, stroke, scale);
|
|
});
|
|
|
|
// Convert to blob
|
|
canvas.toBlob(async (blob) => {
|
|
if (blob) {
|
|
await onAccept(blob, true, finalName);
|
|
}
|
|
onClose();
|
|
}, 'image/png');
|
|
} catch (err) {
|
|
console.error('Failed to save annotated image:', err);
|
|
onClose();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
if (e.target === e.currentTarget) {
|
|
handleAccept();
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center bg-background/90 backdrop-blur-sm"
|
|
data-popover-layer
|
|
onClick={handleBackdropClick}
|
|
>
|
|
{/* Canvas with image and toolbar */}
|
|
<div className="relative flex flex-col items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
|
{/* Toolbar - above image */}
|
|
<Toolbar
|
|
tool={state.tool}
|
|
color={state.color}
|
|
strokeSize={state.strokeSize}
|
|
canUndo={state.strokes.length > 0}
|
|
onToolChange={(tool) => setState(s => ({ ...s, tool }))}
|
|
onColorChange={(color) => setState(s => ({ ...s, color }))}
|
|
onStrokeSizeChange={(strokeSize) => setState(s => ({ ...s, strokeSize }))}
|
|
onUndo={handleUndo}
|
|
onClear={handleClear}
|
|
onSave={handleAccept}
|
|
/>
|
|
|
|
<Canvas
|
|
imageSrc={imageSrc}
|
|
strokes={state.strokes}
|
|
currentStroke={state.currentStroke}
|
|
tool={state.tool}
|
|
color={state.color}
|
|
onStrokeStart={handleStrokeStart}
|
|
onStrokeMove={handleStrokeMove}
|
|
onStrokeEnd={handleStrokeEnd}
|
|
onImageLoad={handleImageLoad}
|
|
/>
|
|
|
|
{/* Image name input */}
|
|
{initialName && (
|
|
<div className="flex items-center gap-2 w-full max-w-xs">
|
|
<label className="text-xs text-muted-foreground whitespace-nowrap">Name</label>
|
|
<input
|
|
ref={nameInputRef}
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
|
e.preventDefault();
|
|
handleAccept();
|
|
}
|
|
}}
|
|
className="flex-1 px-2 py-1 text-xs bg-muted/50 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
|
placeholder="Image name..."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Accept hint */}
|
|
<div className="text-xs text-muted-foreground">
|
|
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Esc</kbd> or <kbd className="px-1.5 py-0.5 bg-muted rounded text-foreground">Enter</kbd> or click outside to accept
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading overlay */}
|
|
{saving && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
|
<div className="text-sm text-muted-foreground">Saving...</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ImageAnnotator;
|