@reekon-tools/boldr-utils 1.19.6 → 1.20.0

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 (77) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +10 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +214 -29
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +7 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +11 -2
  7. package/dist/annotation/canvas/Tool.d.ts +6 -0
  8. package/dist/annotation/canvas/snapGeometry.d.ts +32 -0
  9. package/dist/annotation/canvas/snapGeometry.js +168 -0
  10. package/dist/annotation/canvas/tools/measurementTool.js +38 -4
  11. package/dist/annotation/canvas/tools/selectTool.js +43 -1
  12. package/dist/annotation/canvas/tools/shapeTool.js +27 -5
  13. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
  14. package/dist/annotation/canvas/useAnnotationCanvasState.js +16 -1
  15. package/dist/exports.d.ts +2 -1
  16. package/dist/exports.js +1 -0
  17. package/package.json +1 -1
  18. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  19. package/dist/canvas/AnnotationCanvas.js +0 -10
  20. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  21. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  22. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  23. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  24. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  25. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  26. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  27. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  28. package/dist/canvas/Tool.d.ts +0 -38
  29. package/dist/canvas/Tool.js +0 -1
  30. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  31. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  32. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  33. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  34. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  35. package/dist/canvas/elements/ShapeElement.js +0 -62
  36. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  37. package/dist/canvas/elements/StrokeElement.js +0 -18
  38. package/dist/canvas/measurementPicker.d.ts +0 -10
  39. package/dist/canvas/measurementPicker.js +0 -1
  40. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  41. package/dist/canvas/measurementStampOverlay.js +0 -1
  42. package/dist/canvas/pointerAdapter.d.ts +0 -3
  43. package/dist/canvas/pointerAdapter.js +0 -19
  44. package/dist/canvas/stampLayout.d.ts +0 -5
  45. package/dist/canvas/stampLayout.js +0 -14
  46. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  47. package/dist/canvas/tools/measurementStampTool.js +0 -37
  48. package/dist/canvas/tools/panTool.d.ts +0 -5
  49. package/dist/canvas/tools/panTool.js +0 -25
  50. package/dist/canvas/tools/penTool.d.ts +0 -13
  51. package/dist/canvas/tools/penTool.js +0 -68
  52. package/dist/canvas/tools/selectTool.d.ts +0 -2
  53. package/dist/canvas/tools/selectTool.js +0 -182
  54. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  55. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  56. package/dist/canvas/viewport.d.ts +0 -16
  57. package/dist/canvas/viewport.js +0 -54
  58. package/dist/data/AnnotationDataContext.d.ts +0 -8
  59. package/dist/data/AnnotationDataContext.js +0 -11
  60. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  61. package/dist/data/AnnotationDataProvider.js +0 -4
  62. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  63. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  64. package/dist/data/canvasPersistence.d.ts +0 -3
  65. package/dist/data/canvasPersistence.js +0 -26
  66. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  67. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  68. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  69. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  70. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  71. package/dist/data/hooks/useAnnotationList.js +0 -26
  72. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  73. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  74. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  75. package/dist/hooks/useParseMeasurement.js +0 -14
  76. package/dist/utils/evaluateFormula.d.ts +0 -20
  77. package/dist/utils/evaluateFormula.js +0 -31
@@ -22,6 +22,7 @@ export interface AnnotationCanvasInnerProps {
22
22
  resolveImageUrl?: (storagePath: string) => Promise<string>;
23
23
  deferBackgroundToResolver?: boolean;
24
24
  hideLinkBadges?: boolean;
25
+ snapToGeometry?: boolean;
25
26
  pickMeasurement?: () => Promise<MeasurementRef | null>;
26
27
  requestTextInput?: RequestTextInput;
27
28
  renderBackground?: RenderCanvasBackground;
@@ -6,6 +6,7 @@ import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
6
6
  import { buildRemoveMeasurementOps, hitPlacedMeasurement, } from './measurementGeometry.js';
7
7
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
8
8
  import { stampTileDims } from './stampLayout.js';
9
+ import { SNAP_RING_PX, SNAP_RING_WIDTH_PX } from './snapGeometry.js';
9
10
  // Screen-px radius of a measurement-annotation endpoint handle (matches the
10
11
  // native HANDLE_RADIUS_PX). Divided by zoom for a constant on-screen size.
11
12
  const HANDLE_PX = 7;
@@ -338,6 +339,15 @@ export const AnnotationCanvasInner = (props) => {
338
339
  ? HANDLE_RING_PX / state.viewport.zoom
339
340
  : undefined,
340
341
  showPlaneHandles: activeTool?.planeEditing === true,
342
+ // Magnetic-snap ring for the JS-path drags (select / measurement /
343
+ // shape tools report the target through ctx.snap.setIndicator).
344
+ snapIndicator: state.snapIndicator
345
+ ? {
346
+ c: state.snapIndicator,
347
+ r: SNAP_RING_PX / state.viewport.zoom,
348
+ ringWidth: SNAP_RING_WIDTH_PX / state.viewport.zoom,
349
+ }
350
+ : null,
341
351
  customPreview,
342
352
  }), renderMeasurementStamp && (_jsx("div", { style: {
343
353
  position: 'absolute',
@@ -23,6 +23,7 @@ export interface AnnotationCanvasInnerProps {
23
23
  resolveImageUrl?: (storagePath: string) => Promise<string>;
24
24
  deferBackgroundToResolver?: boolean;
25
25
  hideLinkBadges?: boolean;
26
+ snapToGeometry?: boolean;
26
27
  pickMeasurement?: () => Promise<MeasurementRef | null>;
27
28
  requestTextInput?: RequestTextInput;
28
29
  renderBackground?: RenderCanvasBackground;
@@ -11,6 +11,7 @@ import { MIN_BACKGROUND_DOC_EXTENT, backgroundLayersOf, backgroundLayerDocRect,
11
11
  import { BackgroundSkImageLoader } from './elements/BackgroundImageElement.js';
12
12
  import { buildRemoveMeasurementOps, linePosOf, } from './measurementGeometry.js';
13
13
  import { planeEdgeLine, planeGridSegments, planeGridSegmentsForScale, resolvePlaneGridSpacingUm, } from './planeGeometry.js';
14
+ import { SNAP_RADIUS_PX, SNAP_RING_PX, SNAP_RING_WIDTH_PX, collectSnapTargetsFlat, snapDelta, snapNearest, } from './snapGeometry.js';
14
15
  import { buildShapeFromDrag } from './tools/shapeTool.js';
15
16
  import { SELECTION_PAD } from './textGeometry.js';
16
17
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
@@ -64,7 +65,7 @@ const LOUPE_CROSS_WIDTH_PX = 1.5;
64
65
  // is only re-synced once each gesture ends. The tool/tap path still hops to
65
66
  // JS (`runOnJS(true)`) since drawing/selection are React-state operations.
66
67
  export const AnnotationCanvasInner = (props) => {
67
- const { resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, renderBackground, } = props;
68
+ const { resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, snapToGeometry = false, renderBackground, } = props;
68
69
  const valueFont = useFont(stampFontSource, stampValueFontSize);
69
70
  // The animated-viewport implementation needs the Reanimated shared values,
70
71
  // which are created below AFTER the state hook — so the hook gets a stable
@@ -293,7 +294,10 @@ export const AnnotationCanvasInner = (props) => {
293
294
  // owned by the UI thread — the shape twin of `livePoints`. The drag worklet
294
295
  // tracks the start/current world points; derived paths below render the
295
296
  // live shape. React is untouched until the single commit on release.
296
- const liveShape = useSharedValue({ active: false, ax: 0, ay: 0, bx: 0, by: 0 });
297
+ // `snapR` is the doc-space magnetic-snap radius frozen at press (0 = off)
298
+ // and `snapped` whether the live end is currently locked onto a target
299
+ // (drives the snap ring) — see snapGeometry.ts.
300
+ const liveShape = useSharedValue({ active: false, ax: 0, ay: 0, bx: 0, by: 0, snapR: 0, snapped: false });
297
301
  // Released shapes awaiting their committed paint — the shape twin of
298
302
  // `handoffStrokes`, with the same lifecycle: appended in the gesture's
299
303
  // onEnd worklet tick, released by the paint-watch effect one rAF after the
@@ -317,6 +321,28 @@ export const AnnotationCanvasInner = (props) => {
317
321
  width: shapeDraw.width,
318
322
  };
319
323
  }, [shapeDraw, shapeCfg]);
324
+ // Magnetic snapping for line endpoints (snapGeometry.ts). `snapCfg` mirrors
325
+ // the `snapToGeometry` prop for the worklets (derived values and gesture
326
+ // callbacks are created once and can't close over a changing prop).
327
+ // `snapTargets` is the flat `[x, y, kind, …]` target buffer, written ONCE
328
+ // per drag — from JS in beginSelectDrag for endpoint drags (excluding the
329
+ // grabbed element), and by the effect below for the shape rubber-band,
330
+ // whose onBegin is a worklet with no doc access. The effect only runs while
331
+ // a line-kind shape tool is active, so it can never clobber a select drag's
332
+ // harvest; keyed on the COMMITTED doc so a just-drawn line becomes a target.
333
+ const snapCfg = useSharedValue({
334
+ enabled: false,
335
+ radiusPx: SNAP_RADIUS_PX,
336
+ });
337
+ useEffect(() => {
338
+ snapCfg.value = { enabled: snapToGeometry, radiusPx: SNAP_RADIUS_PX };
339
+ }, [snapToGeometry, snapCfg]);
340
+ const snapTargets = useSharedValue([]);
341
+ useEffect(() => {
342
+ if (!snapToGeometry || !shapeDraw || shapeDraw.kind !== 'line')
343
+ return;
344
+ snapTargets.value = collectSnapTargetsFlat(state.ctx.document);
345
+ }, [snapToGeometry, shapeDraw, state.ctx.document, snapTargets]);
320
346
  // Outline of the live rubber-band + every pending handoff shape, as one
321
347
  // stroked path. WORKLET TWIN of shapeGeometry.shapePointsFromDrag +
322
348
  // ShapeElement's per-kind rendering — keep in sync. Merging into one path
@@ -456,7 +482,9 @@ export const AnnotationCanvasInner = (props) => {
456
482
  // (0 = a, 1 = b) is moving, so the Skia line + overlay tile can be driven on
457
483
  // the UI thread from `dragX`/`dragY`.
458
484
  const [epDragId, setEpDragId] = useState(null);
459
- const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0 });
485
+ // `snapR`: doc-space magnetic-snap radius frozen at grab (0 = off) — see
486
+ // epLive below and snapGeometry.ts.
487
+ const epCtx = useSharedValue({ ax: 0, ay: 0, bx: 0, by: 0, t0: 0.5, handle: 0, snapR: 0 });
460
488
  const epTargetRef = useRef(null);
461
489
  // Endpoint drag of a line/arrow SHAPE (vs. a measurement line above).
462
490
  // `shapeEpDragId` (React state) tells the Skia layer to render that shape's
@@ -468,19 +496,32 @@ export const AnnotationCanvasInner = (props) => {
468
496
  // Live line endpoints during an endpoint drag: the grabbed handle follows the
469
497
  // finger (dragX/dragY); the other stays put. Fed to the Skia <Line> for the
470
498
  // dragged annotation.
499
+ // The grabbed endpoint's live position: base + drag, then magnetically
500
+ // snapped to the nearest target within epCtx.snapR (0 = off). The SINGLE
501
+ // source for the live line, the tile overlay's follow math, the loupe
502
+ // crosshair and the snap ring — and the select gesture's onEnd recomputes
503
+ // the same snap (snapDelta, same inputs) so the commit matches it exactly.
504
+ const epLive = useDerivedValue(() => {
505
+ 'worklet';
506
+ const c = epCtx.value;
507
+ const mx = (c.handle === 0 ? c.ax : c.bx) + dragX.value;
508
+ const my = (c.handle === 0 ? c.ay : c.by) + dragY.value;
509
+ const hit = c.snapR > 0 ? snapNearest(mx, my, snapTargets.value, c.snapR) : null;
510
+ return hit
511
+ ? { x: hit.x, y: hit.y, snapped: true }
512
+ : { x: mx, y: my, snapped: false };
513
+ });
471
514
  const liveLineP1 = useDerivedValue(() => {
472
515
  'worklet';
473
516
  const c = epCtx.value;
474
- return c.handle === 0
475
- ? { x: c.ax + dragX.value, y: c.ay + dragY.value }
476
- : { x: c.ax, y: c.ay };
517
+ const e = epLive.value;
518
+ return c.handle === 0 ? { x: e.x, y: e.y } : { x: c.ax, y: c.ay };
477
519
  });
478
520
  const liveLineP2 = useDerivedValue(() => {
479
521
  'worklet';
480
522
  const c = epCtx.value;
481
- return c.handle === 1
482
- ? { x: c.bx + dragX.value, y: c.by + dragY.value }
483
- : { x: c.bx, y: c.by };
523
+ const e = epLive.value;
524
+ return c.handle === 1 ? { x: e.x, y: e.y } : { x: c.bx, y: c.by };
484
525
  });
485
526
  // Screen-constant endpoint handle radius: a fixed pixel size divided by the
486
527
  // live zoom (the handles are drawn inside the zoom-scaled world group).
@@ -488,6 +529,45 @@ export const AnnotationCanvasInner = (props) => {
488
529
  'worklet';
489
530
  return HANDLE_RADIUS_PX / zoom.value;
490
531
  });
532
+ // Snap ring (magnetic endpoint snapping). Three sources, in priority order:
533
+ // the UI-thread shape rubber-band, the UI-thread endpoint drag (gated off
534
+ // once the gesture ends or cancels), and the JS-path tools (measurement /
535
+ // pointer-path drags), whose ctx.snap.setIndicator lands in
536
+ // state.snapIndicator and is mirrored into a shared value here. Visibility
537
+ // is an opacity so nothing remounts per frame.
538
+ const jsSnapIndicator = useSharedValue(null);
539
+ useEffect(() => {
540
+ jsSnapIndicator.value = state.snapIndicator;
541
+ }, [state.snapIndicator, jsSnapIndicator]);
542
+ const snapIndicatorC = useDerivedValue(() => {
543
+ 'worklet';
544
+ const sh = liveShape.value;
545
+ if (sh.active && sh.snapped)
546
+ return { x: sh.bx, y: sh.by };
547
+ const e = epLive.value;
548
+ if (e.snapped && !dragEnded.value)
549
+ return { x: e.x, y: e.y };
550
+ const j = jsSnapIndicator.value;
551
+ return j ? { x: j.x, y: j.y } : { x: 0, y: 0 };
552
+ });
553
+ const snapIndicatorOpacity = useDerivedValue(() => {
554
+ 'worklet';
555
+ const sh = liveShape.value;
556
+ if (sh.active && sh.snapped)
557
+ return 1;
558
+ const e = epLive.value;
559
+ if (e.snapped && !dragEnded.value)
560
+ return 1;
561
+ return jsSnapIndicator.value ? 1 : 0;
562
+ });
563
+ const snapRingRadius = useDerivedValue(() => {
564
+ 'worklet';
565
+ return SNAP_RING_PX / zoom.value;
566
+ });
567
+ const snapRingWidth = useDerivedValue(() => {
568
+ 'worklet';
569
+ return SNAP_RING_WIDTH_PX / zoom.value;
570
+ });
491
571
  const handleRingWidth = useDerivedValue(() => {
492
572
  'worklet';
493
573
  return HANDLE_RING_PX / zoom.value;
@@ -899,9 +979,9 @@ export const AnnotationCanvasInner = (props) => {
899
979
  else if (loupeCrossMode === 'endpoint') {
900
980
  // Measurement-line and shape line/arrow endpoint drags both stage
901
981
  // their line in epCtx; the grabbed end follows the finger.
902
- const c = epCtx.value;
903
- wx = (c.handle === 0 ? c.ax : c.bx) + dragX.value;
904
- wy = (c.handle === 0 ? c.ay : c.by) + dragY.value;
982
+ const e = epLive.value;
983
+ wx = e.x;
984
+ wy = e.y;
905
985
  }
906
986
  else if (loupeCrossMode === 'rect-corner') {
907
987
  const c = rectCtx.value;
@@ -1402,9 +1482,29 @@ export const AnnotationCanvasInner = (props) => {
1402
1482
  .maxPointers(1)
1403
1483
  .onBegin((e) => {
1404
1484
  'worklet';
1405
- const wx = e.x / zoom.value + panX.value;
1406
- const wy = e.y / zoom.value + panY.value;
1407
- liveShape.value = { active: true, ax: wx, ay: wy, bx: wx, by: wy };
1485
+ let wx = e.x / zoom.value + panX.value;
1486
+ let wy = e.y / zoom.value + panY.value;
1487
+ // Magnetic snap (line tool only): freeze the doc-space radius for
1488
+ // this drag and snap the START point too — the WORKLET counterpart
1489
+ // of shapeTool's snapStateForPress/snapWorld (targets come from the
1490
+ // doc-synced `snapTargets` effect).
1491
+ const snapR = cfg.kind === 'line' && snapCfg.value.enabled
1492
+ ? snapCfg.value.radiusPx / zoom.value
1493
+ : 0;
1494
+ const hit = snapR > 0 ? snapNearest(wx, wy, snapTargets.value, snapR) : null;
1495
+ if (hit) {
1496
+ wx = hit.x;
1497
+ wy = hit.y;
1498
+ }
1499
+ liveShape.value = {
1500
+ active: true,
1501
+ ax: wx,
1502
+ ay: wy,
1503
+ bx: wx,
1504
+ by: wy,
1505
+ snapR,
1506
+ snapped: hit !== null,
1507
+ };
1408
1508
  magTouchX.value = e.x;
1409
1509
  magTouchY.value = e.y;
1410
1510
  runOnJS(setMagnifying)(true);
@@ -1416,12 +1516,23 @@ export const AnnotationCanvasInner = (props) => {
1416
1516
  return;
1417
1517
  magTouchX.value = e.x;
1418
1518
  magTouchY.value = e.y;
1519
+ let bx = e.x / zoom.value + panX.value;
1520
+ let by = e.y / zoom.value + panY.value;
1521
+ const hit = s.snapR > 0
1522
+ ? snapNearest(bx, by, snapTargets.value, s.snapR)
1523
+ : null;
1524
+ if (hit) {
1525
+ bx = hit.x;
1526
+ by = hit.y;
1527
+ }
1419
1528
  liveShape.value = {
1420
1529
  active: true,
1421
1530
  ax: s.ax,
1422
1531
  ay: s.ay,
1423
- bx: e.x / zoom.value + panX.value,
1424
- by: e.y / zoom.value + panY.value,
1532
+ bx,
1533
+ by,
1534
+ snapR: s.snapR,
1535
+ snapped: hit !== null,
1425
1536
  };
1426
1537
  })
1427
1538
  .onEnd(() => {
@@ -1434,7 +1545,15 @@ export const AnnotationCanvasInner = (props) => {
1434
1545
  const dxPx = (s.bx - s.ax) * zoom.value;
1435
1546
  const dyPx = (s.by - s.ay) * zoom.value;
1436
1547
  if (dxPx * dxPx + dyPx * dyPx < minDragSq) {
1437
- liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
1548
+ liveShape.value = {
1549
+ active: false,
1550
+ ax: 0,
1551
+ ay: 0,
1552
+ bx: 0,
1553
+ by: 0,
1554
+ snapR: 0,
1555
+ snapped: false,
1556
+ };
1438
1557
  return;
1439
1558
  }
1440
1559
  // Hand off in THIS worklet tick (see the stroke onEnd): the live
@@ -1454,14 +1573,30 @@ export const AnnotationCanvasInner = (props) => {
1454
1573
  by: s.by,
1455
1574
  },
1456
1575
  ];
1457
- liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
1576
+ liveShape.value = {
1577
+ active: false,
1578
+ ax: 0,
1579
+ ay: 0,
1580
+ bx: 0,
1581
+ by: 0,
1582
+ snapR: 0,
1583
+ snapped: false,
1584
+ };
1458
1585
  runOnJS(commitShape)(id, { x: s.ax, y: s.ay }, { x: s.bx, y: s.by });
1459
1586
  })
1460
1587
  .onFinalize(() => {
1461
1588
  'worklet';
1462
1589
  // Cancel path (no onEnd): drop the in-flight rubber-band. After a
1463
1590
  // normal end the slot is already inactive, so this is a no-op.
1464
- liveShape.value = { active: false, ax: 0, ay: 0, bx: 0, by: 0 };
1591
+ liveShape.value = {
1592
+ active: false,
1593
+ ax: 0,
1594
+ ay: 0,
1595
+ bx: 0,
1596
+ by: 0,
1597
+ snapR: 0,
1598
+ snapped: false,
1599
+ };
1465
1600
  runOnJS(setMagnifying)(false);
1466
1601
  });
1467
1602
  };
@@ -1487,6 +1622,16 @@ export const AnnotationCanvasInner = (props) => {
1487
1622
  if (handle) {
1488
1623
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
1489
1624
  if (m?.line) {
1625
+ // Magnetic snap: harvest every OTHER element's targets once and
1626
+ // freeze the capture radius in doc space at the grab zoom (the
1627
+ // JS twin is selectTool's snapStateForGrab). Same JS tick as the
1628
+ // epCtx write, so both land in the same UI batch.
1629
+ const snapR = st.ctx.snap?.enabled
1630
+ ? st.ctx.snap.radiusPx / zoomNow
1631
+ : 0;
1632
+ if (snapR > 0) {
1633
+ snapTargets.value = collectSnapTargetsFlat(st.ctx.document, selId);
1634
+ }
1490
1635
  epCtx.value = {
1491
1636
  ax: m.line.a.x,
1492
1637
  ay: m.line.a.y,
@@ -1494,6 +1639,7 @@ export const AnnotationCanvasInner = (props) => {
1494
1639
  by: m.line.b.y,
1495
1640
  t0: m.linePos ?? 0.5,
1496
1641
  handle: handle === 'a' ? 0 : 1,
1642
+ snapR,
1497
1643
  };
1498
1644
  epTargetRef.current = { id: selId, handle };
1499
1645
  setEpDragId(selId);
@@ -1509,6 +1655,12 @@ export const AnnotationCanvasInner = (props) => {
1509
1655
  const a = s?.geometry.points[0];
1510
1656
  const b = s?.geometry.points[1];
1511
1657
  if (a && b) {
1658
+ const snapR = st.ctx.snap?.enabled
1659
+ ? st.ctx.snap.radiusPx / zoomNow
1660
+ : 0;
1661
+ if (snapR > 0) {
1662
+ snapTargets.value = collectSnapTargetsFlat(st.ctx.document, selId);
1663
+ }
1512
1664
  epCtx.value = {
1513
1665
  ax: a.x,
1514
1666
  ay: a.y,
@@ -1516,6 +1668,7 @@ export const AnnotationCanvasInner = (props) => {
1516
1668
  by: b.y,
1517
1669
  t0: 0.5,
1518
1670
  handle: shapeHandle === 'a' ? 0 : 1,
1671
+ snapR,
1519
1672
  };
1520
1673
  shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
1521
1674
  setShapeEpDragId(selId);
@@ -1794,6 +1947,10 @@ export const AnnotationCanvasInner = (props) => {
1794
1947
  dragEnded.value = false;
1795
1948
  dragX.value = 0;
1796
1949
  dragY.value = 0;
1950
+ // A stale snap radius from a previous endpoint drag must not bend
1951
+ // a move/slide's delta in onEnd; beginSelectDrag re-arms it below
1952
+ // (queued after this write) when this grab IS an endpoint.
1953
+ epCtx.value = { ...epCtx.value, snapR: 0 };
1797
1954
  // The loupe (turned on by beginSelectDrag only for precision sub-
1798
1955
  // drags) follows the live finger, not the hit-test origin.
1799
1956
  magTouchX.value = e.x;
@@ -1817,12 +1974,27 @@ export const AnnotationCanvasInner = (props) => {
1817
1974
  .onEnd(() => {
1818
1975
  'worklet';
1819
1976
  dragEnded.value = true;
1820
- runOnJS(endSelectDrag)(dragX.value, dragY.value);
1977
+ let dx = dragX.value;
1978
+ let dy = dragY.value;
1979
+ // Endpoint drag with snapping armed: hand JS the EFFECTIVE delta
1980
+ // (the one landing on the snapped target) — same inputs as epLive,
1981
+ // so the commit matches the live preview and the delta-based patch
1982
+ // builders stay untouched.
1983
+ const c = epCtx.value;
1984
+ if (c.snapR > 0) {
1985
+ const base = c.handle === 0 ? { x: c.ax, y: c.ay } : { x: c.bx, y: c.by };
1986
+ const r = snapDelta(base, { x: dx, y: dy }, snapTargets.value, c.snapR);
1987
+ dx = r.delta.x;
1988
+ dy = r.delta.y;
1989
+ }
1990
+ runOnJS(endSelectDrag)(dx, dy);
1821
1991
  })
1822
1992
  .onFinalize(() => {
1823
1993
  'worklet';
1824
1994
  if (!dragEnded.value)
1825
1995
  runOnJS(cancelSelectDrag)();
1996
+ // Also closes the snap-ring gate on the cancel path.
1997
+ dragEnded.value = true;
1826
1998
  });
1827
1999
  };
1828
2000
  // Plane tool: a hybrid pan. Handle drags run on the UI thread (shared
@@ -2022,6 +2194,8 @@ export const AnnotationCanvasInner = (props) => {
2022
2194
  epCtx,
2023
2195
  resizeCtx,
2024
2196
  rectCtx,
2197
+ snapCfg,
2198
+ snapTargets,
2025
2199
  ]);
2026
2200
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
2027
2201
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
@@ -2099,6 +2273,15 @@ export const AnnotationCanvasInner = (props) => {
2099
2273
  shapeEndpointDragId: shapeEpDragId,
2100
2274
  liveLineP1,
2101
2275
  liveLineP2,
2276
+ // Magnetic-snap ring; rides into the loupe pass via the spread below.
2277
+ snapIndicator: snapToGeometry
2278
+ ? {
2279
+ c: snapIndicatorC,
2280
+ r: snapRingRadius,
2281
+ ringWidth: snapRingWidth,
2282
+ opacity: snapIndicatorOpacity,
2283
+ }
2284
+ : null,
2102
2285
  bgCropId,
2103
2286
  liveBgCrop: {
2104
2287
  x: liveBgCropX,
@@ -2157,7 +2340,7 @@ export const AnnotationCanvasInner = (props) => {
2157
2340
  // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
2158
2341
  _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [backgroundLayers.map((layer) => (_jsx(BackgroundSkImageLoader, { layer: layer, resolveUrl: resolveImageUrl, deferToResolver: deferBackgroundToResolver, onImage: onBackgroundSkImage }, layer.id))), _jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
2159
2342
  ? (state.measurementsById.get(placed.measurementId) ?? null)
2160
- : (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, planeEdgeDragging: planeDrag != null && placed.planeId === planeDrag.planeId, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, planeTileCtx: planeTileCtx, planeDragTX: planeDragTX, planeDragTY: planeDragTY, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
2343
+ : (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, planeEdgeDragging: planeDrag != null && placed.planeId === planeDrag.planeId, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, epLive: epLive, rectCtx: rectCtx, planeTileCtx: planeTileCtx, planeDragTX: planeDragTX, planeDragTY: planeDragTY, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, headerTileScaleFactor: state.headerTileScaleFactor, fileTileScaleFactor: state.fileTileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
2161
2344
  ? () => {
2162
2345
  const defaultRemove = () => {
2163
2346
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
@@ -2184,7 +2367,7 @@ export const AnnotationCanvasInner = (props) => {
2184
2367
  measurementLineScale: 1 / LOUPE_MAGNIFICATION,
2185
2368
  }) }))] }));
2186
2369
  };
2187
- const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
2370
+ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, epLive, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
2188
2371
  // Square tile or wide group-header pill — one footprint source of truth
2189
2372
  // shared with the hit-test (stampTileDims).
2190
2373
  const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale, headerTileScaleFactor ?? tileScaleFactor, fileTileScaleFactor ?? tileScaleFactor);
@@ -2260,13 +2443,15 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
2260
2443
  worldY = c.ay + aby * t;
2261
2444
  }
2262
2445
  else if (endpointDragging) {
2263
- // Tile stays at its linePos along the LIVE line: fold the drag delta into
2264
- // the moving endpoint (handle 0 = a, 1 = b), then lerp at t0.
2446
+ // Tile stays at its linePos along the LIVE line: the moving endpoint
2447
+ // (handle 0 = a, 1 = b) comes from epLive — already drag-offset and
2448
+ // magnetically snapped — then lerp at t0.
2265
2449
  const c = epCtx.value;
2266
- const ax = c.handle === 0 ? c.ax + dragX.value : c.ax;
2267
- const ay = c.handle === 0 ? c.ay + dragY.value : c.ay;
2268
- const bx = c.handle === 1 ? c.bx + dragX.value : c.bx;
2269
- const by = c.handle === 1 ? c.by + dragY.value : c.by;
2450
+ const e = epLive.value;
2451
+ const ax = c.handle === 0 ? e.x : c.ax;
2452
+ const ay = c.handle === 0 ? e.y : c.ay;
2453
+ const bx = c.handle === 1 ? e.x : c.bx;
2454
+ const by = c.handle === 1 ? e.y : c.by;
2270
2455
  worldX = ax + (bx - ax) * c.t0;
2271
2456
  worldY = ay + (by - ay) * c.t0;
2272
2457
  }
@@ -111,6 +111,12 @@ export interface AnnotationCanvasSkiaProps {
111
111
  handleRingWidth?: number | {
112
112
  value: number;
113
113
  };
114
+ snapIndicator?: {
115
+ c: AnimatedPoint;
116
+ r: AnimatedNumber;
117
+ ringWidth: AnimatedNumber;
118
+ opacity?: AnimatedNumber;
119
+ } | null;
114
120
  showPlaneHandles?: boolean;
115
121
  planeDragId?: string | null;
116
122
  planeDragIndex?: number | null;
@@ -127,5 +133,5 @@ export interface AnnotationCanvasSkiaProps {
127
133
  wrapContent?: (content: ReactNode) => ReactNode;
128
134
  canvasRef?: RefObject<CanvasRef | null>;
129
135
  }
130
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale, measurementLineScale, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
136
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, snapIndicator, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale, measurementLineScale, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
131
137
  export {};
@@ -30,6 +30,10 @@ const MEASUREMENT_HANDLE_COLOR = '#0066FF';
30
30
  // hit-test the text resize handle, which sits on the padded box corner.
31
31
  const SELECTION_COLOR = '#0066FF';
32
32
  const SELECTION_STROKE = 1.5;
33
+ // Snap ring (magnetic endpoint snapping, see snapGeometry.ts): drawn around
34
+ // the target the dragged endpoint is currently locked onto. Orange so it reads
35
+ // against both the blue selection chrome and the yellow lock accent.
36
+ const SNAP_INDICATOR_COLOR = '#FF7A00';
33
37
  // Outline of a selected LOCKED background layer: the lock accent (the same
34
38
  // yellow the selected group-header border uses), distinct from the blue
35
39
  // manipulable-selection chrome — locked means "selected, but pinned".
@@ -60,6 +64,11 @@ const getLinkedGlyphPath = () => (linkedGlyphPath ?? (linkedGlyphPath = Skia.Pat
60
64
  // a solid-colored handle would camouflage into. `c`/`r`/`ringWidth` may be
61
65
  // Reanimated values (native live-drag) or plain numbers (web).
62
66
  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 })] }));
67
+ // The snap indicator: a stroke-only ring (NOT a Handle — its white disc would
68
+ // hide the very pixel being snapped to). Screen-constant like the handles;
69
+ // `opacity` lets the native UI-thread path show/hide it without remounting
70
+ // (a zero radius still paints a strokeWidth-sized dot, so radius can't gate it).
71
+ const SnapRing = ({ c, r, ringWidth, opacity = 1, }) => (_jsx(Circle, { c: c, r: r, color: SNAP_INDICATOR_COLOR, style: "stroke", strokeWidth: ringWidth, opacity: opacity }));
63
72
  // Bounds of a flat [x,y,x,y,…] stroke point array, or null if empty.
64
73
  const strokeBounds = (points) => {
65
74
  if (points.length < 2)
@@ -105,7 +114,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
105
114
  // since the function-call pattern works identically on native we use it
106
115
  // in both Inners for consistency. Don't add hooks here; this is a plain
107
116
  // JSX-returning helper, not a component.
108
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
117
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, hideLinkBadges, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, bgCropId, liveBgCrop, liveBgCropClip, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, snapIndicator, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, measurementLineScale = 1, customPreview, wrapContent, canvasRef, }) => {
109
118
  // Doc rect a SCALE-mode plane grid covers: the first background layer (the
110
119
  // photo being calibrated), else the document rect. Pinned to layer 0 like
111
120
  // the tile-sizing reference — adding a second image must not re-span the
@@ -340,6 +349,6 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
340
349
  corners.map((c, i) => (_jsx(Handle, { c: c, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }, i)))] }));
341
350
  }
342
351
  return null;
343
- })(), 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] }));
352
+ })(), 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) })) })), snapIndicator && (_jsx(SnapRing, { c: snapIndicator.c, r: snapIndicator.r, ringWidth: snapIndicator.ringWidth, opacity: snapIndicator.opacity })), customPreview] }));
344
353
  return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
345
354
  };
@@ -19,6 +19,11 @@ export interface CanvasPointerEvent {
19
19
  metaKey?: boolean;
20
20
  ctrlKey?: boolean;
21
21
  }
22
+ export interface SnapContext {
23
+ enabled: boolean;
24
+ radiusPx: number;
25
+ setIndicator(point: Vec2 | null): void;
26
+ }
22
27
  export interface ToolContext {
23
28
  document: AnnotationCanvasState;
24
29
  selection: Selection | null;
@@ -27,6 +32,7 @@ export interface ToolContext {
27
32
  tileScaleFactor: number;
28
33
  headerTileScaleFactor: number;
29
34
  fileTileScaleFactor: number;
35
+ snap?: SnapContext | null;
30
36
  preview(patch: AnnotationDocumentPatch): void;
31
37
  commit(patch: AnnotationDocumentPatch): void;
32
38
  setSelection(selection: Selection | null): void;
@@ -0,0 +1,32 @@
1
+ import type { AnnotationCanvasState, AnnotationElementId, Vec2 } from '../../types/annotation.js';
2
+ export declare const SNAP_RADIUS_PX = 16;
3
+ export declare const SNAP_RING_PX = 11;
4
+ export declare const SNAP_RING_WIDTH_PX = 2;
5
+ export declare const SNAP_KIND_POINT = 0;
6
+ export declare const SNAP_KIND_MIDPOINT = 1;
7
+ export type SnapKind = 0 | 1;
8
+ export declare const SNAP_STRIDE = 3;
9
+ export interface SnapTarget {
10
+ x: number;
11
+ y: number;
12
+ kind: SnapKind;
13
+ sourceId: AnnotationElementId;
14
+ }
15
+ export interface SnapHit {
16
+ x: number;
17
+ y: number;
18
+ kind: SnapKind;
19
+ index: number;
20
+ }
21
+ export declare const collectSnapTargets: (doc: AnnotationCanvasState, excludeId?: AnnotationElementId | null) => SnapTarget[];
22
+ export declare const encodeSnapTargets: (targets: readonly SnapTarget[]) => number[];
23
+ export declare const collectSnapTargetsFlat: (doc: AnnotationCanvasState, excludeId?: AnnotationElementId | null) => number[];
24
+ export declare const snapNearest: (x: number, y: number, flat: readonly number[], radiusDoc: number) => SnapHit | null;
25
+ export declare const snapDelta: (base: Vec2, delta: Vec2, flat: readonly number[], radiusDoc: number) => {
26
+ delta: Vec2;
27
+ hit: SnapHit | null;
28
+ };
29
+ export declare const snapPoint: (p: Vec2, flat: readonly number[], radiusDoc: number) => {
30
+ point: Vec2;
31
+ hit: SnapHit | null;
32
+ };