@reekon-tools/boldr-utils 1.6.17 → 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.
@@ -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;
@@ -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
  }
@@ -37,11 +37,11 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
37
37
  const dy = py - cy;
38
38
  return dx * dx + dy * dy;
39
39
  };
40
- const findHit = (doc, world, zoom) => {
40
+ const findHit = (doc, world, zoom, viewportTileScale = 1) => {
41
41
  // Hit-test in z-order (top first): measurements > shapes > strokes.
42
42
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
43
43
  const m = doc.placedMeasurements[i];
44
- if (hitPlacedMeasurement(m, world, zoom)) {
44
+ if (hitPlacedMeasurement(m, world, zoom, doc.tileScaleFactor, viewportTileScale)) {
45
45
  return { id: m.id, kind: 'measurement' };
46
46
  }
47
47
  }
@@ -128,12 +128,16 @@ const translatePatch = (elementKind, id, doc, delta) => {
128
128
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
129
129
  // Grabbing the tile of a line annotation slides it along the line; everything
130
130
  // else (bare stamps, grabs on the line body) is a group move.
131
- const classifyGrab = (doc, id, world, zoom) => {
131
+ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1) => {
132
132
  const m = doc.placedMeasurements.find((x) => x.id === id);
133
133
  if (!m)
134
134
  return 'move';
135
- // Same footprint the tile is drawn at (smaller for unassociated inputs, #6).
136
- const half = (stampTileSize(m) / 2 + HIT_PADDING) / zoom;
135
+ // Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
136
+ // folds in the document-wide tile scale + viewport scale so slide-grab
137
+ // matches the draw).
138
+ const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
139
+ HIT_PADDING) /
140
+ zoom;
137
141
  const onTile = Math.abs(world.x - m.anchor.x) <= half &&
138
142
  Math.abs(world.y - m.anchor.y) <= half;
139
143
  return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
@@ -190,6 +194,48 @@ const endpointPatch = (doc, id, handle, delta) => {
190
194
  const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
191
195
  return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
192
196
  };
197
+ // Which endpoint handle of a (selected) line/arrow SHAPE is under `world`.
198
+ // Shape lines store their endpoints as geometry.points[0]/[1] (unlike
199
+ // measurement lines, which carry them on `line.a/b` alongside a tile). Prefers
200
+ // the nearer endpoint when both are within range.
201
+ const findShapeHandleHit = (doc, id, world, zoom) => {
202
+ const s = doc.shapes.find((x) => x.id === id);
203
+ if (!s || (s.kind !== 'line' && s.kind !== 'arrow'))
204
+ return null;
205
+ const [a, b] = s.geometry.points;
206
+ if (!a || !b)
207
+ return null;
208
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
209
+ const da = (world.x - a.x) ** 2 + (world.y - a.y) ** 2;
210
+ const db = (world.x - b.x) ** 2 + (world.y - b.y) ** 2;
211
+ if (da <= r2 && da <= db)
212
+ return 'a';
213
+ if (db <= r2)
214
+ return 'b';
215
+ return null;
216
+ };
217
+ // Move one endpoint of a line/arrow shape by a world delta (resize/rotate).
218
+ // Rewrites the whole geometry (updateShape merges shallowly at the top level,
219
+ // so `closed` and any other geometry fields must be carried through).
220
+ const shapeEndpointPatch = (doc, id, handle, delta) => {
221
+ const s = doc.shapes.find((x) => x.id === id);
222
+ if (!s || (s.kind !== 'line' && s.kind !== 'arrow'))
223
+ return null;
224
+ const [a, b] = s.geometry.points;
225
+ if (!a || !b)
226
+ return null;
227
+ const na = handle === 'a' ? { x: a.x + delta.x, y: a.y + delta.y } : a;
228
+ const nb = handle === 'b' ? { x: b.x + delta.x, y: b.y + delta.y } : b;
229
+ return {
230
+ ops: [
231
+ {
232
+ op: 'updateShape',
233
+ id,
234
+ patch: { geometry: { ...s.geometry, points: [na, nb] } },
235
+ },
236
+ ],
237
+ };
238
+ };
193
239
  // Which corner handle of a (selected) rectangle annotation is under `world`.
194
240
  // Prefers the nearest corner when several are within range (small rects).
195
241
  const findRectCornerHit = (doc, id, world, zoom) => {
@@ -272,7 +318,11 @@ const resizePatch = (doc, id, delta) => {
272
318
  // Patch for the current drag mode (web pointer path), from a world-space delta.
273
319
  const dragPatch = (s, doc, delta, zoom) => {
274
320
  if (s.mode === 'endpoint' && s.handle) {
275
- return endpointPatch(doc, s.id, s.handle, delta);
321
+ // Shape lines and measurement lines store endpoints differently, so the
322
+ // patch builder is keyed on which kind is being dragged.
323
+ return s.elementKind === 'shape'
324
+ ? shapeEndpointPatch(doc, s.id, s.handle, delta)
325
+ : endpointPatch(doc, s.id, s.handle, delta);
276
326
  }
277
327
  if (s.mode === 'slide')
278
328
  return slidePatch(doc, s.id, delta, zoom);
@@ -292,7 +342,7 @@ export const createSelectTool = () => ({
292
342
  // reusing the same hit-test and translate logic the pointer handlers below
293
343
  // use for web — one source of truth.
294
344
  dragSelection: {
295
- hitTest: (doc, world, zoom) => findHit(doc, world, zoom),
345
+ hitTest: (doc, world, zoom, viewportTileScale) => findHit(doc, world, zoom, viewportTileScale),
296
346
  buildTranslatePatch: (doc, id, kind, delta) => {
297
347
  const op = translatePatch(kind, id, doc, delta);
298
348
  return op ? { ops: [op] } : null;
@@ -301,6 +351,8 @@ export const createSelectTool = () => ({
301
351
  buildSlidePatch: slidePatch,
302
352
  hitTestHandle: findHandleHit,
303
353
  buildEndpointPatch: endpointPatch,
354
+ hitTestShapeHandle: findShapeHandleHit,
355
+ buildShapeEndpointPatch: shapeEndpointPatch,
304
356
  hitTestResizeHandle: findResizeHandleHit,
305
357
  buildResizePatch: resizePatch,
306
358
  hitTestRectCorner: findRectCornerHit,
@@ -328,6 +380,19 @@ export const createSelectTool = () => ({
328
380
  delta: { x: 0, y: 0 },
329
381
  };
330
382
  }
383
+ const shapeHandle = findShapeHandleHit(ctx.document, selId, world, zoom);
384
+ if (shapeHandle) {
385
+ ctx.setSelection({ ids: [selId] });
386
+ return {
387
+ kind: 'dragging',
388
+ id: selId,
389
+ elementKind: 'shape',
390
+ mode: 'endpoint',
391
+ handle: shapeHandle,
392
+ start: world,
393
+ delta: { x: 0, y: 0 },
394
+ };
395
+ }
331
396
  if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
332
397
  return {
333
398
  kind: 'dragging',
@@ -352,14 +417,15 @@ export const createSelectTool = () => ({
352
417
  };
353
418
  }
354
419
  }
355
- const hit = findHit(ctx.document, world, zoom);
420
+ const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale);
356
421
  if (!hit) {
357
422
  ctx.setSelection(null);
358
423
  return { kind: 'idle' };
359
424
  }
360
425
  ctx.setSelection({ ids: [hit.id] });
361
426
  const mode = hit.kind === 'measurement' &&
362
- classifyGrab(ctx.document, hit.id, world, zoom) === 'slide'
427
+ classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
428
+ 'slide'
363
429
  ? 'slide'
364
430
  : 'move';
365
431
  return {
@@ -49,6 +49,7 @@ export interface AnnotationCanvasStateApi {
49
49
  activeTool: Tool | null;
50
50
  toolState: ToolState;
51
51
  ctx: ToolContext;
52
+ tileViewportScale: number;
52
53
  penDrawingStroke: AnnotationStroke | null;
53
54
  customPreviewState: ToolState;
54
55
  dispatchPointerDown(event: CanvasPointerEvent): void;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
3
  import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
4
  import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
+ import { viewportTileScale } from './stampLayout.js';
5
6
  // Platform-agnostic state machine for the annotation canvas. Web and native
6
7
  // inners share this hook; each wraps it with platform-specific event
7
8
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
@@ -21,10 +22,16 @@ export const useAnnotationCanvasState = (props) => {
21
22
  return map;
22
23
  }, [measurements]);
23
24
  const viewportApi = useMemo(() => createViewportApi(viewport), [viewport]);
25
+ // How large the document renders when fit to this canvas, as a tile-footprint
26
+ // multiplier (zoom-independent). Keeps tiles a consistent fraction of the
27
+ // drawing on a phone vs a desktop pane. Used by the overlays (drawn size) and
28
+ // the tools (hit box) so both agree.
29
+ const tileViewportScale = useMemo(() => viewportTileScale(width, height, canvas.viewport.width, canvas.viewport.height), [width, height, canvas.viewport.width, canvas.viewport.height]);
24
30
  const ctx = useMemo(() => ({
25
31
  document: canvas,
26
32
  selection,
27
33
  viewport: viewportApi,
34
+ tileViewportScale,
28
35
  preview(patch) {
29
36
  setPreviewPatch(patch);
30
37
  },
@@ -56,6 +63,7 @@ export const useAnnotationCanvasState = (props) => {
56
63
  canvas,
57
64
  selection,
58
65
  viewportApi,
66
+ tileViewportScale,
59
67
  onCommit,
60
68
  onSelectionChange,
61
69
  pickMeasurement,
@@ -361,6 +369,7 @@ export const useAnnotationCanvasState = (props) => {
361
369
  activeTool,
362
370
  toolState,
363
371
  ctx,
372
+ tileViewportScale,
364
373
  penDrawingStroke,
365
374
  customPreviewState: toolState,
366
375
  dispatchPointerDown,
package/dist/exports.d.ts CHANGED
@@ -21,6 +21,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
21
21
  export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
22
22
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
23
23
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
24
+ export { STAMP_TILE_SIZE, STAMP_INPUT_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
24
25
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
25
26
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
26
27
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
package/dist/exports.js CHANGED
@@ -21,6 +21,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
21
21
  export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
22
22
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
23
23
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
24
+ export { STAMP_TILE_SIZE, STAMP_INPUT_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
24
25
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
25
26
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
26
27
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
@@ -103,6 +103,7 @@ export interface AnnotationCanvasState {
103
103
  strokes: AnnotationStroke[];
104
104
  shapes: AnnotationShape[];
105
105
  placedMeasurements: PlacedMeasurementRef[];
106
+ tileScaleFactor?: number;
106
107
  externalPayloadPath?: string;
107
108
  }
108
109
  export type AnnotationElement = (AnnotationStroke & {
@@ -148,6 +149,9 @@ export type AnnotationPatchOp = {
148
149
  } | {
149
150
  op: 'setViewport';
150
151
  patch: Partial<AnnotationViewport>;
152
+ } | {
153
+ op: 'setTileScaleFactor';
154
+ value: number;
151
155
  } | {
152
156
  op: 'setLayers';
153
157
  layers: AnnotationLayer[];
@@ -67,6 +67,8 @@ const applyOp = (state, op) => {
67
67
  };
68
68
  case 'setViewport':
69
69
  return { ...state, viewport: { ...state.viewport, ...op.patch } };
70
+ case 'setTileScaleFactor':
71
+ return { ...state, tileScaleFactor: op.value };
70
72
  case 'setLayers':
71
73
  return { ...state, layers: op.layers };
72
74
  }
@@ -141,6 +143,10 @@ const invertOp = (before, op) => {
141
143
  }
142
144
  return { op: 'setViewport', patch: inversePatch };
143
145
  }
146
+ case 'setTileScaleFactor':
147
+ // Restore the prior factor; absent → an explicit 1 (visually identical to
148
+ // absent), keeping the op's `value: number` contract.
149
+ return { op: 'setTileScaleFactor', value: before.tileScaleFactor ?? 1 };
144
150
  case 'setLayers':
145
151
  return { op: 'setLayers', layers: before.layers };
146
152
  }
@@ -217,6 +217,7 @@ export interface Job extends FirestoreDoc, Timestamps {
217
217
  address?: string;
218
218
  description?: string;
219
219
  starred?: boolean;
220
+ lastModified: Date;
220
221
  }
221
222
  export interface Section extends Timestamps {
222
223
  id: string;
@@ -426,6 +427,53 @@ export declare enum DecimalTolerance {
426
427
  Hundredth = "0.01",
427
428
  Thousandth = "0.001"
428
429
  }
430
+ /**
431
+ * Trade/industry a user works in. Collected during registration to personalize
432
+ * the experience. String values are stable identifiers safe for persistence and
433
+ * analytics; they double as i18n key suffixes (e.g. `trades.iron_worker`).
434
+ */
435
+ export declare enum UserTrades {
436
+ Woodworking = "woodworking",
437
+ Plumbing = "plumbing",
438
+ Electrical = "electrical",
439
+ Structural = "structural",
440
+ Framing = "framing",
441
+ Carpenter = "carpenter",
442
+ IronWorker = "iron_worker",
443
+ Joiners = "joiners",
444
+ HVAC = "hvac",
445
+ Painting = "painting",
446
+ GC = "gc",
447
+ IT = "it",
448
+ Surveying = "surveying",
449
+ Pipefitter = "pipe_fitter",
450
+ Drywall = "drywall",
451
+ Welding = "welding",
452
+ Flooring = "flooring",
453
+ Decking = "decking",
454
+ Tile = "tile",
455
+ DIY = "diy",
456
+ Other = "other"
457
+ }
458
+ /**
459
+ * Role a user holds within their trade. Collected during registration. String
460
+ * values double as i18n key suffixes (e.g. `roles.project_manager`).
461
+ */
462
+ export declare enum UserRoles {
463
+ Apprentice = "apprentice",
464
+ Journeyman = "journeyman",
465
+ Owner = "owner",
466
+ ProjectManager = "project_manager",
467
+ Supervisor = "supervisor",
468
+ Foreman = "foreman",
469
+ Superintendent = "superintendent",
470
+ Estimator = "estimator",
471
+ OfficeManager = "office_manager",
472
+ Operator = "operator",
473
+ Laborer = "laborer",
474
+ Engineer = "engineer",
475
+ Safety = "safety"
476
+ }
429
477
  export interface UserDocument extends FirestoreDoc, Timestamps {
430
478
  defaultOrganization: string;
431
479
  displayName: string;
@@ -439,6 +487,12 @@ export interface UserDocument extends FirestoreDoc, Timestamps {
439
487
  printLabelSize: string;
440
488
  printLogoFileId: string;
441
489
  devices?: Record<string, string>;
490
+ /** Four-digit birth year. Optional — not required during registration. */
491
+ birthYear?: number;
492
+ /** The trade/industry the user works in. Captured during registration. */
493
+ industry?: UserTrades;
494
+ /** The user's role within their trade. Optional. */
495
+ role?: UserRoles;
442
496
  }
443
497
  export interface UserComment extends FirestoreDoc, Timestamps {
444
498
  comment: string;
@@ -123,3 +123,52 @@ export var DecimalTolerance;
123
123
  DecimalTolerance["Hundredth"] = "0.01";
124
124
  DecimalTolerance["Thousandth"] = "0.001";
125
125
  })(DecimalTolerance || (DecimalTolerance = {}));
126
+ /**
127
+ * Trade/industry a user works in. Collected during registration to personalize
128
+ * the experience. String values are stable identifiers safe for persistence and
129
+ * analytics; they double as i18n key suffixes (e.g. `trades.iron_worker`).
130
+ */
131
+ export var UserTrades;
132
+ (function (UserTrades) {
133
+ UserTrades["Woodworking"] = "woodworking";
134
+ UserTrades["Plumbing"] = "plumbing";
135
+ UserTrades["Electrical"] = "electrical";
136
+ UserTrades["Structural"] = "structural";
137
+ UserTrades["Framing"] = "framing";
138
+ UserTrades["Carpenter"] = "carpenter";
139
+ UserTrades["IronWorker"] = "iron_worker";
140
+ UserTrades["Joiners"] = "joiners";
141
+ UserTrades["HVAC"] = "hvac";
142
+ UserTrades["Painting"] = "painting";
143
+ UserTrades["GC"] = "gc";
144
+ UserTrades["IT"] = "it";
145
+ UserTrades["Surveying"] = "surveying";
146
+ UserTrades["Pipefitter"] = "pipe_fitter";
147
+ UserTrades["Drywall"] = "drywall";
148
+ UserTrades["Welding"] = "welding";
149
+ UserTrades["Flooring"] = "flooring";
150
+ UserTrades["Decking"] = "decking";
151
+ UserTrades["Tile"] = "tile";
152
+ UserTrades["DIY"] = "diy";
153
+ UserTrades["Other"] = "other";
154
+ })(UserTrades || (UserTrades = {}));
155
+ /**
156
+ * Role a user holds within their trade. Collected during registration. String
157
+ * values double as i18n key suffixes (e.g. `roles.project_manager`).
158
+ */
159
+ export var UserRoles;
160
+ (function (UserRoles) {
161
+ UserRoles["Apprentice"] = "apprentice";
162
+ UserRoles["Journeyman"] = "journeyman";
163
+ UserRoles["Owner"] = "owner";
164
+ UserRoles["ProjectManager"] = "project_manager";
165
+ UserRoles["Supervisor"] = "supervisor";
166
+ UserRoles["Foreman"] = "foreman";
167
+ UserRoles["Superintendent"] = "superintendent";
168
+ UserRoles["Estimator"] = "estimator";
169
+ UserRoles["OfficeManager"] = "office_manager";
170
+ UserRoles["Operator"] = "operator";
171
+ UserRoles["Laborer"] = "laborer";
172
+ UserRoles["Engineer"] = "engineer";
173
+ UserRoles["Safety"] = "safety";
174
+ })(UserRoles || (UserRoles = {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.17",
3
+ "version": "1.6.19",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",