From 9228cc635c1755a17960e8126cbe2c9d2a7ab12b Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 19:41:36 +0200 Subject: [PATCH] feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types --- .../desktop/src/components/ImageAnnotator.tsx | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 apps/desktop/src/components/ImageAnnotator.tsx diff --git a/apps/desktop/src/components/ImageAnnotator.tsx b/apps/desktop/src/components/ImageAnnotator.tsx new file mode 100644 index 0000000..fb3a182 --- /dev/null +++ b/apps/desktop/src/components/ImageAnnotator.tsx @@ -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(null); + const imageRef = useRef(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [ops, setOps] = useState([]); + const [redoStack, setRedoStack] = useState([]); + // 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('pen'); + const [color, _setColor] = useState('#ef4444'); + const [width, _setWidth] = useState(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 ( +
+
+

+ {t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })} +

+ +
+ +
+ {imageLoaded ? ( + + ) : ( +

+ {t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })} +

+ )} +
+ + +
+ ); +} + +// Stub — filled in by P4A.T2 with the actual draw switch per tool. +function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void { + // implemented in P4A.T2 +}