491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
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);
|
|
|
|
const draftRef = useRef<AnnotatorOp | null>(null);
|
|
const [draftTick, setDraftTick] = useState(0);
|
|
|
|
// Hold a stable ref to onCancel so the image-load effect doesn't depend
|
|
// on its identity. Without this, parents that pass an inline `() => …`
|
|
// re-render the modal on every keystroke / state change, the effect re-
|
|
// runs, the previous URL.createObjectURL gets revoked WHILE the new img
|
|
// is still decoding → img.onerror fires ("file not found") → onCancel →
|
|
// modal flashes open + closes instantly.
|
|
const onCancelRef = useRef(onCancel);
|
|
useEffect(() => { onCancelRef.current = onCancel; }, [onCancel]);
|
|
|
|
useEffect(() => {
|
|
// React 18 strict mode in dev double-mounts effects to test idempotency.
|
|
// The first run creates a blob URL, sets img.src, returns a cleanup
|
|
// that revokes — and the cleanup fires BEFORE the (still-in-flight)
|
|
// image fetch completes. The browser then emits ERR_FILE_NOT_FOUND for
|
|
// the revoked URL → img.onerror → modal closes instantly. The
|
|
// `cancelled` flag guards every callback so a torn-down run can't
|
|
// close the modal that the second mount just opened.
|
|
let cancelled = false;
|
|
const url = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
if (cancelled) return;
|
|
imageRef.current = img;
|
|
setImageLoaded(true);
|
|
};
|
|
img.onerror = () => {
|
|
if (cancelled) return;
|
|
console.error('ImageAnnotator: failed to decode source image');
|
|
onCancelRef.current();
|
|
};
|
|
img.src = url;
|
|
return () => {
|
|
cancelled = true;
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
}, [file]);
|
|
|
|
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);
|
|
}
|
|
if (draftRef.current) {
|
|
renderOp(ctx, draftRef.current);
|
|
}
|
|
}, [imageLoaded, ops, draftTick]);
|
|
|
|
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');
|
|
};
|
|
|
|
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
|
const cv = canvasRef.current!;
|
|
const rect = cv.getBoundingClientRect();
|
|
const scaleX = cv.width / rect.width;
|
|
const scaleY = cv.height / rect.height;
|
|
return {
|
|
x: (e.clientX - rect.left) * scaleX,
|
|
y: (e.clientY - rect.top) * scaleY,
|
|
};
|
|
}
|
|
|
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (!imageLoaded) return;
|
|
const cv = canvasRef.current;
|
|
if (!cv) return;
|
|
cv.setPointerCapture(e.pointerId);
|
|
const p = canvasPoint(e);
|
|
|
|
if (tool === 'text') {
|
|
const value = window.prompt(
|
|
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
|
'',
|
|
);
|
|
if (value !== null && value.trim().length > 0) {
|
|
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
|
setRedoStack([]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tool === 'pen' || tool === 'highlighter') {
|
|
draftRef.current = { tool, color, width, points: [p] };
|
|
} else {
|
|
draftRef.current = { tool, color, width, from: p, to: p };
|
|
}
|
|
setDraftTick((n) => n + 1);
|
|
};
|
|
|
|
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (!draftRef.current) return;
|
|
const p = canvasPoint(e);
|
|
const cur = draftRef.current;
|
|
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
|
cur.points = [...(cur.points ?? []), p];
|
|
} else {
|
|
cur.to = p;
|
|
}
|
|
setDraftTick((n) => n + 1);
|
|
};
|
|
|
|
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
|
const cv = canvasRef.current;
|
|
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
|
const cur = draftRef.current;
|
|
draftRef.current = null;
|
|
if (!cur) return;
|
|
const hasContent =
|
|
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
|
? (cur.points?.length ?? 0) >= 2
|
|
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
|
if (hasContent) {
|
|
setOps((p) => [...p, cur]);
|
|
setRedoStack([]);
|
|
}
|
|
setDraftTick((n) => n + 1);
|
|
};
|
|
|
|
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}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerUp}
|
|
className="max-h-full max-w-full cursor-crosshair touch-none 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 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
|
<div className="flex items-center gap-1">
|
|
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
|
const label = t('app:annotator.tool.' + id, {
|
|
defaultValue:
|
|
id === 'pen' ? 'Stift'
|
|
: id === 'highlighter' ? 'Marker'
|
|
: id === 'arrow' ? 'Pfeil'
|
|
: id === 'rect' ? 'Rechteck'
|
|
: id === 'circle' ? 'Kreis'
|
|
: 'Text',
|
|
});
|
|
const active = tool === id;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => setTool(id)}
|
|
aria-pressed={active}
|
|
title={label}
|
|
className={
|
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
|
(active
|
|
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
|
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
|
}
|
|
>
|
|
{toolGlyph(id)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
|
|
|
<div className="flex items-center gap-1">
|
|
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
|
const active = color === c;
|
|
return (
|
|
<button
|
|
key={c}
|
|
type="button"
|
|
onClick={() => setColor(c)}
|
|
aria-pressed={active}
|
|
aria-label={c}
|
|
className={
|
|
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
|
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
|
}
|
|
style={{ backgroundColor: c }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
|
|
|
<div className="flex items-center gap-1">
|
|
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
|
const active = width === w;
|
|
return (
|
|
<button
|
|
key={w}
|
|
type="button"
|
|
onClick={() => setWidth(w)}
|
|
aria-pressed={active}
|
|
title={w + 'px'}
|
|
className={
|
|
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
|
(active
|
|
? 'bg-accent/20 ring-2 ring-accent/40'
|
|
: 'bg-surface-3 hover:bg-surface')
|
|
}
|
|
>
|
|
<div
|
|
className="rounded-full bg-fg"
|
|
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="h-6 w-px bg-line/40" aria-hidden />
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleUndo}
|
|
disabled={ops.length === 0}
|
|
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
↶
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleRedo}
|
|
disabled={redoStack.length === 0}
|
|
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
↷
|
|
</button>
|
|
</div>
|
|
|
|
<div className="ml-auto 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>
|
|
);
|
|
}
|
|
|
|
function toolGlyph(t: AnnotatorTool): string {
|
|
switch (t) {
|
|
case 'pen': return '✎';
|
|
case 'highlighter': return '🖍';
|
|
case 'arrow': return '↗';
|
|
case 'rect': return '▭';
|
|
case 'circle': return '◯';
|
|
case 'text': return 'T';
|
|
}
|
|
}
|
|
|
|
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
|
ctx.save();
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.strokeStyle = op.color;
|
|
ctx.fillStyle = op.color;
|
|
ctx.lineWidth = op.width;
|
|
|
|
switch (op.tool) {
|
|
case 'pen': {
|
|
const pts = op.points;
|
|
if (!pts || pts.length < 1) break;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
|
for (let i = 1; i < pts.length; i++) {
|
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
|
}
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
case 'highlighter': {
|
|
const pts = op.points;
|
|
if (!pts || pts.length < 1) break;
|
|
ctx.globalAlpha = 0.35;
|
|
ctx.lineWidth = op.width * 4;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
|
for (let i = 1; i < pts.length; i++) {
|
|
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
|
}
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
case 'rect': {
|
|
const { from, to } = op;
|
|
if (!from || !to) break;
|
|
ctx.strokeRect(
|
|
Math.min(from.x, to.x),
|
|
Math.min(from.y, to.y),
|
|
Math.abs(to.x - from.x),
|
|
Math.abs(to.y - from.y),
|
|
);
|
|
break;
|
|
}
|
|
case 'circle': {
|
|
const { from, to } = op;
|
|
if (!from || !to) break;
|
|
const cx = (from.x + to.x) / 2;
|
|
const cy = (from.y + to.y) / 2;
|
|
const rx = Math.abs(to.x - from.x) / 2;
|
|
const ry = Math.abs(to.y - from.y) / 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
case 'arrow': {
|
|
const { from, to } = op;
|
|
if (!from || !to) break;
|
|
ctx.beginPath();
|
|
ctx.moveTo(from.x, from.y);
|
|
ctx.lineTo(to.x, to.y);
|
|
ctx.stroke();
|
|
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
const head = Math.max(12, op.width * 3);
|
|
ctx.beginPath();
|
|
ctx.moveTo(to.x, to.y);
|
|
ctx.lineTo(
|
|
to.x - head * Math.cos(angle - Math.PI / 6),
|
|
to.y - head * Math.sin(angle - Math.PI / 6),
|
|
);
|
|
ctx.moveTo(to.x, to.y);
|
|
ctx.lineTo(
|
|
to.x - head * Math.cos(angle + Math.PI / 6),
|
|
to.y - head * Math.sin(angle + Math.PI / 6),
|
|
);
|
|
ctx.stroke();
|
|
break;
|
|
}
|
|
case 'text': {
|
|
const { at, text } = op;
|
|
if (!at || !text) break;
|
|
const fontSize = Math.max(14, op.width * 6);
|
|
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(text, at.x, at.y);
|
|
break;
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|