223 lines
6.6 KiB
JavaScript
223 lines
6.6 KiB
JavaScript
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 <line x1={x1} y1={y1} x2={x2} y2={y2} {...common} />;
|
|
}
|
|
|
|
if (shape.kind === 'rectangle') {
|
|
return <rect x={left} y={top} width={width} height={height} {...common} />;
|
|
}
|
|
|
|
if (shape.kind === 'circle') {
|
|
return (
|
|
<ellipse
|
|
cx={left + width / 2}
|
|
cy={top + height / 2}
|
|
rx={width / 2}
|
|
ry={height / 2}
|
|
{...common}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const arrow = getArrowGeometry(renderShape, imageWidth, imageHeight);
|
|
return (
|
|
<>
|
|
<polyline points={arrow.line} {...common} />
|
|
<polygon
|
|
points={arrow.head}
|
|
fill={shape.color}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={`nodrag nowheel markup-overlay${drawing ? ' markup-overlay-drawing' : ''}`}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={finishDrawing}
|
|
onPointerCancel={finishDrawing}
|
|
onLostPointerCapture={finishDrawing}
|
|
>
|
|
<img ref={imageRef} src={image} alt="markup source" draggable={false} className="markup-image" />
|
|
<svg
|
|
className="markup-svg"
|
|
viewBox={`0 0 ${imageSize.width} ${imageSize.height}`}
|
|
preserveAspectRatio="none"
|
|
>
|
|
{renderedShapes.map((item, index) => (
|
|
<ShapeElement
|
|
key={`${item.kind}-${index}`}
|
|
shape={item}
|
|
imageWidth={imageSize.width}
|
|
imageHeight={imageSize.height}
|
|
/>
|
|
))}
|
|
</svg>
|
|
<div className="markup-toolbar">
|
|
<button className="markup-tool-btn" type="button" onClick={undoLast} disabled={committedShapes.length === 0}>
|
|
Undo
|
|
</button>
|
|
<button className="markup-tool-btn" type="button" onClick={clearAll} disabled={committedShapes.length === 0}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|