# 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 `