@reekon-tools/boldr-utils 1.6.19 → 1.6.20

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.
@@ -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 = {
@@ -225,6 +280,9 @@ export const AnnotationCanvasInner = (props) => {
225
280
  const measurement = placed.measurementId
226
281
  ? (state.measurementsById.get(placed.measurementId) ?? null)
227
282
  : null;
283
+ // Corner-pinned, tile-proportional remove target (see the style
284
+ // comment below) so it can't blanket a small tile and eat its grab.
285
+ const removeSize = Math.min(40, size * 0.4);
228
286
  return (_jsxs("div", { style: {
229
287
  position: 'absolute',
230
288
  left: 0,
@@ -247,11 +305,15 @@ export const AnnotationCanvasInner = (props) => {
247
305
  if (!keepSelection)
248
306
  state.ctx.setSelection(null);
249
307
  }, style: {
308
+ // Sized as a fraction of the tile and corner-pinned so it
309
+ // never covers the center. A fixed 40px target blanketed
310
+ // small tiles (the tile-scale slider shrinks them), eating
311
+ // the center grab so a selected tile couldn't be dragged.
250
312
  position: 'absolute',
251
- top: -10,
252
- right: -10,
253
- width: 40,
254
- height: 40,
313
+ top: 0,
314
+ right: 0,
315
+ width: removeSize,
316
+ height: removeSize,
255
317
  cursor: 'pointer',
256
318
  pointerEvents: 'auto',
257
319
  } }))] }, placed.id));
@@ -261,8 +323,7 @@ export const AnnotationCanvasInner = (props) => {
261
323
  // long-press, so it's derived: pointerdown arms a timer; if it fires before
262
324
  // the pointer lifts (or leaves/cancels), the long-press callback runs and the
263
325
  // 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;
326
+ // onPress/onLongPress semantics (LONG_PRESS_MS, RN's default delayLongPress).
266
327
  const StampPressTarget = ({ onPress, onLongPress, }) => {
267
328
  const timerRef = useRef(null);
268
329
  const longPressFiredRef = useRef(false);
@@ -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,
@@ -1099,6 +1127,12 @@ export const AnnotationCanvasInner = (props) => {
1099
1127
  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
1128
  const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
1101
1129
  const half = size / 2;
1130
+ // Remove-"X" touch target, sized as a fraction of the tile and corner-pinned
1131
+ // so it never reaches the center. A FIXED 36px+hitSlop target blanketed small
1132
+ // tiles (the tile-scale slider can shrink them to ~38px), swallowing the
1133
+ // center grab and making a selected tile impossible to drag (it had to be
1134
+ // deselected first). Capped so it doesn't grow past the old size on big tiles.
1135
+ const removeSize = Math.min(36, size * 0.4);
1102
1136
  const anchorX = placed.anchor.x;
1103
1137
  const anchorY = placed.anchor.y;
1104
1138
  // doc → screen each frame, on the UI thread. Position is a translate
@@ -1166,11 +1200,11 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1166
1200
  selected,
1167
1201
  size,
1168
1202
  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: {
1203
+ }) }), 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
1204
  position: 'absolute',
1171
- top: -8,
1172
- right: -8,
1173
- width: 36,
1174
- height: 36,
1205
+ top: 0,
1206
+ right: 0,
1207
+ width: removeSize,
1208
+ height: removeSize,
1175
1209
  } }))] }));
1176
1210
  };
@@ -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,6 +1,6 @@
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;
@@ -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
@@ -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,6 +2,7 @@ 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, findTextShapeAt } from './textEditing.js';
5
6
  const HIT_PADDING = 6;
6
7
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
7
8
  // override via `hitTest` for more precision later.
@@ -16,8 +17,14 @@ const hitStroke = (stroke, p) => {
16
17
  return false;
17
18
  };
18
19
  // 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;
20
+ // (line/arrow/rect/ellipse/polygon), converted to doc space via zoom. A thin
21
+ // line gives almost nothing to aim at, so the grab corridor is deliberately
22
+ // wider than a fingertip — landing anywhere near the ink grabs it. Kept in sync
23
+ // with measurementGeometry's LINE_GRAB_PX so tap-select and drag-grab agree on
24
+ // what counts as "on the line". (The endpoint-resize handles use their own,
25
+ // tighter HANDLE_GRAB_PX and are checked first on the selected element, so a
26
+ // wider body grab never swallows them.)
27
+ const SHAPE_GRAB_PX = 32;
21
28
  // Screen-px radius of the center snap detent when sliding a tile along its line.
22
29
  // Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
23
30
  // slide worklet inlines the same value — keep them in sync.
@@ -124,6 +131,23 @@ const translatePatch = (elementKind, id, doc, delta) => {
124
131
  }
125
132
  return { op: 'updateStroke', id, patch: { points } };
126
133
  };
134
+ // Whether a world point lands on a measurement's tile (its screen-constant
135
+ // anchor box, converted back to doc space via zoom — the same footprint
136
+ // classifyGrab uses). The tile is the move/slide affordance, so a grab here
137
+ // must win over the endpoint/corner/resize handles that appear once an element
138
+ // is selected; without this, a handle sitting under the tile (a rectangle
139
+ // tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
140
+ // and the tile becomes unmovable until deselected.
141
+ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
142
+ const m = doc.placedMeasurements.find((x) => x.id === id);
143
+ if (!m)
144
+ return false;
145
+ const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
146
+ HIT_PADDING) /
147
+ zoom;
148
+ return (Math.abs(world.x - m.anchor.x) <= half &&
149
+ Math.abs(world.y - m.anchor.y) <= half);
150
+ };
127
151
  // --- Measurement-annotation grab logic (shared by the native UI-thread drag
128
152
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
129
153
  // Grabbing the tile of a line annotation slides it along the line; everything
@@ -348,6 +372,7 @@ export const createSelectTool = () => ({
348
372
  return op ? { ops: [op] } : null;
349
373
  },
350
374
  classifyMeasurementGrab: classifyGrab,
375
+ isSelectedTileGrab: isOnMeasurementTile,
351
376
  buildSlidePatch: slidePatch,
352
377
  hitTestHandle: findHandleHit,
353
378
  buildEndpointPatch: endpointPatch,
@@ -364,9 +389,13 @@ export const createSelectTool = () => ({
364
389
  onPointerDown(event, ctx) {
365
390
  const { world } = event;
366
391
  const zoom = ctx.viewport.state.zoom;
367
- // Endpoint/resize handles show only on the selected element — check first.
392
+ // Endpoint/resize handles show only on the selected element — check first,
393
+ // UNLESS the grab is on that element's tile: the tile is the move/slide
394
+ // affordance and must win over a handle sitting under it, so a selected
395
+ // tile stays draggable (otherwise it can only be moved after deselecting).
368
396
  const selId = ctx.selection?.ids[0];
369
- if (selId) {
397
+ if (selId &&
398
+ !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
370
399
  const handle = findHandleHit(ctx.document, selId, world, zoom);
371
400
  if (handle) {
372
401
  ctx.setSelection({ ids: [selId] });
@@ -463,6 +492,14 @@ export const createSelectTool = () => ({
463
492
  onCancel(_state, ctx) {
464
493
  ctx.preview({ ops: [] });
465
494
  },
495
+ // Long-pressing a placed text shape re-opens the editor (the same edit flow
496
+ // as tapping it with the text tool, via the shared editTextShape). A hold on
497
+ // any other element — or empty canvas — is ignored.
498
+ onLongPress(event, ctx) {
499
+ const shape = findTextShapeAt(ctx.document, event.world);
500
+ if (shape)
501
+ editTextShape(ctx, shape);
502
+ },
466
503
  hitTest(element, p) {
467
504
  if (element.kind === 'measurement')
468
505
  return hitPlacedMeasurement(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 (long-press-to-edit) so a
4
+ // 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
  });
@@ -56,6 +56,7 @@ export interface AnnotationCanvasStateApi {
56
56
  dispatchPointerMove(event: CanvasPointerEvent): void;
57
57
  dispatchPointerUp(event: CanvasPointerEvent): void;
58
58
  dispatchPointerCancel(): void;
59
+ dispatchLongPress(event: CanvasPointerEvent): void;
59
60
  pan(deltaScreen: Vec2): void;
60
61
  zoom(focalScreen: Vec2, nextZoom: number): void;
61
62
  setViewport(next: ViewportState): void;
@@ -121,6 +121,14 @@ export const useAnnotationCanvasState = (props) => {
121
121
  activePointerIdRef.current = null;
122
122
  setToolState(undefined);
123
123
  }, [activeTool, ctx, toolState]);
124
+ const dispatchLongPress = useCallback((event) => {
125
+ if (!activeTool)
126
+ return;
127
+ // Fire-and-forget: onLongPress is a discrete action (it opens the text
128
+ // editor), so it neither reads nor writes the gesture's tool state — the
129
+ // in-flight drag/select state on web stays intact underneath it.
130
+ activeTool.onLongPress?.(event, ctx);
131
+ }, [activeTool, ctx]);
124
132
  const dispatchPointerCancel = useCallback(() => {
125
133
  // Clear FIRST, then let the tool react: state updates batch, so a tool
126
134
  // whose onCancel re-emits a preview (the polygon tool keeps its placed
@@ -376,6 +384,7 @@ export const useAnnotationCanvasState = (props) => {
376
384
  dispatchPointerMove,
377
385
  dispatchPointerUp,
378
386
  dispatchPointerCancel,
387
+ dispatchLongPress,
379
388
  pan,
380
389
  zoom,
381
390
  setViewport,
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.20",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",