@reekon-tools/boldr-utils 1.6.18 → 1.6.19

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.
Files changed (82) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +7 -1
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +58 -5
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +5 -1
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.js +53 -4
  5. package/dist/annotation/canvas/Tool.d.ts +5 -2
  6. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -1
  7. package/dist/annotation/canvas/measurementGeometry.js +3 -2
  8. package/dist/annotation/canvas/stampLayout.d.ts +7 -1
  9. package/dist/annotation/canvas/stampLayout.js +66 -5
  10. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  11. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  12. package/dist/annotation/canvas/tools/panTool.js +1 -1
  13. package/dist/annotation/canvas/tools/selectTool.js +75 -9
  14. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +1 -0
  15. package/dist/annotation/canvas/useAnnotationCanvasState.js +9 -0
  16. package/dist/exports.d.ts +1 -0
  17. package/dist/exports.js +1 -0
  18. package/dist/types/annotation.d.ts +4 -0
  19. package/dist/types/annotation.js +6 -0
  20. package/dist/types/firestore.d.ts +53 -0
  21. package/dist/types/firestore.js +49 -0
  22. package/package.json +1 -1
  23. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  24. package/dist/canvas/AnnotationCanvas.js +0 -10
  25. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  26. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  27. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  28. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  29. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  30. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  31. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  32. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  33. package/dist/canvas/Tool.d.ts +0 -38
  34. package/dist/canvas/Tool.js +0 -1
  35. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  36. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  37. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  38. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  39. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  40. package/dist/canvas/elements/ShapeElement.js +0 -62
  41. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  42. package/dist/canvas/elements/StrokeElement.js +0 -18
  43. package/dist/canvas/measurementPicker.d.ts +0 -10
  44. package/dist/canvas/measurementPicker.js +0 -1
  45. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  46. package/dist/canvas/measurementStampOverlay.js +0 -1
  47. package/dist/canvas/pointerAdapter.d.ts +0 -3
  48. package/dist/canvas/pointerAdapter.js +0 -19
  49. package/dist/canvas/stampLayout.d.ts +0 -5
  50. package/dist/canvas/stampLayout.js +0 -14
  51. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  52. package/dist/canvas/tools/measurementStampTool.js +0 -37
  53. package/dist/canvas/tools/panTool.d.ts +0 -5
  54. package/dist/canvas/tools/panTool.js +0 -25
  55. package/dist/canvas/tools/penTool.d.ts +0 -13
  56. package/dist/canvas/tools/penTool.js +0 -68
  57. package/dist/canvas/tools/selectTool.d.ts +0 -2
  58. package/dist/canvas/tools/selectTool.js +0 -182
  59. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  60. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  61. package/dist/canvas/viewport.d.ts +0 -16
  62. package/dist/canvas/viewport.js +0 -54
  63. package/dist/data/AnnotationDataContext.d.ts +0 -8
  64. package/dist/data/AnnotationDataContext.js +0 -11
  65. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  66. package/dist/data/AnnotationDataProvider.js +0 -4
  67. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  68. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  69. package/dist/data/canvasPersistence.d.ts +0 -3
  70. package/dist/data/canvasPersistence.js +0 -26
  71. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  72. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  73. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  74. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  75. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  76. package/dist/data/hooks/useAnnotationList.js +0 -26
  77. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  78. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  79. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  80. package/dist/hooks/useParseMeasurement.js +0 -14
  81. package/dist/utils/evaluateFormula.d.ts +0 -20
  82. package/dist/utils/evaluateFormula.js +0 -31
@@ -9,6 +9,9 @@ import { stampTileSize } from './stampLayout.js';
9
9
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
10
10
  // native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
11
11
  const HANDLE_PX = 7;
12
+ // Screen-px stroke width of the handle's colored ring (matches native
13
+ // HANDLE_RING_PX); the white-disc + ring keeps the knob legible on any line.
14
+ const HANDLE_RING_PX = 2;
12
15
  const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
13
16
  export const AnnotationCanvasInner = (props) => {
14
17
  const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
@@ -206,13 +209,16 @@ export const AnnotationCanvasInner = (props) => {
206
209
  handleRadius: activeTool?.dragSelection
207
210
  ? HANDLE_PX / state.viewport.zoom
208
211
  : undefined,
212
+ handleRingWidth: activeTool?.dragSelection
213
+ ? HANDLE_RING_PX / state.viewport.zoom
214
+ : undefined,
209
215
  customPreview,
210
216
  }), renderMeasurementStamp && (_jsx("div", { style: {
211
217
  position: 'absolute',
212
218
  inset: 0,
213
219
  pointerEvents: 'none',
214
220
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
215
- const size = stampTileSize(placed);
221
+ const size = stampTileSize(placed, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale);
216
222
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
217
223
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
218
224
  const isSelected = selection?.ids.includes(placed.id) ?? false;
@@ -15,6 +15,9 @@ const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++
15
15
  // Screen-px radius of a measurement-annotation endpoint handle dot. Divided by
16
16
  // the live zoom so the handle is a constant on-screen size.
17
17
  const HANDLE_RADIUS_PX = 7;
18
+ // Screen-px stroke width of the handle's colored ring (white-disc + ring, so the
19
+ // knob stays legible over a line of any color). Also zoom-divided.
20
+ const HANDLE_RING_PX = 2;
18
21
  // Native fingerprint: one finger drives the active tool, two fingers
19
22
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
20
23
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -315,6 +318,13 @@ export const AnnotationCanvasInner = (props) => {
315
318
  const [epDragId, setEpDragId] = useState(null);
316
319
  const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0 });
317
320
  const epTargetRef = useRef(null);
321
+ // Endpoint drag of a line/arrow SHAPE (vs. a measurement line above).
322
+ // `shapeEpDragId` (React state) tells the Skia layer to render that shape's
323
+ // line from the live endpoints; the live geometry rides the SAME `epCtx` +
324
+ // `liveLineP1`/`liveLineP2` shared values (only one endpoint drag is ever in
325
+ // flight). A separate target ref keys the commit to buildShapeEndpointPatch.
326
+ const [shapeEpDragId, setShapeEpDragId] = useState(null);
327
+ const shapeEpTargetRef = useRef(null);
318
328
  // Live line endpoints during an endpoint drag: the grabbed handle follows the
319
329
  // finger (dragX/dragY); the other stays put. Fed to the Skia <Line> for the
320
330
  // dragged annotation.
@@ -338,6 +348,10 @@ export const AnnotationCanvasInner = (props) => {
338
348
  'worklet';
339
349
  return HANDLE_RADIUS_PX / zoom.value;
340
350
  });
351
+ const handleRingWidth = useDerivedValue(() => {
352
+ 'worklet';
353
+ return HANDLE_RING_PX / zoom.value;
354
+ });
341
355
  // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
342
356
  // annotation's rect renders from the live geometry; `rectCtx` carries the
343
357
  // fixed (opposite) corner and the grabbed corner's start position so the
@@ -758,6 +772,27 @@ export const AnnotationCanvasInner = (props) => {
758
772
  return;
759
773
  }
760
774
  }
775
+ // Endpoint handle of a line/arrow shape — selected-element-only, and
776
+ // distinct from the measurement-line handle above (different storage).
777
+ const shapeHandle = cfg.hitTestShapeHandle?.(st.ctx.document, selId, world, zoomNow);
778
+ if (shapeHandle) {
779
+ const s = st.ctx.document.shapes.find((x) => x.id === selId);
780
+ const a = s?.geometry.points[0];
781
+ const b = s?.geometry.points[1];
782
+ if (a && b) {
783
+ epCtx.value = {
784
+ ax: a.x,
785
+ ay: a.y,
786
+ bx: b.x,
787
+ by: b.y,
788
+ t0: 0.5,
789
+ handle: shapeHandle === 'a' ? 0 : 1,
790
+ };
791
+ shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
792
+ setShapeEpDragId(selId);
793
+ return;
794
+ }
795
+ }
761
796
  // Corner resize handle (text shapes) — also selected-element-only.
762
797
  const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
763
798
  if (resizeGeom) {
@@ -787,7 +822,7 @@ export const AnnotationCanvasInner = (props) => {
787
822
  return;
788
823
  }
789
824
  }
790
- const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
825
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
791
826
  if (!hit) {
792
827
  st.ctx.setSelection(null);
793
828
  dragTargetRef.current = null;
@@ -796,7 +831,7 @@ export const AnnotationCanvasInner = (props) => {
796
831
  st.ctx.setSelection({ ids: [hit.id] });
797
832
  // Grabbing a line annotation's tile slides it; otherwise group-move.
798
833
  const grab = hit.kind === 'measurement'
799
- ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow)
834
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale)
800
835
  : 'move';
801
836
  if (grab === 'slide') {
802
837
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
@@ -859,6 +894,18 @@ export const AnnotationCanvasInner = (props) => {
859
894
  setEpDragId(null);
860
895
  return;
861
896
  }
897
+ // Shape endpoint commit: move the grabbed endpoint by the world delta.
898
+ const sepT = shapeEpTargetRef.current;
899
+ if (sepT) {
900
+ if (dx !== 0 || dy !== 0) {
901
+ const patch = cfg.buildShapeEndpointPatch?.(st.ctx.document, sepT.id, sepT.handle, { x: dx, y: dy });
902
+ if (patch)
903
+ st.ctx.commit(patch);
904
+ }
905
+ shapeEpTargetRef.current = null;
906
+ setShapeEpDragId(null);
907
+ return;
908
+ }
862
909
  // Slide commit: map the final world delta to a clamped/snapped linePos.
863
910
  const slideT = slideTargetRef.current;
864
911
  if (slideT) {
@@ -891,11 +938,13 @@ export const AnnotationCanvasInner = (props) => {
891
938
  dragTargetRef.current = null;
892
939
  slideTargetRef.current = null;
893
940
  epTargetRef.current = null;
941
+ shapeEpTargetRef.current = null;
894
942
  resizeTargetRef.current = null;
895
943
  rectTargetRef.current = null;
896
944
  setDraggingId(null);
897
945
  setSlidingId(null);
898
946
  setEpDragId(null);
947
+ setShapeEpDragId(null);
899
948
  setResizingId(null);
900
949
  setRectDragId(null);
901
950
  };
@@ -1018,6 +1067,7 @@ export const AnnotationCanvasInner = (props) => {
1018
1067
  resizeTransform,
1019
1068
  selectedId: selection?.ids[0] ?? null,
1020
1069
  endpointDragId: epDragId,
1070
+ shapeEndpointDragId: shapeEpDragId,
1021
1071
  liveLineP1,
1022
1072
  liveLineP2,
1023
1073
  rectDragId,
@@ -1033,18 +1083,21 @@ export const AnnotationCanvasInner = (props) => {
1033
1083
  // measurement still shows its tile chrome but must not advertise
1034
1084
  // draggability).
1035
1085
  handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
1086
+ handleRingWidth: activeTool?.dragSelection
1087
+ ? handleRingWidth
1088
+ : undefined,
1036
1089
  customPreview,
1037
1090
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1038
1091
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1039
- : 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, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1092
+ : 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.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1040
1093
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1041
1094
  state.ctx.commit({ ops });
1042
1095
  if (!keepSelection)
1043
1096
  state.ctx.setSelection(null);
1044
1097
  } }, placed.id))) }))] }));
1045
1098
  };
1046
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, onStampPress, onStampLongPress, onRemove, }) => {
1047
- const size = stampTileSize(placed);
1099
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, renderMeasurementStamp, tileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
1100
+ const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
1048
1101
  const half = size / 2;
1049
1102
  const anchorX = placed.anchor.x;
1050
1103
  const anchorY = placed.anchor.y;
@@ -59,6 +59,7 @@ export interface AnnotationCanvasSkiaProps {
59
59
  };
60
60
  selectedId?: string | null;
61
61
  endpointDragId?: string | null;
62
+ shapeEndpointDragId?: string | null;
62
63
  liveLineP1?: AnimatedPoint;
63
64
  liveLineP2?: AnimatedPoint;
64
65
  rectDragId?: string | null;
@@ -71,7 +72,10 @@ export interface AnnotationCanvasSkiaProps {
71
72
  handleRadius?: number | {
72
73
  value: number;
73
74
  };
75
+ handleRingWidth?: number | {
76
+ value: number;
77
+ };
74
78
  customPreview?: ReactNode;
75
79
  }
76
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
80
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
77
81
  export {};
@@ -22,6 +22,12 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
22
22
  // hit-test the text resize handle, which sits on the padded box corner.
23
23
  const SELECTION_COLOR = '#0066FF';
24
24
  const SELECTION_STROKE = 1.5;
25
+ // A draggable selection handle (line endpoints, rect corners, text resize):
26
+ // a white disc with a colored ring, so it reads as a distinct, grabbable knob
27
+ // over a line of ANY color — including the default measurement-line blue, which
28
+ // a solid-colored handle would camouflage into. `c`/`r`/`ringWidth` may be
29
+ // Reanimated values (native live-drag) or plain numbers (web).
30
+ const Handle = ({ c, r, ringWidth, color = MEASUREMENT_HANDLE_COLOR, }) => (_jsxs(_Fragment, { children: [_jsx(Circle, { c: c, r: r, color: "#FFFFFF" }), _jsx(Circle, { c: c, r: r, color: color, style: "stroke", strokeWidth: ringWidth })] }));
25
31
  // Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
26
32
  const strokeBounds = (points) => {
27
33
  if (points.length < 2)
@@ -86,7 +92,46 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
86
92
  // since the function-call pattern works identically on native we use it
87
93
  // in both Inners for consistency. Don't add hooks here; this is a plain
88
94
  // JSX-returning helper, not a component.
89
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }) }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => {
95
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, handleRadius, handleRingWidth, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
96
+ // Line/arrow shapes support endpoint editing. When selected they show
97
+ // grab handles at both ends; during an endpoint drag the line renders
98
+ // from the live endpoints (one following the finger) — the shape twin
99
+ // of the measurement-line endpoint drag below. Other shapes, and
100
+ // unselected lines, fall through to the plain ShapeElement render so
101
+ // their memoization is preserved.
102
+ if (shape.kind === 'line' || shape.kind === 'arrow') {
103
+ const isEndpointDrag = shape.id === shapeEndpointDragId;
104
+ const isSelected = shape.id === selectedId;
105
+ const [a, b] = shape.geometry.points;
106
+ if ((isEndpointDrag || isSelected) && a && b) {
107
+ const stroke = shape.style.stroke ?? '#000000';
108
+ const strokeWidth = shape.style.strokeWidth ?? 2;
109
+ const hasArrow = shape.kind === 'arrow' || shape.style.cap === 'arrow';
110
+ const handles = handleRadius != null ? (_jsxs(_Fragment, { children: [_jsx(Handle, { c: isEndpointDrag && liveLineP1 ? liveLineP1 : a, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: isEndpointDrag && liveLineP2 ? liveLineP2 : b, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] })) : null;
111
+ // Endpoint drag: render the body live from the moving endpoints
112
+ // (outside any group translate), suppressing the static arrowhead
113
+ // so it doesn't lag the finger — it reappears on commit. Selected
114
+ // but idle: reuse ShapeElement for the body (caps/arrowhead/dash)
115
+ // and overlay the handles, both wrapped so a group move tracks.
116
+ if (isEndpointDrag) {
117
+ const arrowPath = (() => {
118
+ if (!hasArrow)
119
+ return null;
120
+ const [apex, baseL, baseR] = arrowheadTriangle(b, a, strokeWidth);
121
+ const p = Skia.Path.Make();
122
+ p.moveTo(apex.x, apex.y);
123
+ p.lineTo(baseL.x, baseL.y);
124
+ p.lineTo(baseR.x, baseR.y);
125
+ p.close();
126
+ return p;
127
+ })();
128
+ return (_jsxs(Group, { children: [_jsx(Line, { p1: liveLineP1 ?? a, p2: liveLineP2 ?? b, color: stroke, style: "stroke", strokeWidth: strokeWidth, strokeCap: toSkiaStrokeCap(shape.style.cap), children: shape.style.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(strokeWidth) })) }), handles, arrowPath && (_jsx(Path, { path: arrowPath, color: stroke, style: "fill" }))] }, shape.id));
129
+ }
130
+ return (_jsxs(DraggableElement, { isDragging: shape.id === draggingId, transform: dragTransform, children: [_jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }), handles] }, shape.id));
131
+ }
132
+ }
133
+ return (_jsx(DraggableElement, { isDragging: shape.id === draggingId || shape.id === resizingId, transform: shape.id === resizingId ? resizeTransform : dragTransform, children: _jsx(ShapeElement, { shape: shape, font: valueFont, textFontMgr: textFontMgr }) }, shape.id));
134
+ }), effectiveCanvas.placedMeasurements.map((placed) => {
90
135
  // Rectangle annotation: a stroked border whose center carries the
91
136
  // tile. A corner drag renders from the live geometry (outside the
92
137
  // group translate, like an endpoint drag); otherwise the committed
@@ -100,7 +145,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
100
145
  return (_jsx(Rect, { x: liveRect.x, y: liveRect.y, width: liveRect.width, height: liveRect.height, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }, placed.id));
101
146
  }
102
147
  const n = normalizeRect(placed.rect);
103
- return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: { x: n.minX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.minY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.minX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: { x: n.maxX, y: n.maxY }, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }, placed.id));
148
+ return (_jsxs(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: [_jsx(Rect, { x: n.minX, y: n.minY, width: n.maxX - n.minX, height: n.maxY - n.minY, color: rectColor, style: "stroke", strokeWidth: rectWidth, children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(rectWidth) })) }), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Handle, { c: { x: n.minX, y: n.minY }, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: { x: n.maxX, y: n.minY }, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: { x: n.minX, y: n.maxY }, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: { x: n.maxX, y: n.maxY }, r: handleRadius, ringWidth: handleRingWidth })] }))] }, placed.id));
104
149
  }
105
150
  if (placementOf(placed) !== 'line' || !placed.line)
106
151
  return null;
@@ -126,7 +171,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
126
171
  p.close();
127
172
  return p;
128
173
  })();
129
- const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: p1, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR }), _jsx(Circle, { c: p2, r: handleRadius, color: MEASUREMENT_HANDLE_COLOR })] }))] }));
174
+ const content = (_jsxs(_Fragment, { children: [_jsx(Line, { p1: p1, p2: p2, color: lineColor, style: "stroke", strokeWidth: lineWidth, strokeCap: toSkiaStrokeCap(placed.lineCap), children: placed.lineDash && (_jsx(DashPathEffect, { intervals: dashIntervals(lineWidth) })) }), arrowPath && (_jsx(Path, { path: arrowPath, color: lineColor, style: "fill" })), isSelected && handleRadius != null && (_jsxs(_Fragment, { children: [_jsx(Handle, { c: p1, r: handleRadius, ringWidth: handleRingWidth }), _jsx(Handle, { c: p2, r: handleRadius, ringWidth: handleRingWidth })] }))] }));
130
175
  return isEndpointDrag ? (_jsx(Group, { children: content }, placed.id)) : (_jsx(DraggableElement, { isDragging: placed.id === draggingId, transform: dragTransform, children: content }, placed.id));
131
176
  }), (() => {
132
177
  if (!selectedId)
@@ -139,6 +184,10 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
139
184
  }
140
185
  const shape = effectiveCanvas.shapes.find((s) => s.id === selectedId);
141
186
  if (shape) {
187
+ // Line/arrow shapes show endpoint handles (drawn with the shape
188
+ // above), not a bounding box — matching the measurement-line UX.
189
+ if (shape.kind === 'line' || shape.kind === 'arrow')
190
+ return null;
142
191
  // Text shapes derive their box from the estimated text bounds (the
143
192
  // stored geometry is just the top-left anchor) and add a corner
144
193
  // resize handle; both track the live resize transform so the chrome
@@ -152,7 +201,7 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
152
201
  const isResizing = shape.id === resizingId;
153
202
  const liveTransform = isResizing ? resizeTransform : dragTransform;
154
203
  const resizeGeom = isText ? textResizeGeometry(shape) : null;
155
- return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(Circle, { c: resizeGeom.handle, r: handleRadius, color: SELECTION_COLOR }) }))] }));
204
+ return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isDragging || isResizing, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) }))] }));
156
205
  }
157
206
  return null;
158
207
  })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }) }));
@@ -22,6 +22,7 @@ export interface ToolContext {
22
22
  document: AnnotationCanvasState;
23
23
  selection: Selection | null;
24
24
  viewport: ViewportApi;
25
+ tileViewportScale: number;
25
26
  preview(patch: AnnotationDocumentPatch): void;
26
27
  commit(patch: AnnotationDocumentPatch): void;
27
28
  setSelection(selection: Selection | null): void;
@@ -50,15 +51,17 @@ export interface ShapeDrawConfig {
50
51
  }
51
52
  export type DragElementKind = 'stroke' | 'shape' | 'measurement';
52
53
  export interface DragSelectionConfig {
53
- hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number): {
54
+ hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number): {
54
55
  id: AnnotationElementId;
55
56
  kind: DragElementKind;
56
57
  } | null;
57
58
  buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
58
- classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'slide' | 'move';
59
+ classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): 'slide' | 'move';
59
60
  buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
60
61
  hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
61
62
  buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
63
+ hitTestShapeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
64
+ buildShapeEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
62
65
  hitTestRectCorner?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): {
63
66
  corner: RectCorner;
64
67
  moving: Vec2;
@@ -36,7 +36,7 @@ export declare const rectCornerPoint: (rect: {
36
36
  b: Vec2;
37
37
  }, corner: RectCorner) => Vec2;
38
38
  export declare const oppositeRectCorner: (corner: RectCorner) => RectCorner;
39
- export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number) => boolean;
39
+ export declare const hitPlacedMeasurement: (m: PlacedMeasurementRef, p: Vec2, zoom?: number, tileScaleFactor?: number, viewportScale?: number) => boolean;
40
40
  export interface RemoveMeasurementResult {
41
41
  ops: AnnotationPatchOp[];
42
42
  keepSelection: boolean;
@@ -109,14 +109,15 @@ const segmentDistSq = (p, a, b) => {
109
109
  // stamp tile around the anchor, the line body of a line annotation, or the
110
110
  // border ring of a rectangle annotation (interiors stay transparent to hits so
111
111
  // elements inside remain reachable).
112
- export const hitPlacedMeasurement = (m, p, zoom = 1) => {
112
+ export const hitPlacedMeasurement = (m, p, zoom = 1, tileScaleFactor = 1, viewportScale = 1) => {
113
113
  // The stamp renders as a constant *screen*-size square centered on the
114
114
  // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
115
115
  // screen-space half-extent (+ padding) back to doc space via the zoom so
116
116
  // the hit box always matches what's drawn.
117
117
  // Unassociated inputs use the smaller input-tile footprint (#6); the helper
118
118
  // folds in scale + the associated/blank size so the hit box matches the draw.
119
- const half = (stampTileSize(m) / 2 + STAMP_HIT_PADDING) / zoom;
119
+ const half = (stampTileSize(m, tileScaleFactor, viewportScale) / 2 + STAMP_HIT_PADDING) /
120
+ zoom;
120
121
  const dx = Math.abs(p.x - m.anchor.x);
121
122
  const dy = Math.abs(p.y - m.anchor.y);
122
123
  if (dx <= half && dy <= half)
@@ -1,5 +1,11 @@
1
1
  import type { PlacedMeasurementRef } from '../../types/annotation.js';
2
2
  export declare const STAMP_TILE_SIZE = 96;
3
3
  export declare const STAMP_INPUT_TILE_SIZE = 56;
4
+ export declare const DEFAULT_TILE_SCALE = 1;
5
+ export declare const TILE_SCALE_MIN = 0.4;
6
+ export declare const TILE_SCALE_MAX = 2;
7
+ export declare const clampTileScale: (v: number) => number;
8
+ export declare const renderedDocWidthAtFit: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
9
+ export declare const viewportTileScale: (canvasW: number, canvasH: number, docW: number, docH: number) => number;
4
10
  export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
5
- export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">) => number;
11
+ export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
@@ -15,13 +15,74 @@ export const STAMP_TILE_SIZE = 96;
15
15
  // inputs read as lightweight tap targets; associated tiles keep STAMP_TILE_SIZE
16
16
  // so existing saved annotations are visually unchanged. The single knob for #6.
17
17
  export const STAMP_INPUT_TILE_SIZE = 56;
18
+ // Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
19
+ // knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
20
+ // of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
21
+ // where lines crowd together, or bump them up on a sparse one. Like `scale` it
22
+ // is purely a screen-space multiplier — it does NOT change with zoom. Absent ===
23
+ // DEFAULT_TILE_SCALE (visually identical to documents written before the knob
24
+ // existed).
25
+ export const DEFAULT_TILE_SCALE = 1;
26
+ export const TILE_SCALE_MIN = 0.4;
27
+ export const TILE_SCALE_MAX = 2;
28
+ // Clamp a tile-scale-factor candidate to the supported range. The single guard
29
+ // for the value before it lands in the document (slider input, restored docs).
30
+ export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
31
+ // --- Viewport-relative tile sizing ------------------------------------------
32
+ // A bare-pixel tile is the same size on every device, but the SAME document is
33
+ // fit into wildly different canvases (a phone window vs a desktop pane), so the
34
+ // drawing renders much larger on desktop and a fixed-px tile reads as a tiny
35
+ // fraction of it. To keep a tile a CONSISTENT fraction of the drawing across
36
+ // platforms — while staying independent of the user's live zoom — the tile
37
+ // footprint is multiplied by `viewportTileScale`, which tracks how large the
38
+ // document renders when fit to the canvas (NOT the live zoom).
39
+ // Rendered document width (screen px) at which a tile uses its unscaled base
40
+ // size. Calibrated to a large phone's width so phone canvases land at scale 1
41
+ // (mobile visually unchanged); larger canvases scale up proportionally.
42
+ const TILE_VIEWPORT_REFERENCE_PX = 430;
43
+ // Clamp so phone-sized canvases never shrink tiles below base (lower = 1) and
44
+ // very large monitors don't produce runaway tiles (upper = 4).
45
+ const TILE_VIEWPORT_SCALE_MIN = 1;
46
+ const TILE_VIEWPORT_SCALE_MAX = 4;
47
+ // Screen-px width the document occupies when fit to the canvas:
48
+ // docW * fitZoom, fitZoom = min(canvasW/docW, canvasH/docH)
49
+ // = min(canvasW, docW * canvasH / docH)
50
+ // This — not the raw canvas size — governs the tile's fraction of the drawing,
51
+ // so the same document at the same canvas aspect yields the same value on web
52
+ // and native. Falls back to canvasW when doc dimensions are unknown.
53
+ export const renderedDocWidthAtFit = (canvasW, canvasH, docW, docH) => {
54
+ if (!(docW > 0) || !(docH > 0) || !(canvasW > 0) || !(canvasH > 0)) {
55
+ return canvasW > 0 ? canvasW : TILE_VIEWPORT_REFERENCE_PX;
56
+ }
57
+ return Math.min(canvasW, (docW * canvasH) / docH);
58
+ };
59
+ // Multiplier folded into the tile footprint so tiles are a consistent fraction
60
+ // of the rendered drawing on any canvas. Zoom-INDEPENDENT (a function of canvas
61
+ // + doc dimensions only), so the tile still never changes size as the user
62
+ // pinches/wheels. Defaults to 1 (callers without canvas dimensions are
63
+ // unaffected — e.g. legacy tests).
64
+ export const viewportTileScale = (canvasW, canvasH, docW, docH) => {
65
+ const s = renderedDocWidthAtFit(canvasW, canvasH, docW, docH) /
66
+ TILE_VIEWPORT_REFERENCE_PX;
67
+ return s < TILE_VIEWPORT_SCALE_MIN
68
+ ? TILE_VIEWPORT_SCALE_MIN
69
+ : s > TILE_VIEWPORT_SCALE_MAX
70
+ ? TILE_VIEWPORT_SCALE_MAX
71
+ : s;
72
+ };
18
73
  // A placed measurement is an unassociated input until a measurement reference
19
74
  // is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
20
75
  export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
21
76
  // Screen-space edge length for a placed stamp: the compact input size while
22
77
  // unassociated, full size once a measurement is attached, then scaled by the
23
- // per-stamp `scale`. The ONE source of truth for tile footprint — render
24
- // overlay, hit-test, and slide-grab classification all call this so the drawn
25
- // tile and its touch box always agree.
26
- export const stampTileSize = (m) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
27
- (m.scale ?? 1);
78
+ // per-stamp `scale`, the document-wide `tileScaleFactor`, and the
79
+ // `viewportTileScale` (so tiles read at a consistent fraction of the drawing on
80
+ // any canvas). The ONE source of truth for tile footprint — render overlay,
81
+ // hit-test, and slide-grab classification all call this so the drawn tile and
82
+ // its touch box always agree. `tileScaleFactor` is the canvas-level knob
83
+ // (default 1, from `AnnotationCanvasState.tileScaleFactor`); `viewportScale`
84
+ // (default 1) is the per-canvas multiplier from `viewportTileScale`.
85
+ export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
86
+ (m.scale ?? 1) *
87
+ tileScaleFactor *
88
+ viewportScale;
@@ -0,0 +1,12 @@
1
+ import type { PlacedMeasurementRef } from '../../../types/annotation.js';
2
+ import type { Tool } from '../Tool.js';
3
+ export interface MeasurementLineToolOptions {
4
+ id?: string;
5
+ label?: string;
6
+ minDragPx?: number;
7
+ autoSwitchToSelect?: boolean;
8
+ selectToolId?: string;
9
+ onAutoSwitch?: (toToolId: string) => void;
10
+ onPlaced?: (measurement: PlacedMeasurementRef) => void;
11
+ }
12
+ export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
@@ -0,0 +1,95 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
3
+ let counter = 0;
4
+ const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
6
+ // Build the blank line-measurement annotation for a drag from `a` to `b`: a
7
+ // 2-point line with a value tile magnetized to its center (linePos 0.5). The
8
+ // payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
9
+ // line and a (legacy) center-placed one are byte-identical once committed.
10
+ const buildLineMeasurement = (opts) => {
11
+ const line = { a: opts.a, b: opts.b };
12
+ return {
13
+ id: opts.id,
14
+ layerId: opts.layerId,
15
+ placement: 'line',
16
+ line,
17
+ linePos: DEFAULT_LINE_POS,
18
+ // Center of the line; recomputeAnchor keeps this in sync on edits.
19
+ anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
20
+ x: (opts.a.x + opts.b.x) / 2,
21
+ y: (opts.a.y + opts.b.y) / 2,
22
+ }),
23
+ showLabel: true,
24
+ showValue: true,
25
+ createdAt: Date.now(),
26
+ };
27
+ };
28
+ // Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
29
+ // annotation (a line with a blank value tile at its center); release commits
30
+ // it. Mirrors createShapeTool's interaction so "input lines" are placed the
31
+ // same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
32
+ // old center-place affordance. The annotation stays blank (no measurement
33
+ // associated) until the user fills it via the tile / picker. Skia-free, like
34
+ // every tool factory; it renders its live preview through ctx.preview, so it
35
+ // works on web and (via the generic tool-pan dispatch) on native.
36
+ export const createMeasurementLineTool = (options = {}) => {
37
+ const minDragPx = options.minDragPx ?? 4;
38
+ const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
39
+ const selectToolId = options.selectToolId ?? 'select';
40
+ return {
41
+ id: options.id ?? 'measure-line',
42
+ label: options.label ?? 'Measurement line',
43
+ cursor: 'crosshair',
44
+ onPointerDown(event) {
45
+ return {
46
+ kind: 'measurement-line-drawing',
47
+ id: makeId(),
48
+ startWorld: event.world,
49
+ startScreen: event.screen,
50
+ moved: false,
51
+ };
52
+ },
53
+ onPointerMove(event, ctx, state) {
54
+ const s = state;
55
+ if (s?.kind !== 'measurement-line-drawing')
56
+ return s;
57
+ const measurement = buildLineMeasurement({
58
+ id: s.id,
59
+ layerId: firstLayerId(ctx.document),
60
+ a: s.startWorld,
61
+ b: event.world,
62
+ });
63
+ ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
64
+ return { ...s, moved: true };
65
+ },
66
+ onPointerUp(event, ctx, state) {
67
+ const s = state;
68
+ if (s?.kind !== 'measurement-line-drawing')
69
+ return;
70
+ const dx = event.screen.x - s.startScreen.x;
71
+ const dy = event.screen.y - s.startScreen.y;
72
+ if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
73
+ // Accidental tap — discard the rubber-band, commit nothing.
74
+ ctx.preview({ ops: [] });
75
+ return;
76
+ }
77
+ const measurement = buildLineMeasurement({
78
+ id: s.id,
79
+ layerId: firstLayerId(ctx.document),
80
+ a: s.startWorld,
81
+ b: event.world,
82
+ });
83
+ ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
84
+ // Leave it selected (and hand back to select) so the blank line can be
85
+ // filled / moved straight away — the same flow as the text tool.
86
+ ctx.setSelection({ ids: [measurement.id] });
87
+ options.onPlaced?.(measurement);
88
+ if (autoSwitchToSelect)
89
+ options.onAutoSwitch?.(selectToolId);
90
+ },
91
+ onCancel(_state, ctx) {
92
+ ctx.preview({ ops: [] });
93
+ },
94
+ };
95
+ };
@@ -51,7 +51,7 @@ export const createPanTool = (options = {}) => ({
51
51
  const measurements = ctx.document.placedMeasurements;
52
52
  for (let i = measurements.length - 1; i >= 0; i--) {
53
53
  const m = measurements[i];
54
- if (hitPlacedMeasurement(m, event.world, zoom)) {
54
+ if (hitPlacedMeasurement(m, event.world, zoom, ctx.document.tileScaleFactor, ctx.tileViewportScale)) {
55
55
  ctx.setSelection({ ids: [m.id] });
56
56
  return;
57
57
  }