@reekon-tools/boldr-utils 1.6.18 → 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.
Files changed (86) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +79 -12
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +100 -13
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +5 -1
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.js +53 -4
  5. package/dist/annotation/canvas/Tool.d.ts +7 -2
  6. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -1
  7. package/dist/annotation/canvas/measurementGeometry.js +7 -5
  8. package/dist/annotation/canvas/stampLayout.d.ts +8 -2
  9. package/dist/annotation/canvas/stampLayout.js +72 -9
  10. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  11. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  12. package/dist/annotation/canvas/tools/measurementTool.js +8 -2
  13. package/dist/annotation/canvas/tools/panTool.js +1 -1
  14. package/dist/annotation/canvas/tools/selectTool.js +116 -13
  15. package/dist/annotation/canvas/tools/textEditing.d.ts +4 -0
  16. package/dist/annotation/canvas/tools/textEditing.js +36 -0
  17. package/dist/annotation/canvas/tools/textTool.js +3 -26
  18. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
  19. package/dist/annotation/canvas/useAnnotationCanvasState.js +18 -0
  20. package/dist/exports.d.ts +1 -0
  21. package/dist/exports.js +1 -0
  22. package/dist/types/annotation.d.ts +4 -0
  23. package/dist/types/annotation.js +6 -0
  24. package/dist/types/firestore.d.ts +53 -0
  25. package/dist/types/firestore.js +49 -0
  26. package/package.json +1 -1
  27. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  28. package/dist/canvas/AnnotationCanvas.js +0 -10
  29. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  30. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  31. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  32. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  33. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  34. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  35. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  36. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  37. package/dist/canvas/Tool.d.ts +0 -38
  38. package/dist/canvas/Tool.js +0 -1
  39. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  40. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  41. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  42. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  43. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  44. package/dist/canvas/elements/ShapeElement.js +0 -62
  45. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  46. package/dist/canvas/elements/StrokeElement.js +0 -18
  47. package/dist/canvas/measurementPicker.d.ts +0 -10
  48. package/dist/canvas/measurementPicker.js +0 -1
  49. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  50. package/dist/canvas/measurementStampOverlay.js +0 -1
  51. package/dist/canvas/pointerAdapter.d.ts +0 -3
  52. package/dist/canvas/pointerAdapter.js +0 -19
  53. package/dist/canvas/stampLayout.d.ts +0 -5
  54. package/dist/canvas/stampLayout.js +0 -14
  55. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  56. package/dist/canvas/tools/measurementStampTool.js +0 -37
  57. package/dist/canvas/tools/panTool.d.ts +0 -5
  58. package/dist/canvas/tools/panTool.js +0 -25
  59. package/dist/canvas/tools/penTool.d.ts +0 -13
  60. package/dist/canvas/tools/penTool.js +0 -68
  61. package/dist/canvas/tools/selectTool.d.ts +0 -2
  62. package/dist/canvas/tools/selectTool.js +0 -182
  63. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  64. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  65. package/dist/canvas/viewport.d.ts +0 -16
  66. package/dist/canvas/viewport.js +0 -54
  67. package/dist/data/AnnotationDataContext.d.ts +0 -8
  68. package/dist/data/AnnotationDataContext.js +0 -11
  69. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  70. package/dist/data/AnnotationDataProvider.js +0 -4
  71. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  72. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  73. package/dist/data/canvasPersistence.d.ts +0 -3
  74. package/dist/data/canvasPersistence.js +0 -26
  75. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  76. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  77. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  78. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  79. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  80. package/dist/data/hooks/useAnnotationList.js +0 -26
  81. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  82. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  83. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  84. package/dist/hooks/useParseMeasurement.js +0 -14
  85. package/dist/utils/evaluateFormula.d.ts +0 -20
  86. package/dist/utils/evaluateFormula.js +0 -31
@@ -9,6 +9,16 @@ 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;
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;
12
22
  const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
13
23
  export const AnnotationCanvasInner = (props) => {
14
24
  const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
@@ -36,7 +46,19 @@ export const AnnotationCanvasInner = (props) => {
36
46
  const containerRef = useRef(null);
37
47
  const panGestureRef = useRef(null);
38
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);
39
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]);
40
62
  const toCanvasPointer = useCallback((event) => {
41
63
  const rect = containerRef.current?.getBoundingClientRect();
42
64
  const screen = {
@@ -79,8 +101,31 @@ export const AnnotationCanvasInner = (props) => {
79
101
  return;
80
102
  }
81
103
  event.currentTarget.setPointerCapture(event.pointerId);
82
- state.dispatchPointerDown(toCanvasPointer(event));
83
- }, [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]);
84
129
  const handlePointerMove = useCallback((event) => {
85
130
  const pan = panGestureRef.current;
86
131
  if (pan && event.pointerId === pan.pointerId) {
@@ -99,20 +144,33 @@ export const AnnotationCanvasInner = (props) => {
99
144
  };
100
145
  return;
101
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
+ }
102
158
  state.dispatchPointerMove(toCanvasPointer(event));
103
- }, [state, toCanvasPointer]);
159
+ }, [state, toCanvasPointer, clearLongPress]);
104
160
  const handlePointerUp = useCallback((event) => {
161
+ clearLongPress();
105
162
  const pan = panGestureRef.current;
106
163
  if (pan && event.pointerId === pan.pointerId) {
107
164
  panGestureRef.current = null;
108
165
  return;
109
166
  }
110
167
  state.dispatchPointerUp(toCanvasPointer(event));
111
- }, [state, toCanvasPointer]);
168
+ }, [state, toCanvasPointer, clearLongPress]);
112
169
  const handlePointerCancel = useCallback(() => {
170
+ clearLongPress();
113
171
  panGestureRef.current = null;
114
172
  state.dispatchPointerCancel();
115
- }, [state]);
173
+ }, [state, clearLongPress]);
116
174
  const handleWheel = useCallback((event) => {
117
175
  const rect = containerRef.current?.getBoundingClientRect();
118
176
  const focal = {
@@ -206,19 +264,25 @@ export const AnnotationCanvasInner = (props) => {
206
264
  handleRadius: activeTool?.dragSelection
207
265
  ? HANDLE_PX / state.viewport.zoom
208
266
  : undefined,
267
+ handleRingWidth: activeTool?.dragSelection
268
+ ? HANDLE_RING_PX / state.viewport.zoom
269
+ : undefined,
209
270
  customPreview,
210
271
  }), renderMeasurementStamp && (_jsx("div", { style: {
211
272
  position: 'absolute',
212
273
  inset: 0,
213
274
  pointerEvents: 'none',
214
275
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
215
- const size = stampTileSize(placed);
276
+ const size = stampTileSize(placed, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale);
216
277
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
217
278
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
218
279
  const isSelected = selection?.ids.includes(placed.id) ?? false;
219
280
  const measurement = placed.measurementId
220
281
  ? (state.measurementsById.get(placed.measurementId) ?? null)
221
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);
222
286
  return (_jsxs("div", { style: {
223
287
  position: 'absolute',
224
288
  left: 0,
@@ -241,11 +305,15 @@ export const AnnotationCanvasInner = (props) => {
241
305
  if (!keepSelection)
242
306
  state.ctx.setSelection(null);
243
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.
244
312
  position: 'absolute',
245
- top: -10,
246
- right: -10,
247
- width: 40,
248
- height: 40,
313
+ top: 0,
314
+ right: 0,
315
+ width: removeSize,
316
+ height: removeSize,
249
317
  cursor: 'pointer',
250
318
  pointerEvents: 'auto',
251
319
  } }))] }, placed.id));
@@ -255,8 +323,7 @@ export const AnnotationCanvasInner = (props) => {
255
323
  // long-press, so it's derived: pointerdown arms a timer; if it fires before
256
324
  // the pointer lifts (or leaves/cancels), the long-press callback runs and the
257
325
  // trailing click is swallowed. Mirrors the native overlay's TouchableOpacity
258
- // onPress/onLongPress semantics (500ms, RN's default delayLongPress).
259
- const LONG_PRESS_MS = 500;
326
+ // onPress/onLongPress semantics (LONG_PRESS_MS, RN's default delayLongPress).
260
327
  const StampPressTarget = ({ onPress, onLongPress, }) => {
261
328
  const timerRef = useRef(null);
262
329
  const longPressFiredRef = useRef(false);
@@ -12,9 +12,18 @@ 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;
24
+ // Screen-px stroke width of the handle's colored ring (white-disc + ring, so the
25
+ // knob stays legible over a line of any color). Also zoom-divided.
26
+ const HANDLE_RING_PX = 2;
18
27
  // Native fingerprint: one finger drives the active tool, two fingers
19
28
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
20
29
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -152,6 +161,7 @@ export const AnnotationCanvasInner = (props) => {
152
161
  const shapeDraw = state.activeTool?.shapeDraw ?? null;
153
162
  const panViewport = !!state.activeTool?.panViewport;
154
163
  const dragSelection = state.activeTool?.dragSelection ?? null;
164
+ const longPressEnabled = !!state.activeTool?.onLongPress;
155
165
  // In-flight shape rubber-band (line/arrow/rect/triangle/circle tools),
156
166
  // owned by the UI thread — the shape twin of `livePoints`. The drag worklet
157
167
  // tracks the start/current world points; derived paths below render the
@@ -315,6 +325,13 @@ export const AnnotationCanvasInner = (props) => {
315
325
  const [epDragId, setEpDragId] = useState(null);
316
326
  const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0 });
317
327
  const epTargetRef = useRef(null);
328
+ // Endpoint drag of a line/arrow SHAPE (vs. a measurement line above).
329
+ // `shapeEpDragId` (React state) tells the Skia layer to render that shape's
330
+ // line from the live endpoints; the live geometry rides the SAME `epCtx` +
331
+ // `liveLineP1`/`liveLineP2` shared values (only one endpoint drag is ever in
332
+ // flight). A separate target ref keys the commit to buildShapeEndpointPatch.
333
+ const [shapeEpDragId, setShapeEpDragId] = useState(null);
334
+ const shapeEpTargetRef = useRef(null);
318
335
  // Live line endpoints during an endpoint drag: the grabbed handle follows the
319
336
  // finger (dragX/dragY); the other stays put. Fed to the Skia <Line> for the
320
337
  // dragged annotation.
@@ -338,6 +355,10 @@ export const AnnotationCanvasInner = (props) => {
338
355
  'worklet';
339
356
  return HANDLE_RADIUS_PX / zoom.value;
340
357
  });
358
+ const handleRingWidth = useDerivedValue(() => {
359
+ 'worklet';
360
+ return HANDLE_RING_PX / zoom.value;
361
+ });
341
362
  // Rectangle-annotation corner drag. `rectDragId` (React state) marks which
342
363
  // annotation's rect renders from the live geometry; `rectCtx` carries the
343
364
  // fixed (opposite) corner and the grabbed corner's start position so the
@@ -465,6 +486,20 @@ export const AnnotationCanvasInner = (props) => {
465
486
  inFlightRef.current = null;
466
487
  }
467
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
+ });
468
503
  const tap = Gesture.Tap()
469
504
  .maxDuration(250)
470
505
  .runOnJS(true)
@@ -738,9 +773,13 @@ export const AnnotationCanvasInner = (props) => {
738
773
  const world = st.ctx.viewport.screenToWorld(screen);
739
774
  const zoomNow = st.ctx.viewport.state.zoom;
740
775
  // Endpoint handles show only on the selected annotation, so check the
741
- // 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).
742
780
  const selId = st.ctx.selection?.ids[0];
743
- if (selId) {
781
+ if (selId &&
782
+ !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale)) {
744
783
  const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
745
784
  if (handle) {
746
785
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
@@ -758,6 +797,27 @@ export const AnnotationCanvasInner = (props) => {
758
797
  return;
759
798
  }
760
799
  }
800
+ // Endpoint handle of a line/arrow shape — selected-element-only, and
801
+ // distinct from the measurement-line handle above (different storage).
802
+ const shapeHandle = cfg.hitTestShapeHandle?.(st.ctx.document, selId, world, zoomNow);
803
+ if (shapeHandle) {
804
+ const s = st.ctx.document.shapes.find((x) => x.id === selId);
805
+ const a = s?.geometry.points[0];
806
+ const b = s?.geometry.points[1];
807
+ if (a && b) {
808
+ epCtx.value = {
809
+ ax: a.x,
810
+ ay: a.y,
811
+ bx: b.x,
812
+ by: b.y,
813
+ t0: 0.5,
814
+ handle: shapeHandle === 'a' ? 0 : 1,
815
+ };
816
+ shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
817
+ setShapeEpDragId(selId);
818
+ return;
819
+ }
820
+ }
761
821
  // Corner resize handle (text shapes) — also selected-element-only.
762
822
  const resizeGeom = cfg.hitTestResizeHandle?.(st.ctx.document, selId, world, zoomNow);
763
823
  if (resizeGeom) {
@@ -787,7 +847,7 @@ export const AnnotationCanvasInner = (props) => {
787
847
  return;
788
848
  }
789
849
  }
790
- const hit = cfg.hitTest(st.ctx.document, world, zoomNow);
850
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
791
851
  if (!hit) {
792
852
  st.ctx.setSelection(null);
793
853
  dragTargetRef.current = null;
@@ -796,7 +856,7 @@ export const AnnotationCanvasInner = (props) => {
796
856
  st.ctx.setSelection({ ids: [hit.id] });
797
857
  // Grabbing a line annotation's tile slides it; otherwise group-move.
798
858
  const grab = hit.kind === 'measurement'
799
- ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow)
859
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale)
800
860
  : 'move';
801
861
  if (grab === 'slide') {
802
862
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
@@ -859,6 +919,18 @@ export const AnnotationCanvasInner = (props) => {
859
919
  setEpDragId(null);
860
920
  return;
861
921
  }
922
+ // Shape endpoint commit: move the grabbed endpoint by the world delta.
923
+ const sepT = shapeEpTargetRef.current;
924
+ if (sepT) {
925
+ if (dx !== 0 || dy !== 0) {
926
+ const patch = cfg.buildShapeEndpointPatch?.(st.ctx.document, sepT.id, sepT.handle, { x: dx, y: dy });
927
+ if (patch)
928
+ st.ctx.commit(patch);
929
+ }
930
+ shapeEpTargetRef.current = null;
931
+ setShapeEpDragId(null);
932
+ return;
933
+ }
862
934
  // Slide commit: map the final world delta to a clamped/snapped linePos.
863
935
  const slideT = slideTargetRef.current;
864
936
  if (slideT) {
@@ -891,11 +963,13 @@ export const AnnotationCanvasInner = (props) => {
891
963
  dragTargetRef.current = null;
892
964
  slideTargetRef.current = null;
893
965
  epTargetRef.current = null;
966
+ shapeEpTargetRef.current = null;
894
967
  resizeTargetRef.current = null;
895
968
  rectTargetRef.current = null;
896
969
  setDraggingId(null);
897
970
  setSlidingId(null);
898
971
  setEpDragId(null);
972
+ setShapeEpDragId(null);
899
973
  setResizingId(null);
900
974
  setRectDragId(null);
901
975
  };
@@ -947,7 +1021,9 @@ export const AnnotationCanvasInner = (props) => {
947
1021
  : dragSelection
948
1022
  ? buildSelectDragPan(dragSelection)
949
1023
  : toolPan;
950
- 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);
951
1027
  }, [
952
1028
  zoom,
953
1029
  panX,
@@ -962,6 +1038,7 @@ export const AnnotationCanvasInner = (props) => {
962
1038
  shapeDraw,
963
1039
  panViewport,
964
1040
  dragSelection,
1041
+ longPressEnabled,
965
1042
  dragX,
966
1043
  dragY,
967
1044
  dragEnded,
@@ -1018,6 +1095,7 @@ export const AnnotationCanvasInner = (props) => {
1018
1095
  resizeTransform,
1019
1096
  selectedId: selection?.ids[0] ?? null,
1020
1097
  endpointDragId: epDragId,
1098
+ shapeEndpointDragId: shapeEpDragId,
1021
1099
  liveLineP1,
1022
1100
  liveLineP2,
1023
1101
  rectDragId,
@@ -1033,19 +1111,28 @@ export const AnnotationCanvasInner = (props) => {
1033
1111
  // measurement still shows its tile chrome but must not advertise
1034
1112
  // draggability).
1035
1113
  handleRadius: activeTool?.dragSelection ? handleRadius : undefined,
1114
+ handleRingWidth: activeTool?.dragSelection
1115
+ ? handleRingWidth
1116
+ : undefined,
1036
1117
  customPreview,
1037
1118
  }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1038
1119
  ? (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: () => {
1120
+ : 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
1121
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1041
1122
  state.ctx.commit({ ops });
1042
1123
  if (!keepSelection)
1043
1124
  state.ctx.setSelection(null);
1044
1125
  } }, placed.id))) }))] }));
1045
1126
  };
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);
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, }) => {
1128
+ const size = stampTileSize(placed, tileScaleFactor, tileViewportScale);
1048
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);
1049
1136
  const anchorX = placed.anchor.x;
1050
1137
  const anchorY = placed.anchor.y;
1051
1138
  // doc → screen each frame, on the UI thread. Position is a translate
@@ -1113,11 +1200,11 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1113
1200
  selected,
1114
1201
  size,
1115
1202
  zoom: zoomSnapshot,
1116
- }) }), 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: {
1117
1204
  position: 'absolute',
1118
- top: -8,
1119
- right: -8,
1120
- width: 36,
1121
- height: 36,
1205
+ top: 0,
1206
+ right: 0,
1207
+ width: removeSize,
1208
+ height: removeSize,
1122
1209
  } }))] }));
1123
1210
  };
@@ -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,21 +51,24 @@ 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;
65
68
  fixed: Vec2;
66
69
  } | null;
67
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;
68
72
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
69
73
  buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
70
74
  }
@@ -80,6 +84,7 @@ export interface Tool {
80
84
  onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
81
85
  onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
82
86
  onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
87
+ onLongPress?(event: CanvasPointerEvent, ctx: ToolContext): void;
83
88
  onCancel?(state: ToolState, ctx: ToolContext): void;
84
89
  onDeactivate?(ctx: ToolContext): void;
85
90
  renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
@@ -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;
@@ -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;
@@ -109,14 +110,15 @@ const segmentDistSq = (p, a, b) => {
109
110
  // stamp tile around the anchor, the line body of a line annotation, or the
110
111
  // border ring of a rectangle annotation (interiors stay transparent to hits so
111
112
  // elements inside remain reachable).
112
- export const hitPlacedMeasurement = (m, p, zoom = 1) => {
113
+ export const hitPlacedMeasurement = (m, p, zoom = 1, tileScaleFactor = 1, viewportScale = 1) => {
113
114
  // The stamp renders as a constant *screen*-size square centered on the
114
115
  // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
115
116
  // screen-space half-extent (+ padding) back to doc space via the zoom so
116
117
  // the hit box always matches what's drawn.
117
118
  // Unassociated inputs use the smaller input-tile footprint (#6); the helper
118
119
  // folds in scale + the associated/blank size so the hit box matches the draw.
119
- const half = (stampTileSize(m) / 2 + STAMP_HIT_PADDING) / zoom;
120
+ const half = (stampTileSize(m, tileScaleFactor, viewportScale) / 2 + STAMP_HIT_PADDING) /
121
+ zoom;
120
122
  const dx = Math.abs(p.x - m.anchor.x);
121
123
  const dy = Math.abs(p.y - m.anchor.y);
122
124
  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
- export declare const STAMP_INPUT_TILE_SIZE = 56;
3
+ export declare const STAMP_INPUT_TILE_SIZE = 44;
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;