feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types
This commit is contained in:
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { XIcon } from './icons';
|
||||||
|
|
||||||
|
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||||
|
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||||
|
export type AnnotatorWidth = 2 | 4 | 8;
|
||||||
|
|
||||||
|
export interface AnnotatorOp {
|
||||||
|
tool: AnnotatorTool;
|
||||||
|
color: AnnotatorColor;
|
||||||
|
width: AnnotatorWidth;
|
||||||
|
points?: Array<{ x: number; y: number }>;
|
||||||
|
from?: { x: number; y: number };
|
||||||
|
to?: { x: number; y: number };
|
||||||
|
text?: string;
|
||||||
|
at?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: File;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (next: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||||
|
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
|
||||||
|
// Defaults shown here are intentional so T2's pointer handlers work
|
||||||
|
// immediately with sensible behavior before T3's UI is wired.
|
||||||
|
const [tool, _setTool] = useState<AnnotatorTool>('pen');
|
||||||
|
const [color, _setColor] = useState<AnnotatorColor>('#ef4444');
|
||||||
|
const [width, _setWidth] = useState<AnnotatorWidth>(4);
|
||||||
|
|
||||||
|
void tool; void color; void width; void _setTool; void _setColor; void _setWidth; void redoStack;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
imageRef.current = img;
|
||||||
|
setImageLoaded(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('ImageAnnotator: failed to decode source image');
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file, onCancel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageLoaded) return;
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
const img = imageRef.current;
|
||||||
|
if (!cv || !img) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
for (const op of ops) {
|
||||||
|
renderOp(ctx, op);
|
||||||
|
}
|
||||||
|
}, [imageLoaded, ops]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onCancel();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUndo = () => {
|
||||||
|
setOps((cur) => {
|
||||||
|
if (cur.length === 0) return cur;
|
||||||
|
const next = cur.slice(0, -1);
|
||||||
|
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedo = () => {
|
||||||
|
setRedoStack((r) => {
|
||||||
|
if (r.length === 0) return r;
|
||||||
|
const top = r[r.length - 1]!;
|
||||||
|
setOps((cur) => [...cur, top]);
|
||||||
|
return r.slice(0, -1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setOps([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const cv = canvasRef.current;
|
||||||
|
if (!cv) return;
|
||||||
|
cv.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
console.error('ImageAnnotator: toBlob returned null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
const next = new File([blob], baseName + '-annotated.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
onSave(next);
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||||
|
>
|
||||||
|
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||||
|
<h2 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||||
|
{imageLoaded ? (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="max-h-full max-w-full cursor-crosshair rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||||
|
<div className="text-xs text-fg-muted">
|
||||||
|
{ops.length === 0
|
||||||
|
? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
|
||||||
|
: ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={ops.length === 0}
|
||||||
|
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!imageLoaded}
|
||||||
|
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub — filled in by P4A.T2 with the actual draw switch per tool.
|
||||||
|
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
|
||||||
|
// implemented in P4A.T2
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user