diff --git a/docs/superpowers/plans/2026-05-16-phase4a-image-annotation.md b/docs/superpowers/plans/2026-05-16-phase4a-image-annotation.md new file mode 100644 index 0000000..db47fdf --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase4a-image-annotation.md @@ -0,0 +1,849 @@ +# Phase 4A — Image Annotation vor Send + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the user mark up an image attachment (pen, arrow, rectangle, circle, text, highlighter) BEFORE it's sent. The annotated copy replaces the original `File` in the composer's attachments[] state; the recipient sees the flattened PNG with the annotation baked in (no separate metadata, no decoding work on receive). + +**Architecture:** +- New self-contained component `ImageAnnotator.tsx` (~400 LOC, no external deps): a full-screen modal with a single `` rendering the original image plus an in-memory "ops stack" of drawing commands. Every tool stroke is one op; undo pops from the stack into a redo buffer; redo moves it back. The canvas re-renders the whole stack on every change — simple, debuggable, fast at typical image sizes. +- New `AttachmentPreview` prop `onEdit?: () => void`. When the preview is an image and `onEdit` is wired, a `✏` button overlays the thumb. Clicking it opens `ImageAnnotator`; on Save the modal calls `onSave(newFile)` and `ConversationPage` swaps the entry in `attachments[]`. +- Save flow: `canvas.toBlob({ type: 'image/png' })` → wrap in `new File([blob], original.name.replace(/\.\w+$/, '') + '-annotated.png', { type: 'image/png', lastModified: Date.now() })` → return through `onSave`. + +**Tech Stack:** React 18 + TypeScript + HTML5 Canvas. Reuses Tailwind classes from the existing modal/toolbar code in the app. No new dependencies. + +**Non-goals:** +- No image-only crop / rotate / filter (just annotation). +- No persistence of in-progress annotations between modal opens. +- No collaboration / sharing of the op-stack (recipient sees flattened PNG only). +- No SVG / vector output; PNG raster only. + +--- + +## Pre-flight + +- [ ] **Verify clean working tree on `main`** + +Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status` +Expected: clean (ignored `.env.local` is fine). + +- [ ] **Confirm tooling is green** + +Run: `pnpm --filter @chat-app/desktop typecheck` +Expected: PASS. If it's red, STOP and report — don't start on a broken baseline. + +--- + +## Task 1: ImageAnnotator component skeleton + canvas mount + op-stack types + +**Why:** Get the modal rendering with the image visible inside the canvas before adding any drawing logic. Establishes the file's structure (state, refs, types) that the next tasks fill in. + +**Files:** +- Create: `apps/desktop/src/components/ImageAnnotator.tsx` + +- [ ] **Step 1: Write the file** + +Create `apps/desktop/src/components/ImageAnnotator.tsx`: + +```tsx +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([]); + const [tool, setTool] = useState('pen'); + const [color, setColor] = useState('#ef4444'); + const [width, setWidth] = useState(4); + + 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…' })} +

+ )} +
+ +
+
+ {ops.length === 0 + ? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' }) + : ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })} +
+
+ + +
+
+
+ ); +} + +// renderOp is filled in by Task 2. Stub for now so the canvas-replay loop +// in the main useEffect compiles cleanly. +function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void { + // implemented in Task 2 +} +``` + +If `text-accent-fg` doesn't exist in this project's Tailwind, use the same class the existing accent buttons use — grep `Grep -n "bg-accent " apps/desktop/src/components/RemoteRevokedScreen.tsx` (P3.T7 wrote this very recently) to find the conventional pair. + +- [ ] **Step 2: Typecheck** + +Run: `pnpm --filter @chat-app/desktop typecheck` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +cd "D:\Programmieren\ChatApp-Electron\chat-app" +git add apps/desktop/src/components/ImageAnnotator.tsx +git commit -m "feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types" +``` + +--- + +## Task 2: Drawing implementation — render all 6 tools + capture pointer events + +**Why:** This is the drawing engine. After this task the user can free-hand-draw + shapes on the canvas with the defaults (pen / red / width 4). Tool/color/width pickers come in Task 3. + +**Files:** +- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the `renderOp` stub + add pointer handlers + draft state) + +- [ ] **Step 1: Replace the `renderOp` stub with the real renderer** + +At the bottom of the file, replace the stub with: + +```ts +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(); +} +``` + +- [ ] **Step 2: Add pointer-event capture + draft state** + +Inside the component, near the other refs, add: + +```ts +const draftRef = useRef(null); +const [draftTick, setDraftTick] = useState(0); +``` + +Replace the existing main render `useEffect` body so it ALSO draws the in-progress draft (live preview during pointer-down): + +```ts +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]); +``` + +Add this helper right above the `return` (to convert client coords to internal canvas coords — important because the canvas is scaled to fit): + +```ts +function canvasPoint(e: React.PointerEvent): { 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, + }; +} +``` + +Add these handlers (also above the `return`): + +```ts +const handlePointerDown = (e: React.PointerEvent) => { + 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) => { + 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) => { + 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); +}; +``` + +Wire the handlers onto the `` element — replace the existing `` JSX with: + +```tsx + +``` + +- [ ] **Step 3: Typecheck** + +Run: `pnpm --filter @chat-app/desktop typecheck` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src/components/ImageAnnotator.tsx +git commit -m "feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter" +``` + +--- + +## Task 3: Toolbar — tool picker, color swatches, width swatches, undo/redo + +**Why:** The user can already DRAW (default pen + red + width 4) — but can't change tool/color/width or visibly undo. This task adds the controls. + +**Files:** +- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the footer) + +- [ ] **Step 1: Add the tool/color/width palette to the footer** + +Replace the existing `