import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getArrowGeometry, MARKUP_DEFAULT_COLOR, MARKUP_DEFAULT_SHAPE, getMarkupPreviewStrokeWidth, parseMarkupShapes, sanitizeMarkupColor, sanitizeMarkupShape, } from './markupShapeGeometry.js'; function clampFraction(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return 0; return Math.max(0, Math.min(1, numeric)); } function ShapeElement({ shape, imageWidth, imageHeight }) { const x1 = shape.x1 * imageWidth; const y1 = shape.y1 * imageHeight; const x2 = shape.x2 * imageWidth; const y2 = shape.y2 * imageHeight; const left = Math.min(x1, x2); const top = Math.min(y1, y2); const width = Math.abs(x2 - x1); const height = Math.abs(y2 - y1); const strokeWidth = getMarkupPreviewStrokeWidth(shape.width, imageWidth, imageHeight); const renderShape = { ...shape, width: strokeWidth }; const common = { fill: 'none', stroke: shape.color, strokeWidth, strokeLinecap: shape.kind === 'arrow' ? 'square' : 'round', strokeLinejoin: 'round', }; if (shape.kind === 'line') { return ; } if (shape.kind === 'rectangle') { return ; } if (shape.kind === 'circle') { return ( ); } const arrow = getArrowGeometry(renderShape, imageWidth, imageHeight); return ( <> ); } export default function MarkupOverlay({ image, shape, strokeColor, strokeWidth, markupShapes, nodeId, onWidgetChange, }) { const containerRef = useRef(null); const imageRef = useRef(null); const shapesRef = useRef([]); const [draftShape, setDraftShape] = useState(null); const [drawing, setDrawing] = useState(false); const [imageSize, setImageSize] = useState({ width: 1, height: 1 }); const normalizedShape = useMemo( () => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : MARKUP_DEFAULT_SHAPE), [shape], ); const normalizedColor = useMemo( () => sanitizeMarkupColor(strokeColor, MARKUP_DEFAULT_COLOR), [strokeColor], ); const normalizedWidth = useMemo( () => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))), [strokeWidth], ); const committedShapes = useMemo( () => parseMarkupShapes(markupShapes, normalizedShape, normalizedColor, normalizedWidth), [markupShapes, normalizedShape, normalizedColor, normalizedWidth], ); useEffect(() => { shapesRef.current = committedShapes; }, [committedShapes]); useEffect(() => { const img = imageRef.current; if (!img) return; const updateImageSize = () => { const width = Math.max(1, img.naturalWidth || img.width || 1); const height = Math.max(1, img.naturalHeight || img.height || 1); setImageSize({ width, height }); }; updateImageSize(); if (!img.complete) { img.addEventListener('load', updateImageSize); return () => img.removeEventListener('load', updateImageSize); } return undefined; }, [image]); const getPoint = useCallback((event) => { const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return null; return { x: Number(clampFraction((event.clientX - rect.left) / rect.width).toFixed(4)), y: Number(clampFraction((event.clientY - rect.top) / rect.height).toFixed(4)), }; }, []); const commitShapes = useCallback((nextShapes) => { if (!nodeId || !onWidgetChange) return; onWidgetChange(nodeId, 'markup_shapes', JSON.stringify(nextShapes)); }, [nodeId, onWidgetChange]); const handlePointerDown = useCallback((event) => { if (!onWidgetChange || event.target.closest('button')) return; const point = getPoint(event); if (!point) return; event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setDrawing(true); setDraftShape({ kind: normalizedShape, color: normalizedColor, width: normalizedWidth, x1: point.x, y1: point.y, x2: point.x, y2: point.y, }); }, [getPoint, normalizedColor, normalizedShape, normalizedWidth, onWidgetChange]); const handlePointerMove = useCallback((event) => { if (!drawing) return; const point = getPoint(event); if (!point) return; setDraftShape((current) => (current ? { ...current, x2: point.x, y2: point.y } : current)); }, [drawing, getPoint]); const finishDrawing = useCallback(() => { if (!draftShape) { setDrawing(false); return; } const nextShape = sanitizeMarkupShape(draftShape, normalizedShape, normalizedColor, normalizedWidth); setDraftShape(null); setDrawing(false); if (!nextShape) return; commitShapes([...shapesRef.current, nextShape]); }, [commitShapes, draftShape, normalizedColor, normalizedShape, normalizedWidth]); const undoLast = useCallback(() => { if (shapesRef.current.length === 0) return; commitShapes(shapesRef.current.slice(0, -1)); }, [commitShapes]); const clearAll = useCallback(() => { commitShapes([]); }, [commitShapes]); const renderedShapes = draftShape ? [...committedShapes, draftShape] : committedShapes; return (
markup source {renderedShapes.map((item, index) => ( ))}
); }