@reekon-tools/boldr-utils 1.15.3 → 1.15.5

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.
@@ -7,9 +7,10 @@ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useShare
7
7
  import { stampTileDims } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
- import { backgroundLayersOf } from './backgroundLayers.js';
10
+ import { backgroundLayersOf, backgroundLayerDocRect, } from './backgroundLayers.js';
11
11
  import { BackgroundSkImageLoader } from './elements/BackgroundImageElement.js';
12
- import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
12
+ import { buildRemoveMeasurementOps, linePosOf, } from './measurementGeometry.js';
13
+ import { planeEdgeLine, planeGridSegments, planeGridSegmentsForScale, resolvePlaneGridSpacingUm, } from './planeGeometry.js';
13
14
  import { buildShapeFromDrag } from './tools/shapeTool.js';
14
15
  import { SELECTION_PAD } from './textGeometry.js';
15
16
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
@@ -272,6 +273,7 @@ export const AnnotationCanvasInner = (props) => {
272
273
  const shapeDraw = state.activeTool?.shapeDraw ?? null;
273
274
  const panViewport = !!state.activeTool?.panViewport;
274
275
  const dragSelection = state.activeTool?.dragSelection ?? null;
276
+ const planeEdit = state.activeTool?.planeEdit ?? null;
275
277
  const longPressEnabled = !!state.activeTool?.onLongPress;
276
278
  // In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
277
279
  // owned by the UI thread — the shape twin of `livePoints`. The drag worklet
@@ -476,6 +478,134 @@ export const AnnotationCanvasInner = (props) => {
476
478
  'worklet';
477
479
  return HANDLE_RING_PX / zoom.value;
478
480
  });
481
+ // ---- Plane-calibration handle drag (UI thread; Tool.planeEdit) ----------
482
+ // The plane tool's JS preview path re-rendered the whole canvas (and
483
+ // recomputed the photo-spanning grid) per pointer-move, dropping JS frames
484
+ // on device. Instead the drag runs like the select tool's: the gesture
485
+ // begin hit-tests once on JS, the translation lives in shared values, and
486
+ // the drop commits exactly once. Everything the drag visually moves is
487
+ // rebuilt per frame on the UI thread: the outline (worklet twin of
488
+ // PlaneElement's path), the grid (planeGeometry's generators are worklets),
489
+ // and the dimension-tile chips (a plane branch in the RN overlay's animated
490
+ // style, driven by planeTileCtx). Only the tiles' computed VALUES wait for
491
+ // the commit — they're user-entered dims, which a corner drag can't change.
492
+ // planeDragMode: 0 idle · 1 handle drag (UI thread) · 2 everything else
493
+ // (marking / blocked-creation), which streams JS pointer events like
494
+ // toolPan would have.
495
+ const planeDragMode = useSharedValue(0);
496
+ const planeDragEnded = useSharedValue(false);
497
+ const planeDragTX = useSharedValue(0);
498
+ const planeDragTY = useSharedValue(0);
499
+ const planeDragStartX = useSharedValue(0);
500
+ const planeDragStartY = useSharedValue(0);
501
+ // React gate for the live render (which plane / which handle); mirrored in
502
+ // a ref for the gesture's JS handlers, which must read the latest value
503
+ // without re-building the gesture.
504
+ const [planeDrag, setPlaneDrag] = useState(null);
505
+ const planeDragRef = useRef(planeDrag);
506
+ const planeJsPointerRef = useRef(null);
507
+ // The dragged handle's live world position: grab-start + screen translation
508
+ // converted to doc units by the live zoom.
509
+ const planeDragHandle = useDerivedValue(() => {
510
+ 'worklet';
511
+ return {
512
+ x: planeDragStartX.value + planeDragTX.value / zoom.value,
513
+ y: planeDragStartY.value + planeDragTY.value / zoom.value,
514
+ };
515
+ });
516
+ // WORKLET TWIN of PlaneElement's outline path (quad ring / reference line;
517
+ // the scale ticks are skipped live and reappear on commit). The committed
518
+ // plane + dragged index are captured from React state — setPlaneDrag
519
+ // renders before any movement applies, so the capture is fresh.
520
+ const planeDragPlane = planeDrag
521
+ ? ((state.effectiveCanvas.planes ?? []).find((p) => p.id === planeDrag.planeId) ?? null)
522
+ : null;
523
+ const planeDragIndexVal = planeDrag?.index ?? -1;
524
+ const planeDragPath = useDerivedValue(() => {
525
+ 'worklet';
526
+ const path = Skia.Path.Make();
527
+ if (!planeDragPlane || planeDragMode.value !== 1)
528
+ return path;
529
+ const lx = planeDragStartX.value + planeDragTX.value / zoom.value;
530
+ const ly = planeDragStartY.value + planeDragTY.value / zoom.value;
531
+ if (planeDragPlane.mode === 'perspective') {
532
+ const c = planeDragPlane.corners;
533
+ const px = (i) => (i === planeDragIndexVal ? lx : c[i].x);
534
+ const py = (i) => (i === planeDragIndexVal ? ly : c[i].y);
535
+ path.moveTo(px(0), py(0));
536
+ path.lineTo(px(1), py(1));
537
+ path.lineTo(px(2), py(2));
538
+ path.lineTo(px(3), py(3));
539
+ path.close();
540
+ return path;
541
+ }
542
+ const a = planeDragIndexVal === 0 ? { x: lx, y: ly } : planeDragPlane.refLine.a;
543
+ const b = planeDragIndexVal === 1 ? { x: lx, y: ly } : planeDragPlane.refLine.b;
544
+ path.moveTo(a.x, a.y);
545
+ path.lineTo(b.x, b.y);
546
+ return path;
547
+ });
548
+ // Live grid during the drag. Spacing and bounds are start-of-drag constants
549
+ // computed on JS (the dims a corner drag can't change decide the spacing;
550
+ // the photo rect decides the bounds — both mirror PlaneElement's inputs),
551
+ // captured into the derived value; the segments themselves recompute per
552
+ // frame on the UI thread from the live corner — planeGeometry's generators
553
+ // carry 'worklet' directives for exactly this call site. Scale mode's doc
554
+ // spacing tracks the live reference line, which is correct: stretching the
555
+ // line IS changing the calibration's ratio.
556
+ const planeDragGridBounds = useMemo(() => {
557
+ const first = backgroundLayers[0];
558
+ return first
559
+ ? backgroundLayerDocRect(first)
560
+ : {
561
+ x: 0,
562
+ y: 0,
563
+ width: state.effectiveCanvas.viewport.width,
564
+ height: state.effectiveCanvas.viewport.height,
565
+ };
566
+ }, [backgroundLayers, state.effectiveCanvas.viewport]);
567
+ const planeDragGridSpacing = planeDragPlane && planeDragPlane.gridVisible !== false
568
+ ? resolvePlaneGridSpacingUm(planeDragPlane, 'metric', {
569
+ width: planeDragGridBounds.width,
570
+ height: planeDragGridBounds.height,
571
+ })
572
+ : null;
573
+ const planeDragGridPath = useDerivedValue(() => {
574
+ 'worklet';
575
+ const path = Skia.Path.Make();
576
+ if (!planeDragPlane ||
577
+ planeDragGridSpacing == null ||
578
+ planeDragMode.value !== 1) {
579
+ return path;
580
+ }
581
+ const lx = planeDragStartX.value + planeDragTX.value / zoom.value;
582
+ const ly = planeDragStartY.value + planeDragTY.value / zoom.value;
583
+ let segments;
584
+ if (planeDragPlane.mode === 'perspective') {
585
+ const c = planeDragPlane.corners;
586
+ const live = [0, 1, 2, 3].map((i) => i === planeDragIndexVal ? { x: lx, y: ly } : c[i]);
587
+ // A mid-drag non-convex quad yields a null homography → empty grid for
588
+ // that frame; the outline keeps tracking and the drop snaps back.
589
+ segments = planeGridSegments({ ...planeDragPlane, corners: live }, planeDragGridSpacing, planeDragGridBounds);
590
+ }
591
+ else {
592
+ const a = planeDragIndexVal === 0 ? { x: lx, y: ly } : planeDragPlane.refLine.a;
593
+ const b = planeDragIndexVal === 1 ? { x: lx, y: ly } : planeDragPlane.refLine.b;
594
+ segments = planeGridSegmentsForScale({ ...planeDragPlane, refLine: { a, b } }, planeDragGridSpacing, planeDragGridBounds);
595
+ }
596
+ for (const s of segments) {
597
+ path.moveTo(s.a.x, s.a.y);
598
+ path.lineTo(s.b.x, s.b.y);
599
+ }
600
+ return path;
601
+ });
602
+ // Per-tile follow data for the dragged plane's dimension chips, written
603
+ // once at drag begin (see beginPlaneDrag): the committed edge endpoints,
604
+ // whether each end follows the dragged handle, and the tile's linePos.
605
+ // The overlay's animated style lerps along the live edge at that same
606
+ // linePos — the exact anchor syncDimensionTileOps recomputes on commit,
607
+ // so the chip lands where it already is.
608
+ const planeTileCtx = useSharedValue({});
479
609
  // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
480
610
  // annotation's rect renders from the live geometry; `rectCtx` carries the
481
611
  // fixed (opposite) corner and the grabbed corner's start position so the
@@ -1487,12 +1617,158 @@ export const AnnotationCanvasInner = (props) => {
1487
1617
  runOnJS(cancelSelectDrag)();
1488
1618
  });
1489
1619
  };
1620
+ // Plane tool: a hybrid pan. Handle drags run on the UI thread (shared
1621
+ // values → live outline; single commit on release — see the planeDrag*
1622
+ // values above); everything else (scale-mode marking, blocked-creation
1623
+ // taps) falls back to streaming JS pointer events exactly like toolPan.
1624
+ // The begin hit-test runs once on JS and decides the mode.
1625
+ const buildPlaneEditPan = (cfg) => {
1626
+ const beginPlaneDrag = (origin) => {
1627
+ const st = stateRef.current;
1628
+ const world = st.ctx.viewport.screenToWorld(origin);
1629
+ const index = cfg.hitHandle(st.ctx.document, world, st.viewport.zoom);
1630
+ const planeId = st.ctx.document.planes?.[0]?.id ?? null;
1631
+ if (index != null && planeId != null) {
1632
+ const start = cfg.handlePoints(st.ctx.document)[index];
1633
+ if (start) {
1634
+ planeDragStartX.value = start.x;
1635
+ planeDragStartY.value = start.y;
1636
+ // Follow data for the plane's dimension chips: each tile's edge
1637
+ // (planeEdgeLine — the committed geometry its line mirrors), which
1638
+ // endpoint(s) track the grabbed handle (coordinate equality with
1639
+ // the handle's start — both values come from the same committed
1640
+ // plane, so exact comparison is sound), and its linePos.
1641
+ const plane = st.ctx.document.planes?.find((p) => p.id === planeId);
1642
+ const follow = {};
1643
+ if (plane) {
1644
+ for (const m of st.ctx.document.placedMeasurements) {
1645
+ if (m.planeId !== planeId || !m.planeEdge)
1646
+ continue;
1647
+ const edge = planeEdgeLine(plane, m.planeEdge);
1648
+ if (!edge)
1649
+ continue;
1650
+ follow[m.id] = {
1651
+ ax: edge.a.x,
1652
+ ay: edge.a.y,
1653
+ bx: edge.b.x,
1654
+ by: edge.b.y,
1655
+ ma: edge.a.x === start.x && edge.a.y === start.y ? 1 : 0,
1656
+ mb: edge.b.x === start.x && edge.b.y === start.y ? 1 : 0,
1657
+ t: linePosOf(m),
1658
+ };
1659
+ }
1660
+ }
1661
+ planeTileCtx.value = follow;
1662
+ planeDragMode.value = 1;
1663
+ const drag = { planeId, index };
1664
+ planeDragRef.current = drag;
1665
+ setPlaneDrag(drag);
1666
+ // Precision drop — the loupe follows the finger like the select
1667
+ // tool's sub-drags.
1668
+ setMagnifying(true);
1669
+ return;
1670
+ }
1671
+ }
1672
+ planeDragMode.value = 2;
1673
+ const id = pointerIdRef.current++;
1674
+ planeJsPointerRef.current = { id };
1675
+ st.dispatchPointerDown(buildEvent(id, origin));
1676
+ };
1677
+ const movePlaneDragJs = (screen) => {
1678
+ const f = planeJsPointerRef.current;
1679
+ if (f)
1680
+ stateRef.current.dispatchPointerMove(buildEvent(f.id, screen));
1681
+ };
1682
+ const endPlaneDrag = (screen, translation) => {
1683
+ const st = stateRef.current;
1684
+ if (planeDragMode.value === 2) {
1685
+ const f = planeJsPointerRef.current;
1686
+ if (f)
1687
+ st.dispatchPointerUp(buildEvent(f.id, screen));
1688
+ }
1689
+ else if (planeDragMode.value === 1 && planeDragRef.current) {
1690
+ // Commit the position the live outline showed: handle start + the
1691
+ // finger's translation. NOT screenToWorld(finger) — the hit-test
1692
+ // accepts a grab anywhere inside the handle's radius, so the
1693
+ // finger's absolute position is off the handle by that grab offset,
1694
+ // and committing it made the corner visibly shift on release.
1695
+ // Zoom can't change mid-drag (this pan won the gesture race, so the
1696
+ // pinch never activated), so reading it at drop matches every frame.
1697
+ const world = {
1698
+ x: planeDragStartX.value + translation.x / zoom.value,
1699
+ y: planeDragStartY.value + translation.y / zoom.value,
1700
+ };
1701
+ const patch = cfg.buildHandleDropPatch(st.ctx.document, planeDragRef.current.index, world);
1702
+ // Null = invalid drop (non-convex) — clearing the drag state below
1703
+ // snaps the outline back to the committed plane.
1704
+ if (patch)
1705
+ st.ctx.commit(patch);
1706
+ }
1707
+ planeDragMode.value = 0;
1708
+ planeDragRef.current = null;
1709
+ planeJsPointerRef.current = null;
1710
+ setPlaneDrag(null);
1711
+ setMagnifying(false);
1712
+ };
1713
+ const cancelPlaneDrag = () => {
1714
+ if (planeDragMode.value === 2 && planeJsPointerRef.current) {
1715
+ stateRef.current.dispatchPointerCancel();
1716
+ }
1717
+ planeDragMode.value = 0;
1718
+ planeDragRef.current = null;
1719
+ planeJsPointerRef.current = null;
1720
+ setPlaneDrag(null);
1721
+ setMagnifying(false);
1722
+ };
1723
+ return Gesture.Pan()
1724
+ .minPointers(1)
1725
+ .maxPointers(1)
1726
+ .onStart((e) => {
1727
+ 'worklet';
1728
+ planeDragEnded.value = false;
1729
+ planeDragMode.value = 0;
1730
+ planeDragTX.value = e.translationX;
1731
+ planeDragTY.value = e.translationY;
1732
+ magTouchX.value = e.x;
1733
+ magTouchY.value = e.y;
1734
+ // Hit-test at the touch-down point — onStart fires only after the
1735
+ // pan threshold, so back out the accumulated translation.
1736
+ runOnJS(beginPlaneDrag)({
1737
+ x: e.x - e.translationX,
1738
+ y: e.y - e.translationY,
1739
+ });
1740
+ })
1741
+ .onChange((e) => {
1742
+ 'worklet';
1743
+ planeDragTX.value = e.translationX;
1744
+ planeDragTY.value = e.translationY;
1745
+ magTouchX.value = e.x;
1746
+ magTouchY.value = e.y;
1747
+ if (planeDragMode.value === 2) {
1748
+ runOnJS(movePlaneDragJs)({ x: e.x, y: e.y });
1749
+ }
1750
+ })
1751
+ .onEnd((e) => {
1752
+ 'worklet';
1753
+ planeDragEnded.value = true;
1754
+ // The final translation travels as an argument rather than being
1755
+ // read back from the shared values on JS: the last onChange isn't
1756
+ // guaranteed to have carried it, and onEnd doesn't write them.
1757
+ runOnJS(endPlaneDrag)({ x: e.x, y: e.y }, { x: e.translationX, y: e.translationY });
1758
+ })
1759
+ .onFinalize(() => {
1760
+ 'worklet';
1761
+ if (!planeDragEnded.value)
1762
+ runOnJS(cancelPlaneDrag)();
1763
+ });
1764
+ };
1490
1765
  // One finger, by active tool:
1491
1766
  // - freehand (pen/marker/highlighter) → draw on the UI thread
1492
1767
  // - shape tools (line/rect/…) → rubber-band on the UI thread
1493
1768
  // - Hand → no separate gesture: viewportPan above already spans one
1494
1769
  // finger (min 1), so panning and pinching stay one uncancelled stream
1495
1770
  // - select → drag the hit element on the UI thread
1771
+ // - plane tool → handle drags on the UI thread, marking on the JS thread
1496
1772
  // - everything else → dispatch pointer events on the JS thread
1497
1773
  const oneFinger = freehand
1498
1774
  ? buildDrawPan(freehand)
@@ -1500,9 +1776,11 @@ export const AnnotationCanvasInner = (props) => {
1500
1776
  ? buildShapeDrawPan(shapeDraw)
1501
1777
  : panViewport
1502
1778
  ? null
1503
- : dragSelection
1504
- ? buildSelectDragPan(dragSelection)
1505
- : toolPan;
1779
+ : planeEdit
1780
+ ? buildPlaneEditPan(planeEdit)
1781
+ : dragSelection
1782
+ ? buildSelectDragPan(dragSelection)
1783
+ : toolPan;
1506
1784
  // Long-press only joins the race when the active tool acts on it, so it
1507
1785
  // never pre-empts a one-finger drag/draw on tools that ignore holds.
1508
1786
  return Gesture.Race(tap, ...(longPressEnabled ? [longPress] : []), Gesture.Simultaneous(viewportPan, pinch), ...(oneFinger ? [oneFinger] : []));
@@ -1520,6 +1798,14 @@ export const AnnotationCanvasInner = (props) => {
1520
1798
  shapeDraw,
1521
1799
  panViewport,
1522
1800
  dragSelection,
1801
+ planeEdit,
1802
+ planeDragMode,
1803
+ planeDragEnded,
1804
+ planeDragTX,
1805
+ planeDragTY,
1806
+ planeDragStartX,
1807
+ planeDragStartY,
1808
+ planeTileCtx,
1523
1809
  longPressEnabled,
1524
1810
  dragX,
1525
1811
  dragY,
@@ -1613,6 +1899,13 @@ export const AnnotationCanvasInner = (props) => {
1613
1899
  ? handleRingWidth
1614
1900
  : undefined,
1615
1901
  showPlaneHandles: activeTool?.planeEditing === true,
1902
+ // UI-thread plane handle drag: while set, the Skia tree swaps the plane's
1903
+ // static element for the live outline + handles (see AnnotationCanvasSkia).
1904
+ planeDragId: planeDrag?.planeId ?? null,
1905
+ planeDragIndex: planeDrag?.index ?? null,
1906
+ planeDragPath,
1907
+ planeDragGridPath,
1908
+ planeDragHandle,
1616
1909
  customPreview,
1617
1910
  };
1618
1911
  // Lens window for the loupe: an opaque white base + the magnified scene
@@ -1628,7 +1921,7 @@ export const AnnotationCanvasInner = (props) => {
1628
1921
  // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
1629
1922
  _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [backgroundLayers.map((layer) => (_jsx(BackgroundSkImageLoader, { layer: layer, resolveUrl: resolveImageUrl, onImage: onBackgroundSkImage }, layer.id))), _jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1630
1923
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1631
- : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1924
+ : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, planeEdgeDragging: planeDrag != null && placed.planeId === planeDrag.planeId, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, planeTileCtx: planeTileCtx, planeDragTX: planeDragTX, planeDragTY: planeDragTY, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1632
1925
  ? () => {
1633
1926
  const defaultRemove = () => {
1634
1927
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
@@ -1644,7 +1937,7 @@ export const AnnotationCanvasInner = (props) => {
1644
1937
  wrapContent: loupeWrap,
1645
1938
  }) }))] }));
1646
1939
  };
1647
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1940
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1648
1941
  // Square tile or wide group-header pill — one footprint source of truth
1649
1942
  // shared with the hit-test (stampTileDims).
1650
1943
  const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale, headerTileScaleFactor ?? tileScaleFactor, fileTileScaleFactor ?? tileScaleFactor);
@@ -1658,6 +1951,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1658
1951
  const removeSize = Math.min(36, Math.min(width, height) * 0.4);
1659
1952
  const anchorX = placed.anchor.x;
1660
1953
  const anchorY = placed.anchor.y;
1954
+ const placedId = placed.id;
1661
1955
  // doc → screen each frame, on the UI thread. Position is a translate
1662
1956
  // transform (cheap, no layout) so the tile stays glued to its anchor. While
1663
1957
  // dragging, the world-space drag offset is folded in; while sliding, the
@@ -1736,6 +2030,22 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1736
2030
  worldX = (c.fx + c.mx + dragX.value) / 2;
1737
2031
  worldY = (c.fy + c.my + dragY.value) / 2;
1738
2032
  }
2033
+ else if (planeEdgeDragging) {
2034
+ // Tile stays at its linePos along the LIVE plane edge. The plane drag's
2035
+ // translation is screen px (unlike dragX/dragY's world units), so it
2036
+ // converts through the live zoom, same as the outline twin.
2037
+ const c = planeTileCtx.value[placedId];
2038
+ if (c) {
2039
+ const dx = planeDragTX.value / zoom.value;
2040
+ const dy = planeDragTY.value / zoom.value;
2041
+ const ax = c.ma === 1 ? c.ax + dx : c.ax;
2042
+ const ay = c.ma === 1 ? c.ay + dy : c.ay;
2043
+ const bx = c.mb === 1 ? c.bx + dx : c.bx;
2044
+ const by = c.mb === 1 ? c.by + dy : c.by;
2045
+ worldX = ax + (bx - ax) * c.t;
2046
+ worldY = ay + (by - ay) * c.t;
2047
+ }
2048
+ }
1739
2049
  else if (dragging) {
1740
2050
  worldX = anchorX + dragX.value;
1741
2051
  worldY = anchorY + dragY.value;
@@ -89,9 +89,18 @@ 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
+ planeDragGridPath?: SkPath | {
98
+ value: SkPath;
99
+ };
100
+ planeDragHandle?: AnimatedPoint;
92
101
  customPreview?: ReactNode;
93
102
  wrapContent?: (content: ReactNode) => ReactNode;
94
103
  canvasRef?: RefObject<CanvasRef | null>;
95
104
  }
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;
105
+ 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, planeDragGridPath, planeDragHandle, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
97
106
  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_GRID_COLOR, PLANE_GRID_OPACITY, PLANE_GRID_WIDTH, 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, planeDragGridPath, 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) => (_jsx(PlaneElement, { plane: plane, scaleBounds: planeScaleBounds, showHandles: showPlaneHandles === true, handleRadius: showPlaneHandles ? handleRadius : undefined, handleRingWidth: showPlaneHandles ? handleRingWidth : undefined }, plane.id))), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
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: [planeDragGridPath && (_jsx(Path, { path: planeDragGridPath, color: PLANE_GRID_COLOR, style: "stroke", strokeWidth: PLANE_GRID_WIDTH, opacity: PLANE_GRID_OPACITY })), _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;
@@ -1,4 +1,9 @@
1
1
  import type { PlaneCalibration } from '../../../types/annotation.js';
2
+ export declare const PLANE_CHROME_COLOR: "#BD30A8";
3
+ export declare const PLANE_OUTLINE_WIDTH = 14;
4
+ export declare const PLANE_GRID_COLOR = "#FFD1F8";
5
+ export declare const PLANE_GRID_OPACITY = 0.9;
6
+ export declare const PLANE_GRID_WIDTH = 2.5;
2
7
  type AnimatedNumber = number | {
3
8
  value: number;
4
9
  };
@@ -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
- const PLANE_COLOR = FormulaColors.purple;
12
- const PLANE_GRID_COLOR = '#FFD1F8';
13
- const PLANE_GRID_OPACITY = 0.9;
11
+ // Exported for the live UI-thread handle drag, which redraws the outline and
12
+ // grid in 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;
16
+ export const PLANE_GRID_COLOR = '#FFD1F8';
17
+ export const PLANE_GRID_OPACITY = 0.9;
14
18
  // Doc-space stroke widths (scale with zoom, like the drawn shapes).
15
- const PLANE_GRID_WIDTH = 2.5;
16
- const PLANE_OUTLINE_WIDTH = 14;
19
+ export const PLANE_GRID_WIDTH = 2.5;
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;
@@ -44,7 +44,10 @@ export const projectToLinePos = (line, world) => {
44
44
  export const snapLinePos = (t, threshold = 0.06) => Math.abs(t - 0.5) <= threshold ? 0.5 : t;
45
45
  // Euclidean length of a line in doc space (used to convert a screen-px snap
46
46
  // radius into a t-space threshold).
47
+ // 'worklet': called from planeGeometry's grid generators, which run per-frame
48
+ // on the UI thread during a plane handle drag. Inert everywhere else.
47
49
  export const lineLength = (line) => {
50
+ 'worklet';
48
51
  const dx = line.b.x - line.a.x;
49
52
  const dy = line.b.y - line.a.y;
50
53
  return Math.sqrt(dx * dx + dy * dy);
@@ -7,15 +7,17 @@
7
7
  // SIZING (stampLayout.ts). Everything here is plane calibration — mapping
8
8
  // DOC-space geometry to physical lengths in micrometers.
9
9
  //
10
- // v1 keeps all of this on the JS thread (values recompute on commit, not
11
- // per-frame), so there are no worklet twins of these functions yet. If live
12
- // drag readouts are ever needed, the twin goes next to the existing geometry
13
- // twins in AnnotationCanvasInner.native.tsx.
10
+ // The homography/grid chain carries 'worklet' directives (inert everywhere
11
+ // but a Reanimated build): AnnotationCanvasInner.native.tsx rebuilds the grid
12
+ // per frame on the UI thread during a plane handle drag, calling these
13
+ // directly rather than maintaining an inline twin of ~200 lines of math.
14
+ // Values (planeDistanceUm etc.) still recompute on commit, JS-side.
14
15
  import { lineLength } from './measurementGeometry.js';
15
16
  // Degeneracy guards are scaled to the quad's own extent — doc coordinates are
16
17
  // arbitrary (a photo canvas is typically ~1000 units across), so a fixed
17
18
  // epsilon would misfire on very small or very large documents.
18
19
  const relativeEps = (corners) => {
20
+ 'worklet';
19
21
  let span = 1;
20
22
  for (const p of corners) {
21
23
  span = Math.max(span, Math.abs(p.x), Math.abs(p.y));
@@ -27,6 +29,7 @@ const relativeEps = (corners) => {
27
29
  // (collinear/coincident corners). Corners must already be in TL,TR,BR,BL order
28
30
  // (see normalizeQuadOrder).
29
31
  export const homographyFromQuad = (corners) => {
32
+ 'worklet';
30
33
  const [p0, p1, p2, p3] = corners;
31
34
  const eps = relativeEps(corners);
32
35
  const sx = p0.x - p1.x + p2.x - p3.x;
@@ -71,6 +74,7 @@ export const homographyFromQuad = (corners) => {
71
74
  // Returns null when that entry (a·e − b·d) is ~zero — the inverse would put
72
75
  // the whole doc plane on the vanishing line.
73
76
  export const invertHomography = (m) => {
77
+ 'worklet';
74
78
  const norm = m.a * m.e - m.b * m.d;
75
79
  if (Math.abs(norm) < 1e-12)
76
80
  return null;
@@ -88,6 +92,7 @@ export const invertHomography = (m) => {
88
92
  // Apply a homography to a point. Null when the point sits (numerically) on the
89
93
  // vanishing line — the plane there is at infinity.
90
94
  export const applyHomography = (m, p) => {
95
+ 'worklet';
91
96
  const w = m.g * p.x + m.h * p.y + 1;
92
97
  if (Math.abs(w) < 1e-12)
93
98
  return null;
@@ -148,12 +153,15 @@ export const normalizeQuadOrder = (pts) => {
148
153
  // The dimensions a calibration has been given so far. Null until the user has
149
154
  // entered them all (through the plane's dimension tiles) — an incomplete
150
155
  // calibration renders its geometry but computes nothing.
151
- const perspectiveDims = (cal) => cal.widthUm != null &&
152
- cal.widthUm > 0 &&
153
- cal.heightUm != null &&
154
- cal.heightUm > 0
155
- ? { widthUm: cal.widthUm, heightUm: cal.heightUm }
156
- : null;
156
+ const perspectiveDims = (cal) => {
157
+ 'worklet';
158
+ return cal.widthUm != null &&
159
+ cal.widthUm > 0 &&
160
+ cal.heightUm != null &&
161
+ cal.heightUm > 0
162
+ ? { widthUm: cal.widthUm, heightUm: cal.heightUm }
163
+ : null;
164
+ };
157
165
  // Doc-space point → plane coordinates in micrometers (x across the rectangle's
158
166
  // width, y down its height; the TL corner is the origin). Null when the
159
167
  // calibration's quad is degenerate, its dimensions aren't entered yet, or the
@@ -333,6 +341,7 @@ export const resolvePlaneGridSpacingUm = (cal, system, scaleBounds) => {
333
341
  const GRID_EXTENT_CAP = 12;
334
342
  // Liang–Barsky segment/rect clip, DOC space. Null when fully outside.
335
343
  const clipSegmentToRect = (a, b, rect) => {
344
+ 'worklet';
336
345
  const dx = b.x - a.x;
337
346
  const dy = b.y - a.y;
338
347
  let t0 = 0;
@@ -385,6 +394,7 @@ const clipSegmentToRect = (a, b, rect) => {
385
394
  // far side of the horizon are dropped (w stays positive across a segment
386
395
  // only when it's positive at both ends — w is affine on the plane).
387
396
  export const planeGridSegments = (cal, spacingUm, bounds) => {
397
+ 'worklet';
388
398
  const dims = perspectiveDims(cal);
389
399
  const h = homographyFromQuad(cal.corners);
390
400
  if (!dims ||
@@ -476,6 +486,7 @@ export const planeGridSegments = (cal, spacingUm, bounds) => {
476
486
  // rect, or the document rect when there's no background), DOC space. Lines
477
487
  // anchor at the bounds origin.
478
488
  export const planeGridSegmentsForScale = (cal, spacingUm, bounds) => {
489
+ 'worklet';
479
490
  const refDoc = lineLength(cal.refLine);
480
491
  const lengthUm = cal.lengthUm ?? 0;
481
492
  if (!(refDoc > 0) ||
@@ -25,6 +25,22 @@ const committedPlane = (doc) => doc.planes?.[0] ?? null;
25
25
  const planeHandles = (plane) => plane.mode === 'perspective'
26
26
  ? plane.corners
27
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
+ };
28
44
  // The committed plane with one handle moved to `p`.
29
45
  const withHandleMoved = (plane, index, p) => {
30
46
  if (plane.mode === 'perspective') {
@@ -217,18 +233,26 @@ export const createPlaneTool = (options) => {
217
233
  return null;
218
234
  };
219
235
  // Which committed-plane handle a press landed on, if any.
220
- const hitHandle = (ctx, world) => {
221
- const plane = committedPlane(ctx.document);
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);
222
242
  if (!plane)
223
243
  return null;
224
- const tol = HANDLE_GRAB_PX / ctx.viewport.state.zoom;
225
- const tolSq = tol * tol;
226
- const handles = planeHandles(plane);
227
- for (let i = 0; i < handles.length; i++) {
228
- if (distSq(world, handles[i]) <= tolSq)
229
- return i;
244
+ const next = withHandleMoved(plane, index, world);
245
+ if (next.mode === 'perspective' &&
246
+ !isQuadConvexNonDegenerate(next.corners)) {
247
+ options.onInvalidQuad?.();
248
+ return null;
230
249
  }
231
- return null;
250
+ return {
251
+ ops: [
252
+ { op: 'setPlane', plane: next },
253
+ ...syncDimensionTileOps(doc, next),
254
+ ],
255
+ };
232
256
  };
233
257
  return {
234
258
  id: options.id ?? `plane-${mode}`,
@@ -236,6 +260,17 @@ export const createPlaneTool = (options) => {
236
260
  (mode === 'perspective' ? 'Perspective plane' : 'Scale reference'),
237
261
  cursor: 'crosshair',
238
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
+ },
239
274
  onPointerDown(event, ctx) {
240
275
  // Editing an existing plane wins over starting new geometry — but only
241
276
  // when nothing is mid-mark (a quad in progress keeps collecting taps).
@@ -305,23 +340,12 @@ export const createPlaneTool = (options) => {
305
340
  ctx.preview({ ops: [] });
306
341
  if (!s.moved)
307
342
  return;
308
- const plane = committedPlane(ctx.document);
309
- if (!plane)
310
- return;
311
- const next = withHandleMoved(plane, s.handleIndex, event.world);
312
- // A corner drag that breaks the quad snaps back instead of committing
313
- // a plane the homography can't invert.
314
- if (next.mode === 'perspective' &&
315
- !isQuadConvexNonDegenerate(next.corners)) {
316
- options.onInvalidQuad?.();
317
- return;
318
- }
319
- ctx.commit({
320
- ops: [
321
- { op: 'setPlane', plane: next },
322
- ...syncDimensionTileOps(ctx.document, next),
323
- ],
324
- });
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);
325
349
  return;
326
350
  }
327
351
  // While a calibration exists, a TAP on one of its dimension tiles
@@ -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
- setToolState(undefined);
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, toolState);
342
+ const next = activeTool.onPointerDown?.(event, ctx, toolStateRef.current);
328
343
  if (next !== undefined)
329
- setToolState(next);
330
- }, [activeTool, ctx, toolState]);
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, toolState);
355
+ const next = activeTool.onPointerMove?.(event, ctx, toolStateRef.current);
339
356
  if (next !== undefined)
340
- setToolState(next);
341
- }, [activeTool, ctx, toolState]);
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, toolState);
368
+ activeTool.onPointerUp?.(event, ctx, toolStateRef.current);
350
369
  activePointerIdRef.current = null;
351
- setToolState(undefined);
352
- }, [activeTool, ctx, toolState]);
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
- setToolState(undefined);
388
+ const prevToolState = toolStateRef.current;
389
+ updateToolState(undefined);
368
390
  setPreviewPatch(null);
369
391
  if (activeTool)
370
- activeTool.onCancel?.(toolState, ctx);
371
- }, [activeTool, ctx, toolState]);
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
  }, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.15.3",
3
+ "version": "1.15.5",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",