@reekon-tools/boldr-utils 1.15.2 → 1.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotation/canvas/AnnotationCanvasInner.native.js +190 -3
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +7 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +17 -3
- package/dist/annotation/canvas/Tool.d.ts +6 -0
- package/dist/annotation/canvas/elements/PlaneElement.d.ts +2 -0
- package/dist/annotation/canvas/elements/PlaneElement.js +5 -2
- package/dist/annotation/canvas/tools/planeTool.d.ts +2 -1
- package/dist/annotation/canvas/tools/planeTool.js +88 -30
- package/dist/annotation/canvas/useAnnotationCanvasState.js +41 -14
- package/package.json +1 -1
|
@@ -272,6 +272,7 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
272
272
|
const shapeDraw = state.activeTool?.shapeDraw ?? null;
|
|
273
273
|
const panViewport = !!state.activeTool?.panViewport;
|
|
274
274
|
const dragSelection = state.activeTool?.dragSelection ?? null;
|
|
275
|
+
const planeEdit = state.activeTool?.planeEdit ?? null;
|
|
275
276
|
const longPressEnabled = !!state.activeTool?.onLongPress;
|
|
276
277
|
// In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
|
|
277
278
|
// owned by the UI thread — the shape twin of `livePoints`. The drag worklet
|
|
@@ -476,6 +477,70 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
476
477
|
'worklet';
|
|
477
478
|
return HANDLE_RING_PX / zoom.value;
|
|
478
479
|
});
|
|
480
|
+
// ---- Plane-calibration handle drag (UI thread; Tool.planeEdit) ----------
|
|
481
|
+
// The plane tool's JS preview path re-rendered the whole canvas (and
|
|
482
|
+
// recomputed the photo-spanning grid) per pointer-move, dropping JS frames
|
|
483
|
+
// on device. Instead the drag runs like the select tool's: the gesture
|
|
484
|
+
// begin hit-tests once on JS, the translation lives in shared values
|
|
485
|
+
// driving a live outline (worklet twin of PlaneElement's outline path), and
|
|
486
|
+
// the drop commits exactly once. The grid + dimension tiles freeze/hide
|
|
487
|
+
// until the commit lands.
|
|
488
|
+
// planeDragMode: 0 idle · 1 handle drag (UI thread) · 2 everything else
|
|
489
|
+
// (marking / blocked-creation), which streams JS pointer events like
|
|
490
|
+
// toolPan would have.
|
|
491
|
+
const planeDragMode = useSharedValue(0);
|
|
492
|
+
const planeDragEnded = useSharedValue(false);
|
|
493
|
+
const planeDragTX = useSharedValue(0);
|
|
494
|
+
const planeDragTY = useSharedValue(0);
|
|
495
|
+
const planeDragStartX = useSharedValue(0);
|
|
496
|
+
const planeDragStartY = useSharedValue(0);
|
|
497
|
+
// React gate for the live render (which plane / which handle); mirrored in
|
|
498
|
+
// a ref for the gesture's JS handlers, which must read the latest value
|
|
499
|
+
// without re-building the gesture.
|
|
500
|
+
const [planeDrag, setPlaneDrag] = useState(null);
|
|
501
|
+
const planeDragRef = useRef(planeDrag);
|
|
502
|
+
const planeJsPointerRef = useRef(null);
|
|
503
|
+
// The dragged handle's live world position: grab-start + screen translation
|
|
504
|
+
// converted to doc units by the live zoom.
|
|
505
|
+
const planeDragHandle = useDerivedValue(() => {
|
|
506
|
+
'worklet';
|
|
507
|
+
return {
|
|
508
|
+
x: planeDragStartX.value + planeDragTX.value / zoom.value,
|
|
509
|
+
y: planeDragStartY.value + planeDragTY.value / zoom.value,
|
|
510
|
+
};
|
|
511
|
+
});
|
|
512
|
+
// WORKLET TWIN of PlaneElement's outline path (quad ring / reference line;
|
|
513
|
+
// the scale ticks are skipped live and reappear on commit). The committed
|
|
514
|
+
// plane + dragged index are captured from React state — setPlaneDrag
|
|
515
|
+
// renders before any movement applies, so the capture is fresh.
|
|
516
|
+
const planeDragPlane = planeDrag
|
|
517
|
+
? ((state.effectiveCanvas.planes ?? []).find((p) => p.id === planeDrag.planeId) ?? null)
|
|
518
|
+
: null;
|
|
519
|
+
const planeDragIndexVal = planeDrag?.index ?? -1;
|
|
520
|
+
const planeDragPath = useDerivedValue(() => {
|
|
521
|
+
'worklet';
|
|
522
|
+
const path = Skia.Path.Make();
|
|
523
|
+
if (!planeDragPlane || planeDragMode.value !== 1)
|
|
524
|
+
return path;
|
|
525
|
+
const lx = planeDragStartX.value + planeDragTX.value / zoom.value;
|
|
526
|
+
const ly = planeDragStartY.value + planeDragTY.value / zoom.value;
|
|
527
|
+
if (planeDragPlane.mode === 'perspective') {
|
|
528
|
+
const c = planeDragPlane.corners;
|
|
529
|
+
const px = (i) => (i === planeDragIndexVal ? lx : c[i].x);
|
|
530
|
+
const py = (i) => (i === planeDragIndexVal ? ly : c[i].y);
|
|
531
|
+
path.moveTo(px(0), py(0));
|
|
532
|
+
path.lineTo(px(1), py(1));
|
|
533
|
+
path.lineTo(px(2), py(2));
|
|
534
|
+
path.lineTo(px(3), py(3));
|
|
535
|
+
path.close();
|
|
536
|
+
return path;
|
|
537
|
+
}
|
|
538
|
+
const a = planeDragIndexVal === 0 ? { x: lx, y: ly } : planeDragPlane.refLine.a;
|
|
539
|
+
const b = planeDragIndexVal === 1 ? { x: lx, y: ly } : planeDragPlane.refLine.b;
|
|
540
|
+
path.moveTo(a.x, a.y);
|
|
541
|
+
path.lineTo(b.x, b.y);
|
|
542
|
+
return path;
|
|
543
|
+
});
|
|
479
544
|
// Rectangle-annotation corner drag. `rectDragId` (React state) marks which
|
|
480
545
|
// annotation's rect renders from the live geometry; `rectCtx` carries the
|
|
481
546
|
// fixed (opposite) corner and the grabbed corner's start position so the
|
|
@@ -1487,12 +1552,119 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1487
1552
|
runOnJS(cancelSelectDrag)();
|
|
1488
1553
|
});
|
|
1489
1554
|
};
|
|
1555
|
+
// Plane tool: a hybrid pan. Handle drags run on the UI thread (shared
|
|
1556
|
+
// values → live outline; single commit on release — see the planeDrag*
|
|
1557
|
+
// values above); everything else (scale-mode marking, blocked-creation
|
|
1558
|
+
// taps) falls back to streaming JS pointer events exactly like toolPan.
|
|
1559
|
+
// The begin hit-test runs once on JS and decides the mode.
|
|
1560
|
+
const buildPlaneEditPan = (cfg) => {
|
|
1561
|
+
const beginPlaneDrag = (origin) => {
|
|
1562
|
+
const st = stateRef.current;
|
|
1563
|
+
const world = st.ctx.viewport.screenToWorld(origin);
|
|
1564
|
+
const index = cfg.hitHandle(st.ctx.document, world, st.viewport.zoom);
|
|
1565
|
+
const planeId = st.ctx.document.planes?.[0]?.id ?? null;
|
|
1566
|
+
if (index != null && planeId != null) {
|
|
1567
|
+
const start = cfg.handlePoints(st.ctx.document)[index];
|
|
1568
|
+
if (start) {
|
|
1569
|
+
planeDragStartX.value = start.x;
|
|
1570
|
+
planeDragStartY.value = start.y;
|
|
1571
|
+
planeDragMode.value = 1;
|
|
1572
|
+
const drag = { planeId, index };
|
|
1573
|
+
planeDragRef.current = drag;
|
|
1574
|
+
setPlaneDrag(drag);
|
|
1575
|
+
// Precision drop — the loupe follows the finger like the select
|
|
1576
|
+
// tool's sub-drags.
|
|
1577
|
+
setMagnifying(true);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
planeDragMode.value = 2;
|
|
1582
|
+
const id = pointerIdRef.current++;
|
|
1583
|
+
planeJsPointerRef.current = { id };
|
|
1584
|
+
st.dispatchPointerDown(buildEvent(id, origin));
|
|
1585
|
+
};
|
|
1586
|
+
const movePlaneDragJs = (screen) => {
|
|
1587
|
+
const f = planeJsPointerRef.current;
|
|
1588
|
+
if (f)
|
|
1589
|
+
stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
|
|
1590
|
+
};
|
|
1591
|
+
const endPlaneDrag = (screen) => {
|
|
1592
|
+
const st = stateRef.current;
|
|
1593
|
+
if (planeDragMode.value === 2) {
|
|
1594
|
+
const f = planeJsPointerRef.current;
|
|
1595
|
+
if (f)
|
|
1596
|
+
st.dispatchPointerUp(buildEvent(f.id, screen));
|
|
1597
|
+
}
|
|
1598
|
+
else if (planeDragMode.value === 1 && planeDragRef.current) {
|
|
1599
|
+
const world = st.ctx.viewport.screenToWorld(screen);
|
|
1600
|
+
const patch = cfg.buildHandleDropPatch(st.ctx.document, planeDragRef.current.index, world);
|
|
1601
|
+
// Null = invalid drop (non-convex) — clearing the drag state below
|
|
1602
|
+
// snaps the outline back to the committed plane.
|
|
1603
|
+
if (patch)
|
|
1604
|
+
st.ctx.commit(patch);
|
|
1605
|
+
}
|
|
1606
|
+
planeDragMode.value = 0;
|
|
1607
|
+
planeDragRef.current = null;
|
|
1608
|
+
planeJsPointerRef.current = null;
|
|
1609
|
+
setPlaneDrag(null);
|
|
1610
|
+
setMagnifying(false);
|
|
1611
|
+
};
|
|
1612
|
+
const cancelPlaneDrag = () => {
|
|
1613
|
+
if (planeDragMode.value === 2 && planeJsPointerRef.current) {
|
|
1614
|
+
stateRef.current.dispatchPointerCancel();
|
|
1615
|
+
}
|
|
1616
|
+
planeDragMode.value = 0;
|
|
1617
|
+
planeDragRef.current = null;
|
|
1618
|
+
planeJsPointerRef.current = null;
|
|
1619
|
+
setPlaneDrag(null);
|
|
1620
|
+
setMagnifying(false);
|
|
1621
|
+
};
|
|
1622
|
+
return Gesture.Pan()
|
|
1623
|
+
.minPointers(1)
|
|
1624
|
+
.maxPointers(1)
|
|
1625
|
+
.onStart((e) => {
|
|
1626
|
+
'worklet';
|
|
1627
|
+
planeDragEnded.value = false;
|
|
1628
|
+
planeDragMode.value = 0;
|
|
1629
|
+
planeDragTX.value = e.translationX;
|
|
1630
|
+
planeDragTY.value = e.translationY;
|
|
1631
|
+
magTouchX.value = e.x;
|
|
1632
|
+
magTouchY.value = e.y;
|
|
1633
|
+
// Hit-test at the touch-down point — onStart fires only after the
|
|
1634
|
+
// pan threshold, so back out the accumulated translation.
|
|
1635
|
+
runOnJS(beginPlaneDrag)({
|
|
1636
|
+
x: e.x - e.translationX,
|
|
1637
|
+
y: e.y - e.translationY,
|
|
1638
|
+
});
|
|
1639
|
+
})
|
|
1640
|
+
.onChange((e) => {
|
|
1641
|
+
'worklet';
|
|
1642
|
+
planeDragTX.value = e.translationX;
|
|
1643
|
+
planeDragTY.value = e.translationY;
|
|
1644
|
+
magTouchX.value = e.x;
|
|
1645
|
+
magTouchY.value = e.y;
|
|
1646
|
+
if (planeDragMode.value === 2) {
|
|
1647
|
+
runOnJS(movePlaneDragJs)({ x: e.x, y: e.y });
|
|
1648
|
+
}
|
|
1649
|
+
})
|
|
1650
|
+
.onEnd((e) => {
|
|
1651
|
+
'worklet';
|
|
1652
|
+
planeDragEnded.value = true;
|
|
1653
|
+
runOnJS(endPlaneDrag)({ x: e.x, y: e.y });
|
|
1654
|
+
})
|
|
1655
|
+
.onFinalize(() => {
|
|
1656
|
+
'worklet';
|
|
1657
|
+
if (!planeDragEnded.value)
|
|
1658
|
+
runOnJS(cancelPlaneDrag)();
|
|
1659
|
+
});
|
|
1660
|
+
};
|
|
1490
1661
|
// One finger, by active tool:
|
|
1491
1662
|
// - freehand (pen/marker/highlighter) → draw on the UI thread
|
|
1492
1663
|
// - shape tools (line/rect/…) → rubber-band on the UI thread
|
|
1493
1664
|
// - Hand → no separate gesture: viewportPan above already spans one
|
|
1494
1665
|
// finger (min 1), so panning and pinching stay one uncancelled stream
|
|
1495
1666
|
// - select → drag the hit element on the UI thread
|
|
1667
|
+
// - plane tool → handle drags on the UI thread, marking on the JS thread
|
|
1496
1668
|
// - everything else → dispatch pointer events on the JS thread
|
|
1497
1669
|
const oneFinger = freehand
|
|
1498
1670
|
? buildDrawPan(freehand)
|
|
@@ -1500,9 +1672,11 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1500
1672
|
? buildShapeDrawPan(shapeDraw)
|
|
1501
1673
|
: panViewport
|
|
1502
1674
|
? null
|
|
1503
|
-
:
|
|
1504
|
-
?
|
|
1505
|
-
:
|
|
1675
|
+
: planeEdit
|
|
1676
|
+
? buildPlaneEditPan(planeEdit)
|
|
1677
|
+
: dragSelection
|
|
1678
|
+
? buildSelectDragPan(dragSelection)
|
|
1679
|
+
: toolPan;
|
|
1506
1680
|
// Long-press only joins the race when the active tool acts on it, so it
|
|
1507
1681
|
// never pre-empts a one-finger drag/draw on tools that ignore holds.
|
|
1508
1682
|
return Gesture.Race(tap, ...(longPressEnabled ? [longPress] : []), Gesture.Simultaneous(viewportPan, pinch), ...(oneFinger ? [oneFinger] : []));
|
|
@@ -1520,6 +1694,13 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1520
1694
|
shapeDraw,
|
|
1521
1695
|
panViewport,
|
|
1522
1696
|
dragSelection,
|
|
1697
|
+
planeEdit,
|
|
1698
|
+
planeDragMode,
|
|
1699
|
+
planeDragEnded,
|
|
1700
|
+
planeDragTX,
|
|
1701
|
+
planeDragTY,
|
|
1702
|
+
planeDragStartX,
|
|
1703
|
+
planeDragStartY,
|
|
1523
1704
|
longPressEnabled,
|
|
1524
1705
|
dragX,
|
|
1525
1706
|
dragY,
|
|
@@ -1613,6 +1794,12 @@ export const AnnotationCanvasInner = (props) => {
|
|
|
1613
1794
|
? handleRingWidth
|
|
1614
1795
|
: undefined,
|
|
1615
1796
|
showPlaneHandles: activeTool?.planeEditing === true,
|
|
1797
|
+
// UI-thread plane handle drag: while set, the Skia tree swaps the plane's
|
|
1798
|
+
// static element for the live outline + handles (see AnnotationCanvasSkia).
|
|
1799
|
+
planeDragId: planeDrag?.planeId ?? null,
|
|
1800
|
+
planeDragIndex: planeDrag?.index ?? null,
|
|
1801
|
+
planeDragPath,
|
|
1802
|
+
planeDragHandle,
|
|
1616
1803
|
customPreview,
|
|
1617
1804
|
};
|
|
1618
1805
|
// Lens window for the loupe: an opaque white base + the magnified scene
|
|
@@ -89,9 +89,15 @@ export interface AnnotationCanvasSkiaProps {
|
|
|
89
89
|
value: number;
|
|
90
90
|
};
|
|
91
91
|
showPlaneHandles?: boolean;
|
|
92
|
+
planeDragId?: string | null;
|
|
93
|
+
planeDragIndex?: number | null;
|
|
94
|
+
planeDragPath?: SkPath | {
|
|
95
|
+
value: SkPath;
|
|
96
|
+
};
|
|
97
|
+
planeDragHandle?: AnimatedPoint;
|
|
92
98
|
customPreview?: ReactNode;
|
|
93
99
|
wrapContent?: (content: ReactNode) => ReactNode;
|
|
94
100
|
canvasRef?: RefObject<CanvasRef | null>;
|
|
95
101
|
}
|
|
96
|
-
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
102
|
+
export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragHandle, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
|
|
97
103
|
export {};
|
|
@@ -6,7 +6,7 @@ import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeome
|
|
|
6
6
|
import { visualShapeBounds } from './shapeGeometry.js';
|
|
7
7
|
import { backgroundLayerDocRect, backgroundLayersOf, } from './backgroundLayers.js';
|
|
8
8
|
import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
|
|
9
|
-
import { PlaneElement } from './elements/PlaneElement.js';
|
|
9
|
+
import { PlaneElement, PLANE_CHROME_COLOR, PLANE_OUTLINE_WIDTH, } from './elements/PlaneElement.js';
|
|
10
10
|
import { ShapeElement } from './elements/ShapeElement.js';
|
|
11
11
|
import { StrokeElement } from './elements/StrokeElement.js';
|
|
12
12
|
// Default visual constants for the measurement-annotation line (the tile itself
|
|
@@ -80,7 +80,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
|
|
|
80
80
|
// since the function-call pattern works identically on native we use it
|
|
81
81
|
// in both Inners for consistency. Don't add hooks here; this is a plain
|
|
82
82
|
// JSX-returning helper, not a component.
|
|
83
|
-
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, customPreview, wrapContent, canvasRef, }) => {
|
|
83
|
+
export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragHandle, customPreview, wrapContent, canvasRef, }) => {
|
|
84
84
|
// Doc rect a SCALE-mode plane grid covers: the first background layer (the
|
|
85
85
|
// photo being calibrated), else the document rect. Pinned to layer 0 like
|
|
86
86
|
// the tile-sizing reference — adding a second image must not re-span the
|
|
@@ -94,7 +94,16 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
94
94
|
width: effectiveCanvas.viewport.width,
|
|
95
95
|
height: effectiveCanvas.viewport.height,
|
|
96
96
|
};
|
|
97
|
-
const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundLayersOf(effectiveCanvas.viewport).map((layer) => (_jsx(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: _jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height }) }, layer.id))), (effectiveCanvas.planes ?? []).map((plane) =>
|
|
97
|
+
const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundLayersOf(effectiveCanvas.viewport).map((layer) => (_jsx(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: _jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height }) }, layer.id))), (effectiveCanvas.planes ?? []).map((plane) => {
|
|
98
|
+
if (plane.id === planeDragId && planeDragPath) {
|
|
99
|
+
const statics = plane.mode === 'perspective'
|
|
100
|
+
? plane.corners
|
|
101
|
+
: [plane.refLine.a, plane.refLine.b];
|
|
102
|
+
return (_jsxs(Group, { children: [_jsx(Path, { path: planeDragPath, color: PLANE_CHROME_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH, strokeJoin: "round" }), handleRadius != null &&
|
|
103
|
+
statics.map((p, i) => i === planeDragIndex ? null : (_jsxs(Group, { children: [_jsx(Circle, { c: p, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: p, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }, i))), handleRadius != null && planeDragHandle && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: planeDragHandle, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: planeDragHandle, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }))] }, plane.id));
|
|
104
|
+
}
|
|
105
|
+
return (_jsx(PlaneElement, { plane: plane, scaleBounds: planeScaleBounds, showHandles: showPlaneHandles === true, handleRadius: showPlaneHandles ? handleRadius : undefined, handleRingWidth: showPlaneHandles ? handleRingWidth : undefined }, plane.id));
|
|
106
|
+
}), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
|
|
98
107
|
// Line/arrow shapes support endpoint editing. When selected they show
|
|
99
108
|
// grab handles at both ends; during an endpoint drag the line renders
|
|
100
109
|
// from the live endpoints (one following the finger) — the shape twin
|
|
@@ -162,6 +171,11 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
|
|
|
162
171
|
}
|
|
163
172
|
if (placementOf(placed) !== 'line' || !placed.line)
|
|
164
173
|
return null;
|
|
174
|
+
// A dimension tile's line IS its calibration edge: while that plane's
|
|
175
|
+
// handle is being live-dragged, the stale committed edge would linger
|
|
176
|
+
// under the moving live outline — hide it until the drop commits.
|
|
177
|
+
if (placed.planeId && placed.planeId === planeDragId)
|
|
178
|
+
return null;
|
|
165
179
|
const isEndpointDrag = placed.id === endpointDragId;
|
|
166
180
|
const isSelected = placed.id === selectedId;
|
|
167
181
|
const p1 = isEndpointDrag && liveLineP1 ? liveLineP1 : placed.line.a;
|
|
@@ -90,6 +90,11 @@ export interface DragSelectionConfig {
|
|
|
90
90
|
hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
|
|
91
91
|
buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
|
|
92
92
|
}
|
|
93
|
+
export interface PlaneEditConfig {
|
|
94
|
+
hitHandle(doc: AnnotationCanvasState, world: Vec2, zoom: number): number | null;
|
|
95
|
+
handlePoints(doc: AnnotationCanvasState): Vec2[];
|
|
96
|
+
buildHandleDropPatch(doc: AnnotationCanvasState, index: number, world: Vec2): AnnotationDocumentPatch | null;
|
|
97
|
+
}
|
|
93
98
|
export interface Tool {
|
|
94
99
|
id: string;
|
|
95
100
|
label: string;
|
|
@@ -100,6 +105,7 @@ export interface Tool {
|
|
|
100
105
|
panViewport?: boolean;
|
|
101
106
|
dragSelection?: DragSelectionConfig;
|
|
102
107
|
planeEditing?: boolean;
|
|
108
|
+
planeEdit?: PlaneEditConfig;
|
|
103
109
|
onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
104
110
|
onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
105
111
|
onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
|
|
@@ -8,12 +8,15 @@ import { FormulaColors } from '../../../theme/colors.js';
|
|
|
8
8
|
// information: the formula purple outlines the calibrated quad / reference
|
|
9
9
|
// line (and inks its handles and dimension-tile lines — see planeTool), while
|
|
10
10
|
// the grid it casts gets its own pale pink so the two never blur.
|
|
11
|
-
|
|
11
|
+
// Exported for the live UI-thread handle drag, which redraws the outline in
|
|
12
|
+
// AnnotationCanvasSkia while this element is hidden.
|
|
13
|
+
export const PLANE_CHROME_COLOR = FormulaColors.purple;
|
|
14
|
+
export const PLANE_OUTLINE_WIDTH = 14;
|
|
15
|
+
const PLANE_COLOR = PLANE_CHROME_COLOR;
|
|
12
16
|
const PLANE_GRID_COLOR = '#FFD1F8';
|
|
13
17
|
const PLANE_GRID_OPACITY = 0.9;
|
|
14
18
|
// Doc-space stroke widths (scale with zoom, like the drawn shapes).
|
|
15
19
|
const PLANE_GRID_WIDTH = 2.5;
|
|
16
|
-
const PLANE_OUTLINE_WIDTH = 4;
|
|
17
20
|
// Cross-tick half-length (doc units) marking the ends of a scale-mode
|
|
18
21
|
// reference line, so it reads as "a measured span", not just a line.
|
|
19
22
|
const REF_TICK_LEN = 18;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PlaneCalibration, PlaneCalibrationMode } from '../../../types/annotation.js';
|
|
1
|
+
import type { PlacedMeasurementRef, PlaneCalibration, PlaneCalibrationMode } from '../../../types/annotation.js';
|
|
2
2
|
import type { Tool } from '../Tool.js';
|
|
3
3
|
export interface PlaneToolOptions {
|
|
4
4
|
id?: string;
|
|
@@ -7,6 +7,7 @@ export interface PlaneToolOptions {
|
|
|
7
7
|
onPlaced?(plane: PlaneCalibration): void;
|
|
8
8
|
onInvalidQuad?(): void;
|
|
9
9
|
onExistingPlane?(): void;
|
|
10
|
+
onDimensionTileTap?(tile: PlacedMeasurementRef): void;
|
|
10
11
|
minDragPx?: number;
|
|
11
12
|
}
|
|
12
13
|
export declare const createPlaneTool: (options: PlaneToolOptions) => Tool;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
2
|
import { isQuadConvexNonDegenerate, normalizeQuadOrder, planeEdgeLine, } from '../planeGeometry.js';
|
|
3
3
|
import { linePosOf, recomputeAnchor, DEFAULT_LINE_POS, } from '../measurementGeometry.js';
|
|
4
|
+
import { stampTileDims } from '../stampLayout.js';
|
|
4
5
|
import { FormulaColors } from '../../../theme/colors.js';
|
|
5
6
|
// Screen-px grab radius for an existing plane's corner/endpoint handles while
|
|
6
7
|
// the tool is active (matches the drawn handle affordance scale).
|
|
@@ -24,6 +25,22 @@ const committedPlane = (doc) => doc.planes?.[0] ?? null;
|
|
|
24
25
|
const planeHandles = (plane) => plane.mode === 'perspective'
|
|
25
26
|
? plane.corners
|
|
26
27
|
: [plane.refLine.a, plane.refLine.b];
|
|
28
|
+
// Index of the committed calibration's handle under `world`, or null —
|
|
29
|
+
// shared by the tool's JS pointer path and the native UI-thread drag
|
|
30
|
+
// (Tool.planeEdit.hitHandle).
|
|
31
|
+
const hitHandleAt = (doc, world, zoom) => {
|
|
32
|
+
const plane = committedPlane(doc);
|
|
33
|
+
if (!plane)
|
|
34
|
+
return null;
|
|
35
|
+
const tol = HANDLE_GRAB_PX / zoom;
|
|
36
|
+
const tolSq = tol * tol;
|
|
37
|
+
const handles = planeHandles(plane);
|
|
38
|
+
for (let i = 0; i < handles.length; i++) {
|
|
39
|
+
if (distSq(world, handles[i]) <= tolSq)
|
|
40
|
+
return i;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
27
44
|
// The committed plane with one handle moved to `p`.
|
|
28
45
|
const withHandleMoved = (plane, index, p) => {
|
|
29
46
|
if (plane.mode === 'perspective') {
|
|
@@ -53,10 +70,10 @@ const buildDimensionTile = (doc, plane, edge) => {
|
|
|
53
70
|
planeId: plane.id,
|
|
54
71
|
planeEdge: edge,
|
|
55
72
|
// The tile's line IS the plane edge PlaneElement already draws — style it
|
|
56
|
-
// as the same
|
|
57
|
-
// painting the default measurement blue on top of it.
|
|
73
|
+
// as the same purple stroke (and width) so it disappears into the outline
|
|
74
|
+
// instead of painting the default measurement blue on top of it.
|
|
58
75
|
lineColor: PLANE_COLOR,
|
|
59
|
-
lineWidth:
|
|
76
|
+
lineWidth: 14,
|
|
60
77
|
showLabel: true,
|
|
61
78
|
showValue: true,
|
|
62
79
|
createdAt: Date.now(),
|
|
@@ -192,26 +209,68 @@ export const createPlaneTool = (options) => {
|
|
|
192
209
|
ctx.commit({ ops });
|
|
193
210
|
options.onPlaced?.(plane);
|
|
194
211
|
};
|
|
195
|
-
// Which committed-plane
|
|
196
|
-
|
|
212
|
+
// Which committed-plane DIMENSION tile a tap landed on, if any — the tile
|
|
213
|
+
// BOX only (deliberately not the edge line, which spans the whole side and
|
|
214
|
+
// would turn every edge tap into value entry). Same footprint the overlay
|
|
215
|
+
// draws and the select tool grabs (stampTileDims + the ctx scale factors).
|
|
216
|
+
const hitDimensionTile = (ctx, world) => {
|
|
197
217
|
const plane = committedPlane(ctx.document);
|
|
198
218
|
if (!plane)
|
|
199
219
|
return null;
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
220
|
+
const zoom = ctx.viewport.state.zoom;
|
|
221
|
+
for (let i = ctx.document.placedMeasurements.length - 1; i >= 0; i--) {
|
|
222
|
+
const m = ctx.document.placedMeasurements[i];
|
|
223
|
+
if (m.planeId !== plane.id || !m.planeEdge)
|
|
224
|
+
continue;
|
|
225
|
+
const dims = stampTileDims(m, ctx.tileScaleFactor, ctx.tileViewportScale, ctx.headerTileScaleFactor, ctx.fileTileScaleFactor);
|
|
226
|
+
const halfW = (dims.width / 2 + 6) / zoom;
|
|
227
|
+
const halfH = (dims.height / 2 + 6) / zoom;
|
|
228
|
+
if (Math.abs(world.x - m.anchor.x) <= halfW &&
|
|
229
|
+
Math.abs(world.y - m.anchor.y) <= halfH) {
|
|
230
|
+
return m;
|
|
231
|
+
}
|
|
206
232
|
}
|
|
207
233
|
return null;
|
|
208
234
|
};
|
|
235
|
+
// Which committed-plane handle a press landed on, if any.
|
|
236
|
+
const hitHandle = (ctx, world) => hitHandleAt(ctx.document, world, ctx.viewport.state.zoom);
|
|
237
|
+
// Drop patch for a handle drag — shared by the JS pointer path below and
|
|
238
|
+
// the native UI-thread drag (Tool.planeEdit). Null (snap back, host
|
|
239
|
+
// toasted) when the drop would break the quad.
|
|
240
|
+
const handleDropPatch = (doc, index, world) => {
|
|
241
|
+
const plane = committedPlane(doc);
|
|
242
|
+
if (!plane)
|
|
243
|
+
return null;
|
|
244
|
+
const next = withHandleMoved(plane, index, world);
|
|
245
|
+
if (next.mode === 'perspective' &&
|
|
246
|
+
!isQuadConvexNonDegenerate(next.corners)) {
|
|
247
|
+
options.onInvalidQuad?.();
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
ops: [
|
|
252
|
+
{ op: 'setPlane', plane: next },
|
|
253
|
+
...syncDimensionTileOps(doc, next),
|
|
254
|
+
],
|
|
255
|
+
};
|
|
256
|
+
};
|
|
209
257
|
return {
|
|
210
258
|
id: options.id ?? `plane-${mode}`,
|
|
211
259
|
label: options.label ??
|
|
212
260
|
(mode === 'perspective' ? 'Perspective plane' : 'Scale reference'),
|
|
213
261
|
cursor: 'crosshair',
|
|
214
262
|
planeEditing: true,
|
|
263
|
+
// UI-thread handle drags on native (see PlaneEditConfig in Tool.ts): the
|
|
264
|
+
// same pure hit-test and drop-patch the JS pointer path below uses, so
|
|
265
|
+
// both platforms share one source of truth.
|
|
266
|
+
planeEdit: {
|
|
267
|
+
hitHandle: hitHandleAt,
|
|
268
|
+
handlePoints: (doc) => {
|
|
269
|
+
const plane = committedPlane(doc);
|
|
270
|
+
return plane ? planeHandles(plane) : [];
|
|
271
|
+
},
|
|
272
|
+
buildHandleDropPatch: handleDropPatch,
|
|
273
|
+
},
|
|
215
274
|
onPointerDown(event, ctx) {
|
|
216
275
|
// Editing an existing plane wins over starting new geometry — but only
|
|
217
276
|
// when nothing is mid-mark (a quad in progress keeps collecting taps).
|
|
@@ -281,28 +340,27 @@ export const createPlaneTool = (options) => {
|
|
|
281
340
|
ctx.preview({ ops: [] });
|
|
282
341
|
if (!s.moved)
|
|
283
342
|
return;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (next.mode === 'perspective' &&
|
|
291
|
-
!isQuadConvexNonDegenerate(next.corners)) {
|
|
292
|
-
options.onInvalidQuad?.();
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
ctx.commit({
|
|
296
|
-
ops: [
|
|
297
|
-
{ op: 'setPlane', plane: next },
|
|
298
|
-
...syncDimensionTileOps(ctx.document, next),
|
|
299
|
-
],
|
|
300
|
-
});
|
|
343
|
+
// A drop that breaks the quad snaps back (handleDropPatch returns
|
|
344
|
+
// null and raises onInvalidQuad) instead of committing a plane the
|
|
345
|
+
// homography can't invert.
|
|
346
|
+
const patch = handleDropPatch(ctx.document, s.handleIndex, event.world);
|
|
347
|
+
if (patch)
|
|
348
|
+
ctx.commit(patch);
|
|
301
349
|
return;
|
|
302
350
|
}
|
|
303
|
-
//
|
|
304
|
-
//
|
|
351
|
+
// While a calibration exists, a TAP on one of its dimension tiles
|
|
352
|
+
// selects it and opens value entry (the host's onDimensionTileTap) —
|
|
353
|
+
// dimensions stay editable without leaving calibration mode. Any other
|
|
354
|
+
// attempt to mark new geometry is blocked: delete the plane first.
|
|
355
|
+
// (A real drag — scale mode with `moved` — is never a tile tap.)
|
|
305
356
|
if (committedPlane(ctx.document)) {
|
|
357
|
+
const isTap = s.kind !== 'plane-scale-drawing' || !s.moved;
|
|
358
|
+
const tile = isTap ? hitDimensionTile(ctx, event.world) : null;
|
|
359
|
+
if (tile) {
|
|
360
|
+
ctx.setSelection({ ids: [tile.id] });
|
|
361
|
+
options.onDimensionTileTap?.(tile);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
306
364
|
options.onExistingPlane?.();
|
|
307
365
|
return;
|
|
308
366
|
}
|
|
@@ -105,6 +105,18 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
105
105
|
const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
|
|
106
106
|
const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
|
|
107
107
|
const [toolState, setToolState] = useState(undefined);
|
|
108
|
+
// Synchronous mirror for the DISPATCHERS. The native tap gesture
|
|
109
|
+
// synthesizes pointerDown + pointerUp inside one JS task, so the up runs
|
|
110
|
+
// before React re-renders — a dispatcher reading the state closure would
|
|
111
|
+
// hand onPointerUp the PRE-tap state (undefined) and tap-driven tools
|
|
112
|
+
// (quad corners, dimension-tile taps) silently no-op. React state remains
|
|
113
|
+
// the render-side source (renderPreview / penDrawingStroke); every write
|
|
114
|
+
// goes through updateToolState so the two never drift.
|
|
115
|
+
const toolStateRef = useRef(undefined);
|
|
116
|
+
const updateToolState = (next) => {
|
|
117
|
+
toolStateRef.current = next;
|
|
118
|
+
setToolState(next);
|
|
119
|
+
};
|
|
108
120
|
const [previewPatch, setPreviewPatch] = useState(null);
|
|
109
121
|
const undoStackRef = useRef([]);
|
|
110
122
|
const redoStackRef = useRef([]);
|
|
@@ -317,17 +329,22 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
317
329
|
return;
|
|
318
330
|
prev.onDeactivate?.(ctxRef.current);
|
|
319
331
|
activePointerIdRef.current = null;
|
|
320
|
-
|
|
332
|
+
updateToolState(undefined);
|
|
321
333
|
setPreviewPatch(null);
|
|
334
|
+
// updateToolState is a stable-in-effect helper (ref + setState); the
|
|
335
|
+
// effect only re-runs on a tool identity change.
|
|
336
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
322
337
|
}, [activeTool]);
|
|
323
338
|
const dispatchPointerDown = useCallback((event) => {
|
|
324
339
|
if (!activeTool)
|
|
325
340
|
return;
|
|
326
341
|
activePointerIdRef.current = event.pointerId;
|
|
327
|
-
const next = activeTool.onPointerDown?.(event, ctx,
|
|
342
|
+
const next = activeTool.onPointerDown?.(event, ctx, toolStateRef.current);
|
|
328
343
|
if (next !== undefined)
|
|
329
|
-
|
|
330
|
-
},
|
|
344
|
+
updateToolState(next);
|
|
345
|
+
},
|
|
346
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
347
|
+
[activeTool, ctx]);
|
|
331
348
|
const dispatchPointerMove = useCallback((event) => {
|
|
332
349
|
if (!activeTool)
|
|
333
350
|
return;
|
|
@@ -335,10 +352,12 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
335
352
|
event.pointerId !== activePointerIdRef.current) {
|
|
336
353
|
return;
|
|
337
354
|
}
|
|
338
|
-
const next = activeTool.onPointerMove?.(event, ctx,
|
|
355
|
+
const next = activeTool.onPointerMove?.(event, ctx, toolStateRef.current);
|
|
339
356
|
if (next !== undefined)
|
|
340
|
-
|
|
341
|
-
},
|
|
357
|
+
updateToolState(next);
|
|
358
|
+
},
|
|
359
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
360
|
+
[activeTool, ctx]);
|
|
342
361
|
const dispatchPointerUp = useCallback((event) => {
|
|
343
362
|
if (!activeTool)
|
|
344
363
|
return;
|
|
@@ -346,10 +365,12 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
346
365
|
event.pointerId !== activePointerIdRef.current) {
|
|
347
366
|
return;
|
|
348
367
|
}
|
|
349
|
-
activeTool.onPointerUp?.(event, ctx,
|
|
368
|
+
activeTool.onPointerUp?.(event, ctx, toolStateRef.current);
|
|
350
369
|
activePointerIdRef.current = null;
|
|
351
|
-
|
|
352
|
-
},
|
|
370
|
+
updateToolState(undefined);
|
|
371
|
+
},
|
|
372
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
373
|
+
[activeTool, ctx]);
|
|
353
374
|
const dispatchLongPress = useCallback((event) => {
|
|
354
375
|
if (!activeTool)
|
|
355
376
|
return;
|
|
@@ -364,11 +385,13 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
364
385
|
// vertices visible across an interrupting two-finger pan) wins over the
|
|
365
386
|
// clear instead of being clobbered by it.
|
|
366
387
|
activePointerIdRef.current = null;
|
|
367
|
-
|
|
388
|
+
const prevToolState = toolStateRef.current;
|
|
389
|
+
updateToolState(undefined);
|
|
368
390
|
setPreviewPatch(null);
|
|
369
391
|
if (activeTool)
|
|
370
|
-
activeTool.onCancel?.(
|
|
371
|
-
|
|
392
|
+
activeTool.onCancel?.(prevToolState, ctx);
|
|
393
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
394
|
+
}, [activeTool, ctx]);
|
|
372
395
|
const pan = useCallback((deltaScreen) => {
|
|
373
396
|
setViewport((v) => panBy(v, deltaScreen));
|
|
374
397
|
}, []);
|
|
@@ -772,6 +795,10 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
772
795
|
// Build one remove op per selected element, dispatched by which
|
|
773
796
|
// collection owns the id. A single multi-op commit makes the whole
|
|
774
797
|
// deletion one undo step (inverse re-adds each element).
|
|
798
|
+
// Plane DIMENSION tiles (planeEdge) are exempt: they are the
|
|
799
|
+
// calibration's entry affordances and live and die with it
|
|
800
|
+
// (removePlane / the plane tool's replace flow) — their value is
|
|
801
|
+
// replaced by re-entering it, never by deleting the tile.
|
|
775
802
|
const ops = [
|
|
776
803
|
...doc.strokes
|
|
777
804
|
.filter((s) => idSet.has(s.id))
|
|
@@ -780,7 +807,7 @@ export const useAnnotationCanvasState = (props) => {
|
|
|
780
807
|
.filter((s) => idSet.has(s.id))
|
|
781
808
|
.map((s) => ({ op: 'removeShape', id: s.id })),
|
|
782
809
|
...doc.placedMeasurements
|
|
783
|
-
.filter((m) => idSet.has(m.id))
|
|
810
|
+
.filter((m) => idSet.has(m.id) && !m.planeEdge)
|
|
784
811
|
.map((m) => ({ op: 'removeMeasurement', id: m.id })),
|
|
785
812
|
];
|
|
786
813
|
if (ops.length === 0)
|