mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
386a9e6d4a
- Add global (plan-level) and per-annotation image attachments - Image upload via drag-drop, file picker, or clipboard paste (Cmd+V) - ImageAnnotator component for drawing on images before saving - Freehand pen, arrow, and circle tools - Adjustable stroke size and color presets - Edge-to-edge circle drawing - Server endpoints for image upload (/api/upload) and serving (/api/image) - Images stored as paths in /tmp/plannotator/, included in export for Claude - Preserve image paths in URL sharing for round-trip scenarios - Uses perfect-freehand (~2KB) for smooth pen strokes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
119 lines
3.0 KiB
TypeScript
119 lines
3.0 KiB
TypeScript
import React, { useRef, useEffect, useCallback } from 'react';
|
|
import type { Point, Stroke, Tool } from './types';
|
|
import { renderStroke } from './utils';
|
|
|
|
interface CanvasProps {
|
|
imageSrc: string;
|
|
strokes: Stroke[];
|
|
currentStroke: Stroke | null;
|
|
tool: Tool;
|
|
color: string;
|
|
onStrokeStart: (point: Point) => void;
|
|
onStrokeMove: (point: Point) => void;
|
|
onStrokeEnd: () => void;
|
|
onImageLoad: (img: HTMLImageElement) => void;
|
|
}
|
|
|
|
export const Canvas: React.FC<CanvasProps> = ({
|
|
imageSrc,
|
|
strokes,
|
|
currentStroke,
|
|
onStrokeStart,
|
|
onStrokeMove,
|
|
onStrokeEnd,
|
|
onImageLoad,
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const imageRef = useRef<HTMLImageElement>(null);
|
|
const isDrawing = useRef(false);
|
|
|
|
// Render all strokes
|
|
const render = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const ctx = canvas?.getContext('2d');
|
|
if (!canvas || !ctx) return;
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Render completed strokes
|
|
strokes.forEach(stroke => renderStroke(ctx, stroke));
|
|
|
|
// Render current stroke
|
|
if (currentStroke) {
|
|
renderStroke(ctx, currentStroke);
|
|
}
|
|
}, [strokes, currentStroke]);
|
|
|
|
// Re-render when strokes change
|
|
useEffect(() => {
|
|
render();
|
|
}, [render]);
|
|
|
|
// Handle image load and canvas sizing
|
|
const handleImageLoad = () => {
|
|
const img = imageRef.current;
|
|
const canvas = canvasRef.current;
|
|
if (!img || !canvas) return;
|
|
|
|
// Match canvas to displayed image size
|
|
canvas.width = img.clientWidth;
|
|
canvas.height = img.clientHeight;
|
|
|
|
onImageLoad(img);
|
|
render();
|
|
};
|
|
|
|
// Get point from pointer event relative to canvas
|
|
const getPoint = (e: React.PointerEvent): Point => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
return {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top,
|
|
pressure: e.pressure,
|
|
};
|
|
};
|
|
|
|
const handlePointerDown = (e: React.PointerEvent) => {
|
|
e.preventDefault();
|
|
isDrawing.current = true;
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
onStrokeStart(getPoint(e));
|
|
};
|
|
|
|
const handlePointerMove = (e: React.PointerEvent) => {
|
|
if (!isDrawing.current) return;
|
|
onStrokeMove(getPoint(e));
|
|
};
|
|
|
|
const handlePointerUp = () => {
|
|
if (!isDrawing.current) return;
|
|
isDrawing.current = false;
|
|
onStrokeEnd();
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative inline-block">
|
|
<img
|
|
ref={imageRef}
|
|
src={imageSrc}
|
|
alt="Annotate"
|
|
onLoad={handleImageLoad}
|
|
className="max-w-[85vw] max-h-[70vh] rounded-lg"
|
|
draggable={false}
|
|
/>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="absolute inset-0 cursor-crosshair touch-none"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerUp}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|