add rect masking

This commit is contained in:
2026-04-15 23:58:34 -07:00
parent 349142f0e6
commit 31422e76db
12 changed files with 491 additions and 24 deletions

View File

@@ -13,15 +13,40 @@ interface CropBoxOverlayProps {
bLocked: boolean;
nodeId: string;
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
square?: boolean;
xreal?: number;
yreal?: number;
}
function snapPhysicalSquare(
anchorX: number, anchorY: number,
moverX: number, moverY: number,
xreal: number, yreal: number,
) {
const dx = moverX - anchorX;
const dy = moverY - anchorY;
const ax = xreal > 0 ? xreal : 1;
const ay = yreal > 0 ? yreal : 1;
const shortPhys = Math.min(Math.abs(dx) * ax, Math.abs(dy) * ay);
const sx = dx >= 0 ? 1 : -1;
const sy = dy >= 0 ? 1 : -1;
return {
x: anchorX + sx * (shortPhys / ax),
y: anchorY + sy * (shortPhys / ay),
};
}
export default function CropBoxOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
square = false,
xreal = 1,
yreal = 1,
}: CropBoxOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<string | null>(null);
const panStartRef = useRef<{ fx: number; fy: number; x1: number; y1: number; x2: number; y2: number } | null>(null);
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
return pointerToFraction(e, containerRef.current!);
@@ -30,28 +55,66 @@ export default function CropBoxOverlay({
const onPointerDown = useCallback((point: string) => (e: React.PointerEvent<Element>) => {
if (point === 'p1' && aLocked) return;
if (point === 'p2' && bLocked) return;
if (point === 'rect' && (aLocked || bLocked)) return;
e.stopPropagation();
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
if (point === 'rect') {
const { fx, fy } = getCoords(e);
panStartRef.current = { fx, fy, x1, y1, x2, y2 };
}
setDragging(point);
}, [aLocked, bLocked]);
}, [aLocked, bLocked, getCoords, x1, y1, x2, y2]);
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
if (!dragging || !containerRef.current) return;
const { fx, fy } = getCoords(e);
const vx = parseFloat(fx.toFixed(3));
const vy = parseFloat(fy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vx);
onWidgetChange(nodeId, 'y1', vy);
} else {
onWidgetChange(nodeId, 'x2', vx);
onWidgetChange(nodeId, 'y2', vy);
if (dragging === 'rect') {
const start = panStartRef.current;
if (!start) return;
const left = Math.min(start.x1, start.x2);
const right = Math.max(start.x1, start.x2);
const top = Math.min(start.y1, start.y2);
const bottom = Math.max(start.y1, start.y2);
let dx = fx - start.fx;
let dy = fy - start.fy;
dx = Math.max(-left, Math.min(1 - right, dx));
dy = Math.max(-top, Math.min(1 - bottom, dy));
const nx1 = parseFloat((start.x1 + dx).toFixed(3));
const ny1 = parseFloat((start.y1 + dy).toFixed(3));
const nx2 = parseFloat((start.x2 + dx).toFixed(3));
const ny2 = parseFloat((start.y2 + dy).toFixed(3));
onWidgetChange(nodeId, 'x1', nx1);
onWidgetChange(nodeId, 'y1', ny1);
onWidgetChange(nodeId, 'x2', nx2);
onWidgetChange(nodeId, 'y2', ny2);
return;
}
}, [dragging, getCoords, nodeId, onWidgetChange]);
let vx = fx;
let vy = fy;
if (square) {
const anchorX = dragging === 'p2' ? x1 : x2;
const anchorY = dragging === 'p2' ? y1 : y2;
const snapped = snapPhysicalSquare(anchorX, anchorY, fx, fy, xreal, yreal);
vx = snapped.x;
vy = snapped.y;
}
const vxR = parseFloat(vx.toFixed(3));
const vyR = parseFloat(vy.toFixed(3));
if (dragging === 'p1') {
onWidgetChange(nodeId, 'x1', vxR);
onWidgetChange(nodeId, 'y1', vyR);
} else {
onWidgetChange(nodeId, 'x2', vxR);
onWidgetChange(nodeId, 'y2', vyR);
}
}, [dragging, getCoords, nodeId, onWidgetChange, square, xreal, yreal, x1, y1, x2, y2]);
const onPointerUp = useCallback(() => {
setDragging(null);
panStartRef.current = null;
}, []);
const left = Math.min(x1, x2);
@@ -75,13 +138,14 @@ export default function CropBoxOverlay({
<div className="crop-dim" style={{ left: 0, top: `${bottom * 100}%`, width: '100%', height: `${(1 - bottom) * 100}%` }} />
<div
className="crop-rect"
className={`crop-rect ${aLocked || bLocked ? 'crop-rect-locked' : ''}`}
style={{
left: `${left * 100}%`,
top: `${top * 100}%`,
width: `${(right - left) * 100}%`,
height: `${(bottom - top) * 100}%`,
}}
onPointerDown={onPointerDown('rect')}
/>
<div

View File

@@ -1503,6 +1503,9 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
bLocked={!!data.overlay!.b_locked}
nodeId={id}
onWidgetChange={ctx!.onWidgetChange}
square={!!(data.widgetValues.square ?? data.overlay!.square)}
xreal={(data.overlay!.xreal ?? 1) as number}
yreal={(data.overlay!.yreal ?? 1) as number}
/>
) : data.overlay!.kind === 'cursor_points' ? (
<CrossSectionOverlay

View File

@@ -1860,7 +1860,15 @@ html, body, #root {
border: 2px solid var(--accent-lighter);
box-shadow: inset 0 0 0 1px var(--crop-inset);
background: transparent;
pointer-events: none;
cursor: grab;
}
.crop-rect:active {
cursor: grabbing;
}
.crop-rect-locked {
cursor: default;
}
.crop-marker {

View File

@@ -70,6 +70,9 @@ export interface OverlayData {
cy?: number;
ex?: number;
ey?: number;
xreal?: number;
yreal?: number;
square?: boolean;
a_locked?: boolean;
b_locked?: boolean;
section_title?: string;