feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,10 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const [color, _setColor] = useState<AnnotatorColor>('#ef4444');
|
||||
const [width, _setWidth] = useState<AnnotatorWidth>(4);
|
||||
|
||||
void tool; void color; void width; void _setTool; void _setColor; void _setWidth; void redoStack;
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
|
||||
void _setTool; void _setColor; void _setWidth; void redoStack;
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
@@ -68,7 +71,10 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
}, [imageLoaded, ops]);
|
||||
if (draftRef.current) {
|
||||
renderOp(ctx, draftRef.current);
|
||||
}
|
||||
}, [imageLoaded, ops, draftTick]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -125,6 +131,73 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
}, '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"
|
||||
@@ -150,7 +223,11 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
{imageLoaded ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full cursor-crosshair rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
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">
|
||||
@@ -188,7 +265,95 @@ export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Stub — filled in by P4A.T2 with the actual draw switch per tool.
|
||||
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
|
||||
// implemented in P4A.T2
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user