@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.
@@ -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
- : dragSelection
1504
- ? buildSelectDragPan(dragSelection)
1505
- : toolPan;
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) => (_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: [_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,6 @@
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;
2
4
  type AnimatedNumber = number | {
3
5
  value: number;
4
6
  };
@@ -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;
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 cyan stroke so it disappears into the outline instead of
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: 4,
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 handle a press landed on, if any.
196
- const hitHandle = (ctx, world) => {
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 tol = HANDLE_GRAB_PX / ctx.viewport.state.zoom;
201
- const tolSq = tol * tol;
202
- const handles = planeHandles(plane);
203
- for (let i = 0; i < handles.length; i++) {
204
- if (distSq(world, handles[i]) <= tolSq)
205
- return i;
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
- const plane = committedPlane(ctx.document);
285
- if (!plane)
286
- return;
287
- const next = withHandleMoved(plane, s.handleIndex, event.world);
288
- // A corner drag that breaks the quad snaps back instead of committing
289
- // a plane the homography can't invert.
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
- // Creation is blocked while a calibration exists (only its handles are
304
- // editable) the user deletes it first to re-mark.
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
- 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
  }, []);
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.15.2",
3
+ "version": "1.15.4",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",