850 lines
28 KiB
Markdown
850 lines
28 KiB
Markdown
# 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 `<canvas>` 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<HTMLCanvasElement | null>(null);
|
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
|
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
|
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
|
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
|
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
|
const [width, setWidth] = useState<AnnotatorWidth>(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 (
|
|
<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}
|
|
className="max-h-full max-w-full cursor-crosshair 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 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
|
|
<div className="text-xs text-fg-muted">
|
|
{ops.length === 0
|
|
? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
|
|
: ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
|
|
</div>
|
|
<div className="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>
|
|
);
|
|
}
|
|
|
|
// 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<AnnotatorOp | null>(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<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,
|
|
};
|
|
}
|
|
```
|
|
|
|
Add these handlers (also above the `return`):
|
|
|
|
```ts
|
|
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);
|
|
};
|
|
```
|
|
|
|
Wire the handlers onto the `<canvas>` element — replace the existing `<canvas ref={canvasRef} className="..." />` JSX with:
|
|
|
|
```tsx
|
|
<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"
|
|
/>
|
|
```
|
|
|
|
- [ ] **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 `<footer>` element with:
|
|
|
|
```tsx
|
|
<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>
|
|
```
|
|
|
|
And add this helper at the bottom of the file (after `renderOp`):
|
|
|
|
```ts
|
|
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';
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/components/ImageAnnotator.tsx
|
|
git commit -m "feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Wire annotator into the composer — "✏" overlay on image previews + Save round-trip
|
|
|
|
**Why:** The annotator is fully functional but unreachable from the UI. This task adds the entry point.
|
|
|
|
**Files:**
|
|
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (the `AttachmentPreview` function at line ~1393 + the call site at line ~915 + add ImageAnnotator import/state)
|
|
|
|
- [ ] **Step 1: Extend `AttachmentPreview` with an `onEdit` prop**
|
|
|
|
Replace the existing `AttachmentPreview` function (line ~1393) with:
|
|
|
|
```tsx
|
|
function AttachmentPreview({
|
|
file,
|
|
onRemove,
|
|
onEdit,
|
|
}: {
|
|
file: File;
|
|
onRemove: () => void;
|
|
onEdit?: () => void;
|
|
}) {
|
|
const isImage = file.type.startsWith('image/');
|
|
const [url, setUrl] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
if (!isImage) return;
|
|
const u = URL.createObjectURL(file);
|
|
setUrl(u);
|
|
return () => URL.revokeObjectURL(u);
|
|
}, [file, isImage]);
|
|
return (
|
|
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
|
{isImage && url ? (
|
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
|
) : (
|
|
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
|
|
<span className="truncate font-semibold text-fg" title={file.name}>
|
|
{file.name || 'Datei'}
|
|
</span>
|
|
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
|
|
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
|
</div>
|
|
)}
|
|
{isImage && onEdit && (
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
aria-label="Bearbeiten"
|
|
title="Bearbeiten"
|
|
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
|
>
|
|
<PencilIcon className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
aria-label="Entfernen"
|
|
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
If `PencilIcon` isn't already exported from `./components/icons` (grep first: `Grep -n "PencilIcon\|EditIcon" apps/desktop/src/components/icons*`), inline this small SVG below `AttachmentPreview`:
|
|
|
|
```tsx
|
|
function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
<path d="M12 20h9" />
|
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
|
</svg>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add ImageAnnotator state + import to ConversationPage**
|
|
|
|
In the import block at the top of the file (alongside other `../components/...` imports), add:
|
|
|
|
```ts
|
|
import { ImageAnnotator } from '../components/ImageAnnotator';
|
|
```
|
|
|
|
Near the top of `ConversationPage` where other `useState`s live (e.g. near `attachments`), add:
|
|
|
|
```ts
|
|
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
|
```
|
|
|
|
- [ ] **Step 3: Pass `onEdit` to AttachmentPreview**
|
|
|
|
In the `attachments.map(...)` at line ~915, change the JSX to:
|
|
|
|
```tsx
|
|
{attachments.map((file, idx) => (
|
|
<AttachmentPreview
|
|
key={idx}
|
|
file={file}
|
|
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
|
onEdit={
|
|
file.type.startsWith('image/')
|
|
? () => setAnnotatingIndex(idx)
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
```
|
|
|
|
- [ ] **Step 4: Render the annotator at the page root when `annotatingIndex !== null`**
|
|
|
|
Place this near the end of the JSX return — just before the closing `</div>` of the page root:
|
|
|
|
```tsx
|
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
|
<ImageAnnotator
|
|
file={attachments[annotatingIndex]!}
|
|
onCancel={() => setAnnotatingIndex(null)}
|
|
onSave={(next) => {
|
|
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
|
setAnnotatingIndex(null);
|
|
}}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 5: Typecheck**
|
|
|
|
Run: `pnpm --filter @chat-app/desktop typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add apps/desktop/src/pages/ConversationPage.tsx
|
|
git commit -m "feat(P4A.T4): wire ImageAnnotator into composer attachment preview"
|
|
```
|
|
|
|
---
|
|
|
|
## Final gate
|
|
|
|
- [ ] **Step 1: Typecheck both packages**
|
|
|
|
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
|
Expected: both PASS.
|
|
|
|
- [ ] **Step 2: Run shared tests (sanity — these changes don't touch shared)**
|
|
|
|
Run: `pnpm --filter @chat-app/shared test -- --run`
|
|
Expected: PASS — same count as Phase 3 baseline (33 tests).
|
|
|
|
- [ ] **Step 3: Verify no uncommitted changes**
|
|
|
|
Run: `git status`
|
|
Expected: clean working tree on `main`.
|
|
|
|
- [ ] **Step 4: Report**
|
|
|
|
Report: "Phase 4A (Image Annotation) code-complete on `main`. Restart dev, attach an image in any chat, hover the preview → ✏ button appears → click → annotator opens. Draw, choose tools/colors/widths, undo/redo, Save → preview updates with the annotated PNG, ready to send. No migration. Released? defer to user."
|
|
|
|
---
|
|
|
|
## Self-review (resolved inline)
|
|
|
|
1. **Spec coverage** (against `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 97-101):
|
|
- "Attachment-picker for images shows a new ✏ Bearbeiten button before send" → T4 adds the overlay button (image-only via `file.type.startsWith('image/')`).
|
|
- "Opens `<ImageAnnotator>` modal: canvas overlay on the image" → T1 mounts canvas at image's natural size.
|
|
- "Tools: pen, arrow, rectangle, circle, text, highlighter" → T2 implements all 6 in `renderOp`.
|
|
- "6 colors, 3 stroke widths" → T3 swatches use the exact AnnotatorColor/AnnotatorWidth tuples from T1.
|
|
- "Undo/Redo stack, Reset, Save" → T1 callbacks + T3 toolbar buttons; Ctrl+Z / Ctrl+Y bindings in T1 keydown effect.
|
|
- "On Save: canvas.toBlob({type: 'image/png'}) flattens" → T1's `handleSave` + T4's `setAttachments(... map ... idx === annotatingIndex ? next : f)`.
|
|
|
|
2. **Placeholders:** none. Every step has concrete code.
|
|
|
|
3. **Type consistency:**
|
|
- `AnnotatorTool`/`AnnotatorColor`/`AnnotatorWidth` defined in T1, consumed everywhere downstream.
|
|
- `AnnotatorOp` fields match `renderOp` switch arms in T2.
|
|
- `ImageAnnotator` props shape (`file`/`onCancel`/`onSave`) used identically in T4's call site.
|
|
- `AttachmentPreview` extended with optional `onEdit?: () => void` in T4; existing callers passing only `file`/`onRemove` continue to compile because it's optional.
|
|
|
|
4. **One ambiguity surfaced + resolved:** the spec doesn't say whether to overwrite the original File or keep a side-by-side copy. Plan replaces the slot with a `<basename>-annotated.png` File so the recipient sees the marked-up version with a clear filename hint.
|