mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
4465950f0c
* 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
265 lines
8.0 KiB
TypeScript
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;
|