Files
ChatApp/apps/desktop/src/components/ImageAnnotator.tsx
T

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();
}