@reekon-tools/boldr-utils 1.6.19 → 1.6.23

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.
@@ -24,6 +24,9 @@ export interface AnnotationCanvasInnerProps {
24
24
  renderMeasurementStamp?: RenderMeasurementStamp;
25
25
  onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
26
26
  onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
27
+ onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
28
+ resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
29
+ onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
27
30
  stampFontSource?: unknown;
28
31
  stampValueFontSize?: number;
29
32
  stampLabelFontSize?: number;
@@ -3,7 +3,7 @@ import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useMemo, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
6
- import { buildRemoveMeasurementOps } from './measurementGeometry.js';
6
+ import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
7
7
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
8
8
  import { stampTileSize } from './stampLayout.js';
9
9
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
@@ -12,6 +12,13 @@ const HANDLE_PX = 7;
12
12
  // Screen-px stroke width of the handle's colored ring (matches native
13
13
  // HANDLE_RING_PX); the white-disc + ring keeps the knob legible on any line.
14
14
  const HANDLE_RING_PX = 2;
15
+ // Press-and-hold timing/tolerance. The DOM has no native long-press, so it's
16
+ // derived from a hold timer: pressing without moving past the slop for this
17
+ // long fires the tool's onLongPress; movement or an early release cancels it.
18
+ // 500ms / RN's default delayLongPress so web matches native + the stamp
19
+ // overlay's press target below.
20
+ const LONG_PRESS_MS = 500;
21
+ const LONG_PRESS_SLOP_PX = 10;
15
22
  const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
16
23
  export const AnnotationCanvasInner = (props) => {
17
24
  const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
@@ -39,7 +46,19 @@ export const AnnotationCanvasInner = (props) => {
39
46
  const containerRef = useRef(null);
40
47
  const panGestureRef = useRef(null);
41
48
  const spaceDownRef = useRef(false);
49
+ // Armed hold timer + its origin, for the derived long-press (see handlers).
50
+ const longPressTimerRef = useRef(null);
51
+ const longPressRef = useRef(null);
42
52
  const activeTool = tools.find((t) => t.id === activeToolId) ?? null;
53
+ const clearLongPress = useCallback(() => {
54
+ if (longPressTimerRef.current != null) {
55
+ clearTimeout(longPressTimerRef.current);
56
+ longPressTimerRef.current = null;
57
+ }
58
+ longPressRef.current = null;
59
+ }, []);
60
+ // Drop any armed hold timer if the canvas unmounts mid-press.
61
+ useEffect(() => clearLongPress, [clearLongPress]);
43
62
  const toCanvasPointer = useCallback((event) => {
44
63
  const rect = containerRef.current?.getBoundingClientRect();
45
64
  const screen = {
@@ -82,8 +101,31 @@ export const AnnotationCanvasInner = (props) => {
82
101
  return;
83
102
  }
84
103
  event.currentTarget.setPointerCapture(event.pointerId);
85
- state.dispatchPointerDown(toCanvasPointer(event));
86
- }, [state, toCanvasPointer, isPanTriggerDown]);
104
+ const canvasEvent = toCanvasPointer(event);
105
+ state.dispatchPointerDown(canvasEvent);
106
+ // Arm the hold timer only when the active tool acts on a long-press (the
107
+ // select tool, to re-open the text editor). The pointer keeps driving the
108
+ // tool underneath; if the hold fires before any movement past the slop,
109
+ // dispatchLongPress runs with the (unmoved) down point. Movement or
110
+ // pointer up/cancel clears it below.
111
+ if (activeTool?.onLongPress && event.button === 0) {
112
+ clearLongPress();
113
+ const { screen, pointerId } = canvasEvent;
114
+ longPressRef.current = { pointerId, start: screen };
115
+ longPressTimerRef.current = setTimeout(() => {
116
+ longPressTimerRef.current = null;
117
+ const lp = longPressRef.current;
118
+ if (!lp)
119
+ return;
120
+ longPressRef.current = null;
121
+ state.dispatchLongPress({
122
+ pointerId: lp.pointerId,
123
+ screen: lp.start,
124
+ world: state.ctx.viewport.screenToWorld(lp.start),
125
+ });
126
+ }, LONG_PRESS_MS);
127
+ }
128
+ }, [state, toCanvasPointer, isPanTriggerDown, activeTool, clearLongPress]);
87
129
  const handlePointerMove = useCallback((event) => {
88
130
  const pan = panGestureRef.current;
89
131
  if (pan && event.pointerId === pan.pointerId) {
@@ -102,20 +144,33 @@ export const AnnotationCanvasInner = (props) => {
102
144
  };
103
145
  return;
104
146
  }
147
+ // Moving past the slop turns the press into a drag — cancel the hold so a
148
+ // drag never also fires a long-press.
149
+ const lp = longPressRef.current;
150
+ if (lp && event.pointerId === lp.pointerId) {
151
+ const rect = containerRef.current?.getBoundingClientRect();
152
+ const dx = event.clientX - (rect?.left ?? 0) - lp.start.x;
153
+ const dy = event.clientY - (rect?.top ?? 0) - lp.start.y;
154
+ if (dx * dx + dy * dy > LONG_PRESS_SLOP_PX * LONG_PRESS_SLOP_PX) {
155
+ clearLongPress();
156
+ }
157
+ }
105
158
  state.dispatchPointerMove(toCanvasPointer(event));
106
- }, [state, toCanvasPointer]);
159
+ }, [state, toCanvasPointer, clearLongPress]);
107
160
  const handlePointerUp = useCallback((event) => {
161
+ clearLongPress();
108
162
  const pan = panGestureRef.current;
109
163
  if (pan && event.pointerId === pan.pointerId) {
110
164
  panGestureRef.current = null;
111
165
  return;
112
166
  }
113
167
  state.dispatchPointerUp(toCanvasPointer(event));
114
- }, [state, toCanvasPointer]);
168
+ }, [state, toCanvasPointer, clearLongPress]);
115
169
  const handlePointerCancel = useCallback(() => {
170
+ clearLongPress();
116
171
  panGestureRef.current = null;
117
172
  state.dispatchPointerCancel();
118
- }, [state]);
173
+ }, [state, clearLongPress]);
119
174
  const handleWheel = useCallback((event) => {
120
175
  const rect = containerRef.current?.getBoundingClientRect();
121
176
  const focal = {
@@ -189,8 +244,28 @@ export const AnnotationCanvasInner = (props) => {
189
244
  ...style,
190
245
  };
191
246
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
192
- const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
193
- return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
247
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, onMeasurementStampDoubleTap, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
248
+ // Double-click a stamp fire the consumer callback (e.g. open its value
249
+ // editor). Runs the SAME hit-test the select tool uses (top-of-z-order), on
250
+ // the gesture container, so the stamp overlay stays pointer-events:none and
251
+ // drag-to-move / single-tap select are unaffected. dblclick is a distinct
252
+ // browser event that doesn't suppress the preceding pointerdown/up select.
253
+ const handleDoubleClick = useCallback((event) => {
254
+ if (!onMeasurementStampDoubleTap)
255
+ return;
256
+ const rect = containerRef.current?.getBoundingClientRect();
257
+ const screen = {
258
+ x: event.clientX - (rect?.left ?? 0),
259
+ y: event.clientY - (rect?.top ?? 0),
260
+ };
261
+ const world = state.ctx.viewport.screenToWorld(screen);
262
+ const placed = [...state.effectiveCanvas.placedMeasurements]
263
+ .reverse()
264
+ .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale));
265
+ if (placed)
266
+ onMeasurementStampDoubleTap(placed);
267
+ }, [onMeasurementStampDoubleTap, state]);
268
+ return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onDoubleClick: handleDoubleClick, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
194
269
  width,
195
270
  height,
196
271
  effectiveCanvas: state.effectiveCanvas,
@@ -224,7 +299,10 @@ export const AnnotationCanvasInner = (props) => {
224
299
  const isSelected = selection?.ids.includes(placed.id) ?? false;
225
300
  const measurement = placed.measurementId
226
301
  ? (state.measurementsById.get(placed.measurementId) ?? null)
227
- : null;
302
+ : (resolveStampMeasurement?.(placed) ?? null);
303
+ // Corner-pinned, tile-proportional remove target (see the style
304
+ // comment below) so it can't blanket a small tile and eat its grab.
305
+ const removeSize = Math.min(40, size * 0.4);
228
306
  return (_jsxs("div", { style: {
229
307
  position: 'absolute',
230
308
  left: 0,
@@ -242,16 +320,27 @@ export const AnnotationCanvasInner = (props) => {
242
320
  ? () => onMeasurementStampLongPress(placed)
243
321
  : undefined })), isSelected && measurement && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
244
322
  e.stopPropagation();
245
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
246
- state.ctx.commit({ ops });
247
- if (!keepSelection)
248
- state.ctx.setSelection(null);
323
+ const defaultRemove = () => {
324
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
325
+ state.ctx.commit({ ops });
326
+ if (!keepSelection)
327
+ state.ctx.setSelection(null);
328
+ };
329
+ if (onMeasurementStampRemove) {
330
+ onMeasurementStampRemove(placed, defaultRemove);
331
+ return;
332
+ }
333
+ defaultRemove();
249
334
  }, style: {
335
+ // Sized as a fraction of the tile and corner-pinned so it
336
+ // never covers the center. A fixed 40px target blanketed
337
+ // small tiles (the tile-scale slider shrinks them), eating
338
+ // the center grab so a selected tile couldn't be dragged.
250
339
  position: 'absolute',
251
- top: -10,
252
- right: -10,
253
- width: 40,
254
- height: 40,
340
+ top: 0,
341
+ right: 0,
342
+ width: removeSize,
343
+ height: removeSize,
255
344
  cursor: 'pointer',
256
345
  pointerEvents: 'auto',
257
346
  } }))] }, placed.id));
@@ -261,8 +350,7 @@ export const AnnotationCanvasInner = (props) => {
261
350
  // long-press, so it's derived: pointerdown arms a timer; if it fires before
262
351
  // the pointer lifts (or leaves/cancels), the long-press callback runs and the
263
352
  // trailing click is swallowed. Mirrors the native overlay's TouchableOpacity
264
- // onPress/onLongPress semantics (500ms, RN's default delayLongPress).
265
- const LONG_PRESS_MS = 500;
353
+ // onPress/onLongPress semantics (LONG_PRESS_MS, RN's default delayLongPress).
266
354
  const StampPressTarget = ({ onPress, onLongPress, }) => {
267
355
  const timerRef = useRef(null);
268
356
  const longPressFiredRef = useRef(false);
@@ -25,6 +25,9 @@ export interface AnnotationCanvasInnerProps {
25
25
  renderMeasurementStamp?: RenderMeasurementStamp;
26
26
  onMeasurementStampPress?: (placed: PlacedMeasurementRef) => void;
27
27
  onMeasurementStampLongPress?: (placed: PlacedMeasurementRef) => void;
28
+ onMeasurementStampDoubleTap?: (placed: PlacedMeasurementRef) => void;
29
+ resolveStampMeasurement?: (placed: PlacedMeasurementRef) => Measurement | null;
30
+ onMeasurementStampRemove?: (placed: PlacedMeasurementRef, defaultRemove: () => void) => void;
28
31
  stampFontSource?: unknown;
29
32
  stampValueFontSize?: number;
30
33
  stampLabelFontSize?: number;
@@ -12,6 +12,12 @@ import { buildShapeFromDrag } from './tools/shapeTool.js';
12
12
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
13
13
  let strokeCounter = 0;
14
14
  const makeStrokeId = () => `stroke-${Date.now().toString(36)}-${(strokeCounter++).toString(36)}`;
15
+ // Press-and-hold timing/tolerance for the long-press gesture (matches the
16
+ // measurement-stamp overlay's TouchableOpacity feel — 500ms / RN default).
17
+ // Holding still this long without moving past the slop fires onLongPress;
18
+ // moving first lets the drag win instead.
19
+ const LONG_PRESS_MS = 500;
20
+ const LONG_PRESS_SLOP_PX = 10;
15
21
  // Screen-px radius of a measurement-annotation endpoint handle dot. Divided by
16
22
  // the live zoom so the handle is a constant on-screen size.
17
23
  const HANDLE_RADIUS_PX = 7;
@@ -155,6 +161,7 @@ export const AnnotationCanvasInner = (props) => {
155
161
  const shapeDraw = state.activeTool?.shapeDraw ?? null;
156
162
  const panViewport = !!state.activeTool?.panViewport;
157
163
  const dragSelection = state.activeTool?.dragSelection ?? null;
164
+ const longPressEnabled = !!state.activeTool?.onLongPress;
158
165
  // In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
159
166
  // owned by the UI thread — the shape twin of `livePoints`. The drag worklet
160
167
  // tracks the start/current world points; derived paths below render the
@@ -479,6 +486,20 @@ export const AnnotationCanvasInner = (props) => {
479
486
  inFlightRef.current = null;
480
487
  }
481
488
  });
489
+ // Press-and-hold (one finger, no movement) → dispatch a long-press to the
490
+ // active tool (the select tool re-opens the text editor for a placed text
491
+ // shape). Races against the one-finger drag: holding still activates this
492
+ // at LONG_PRESS_MS, moving first activates the drag/pan instead (this one's
493
+ // maxDistance cancels it). A quick tap is shorter than the hold, so the tap
494
+ // gesture still owns selection.
495
+ const longPress = Gesture.LongPress()
496
+ .minDuration(LONG_PRESS_MS)
497
+ .maxDistance(LONG_PRESS_SLOP_PX)
498
+ .runOnJS(true)
499
+ .onStart((e) => {
500
+ const id = pointerIdRef.current++;
501
+ stateRef.current.dispatchLongPress(buildEvent(id, { x: e.x, y: e.y }));
502
+ });
482
503
  const tap = Gesture.Tap()
483
504
  .maxDuration(250)
484
505
  .runOnJS(true)
@@ -752,9 +773,13 @@ export const AnnotationCanvasInner = (props) => {
752
773
  const world = st.ctx.viewport.screenToWorld(screen);
753
774
  const zoomNow = st.ctx.viewport.state.zoom;
754
775
  // Endpoint handles show only on the selected annotation, so check the
755
- // current selection's handles before the general hit-test.
776
+ // current selection's handles before the general hit-test — UNLESS the
777
+ // grab is on that element's tile, which must stay draggable (the tile
778
+ // is the move/slide affordance and wins over a handle sitting under it,
779
+ // so a selected tile isn't stuck until you deselect it).
756
780
  const selId = st.ctx.selection?.ids[0];
757
- if (selId) {
781
+ if (selId &&
782
+ !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale)) {
758
783
  const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
759
784
  if (handle) {
760
785
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
@@ -996,7 +1021,9 @@ export const AnnotationCanvasInner = (props) => {
996
1021
  : dragSelection
997
1022
  ? buildSelectDragPan(dragSelection)
998
1023
  : toolPan;
999
- return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), oneFinger);
1024
+ // Long-press only joins the race when the active tool acts on it, so it
1025
+ // never pre-empts a one-finger drag/draw on tools that ignore holds.
1026
+ return Gesture.Race(tap, ...(longPressEnabled ? [longPress] : []), Gesture.Simultaneous(viewportPan, pinch), oneFinger);
1000
1027
  }, [
1001
1028
  zoom,
1002
1029
  panX,
@@ -1011,6 +1038,7 @@ export const AnnotationCanvasInner = (props) => {
1011
1038
  shapeDraw,
1012
1039
  panViewport,
1013
1040
  dragSelection,
1041
+ longPressEnabled,
1014
1042
  dragX,
1015
1043
  dragY,
1016
1044
  dragEnded,
@@ -1021,7 +1049,7 @@ export const AnnotationCanvasInner = (props) => {
1021
1049
  ]);
1022
1050
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
1023
1051
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
1024
- const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, selection, } = props;
1052
+ const { renderMeasurementStamp, onMeasurementStampPress, onMeasurementStampLongPress, resolveStampMeasurement, onMeasurementStampRemove, selection, } = props;
1025
1053
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
1026
1054
  width,
1027
1055
  height,
@@ -1089,16 +1117,29 @@ export const AnnotationCanvasInner = (props) => {
1089
1117
  customPreview,
1090
1118
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1091
1119
  ? (state.measurementsById.get(placed.measurementId) ?? null)
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: () => {
1093
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1094
- state.ctx.commit({ ops });
1095
- if (!keepSelection)
1096
- state.ctx.setSelection(null);
1120
+ : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1121
+ const defaultRemove = () => {
1122
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1123
+ state.ctx.commit({ ops });
1124
+ if (!keepSelection)
1125
+ state.ctx.setSelection(null);
1126
+ };
1127
+ if (onMeasurementStampRemove) {
1128
+ onMeasurementStampRemove(placed, defaultRemove);
1129
+ return;
1130
+ }
1131
+ defaultRemove();
1097
1132
  } }, placed.id))) }))] }));
1098
1133
  };
1099
1134
  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
1135
  const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
1101
1136
  const half = size / 2;
1137
+ // Remove-"X" touch target, sized as a fraction of the tile and corner-pinned
1138
+ // so it never reaches the center. A FIXED 36px+hitSlop target blanketed small
1139
+ // tiles (the tile-scale slider can shrink them to ~38px), swallowing the
1140
+ // center grab and making a selected tile impossible to drag (it had to be
1141
+ // deselected first). Capped so it doesn't grow past the old size on big tiles.
1142
+ const removeSize = Math.min(36, size * 0.4);
1102
1143
  const anchorX = placed.anchor.x;
1103
1144
  const anchorY = placed.anchor.y;
1104
1145
  // doc → screen each frame, on the UI thread. Position is a translate
@@ -1166,11 +1207,11 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1166
1207
  selected,
1167
1208
  size,
1168
1209
  zoom: zoomSnapshot,
1169
- }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: onRemove, style: {
1210
+ }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
1170
1211
  position: 'absolute',
1171
- top: -8,
1172
- right: -8,
1173
- width: 36,
1174
- height: 36,
1212
+ top: 0,
1213
+ right: 0,
1214
+ width: removeSize,
1215
+ height: removeSize,
1175
1216
  } }))] }));
1176
1217
  };
@@ -68,6 +68,7 @@ export interface DragSelectionConfig {
68
68
  fixed: Vec2;
69
69
  } | null;
70
70
  buildRectCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
71
+ isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
71
72
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
72
73
  buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
73
74
  }
@@ -83,6 +84,7 @@ export interface Tool {
83
84
  onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
84
85
  onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
85
86
  onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
87
+ onLongPress?(event: CanvasPointerEvent, ctx: ToolContext): void;
86
88
  onCancel?(state: ToolState, ctx: ToolContext): void;
87
89
  onDeactivate?(ctx: ToolContext): void;
88
90
  renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
@@ -92,9 +92,10 @@ export const oppositeRectCorner = (corner) => {
92
92
  const STAMP_HIT_PADDING = 6;
93
93
  // Screen-space grab tolerance (px) for a measurement-annotation line or rect
94
94
  // border, converted to doc space via zoom (the body is a thin world-space
95
- // stroke). Matches the select tool's drag-grab tolerance so tap-select and
96
- // drag-grab agree on what counts as "on the measurement".
97
- const LINE_GRAB_PX = 12;
95
+ // stroke). Deliberately wider than a fingertip so a thin line isn't fiddly to
96
+ // grab on touch, and kept in sync with the select tool's SHAPE_GRAB_PX so
97
+ // tap-select and drag-grab agree on what counts as "on the measurement".
98
+ const LINE_GRAB_PX = 32;
98
99
  const segmentDistSq = (p, a, b) => {
99
100
  const abx = b.x - a.x;
100
101
  const aby = b.y - a.y;
@@ -1,11 +1,9 @@
1
1
  import type { PlacedMeasurementRef } from '../../types/annotation.js';
2
2
  export declare const STAMP_TILE_SIZE = 96;
3
- export declare const STAMP_INPUT_TILE_SIZE = 56;
3
+ export declare const STAMP_INPUT_TILE_SIZE = 44;
4
4
  export declare const DEFAULT_TILE_SCALE = 1;
5
5
  export declare const TILE_SCALE_MIN = 0.4;
6
6
  export declare const TILE_SCALE_MAX = 2;
7
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;
10
- export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath">) => boolean;
11
- export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
8
+ export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
9
+ export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId" | "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
@@ -11,10 +11,12 @@
11
11
  export const STAMP_TILE_SIZE = 96;
12
12
  // Edge length for an UNASSOCIATED stamp — a measurement annotation with no
13
13
  // measurement picked yet, which renders as a compact "+" input placeholder
14
- // rather than a full readable tile. Smaller than STAMP_TILE_SIZE so empty
15
- // inputs read as lightweight tap targets; associated tiles keep STAMP_TILE_SIZE
16
- // so existing saved annotations are visually unchanged. The single knob for #6.
17
- export const STAMP_INPUT_TILE_SIZE = 56;
14
+ // rather than a full readable tile. Kept noticeably smaller than
15
+ // STAMP_TILE_SIZE so empty inputs read as lightweight tap targets that don't
16
+ // crowd the drawing; associated tiles keep STAMP_TILE_SIZE so existing saved
17
+ // annotations are visually unchanged. Still comfortably tappable once the
18
+ // viewport/tile-scale multipliers (min 1) are folded in. The single knob for #6.
19
+ export const STAMP_INPUT_TILE_SIZE = 44;
18
20
  // Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
19
21
  // knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
20
22
  // of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
@@ -28,60 +30,40 @@ export const TILE_SCALE_MAX = 2;
28
30
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
29
31
  // for the value before it lands in the document (slider input, restored docs).
30
32
  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
- };
33
+ // --- Tile sizing is independent of canvas size ------------------------------
34
+ // A measurement tile's footprint is base × per-tile `scale` × the document-wide
35
+ // `tileScaleFactor` (below) and NOTHING tied to the canvas's pixel size, so
36
+ // the same document shows the same tiles at any canvas/window size.
37
+ //
38
+ // Earlier builds multiplied the footprint by a per-canvas `viewportTileScale`
39
+ // derived from how large the document rendered when fit to the canvas, to keep a
40
+ // tile a constant fraction of the drawing across devices. But that made a tile's
41
+ // size change whenever the canvas was resized (e.g. dragging a desktop pane)
42
+ // the same document read with different-sized tiles at different window sizes.
43
+ // That auto-multiplier is retired in favor of the user-driven "Tile size"
44
+ // slider (the document-wide `tileScaleFactor`), which is explicit, persisted,
45
+ // and consistent everywhere. Mobile is unchanged its old factor was already
46
+ // pinned at 1, calibrated to a phone's width.
47
+ //
48
+ // `stampTileSize` keeps its `viewportScale` parameter (default 1) only so the
49
+ // render overlay, hit-test, and tools that thread it stay call-compatible; it is
50
+ // now always 1.
73
51
  // A placed measurement is an unassociated input until a measurement reference
74
- // is attached (id or path). Such stamps use STAMP_INPUT_TILE_SIZE.
75
- export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath;
52
+ // is attached (id or path) OR it is bound to a form column (`columnId`). Such
53
+ // stamps use STAMP_INPUT_TILE_SIZE. A column-bound tile is a full editable
54
+ // input *card* (it shows the column's label + value editor), so it takes the
55
+ // full STAMP_TILE_SIZE like an associated tile — only the truly blank "+"
56
+ // placeholder uses the compact input size.
57
+ export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath && !m.columnId;
76
58
  // Screen-space edge length for a placed stamp: the compact input size while
77
59
  // unassociated, full size once a measurement is attached, then scaled by the
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`.
60
+ // per-stamp `scale` and the document-wide `tileScaleFactor`. The ONE source of
61
+ // truth for tile footprint render overlay, hit-test, and slide-grab
62
+ // classification all call this so the drawn tile and its touch box always agree.
63
+ // `tileScaleFactor` is the document-wide knob (default 1, from
64
+ // `AnnotationCanvasState.tileScaleFactor`, driven by the "Tile size" slider).
65
+ // `viewportScale` is retained for call compatibility and is always 1 the old
66
+ // per-canvas multiplier was retired (see the note above). Independent of zoom.
85
67
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => (isUnassociatedStamp(m) ? STAMP_INPUT_TILE_SIZE : STAMP_TILE_SIZE) *
86
68
  (m.scale ?? 1) *
87
69
  tileScaleFactor *
@@ -53,10 +53,16 @@ export const createMeasurementTool = (options = {}) => {
53
53
  const selectToolId = options.selectToolId ?? 'select';
54
54
  const place = (ctx, measurement) => {
55
55
  ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
56
- ctx.setSelection({ ids: [measurement.id] });
57
56
  options.onPlaced?.(measurement);
58
- if (autoSwitchToSelect)
57
+ // Selecting the new annotation and handing back to select only makes sense
58
+ // when we actually switch tools. With autoSwitchToSelect off the tool stays
59
+ // active and behaves like the shape tools — commit and keep drawing, with
60
+ // nothing selected — so placing an empty input doesn't kick you out of
61
+ // drawing mode (and the keypad/pill doesn't pop for the blank tile).
62
+ if (autoSwitchToSelect) {
63
+ ctx.setSelection({ ids: [measurement.id] });
59
64
  options.onAutoSwitch?.(selectToolId);
65
+ }
60
66
  };
61
67
  // Bare stamp: tap-to-place, no rubber-band.
62
68
  if (placement === 'none') {
@@ -2,7 +2,20 @@ import { stampTileSize } from '../stampLayout.js';
2
2
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
3
  import { hitShapeOutline } from '../shapeGeometry.js';
4
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
5
+ import { editTextShape } from './textEditing.js';
5
6
  const HIT_PADDING = 6;
7
+ // Whether an element id refers to a text shape (findHit reports text, lines,
8
+ // rects and ellipses all as kind 'shape'; only text supports tap-to-edit).
9
+ const isTextShape = (doc, id) => doc.shapes.some((s) => s.id === id && s.kind === 'text');
10
+ // Re-tap-to-edit for text. The pointer-down records a text shape's id here only
11
+ // when that shape was *already* selected before this gesture; a release with no
12
+ // movement then re-opens its editor. Held at module scope (not in ToolState)
13
+ // because the native tap gesture synthesizes pointer-down and pointer-up in a
14
+ // single React tick — a ToolState set on down isn't visible to up, but this is.
15
+ // Only one select gesture is ever in flight, so a shared cell is safe. A first
16
+ // tap (shape not yet selected) leaves this null, so it only selects; the second
17
+ // tap edits. Cleared on drag, release, and cancel.
18
+ let pendingTextEditId = null;
6
19
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
7
20
  // override via `hitTest` for more precision later.
8
21
  const hitStroke = (stroke, p) => {
@@ -16,8 +29,14 @@ const hitStroke = (stroke, p) => {
16
29
  return false;
17
30
  };
18
31
  // Screen-space grab tolerance (px) added around a geometric shape's outline
19
- // (line/arrow/rect/ellipse/polygon), converted to doc space via zoom.
20
- const SHAPE_GRAB_PX = 12;
32
+ // (line/arrow/rect/ellipse/polygon), converted to doc space via zoom. A thin
33
+ // line gives almost nothing to aim at, so the grab corridor is deliberately
34
+ // wider than a fingertip — landing anywhere near the ink grabs it. Kept in sync
35
+ // with measurementGeometry's LINE_GRAB_PX so tap-select and drag-grab agree on
36
+ // what counts as "on the line". (The endpoint-resize handles use their own,
37
+ // tighter HANDLE_GRAB_PX and are checked first on the selected element, so a
38
+ // wider body grab never swallows them.)
39
+ const SHAPE_GRAB_PX = 32;
21
40
  // Screen-px radius of the center snap detent when sliding a tile along its line.
22
41
  // Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
23
42
  // slide worklet inlines the same value — keep them in sync.
@@ -124,6 +143,23 @@ const translatePatch = (elementKind, id, doc, delta) => {
124
143
  }
125
144
  return { op: 'updateStroke', id, patch: { points } };
126
145
  };
146
+ // Whether a world point lands on a measurement's tile (its screen-constant
147
+ // anchor box, converted back to doc space via zoom — the same footprint
148
+ // classifyGrab uses). The tile is the move/slide affordance, so a grab here
149
+ // must win over the endpoint/corner/resize handles that appear once an element
150
+ // is selected; without this, a handle sitting under the tile (a rectangle
151
+ // tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
152
+ // and the tile becomes unmovable until deselected.
153
+ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
154
+ const m = doc.placedMeasurements.find((x) => x.id === id);
155
+ if (!m)
156
+ return false;
157
+ const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
158
+ HIT_PADDING) /
159
+ zoom;
160
+ return (Math.abs(world.x - m.anchor.x) <= half &&
161
+ Math.abs(world.y - m.anchor.y) <= half);
162
+ };
127
163
  // --- Measurement-annotation grab logic (shared by the native UI-thread drag
128
164
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
129
165
  // Grabbing the tile of a line annotation slides it along the line; everything
@@ -348,6 +384,7 @@ export const createSelectTool = () => ({
348
384
  return op ? { ops: [op] } : null;
349
385
  },
350
386
  classifyMeasurementGrab: classifyGrab,
387
+ isSelectedTileGrab: isOnMeasurementTile,
351
388
  buildSlidePatch: slidePatch,
352
389
  hitTestHandle: findHandleHit,
353
390
  buildEndpointPatch: endpointPatch,
@@ -364,9 +401,17 @@ export const createSelectTool = () => ({
364
401
  onPointerDown(event, ctx) {
365
402
  const { world } = event;
366
403
  const zoom = ctx.viewport.state.zoom;
367
- // Endpoint/resize handles show only on the selected element check first.
404
+ // Reset the re-tap-to-edit latch; only a body grab of an already-selected
405
+ // text shape (below) re-arms it. Grabbing a handle or empty canvas leaves
406
+ // it cleared, so neither can leak an edit into the next release.
407
+ pendingTextEditId = null;
408
+ // Endpoint/resize handles show only on the selected element — check first,
409
+ // UNLESS the grab is on that element's tile: the tile is the move/slide
410
+ // affordance and must win over a handle sitting under it, so a selected
411
+ // tile stays draggable (otherwise it can only be moved after deselecting).
368
412
  const selId = ctx.selection?.ids[0];
369
- if (selId) {
413
+ if (selId &&
414
+ !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
370
415
  const handle = findHandleHit(ctx.document, selId, world, zoom);
371
416
  if (handle) {
372
417
  ctx.setSelection({ ids: [selId] });
@@ -422,6 +467,15 @@ export const createSelectTool = () => ({
422
467
  ctx.setSelection(null);
423
468
  return { kind: 'idle' };
424
469
  }
470
+ // Re-tapping an already-selected text shape (without dragging) opens its
471
+ // editor on release — see onPointerUp. `selId` is the selection from before
472
+ // this down, so a first tap only selects; the second tap edits.
473
+ pendingTextEditId =
474
+ hit.kind === 'shape' &&
475
+ hit.id === selId &&
476
+ isTextShape(ctx.document, hit.id)
477
+ ? hit.id
478
+ : null;
425
479
  ctx.setSelection({ ids: [hit.id] });
426
480
  const mode = hit.kind === 'measurement' &&
427
481
  classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
@@ -445,22 +499,37 @@ export const createSelectTool = () => ({
445
499
  x: event.world.x - s.start.x,
446
500
  y: event.world.y - s.start.y,
447
501
  };
502
+ // Any real movement turns this into a drag, not a re-tap — disarm the edit.
503
+ if (delta.x !== 0 || delta.y !== 0)
504
+ pendingTextEditId = null;
448
505
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
449
506
  if (patch)
450
507
  ctx.preview(patch);
451
508
  return { ...s, delta };
452
509
  },
453
510
  onPointerUp(_event, ctx, state) {
511
+ const editId = pendingTextEditId;
512
+ pendingTextEditId = null;
454
513
  const s = state;
455
- if (s?.kind !== 'dragging')
456
- return;
457
- if (s.delta.x === 0 && s.delta.y === 0)
514
+ // A moved selection commits its drag and is never a tap-to-edit.
515
+ if (s?.kind === 'dragging' && (s.delta.x !== 0 || s.delta.y !== 0)) {
516
+ const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
517
+ if (patch)
518
+ ctx.commit(patch);
458
519
  return;
459
- const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
460
- if (patch)
461
- ctx.commit(patch);
520
+ }
521
+ // No movement: re-tapping an already-selected text shape re-opens its
522
+ // editor (the same edit flow as tapping it with the text tool, via the
523
+ // shared editTextShape). `editId` was latched on the down, so this survives
524
+ // the native tap's synchronous down+up where `state` cannot.
525
+ if (editId) {
526
+ const shape = ctx.document.shapes.find((sh) => sh.id === editId);
527
+ if (shape && shape.kind === 'text')
528
+ editTextShape(ctx, shape);
529
+ }
462
530
  },
463
531
  onCancel(_state, ctx) {
532
+ pendingTextEditId = null;
464
533
  ctx.preview({ ops: [] });
465
534
  },
466
535
  hitTest(element, p) {
@@ -0,0 +1,4 @@
1
+ import type { AnnotationCanvasState, AnnotationShape, Vec2 } from '../../../types/annotation.js';
2
+ import type { ToolContext } from '../Tool.js';
3
+ export declare const findTextShapeAt: (doc: AnnotationCanvasState, world: Vec2) => AnnotationShape | null;
4
+ export declare const editTextShape: (ctx: ToolContext, shape: AnnotationShape, onDone?: () => void) => void;
@@ -0,0 +1,36 @@
1
+ import { hitTestTextShape } from '../textGeometry.js';
2
+ // Topmost text shape under a world point (z-order, top first), or null. Shared
3
+ // by the text tool (tap-to-edit) and the select tool (tap-a-selected-shape-to-
4
+ // edit) so a press resolves to the same element from either tool.
5
+ export const findTextShapeAt = (doc, world) => {
6
+ for (let i = doc.shapes.length - 1; i >= 0; i--) {
7
+ const s = doc.shapes[i];
8
+ if (s.kind === 'text' && hitTestTextShape(s, world))
9
+ return s;
10
+ }
11
+ return null;
12
+ };
13
+ // Re-open the consumer's text input pre-filled with an existing text shape's
14
+ // content and commit the edit: cancelling (null) leaves it untouched, clearing
15
+ // the text deletes the shape, and changed text updates it. The shape is left
16
+ // selected (cleared on delete). `onDone` runs only after a non-cancel,
17
+ // non-delete resolution — the text tool uses it to switch back to select.
18
+ // One source of truth for editing placed text from either tool.
19
+ export const editTextShape = (ctx, shape, onDone) => {
20
+ void ctx.requestTextInput({ initialText: shape.text }).then((text) => {
21
+ if (text === null)
22
+ return;
23
+ if (text === '') {
24
+ ctx.commit({ ops: [{ op: 'removeShape', id: shape.id }] });
25
+ ctx.setSelection(null);
26
+ return;
27
+ }
28
+ if (text !== shape.text) {
29
+ ctx.commit({
30
+ ops: [{ op: 'updateShape', id: shape.id, patch: { text } }],
31
+ });
32
+ }
33
+ ctx.setSelection({ ids: [shape.id] });
34
+ onDone?.();
35
+ });
36
+ };
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
- import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
2
+ import { DEFAULT_TEXT_FONT_SIZE } from '../textGeometry.js';
3
+ import { editTextShape, findTextShapeAt } from './textEditing.js';
3
4
  let counter = 0;
4
5
  const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
6
  // Screen-px a press may travel and still count as a tap. Beyond this the
@@ -7,15 +8,6 @@ const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)
7
8
  // NOT open the text sheet — the cause of the stray "weird popup" on screen.
8
9
  const TAP_SLOP_PX = 10;
9
10
  const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
10
- // Topmost text shape under a world point, for tap-to-edit.
11
- const findTextShapeAt = (doc, world) => {
12
- for (let i = doc.shapes.length - 1; i >= 0; i--) {
13
- const s = doc.shapes[i];
14
- if (s.kind === 'text' && hitTestTextShape(s, world))
15
- return s;
16
- }
17
- return null;
18
- };
19
11
  // Tap-to-type. Tapping empty canvas opens the consumer's text input and
20
12
  // commits a new text shape at the tap point (top-left anchored); tapping an
21
13
  // existing text shape re-opens the input pre-filled to edit it (clearing the
@@ -51,22 +43,7 @@ export const createTextTool = (options = {}) => {
51
43
  }
52
44
  const existing = findTextShapeAt(ctx.document, event.world);
53
45
  if (existing) {
54
- void ctx
55
- .requestTextInput({ initialText: existing.text })
56
- .then((text) => {
57
- if (text === null)
58
- return;
59
- if (text === '') {
60
- ctx.commit({ ops: [{ op: 'removeShape', id: existing.id }] });
61
- ctx.setSelection(null);
62
- return;
63
- }
64
- if (text !== existing.text) {
65
- ctx.commit({
66
- ops: [{ op: 'updateShape', id: existing.id, patch: { text } }],
67
- });
68
- }
69
- ctx.setSelection({ ids: [existing.id] });
46
+ editTextShape(ctx, existing, () => {
70
47
  if (autoSwitchToSelect)
71
48
  options.onAutoSwitch?.(selectToolId);
72
49
  });
@@ -16,6 +16,14 @@ export interface AnnotationCanvasHandle {
16
16
  }): void;
17
17
  setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
18
  associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
19
+ bindColumn(id: AnnotationElementId, binding: {
20
+ groupId: string;
21
+ columnId: string;
22
+ }): void;
23
+ placeColumnTileAtCenter(binding: {
24
+ groupId: string;
25
+ columnId: string;
26
+ }): AnnotationElementId;
19
27
  deleteSelected(): void;
20
28
  }
21
29
  export interface UseAnnotationCanvasStateProps {
@@ -56,6 +64,7 @@ export interface AnnotationCanvasStateApi {
56
64
  dispatchPointerMove(event: CanvasPointerEvent): void;
57
65
  dispatchPointerUp(event: CanvasPointerEvent): void;
58
66
  dispatchPointerCancel(): void;
67
+ dispatchLongPress(event: CanvasPointerEvent): void;
59
68
  pan(deltaScreen: Vec2): void;
60
69
  zoom(focalScreen: Vec2, nextZoom: number): void;
61
70
  setViewport(next: ViewportState): void;
@@ -2,7 +2,6 @@ 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';
6
5
  // Platform-agnostic state machine for the annotation canvas. Web and native
7
6
  // inners share this hook; each wraps it with platform-specific event
8
7
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
@@ -22,11 +21,13 @@ export const useAnnotationCanvasState = (props) => {
22
21
  return map;
23
22
  }, [measurements]);
24
23
  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
+ // Tiles are sized independently of the canvas: their footprint is base ×
25
+ // per-tile scale × the document-wide `tileScaleFactor` ("Tile size" slider),
26
+ // never the canvas pixel size so resizing the pane no longer rescales tiles.
27
+ // Retained as a (constant 1) value because the overlays (drawn size) and tools
28
+ // (hit box) thread it; keeping them in lockstep at 1 means both still agree.
29
+ // (See stampLayout.ts for why the old per-canvas multiplier was retired.)
30
+ const tileViewportScale = 1;
30
31
  const ctx = useMemo(() => ({
31
32
  document: canvas,
32
33
  selection,
@@ -121,6 +122,14 @@ export const useAnnotationCanvasState = (props) => {
121
122
  activePointerIdRef.current = null;
122
123
  setToolState(undefined);
123
124
  }, [activeTool, ctx, toolState]);
125
+ const dispatchLongPress = useCallback((event) => {
126
+ if (!activeTool)
127
+ return;
128
+ // Fire-and-forget: onLongPress is a discrete action (it opens the text
129
+ // editor), so it neither reads nor writes the gesture's tool state — the
130
+ // in-flight drag/select state on web stays intact underneath it.
131
+ activeTool.onLongPress?.(event, ctx);
132
+ }, [activeTool, ctx]);
124
133
  const dispatchPointerCancel = useCallback(() => {
125
134
  // Clear FIRST, then let the tool react: state updates batch, so a tool
126
135
  // whose onCancel re-emits a preview (the polygon tool keeps its placed
@@ -309,6 +318,39 @@ export const useAnnotationCanvasState = (props) => {
309
318
  ],
310
319
  });
311
320
  },
321
+ bindColumn(id, binding) {
322
+ const c = ctxRef.current;
323
+ c.commit({
324
+ ops: [
325
+ {
326
+ op: 'updateMeasurement',
327
+ id,
328
+ patch: { groupId: binding.groupId, columnId: binding.columnId },
329
+ },
330
+ ],
331
+ });
332
+ },
333
+ placeColumnTileAtCenter(binding) {
334
+ const c = ctxRef.current;
335
+ const anchor = c.viewport.screenToWorld({
336
+ x: width / 2,
337
+ y: height / 2,
338
+ });
339
+ const placed = {
340
+ id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
341
+ layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
342
+ groupId: binding.groupId,
343
+ columnId: binding.columnId,
344
+ anchor,
345
+ showLabel: true,
346
+ showValue: true,
347
+ createdAt: Date.now(),
348
+ };
349
+ c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
350
+ // Select it so the consumer can immediately move it / open its editor.
351
+ c.setSelection({ ids: [placed.id] });
352
+ return placed.id;
353
+ },
312
354
  deleteSelected() {
313
355
  const c = ctxRef.current;
314
356
  const ids = c.selection?.ids;
@@ -376,6 +418,7 @@ export const useAnnotationCanvasState = (props) => {
376
418
  dispatchPointerMove,
377
419
  dispatchPointerUp,
378
420
  dispatchPointerCancel,
421
+ dispatchLongPress,
379
422
  pan,
380
423
  zoom,
381
424
  setViewport,
@@ -50,6 +50,7 @@ export interface PlacedMeasurementRef {
50
50
  measurementPath?: string;
51
51
  measurementId?: string;
52
52
  groupId?: string;
53
+ columnId?: string;
53
54
  anchor: Vec2;
54
55
  placement?: MeasurementPlacement;
55
56
  line?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.19",
3
+ "version": "1.6.23",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",