Files
Michael Ramos 4465950f0c feat: add bounded annotation undo and redo (#1426)
* feat: add bounded undo and redo history

* fix: address undo redo review feedback

* chore: refresh guide viewer manifest

* fix undo history review regressions

* chore: refresh guide viewer manifest
2026-08-31 09:12:13 -07:00

265 lines
8.0 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 } from './types';
import { useImageAnnotatorShortcuts } from '../../shortcuts';
import {
DEFAULT_STROKE_HISTORY_STATE,
clearStrokeHistory,
recordStroke,
redoStroke,
undoStroke,
type StrokeHistoryState,
} from './strokeHistory';
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<StrokeHistoryState>(DEFAULT_STROKE_HISTORY_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_STROKE_HISTORY_STATE);
setName(initialName);
}
}, [isOpen, initialName]);
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 recordStroke(s, s.currentStroke);
});
}, []);
const handleUndo = useCallback(() => {
setState(undoStroke);
}, []);
const handleRedo = useCallback(() => {
setState(redoStroke);
}, []);
const handleClear = useCallback(() => {
setState(clearStrokeHistory);
}, []);
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');
if (!ctx) throw new Error('Canvas 2D context is unavailable');
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 keyboardTargetIsInput = (event: KeyboardEvent): boolean =>
event.composedPath()[0] instanceof HTMLInputElement;
useImageAnnotatorShortcuts({
handlers: {
penTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'pen' })) },
arrowTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'arrow' })) },
circleTool: { when: (event) => isOpen && !saving && !keyboardTargetIsInput(event), handle: () => setState((current) => ({ ...current, tool: 'circle' })) },
undo: {
when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.strokes.length > 0,
handle: handleUndo,
},
redo: {
when: (event) => isOpen && !saving && !keyboardTargetIsInput(event) && state.futureStrokes.length > 0,
handle: handleRedo,
},
save: {
when: (event) => isOpen && !saving && !keyboardTargetIsInput(event),
handle: () => { void handleAccept(); },
},
},
});
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}
canRedo={state.futureStrokes.length > 0}
onToolChange={(tool) => setState(s => ({ ...s, tool }))}
onColorChange={(color) => setState(s => ({ ...s, color }))}
onStrokeSizeChange={(strokeSize) => setState(s => ({ ...s, strokeSize }))}
onUndo={handleUndo}
onRedo={handleRedo}
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 === 'Escape') {
e.preventDefault();
e.currentTarget.blur();
return;
}
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;