@reekon-tools/boldr-utils 1.19.6 → 1.20.1

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.
@@ -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
+ };
@@ -0,0 +1,168 @@
1
+ // Magnetic snapping for line endpoints (Asana 1217966180270874). While an
2
+ // endpoint of a measurement line (`placement: 'line'`) or a line/arrow shape is
3
+ // being placed or dragged, it snaps to nearby geometry: other lines' endpoints
4
+ // and midpoints, polygon/rect vertices, and measurement-rectangle corners.
5
+ // Skia-free, safe in tools, hit-tests and unit tests.
6
+ //
7
+ // `snapNearest`/`snapDelta` carry 'worklet' directives: the native Inner calls
8
+ // them per frame on the UI thread (the live endpoint derived values and the
9
+ // shape rubber-band gesture) — the same mechanism planeGeometry's grid
10
+ // generators rely on. Keep them free of closures over non-worklet module
11
+ // functions (numeric constants are fine) so the workletizer can lift them.
12
+ //
13
+ // Targets travel to the UI thread as a FLAT number array (`[x, y, kind, …]`,
14
+ // stride SNAP_STRIDE) written once per drag — see `encodeSnapTargets`.
15
+ import { placementOf, normalizeRect } from './measurementGeometry.js';
16
+ // Screen-px capture radius: the grabbed endpoint jumps to a target once it is
17
+ // within this many pixels of it. Converted to doc space via `/ zoom` at drag
18
+ // begin (frozen for the drag, like the slide detent's SLIDE_SNAP_PX).
19
+ export const SNAP_RADIUS_PX = 16;
20
+ // Screen-px radius / ring width of the snap indicator drawn around the target
21
+ // while a snap is active. Larger than the endpoint handle (HANDLE_RADIUS_PX 7)
22
+ // so the ring frames the handle instead of hiding under it.
23
+ export const SNAP_RING_PX = 11;
24
+ export const SNAP_RING_WIDTH_PX = 2;
25
+ // Target kinds, ordered by priority: on an exact distance tie the LOWER kind
26
+ // wins, so a true endpoint/vertex beats a midpoint sitting on the same pixel.
27
+ export const SNAP_KIND_POINT = 0;
28
+ export const SNAP_KIND_MIDPOINT = 1;
29
+ // Flat encoding stride: [x, y, kind].
30
+ export const SNAP_STRIDE = 3;
31
+ // Every snap target in the document, in doc space. `excludeId` drops the
32
+ // element being edited ENTIRELY (its endpoints and midpoint) — snapping a
33
+ // line's endpoint onto its own other end would collapse it, and onto its own
34
+ // midpoint would halve it. Targets:
35
+ // - measurement `placement:'line'`: a, b (point) + midpoint. Plane-edge
36
+ // tiles included — their endpoints are real geometry even though the
37
+ // select tool refuses to drag them.
38
+ // - measurement `placement:'rectangle'`: the four normalized corners.
39
+ // - shape line/arrow: both endpoints + midpoint.
40
+ // - shape rect: the four normalized corners (stored as two opposite corners).
41
+ // - shape polygon (triangles included): every vertex.
42
+ // - ellipse, text, strokes, backgrounds: nothing.
43
+ // Degenerate (zero-length) lines contribute their coincident endpoints but no
44
+ // midpoint.
45
+ export const collectSnapTargets = (doc, excludeId) => {
46
+ const out = [];
47
+ const point = (p, sourceId) => out.push({ x: p.x, y: p.y, kind: SNAP_KIND_POINT, sourceId });
48
+ const segment = (a, b, sourceId) => {
49
+ point(a, sourceId);
50
+ point(b, sourceId);
51
+ if (a.x !== b.x || a.y !== b.y) {
52
+ out.push({
53
+ x: (a.x + b.x) / 2,
54
+ y: (a.y + b.y) / 2,
55
+ kind: SNAP_KIND_MIDPOINT,
56
+ sourceId,
57
+ });
58
+ }
59
+ };
60
+ const corners = (r, sourceId) => {
61
+ const n = normalizeRect(r);
62
+ point({ x: n.minX, y: n.minY }, sourceId);
63
+ point({ x: n.maxX, y: n.minY }, sourceId);
64
+ point({ x: n.maxX, y: n.maxY }, sourceId);
65
+ point({ x: n.minX, y: n.maxY }, sourceId);
66
+ };
67
+ for (const m of doc.placedMeasurements) {
68
+ if (excludeId != null && m.id === excludeId)
69
+ continue;
70
+ const placement = placementOf(m);
71
+ if (placement === 'line' && m.line)
72
+ segment(m.line.a, m.line.b, m.id);
73
+ else if (placement === 'rectangle' && m.rect)
74
+ corners(m.rect, m.id);
75
+ }
76
+ for (const s of doc.shapes) {
77
+ if (excludeId != null && s.id === excludeId)
78
+ continue;
79
+ const pts = s.geometry.points;
80
+ switch (s.kind) {
81
+ case 'line':
82
+ case 'arrow': {
83
+ const [a, b] = pts;
84
+ if (a && b)
85
+ segment(a, b, s.id);
86
+ break;
87
+ }
88
+ case 'rect': {
89
+ const [a, b] = pts;
90
+ if (a && b)
91
+ corners({ a, b }, s.id);
92
+ break;
93
+ }
94
+ case 'polygon':
95
+ for (const p of pts)
96
+ point(p, s.id);
97
+ break;
98
+ default:
99
+ break;
100
+ }
101
+ }
102
+ return out;
103
+ };
104
+ // Flat `[x, y, kind, …]` buffer for UI-thread transfer (a plain number[],
105
+ // like the freehand `livePoints` — TypedArrays aren't proven shareable here).
106
+ export const encodeSnapTargets = (targets) => {
107
+ const flat = new Array(targets.length * SNAP_STRIDE);
108
+ for (let i = 0; i < targets.length; i++) {
109
+ const t = targets[i];
110
+ flat[i * SNAP_STRIDE] = t.x;
111
+ flat[i * SNAP_STRIDE + 1] = t.y;
112
+ flat[i * SNAP_STRIDE + 2] = t.kind;
113
+ }
114
+ return flat;
115
+ };
116
+ export const collectSnapTargetsFlat = (doc, excludeId) => encodeSnapTargets(collectSnapTargets(doc, excludeId));
117
+ // Nearest target to (x, y) within `radiusDoc` (inclusive), or null. Ties on
118
+ // distance go to the lower kind (point beats midpoint); remaining ties to the
119
+ // earlier target. `radiusDoc <= 0` disables snapping. Worklet-callable.
120
+ export const snapNearest = (x, y, flat, radiusDoc) => {
121
+ 'worklet';
122
+ if (radiusDoc <= 0)
123
+ return null;
124
+ const r2 = radiusDoc * radiusDoc;
125
+ let bestD2 = Infinity;
126
+ let bestKind = Infinity;
127
+ let bestIdx = -1;
128
+ for (let i = 0; i + 2 < flat.length; i += 3) {
129
+ const dx = flat[i] - x;
130
+ const dy = flat[i + 1] - y;
131
+ const d2 = dx * dx + dy * dy;
132
+ if (d2 > r2)
133
+ continue;
134
+ const kind = flat[i + 2];
135
+ if (d2 < bestD2 || (d2 === bestD2 && kind < bestKind)) {
136
+ bestD2 = d2;
137
+ bestKind = kind;
138
+ bestIdx = i;
139
+ }
140
+ }
141
+ if (bestIdx < 0)
142
+ return null;
143
+ return {
144
+ x: flat[bestIdx],
145
+ y: flat[bestIdx + 1],
146
+ kind: flat[bestIdx + 2] === 1 ? 1 : 0,
147
+ index: bestIdx / 3,
148
+ };
149
+ };
150
+ // Snap a DRAG: `base` is the grabbed endpoint's committed position and `delta`
151
+ // the raw world-space drag offset. Returns the effective delta that lands the
152
+ // endpoint exactly on the nearest target (or the raw delta when nothing is in
153
+ // range), so the existing delta-based patch builders stay untouched. Worklet-
154
+ // callable — the native select gesture runs it in onEnd so the commit matches
155
+ // the live preview bit-for-bit.
156
+ export const snapDelta = (base, delta, flat, radiusDoc) => {
157
+ 'worklet';
158
+ const hit = snapNearest(base.x + delta.x, base.y + delta.y, flat, radiusDoc);
159
+ if (!hit)
160
+ return { delta, hit: null };
161
+ return { delta: { x: hit.x - base.x, y: hit.y - base.y }, hit };
162
+ };
163
+ // Snap an ABSOLUTE point (drag-to-create tools place raw world points).
164
+ export const snapPoint = (p, flat, radiusDoc) => {
165
+ 'worklet';
166
+ const hit = snapNearest(p.x, p.y, flat, radiusDoc);
167
+ return hit ? { point: { x: hit.x, y: hit.y }, hit } : { point: p, hit: null };
168
+ };
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
2
  import { DEFAULT_LINE_POS, recomputeAnchor, rectCenter, } from '../measurementGeometry.js';
3
+ import { collectSnapTargetsFlat, snapPoint } from '../snapGeometry.js';
3
4
  // The `line*` style fields for a placed measurement, built from the tool's
4
5
  // style options. Undefined fields are dropped (never emitted) so the render
5
6
  // defaults apply and Firestore never sees an `undefined` value.
@@ -22,6 +23,25 @@ const lineStyleFields = (placement, style) => {
22
23
  }
23
24
  return out;
24
25
  };
26
+ // Snap state for a fresh line press. The new annotation isn't in the doc yet,
27
+ // so nothing needs excluding. Empty when snapping is off.
28
+ const snapStateForPress = (ctx, placement) => placement === 'line' && ctx.snap?.enabled
29
+ ? {
30
+ snap: {
31
+ targets: collectSnapTargetsFlat(ctx.document),
32
+ radiusDoc: ctx.snap.radiusPx / ctx.viewport.state.zoom,
33
+ },
34
+ }
35
+ : {};
36
+ // The world point a drag end should use: snapped when a target is in range.
37
+ // Reports the target (or null) to the indicator.
38
+ const snapEnd = (s, ctx, world) => {
39
+ if (!s.snap)
40
+ return world;
41
+ const r = snapPoint(world, s.snap.targets, s.snap.radiusDoc);
42
+ ctx.snap?.setIndicator(r.hit ? { x: r.hit.x, y: r.hit.y } : null);
43
+ return r.point;
44
+ };
25
45
  let counter = 0;
26
46
  const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
27
47
  const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
@@ -130,13 +150,21 @@ export const createMeasurementTool = (options = {}) => {
130
150
  id: options.id ?? defaultId,
131
151
  label: options.label ?? defaultLabel,
132
152
  cursor: 'crosshair',
133
- onPointerDown(event) {
153
+ onPointerDown(event, ctx) {
154
+ const snap = snapStateForPress(ctx, placement);
155
+ // The START of a new line snaps too (a leader that begins exactly on a
156
+ // corner is the point of the feature); the ring flashes there until the
157
+ // first move re-evaluates it against the moving end.
158
+ const startWorld = snap.snap
159
+ ? snapPoint(event.world, snap.snap.targets, snap.snap.radiusDoc).point
160
+ : event.world;
134
161
  return {
135
162
  kind: 'measurement-drawing',
136
163
  id: makeId(),
137
- startWorld: event.world,
164
+ startWorld,
138
165
  startScreen: event.screen,
139
166
  moved: false,
167
+ ...snap,
140
168
  };
141
169
  },
142
170
  onPointerMove(event, ctx, state) {
@@ -149,7 +177,7 @@ export const createMeasurementTool = (options = {}) => {
149
177
  placement,
150
178
  linePos,
151
179
  a: s.startWorld,
152
- b: event.world,
180
+ b: snapEnd(s, ctx, event.world),
153
181
  style,
154
182
  valueSource: valueSourceFor(ctx),
155
183
  });
@@ -160,6 +188,7 @@ export const createMeasurementTool = (options = {}) => {
160
188
  const s = state;
161
189
  if (s?.kind !== 'measurement-drawing')
162
190
  return;
191
+ ctx.snap?.setIndicator(null);
163
192
  const dx = event.screen.x - s.startScreen.x;
164
193
  const dy = event.screen.y - s.startScreen.y;
165
194
  if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
@@ -167,18 +196,23 @@ export const createMeasurementTool = (options = {}) => {
167
196
  ctx.preview({ ops: [] });
168
197
  return;
169
198
  }
199
+ // Same snap as the last move, so the commit equals the preview.
200
+ const b = s.snap
201
+ ? snapPoint(event.world, s.snap.targets, s.snap.radiusDoc).point
202
+ : event.world;
170
203
  place(ctx, buildMeasurement({
171
204
  id: s.id,
172
205
  layerId: firstLayerId(ctx.document),
173
206
  placement,
174
207
  linePos,
175
208
  a: s.startWorld,
176
- b: event.world,
209
+ b,
177
210
  style,
178
211
  valueSource: valueSourceFor(ctx),
179
212
  }));
180
213
  },
181
214
  onCancel(_state, ctx) {
215
+ ctx.snap?.setIndicator(null);
182
216
  ctx.preview({ ops: [] });
183
217
  },
184
218
  };
@@ -2,6 +2,7 @@ import { MAX_BACKGROUND_DOC_EXTENT, MIN_BACKGROUND_DOC_EXTENT, backgroundCropBar
2
2
  import { stampTileDims } from '../stampLayout.js';
3
3
  import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
4
4
  import { hitShapeOutline, visualShapeBounds } from '../shapeGeometry.js';
5
+ import { collectSnapTargetsFlat, snapDelta } from '../snapGeometry.js';
5
6
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
6
7
  import { editTextShape } from './textEditing.js';
7
8
  const HIT_PADDING = 6;
@@ -238,6 +239,32 @@ const slidePatch = (doc, id, delta, zoom) => {
238
239
  ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
239
240
  };
240
241
  };
242
+ // Committed position of the grabbed endpoint — the base the drag delta (and
243
+ // the snap) is measured from. Measurement lines keep endpoints on `line.a/b`,
244
+ // shape lines on `geometry.points[0/1]`.
245
+ const endpointBase = (doc, id, elementKind, handle) => {
246
+ if (elementKind === 'shape') {
247
+ const sh = doc.shapes.find((x) => x.id === id);
248
+ const p = sh?.geometry.points[handle === 'a' ? 0 : 1];
249
+ return p ?? null;
250
+ }
251
+ const m = doc.placedMeasurements.find((x) => x.id === id);
252
+ if (!m?.line)
253
+ return null;
254
+ return handle === 'a' ? m.line.a : m.line.b;
255
+ };
256
+ // Snap bookkeeping for an endpoint grab: harvest every OTHER element's targets
257
+ // once (ctx.document is the committed doc, so in-flight previews never leak
258
+ // in) and freeze the capture radius in doc space at the grab zoom. Empty when
259
+ // snapping is off, so the drag stays a plain delta.
260
+ const snapStateForGrab = (ctx, excludeId, zoom) => ctx.snap?.enabled
261
+ ? {
262
+ snap: {
263
+ targets: collectSnapTargetsFlat(ctx.document, excludeId),
264
+ radiusDoc: ctx.snap.radiusPx / zoom,
265
+ },
266
+ }
267
+ : {};
241
268
  // Which endpoint handle of a (selected) line annotation is under `world`.
242
269
  // Prefers the nearer endpoint when both are within range.
243
270
  const findHandleHit = (doc, id, world, zoom) => {
@@ -777,6 +804,7 @@ export const createSelectTool = (options = {}) => ({
777
804
  handle,
778
805
  start: world,
779
806
  delta: { x: 0, y: 0 },
807
+ ...snapStateForGrab(ctx, selId, zoom),
780
808
  };
781
809
  }
782
810
  const shapeHandle = findShapeHandleHit(ctx.document, selId, world, zoom);
@@ -790,6 +818,7 @@ export const createSelectTool = (options = {}) => ({
790
818
  handle: shapeHandle,
791
819
  start: world,
792
820
  delta: { x: 0, y: 0 },
821
+ ...snapStateForGrab(ctx, selId, zoom),
793
822
  };
794
823
  }
795
824
  const resizeGeom = findResizeHandleHit(ctx.document, selId, world, zoom);
@@ -935,7 +964,7 @@ export const createSelectTool = (options = {}) => ({
935
964
  }
936
965
  if (s?.kind !== 'dragging')
937
966
  return s;
938
- const delta = {
967
+ let delta = {
939
968
  x: event.world.x - s.start.x,
940
969
  y: event.world.y - s.start.y,
941
970
  };
@@ -945,12 +974,24 @@ export const createSelectTool = (options = {}) => ({
945
974
  pendingTextEditId = null;
946
975
  pendingBackgroundDeselectId = null;
947
976
  }
977
+ // Magnetic endpoint snap: bend the delta so the grabbed end lands on the
978
+ // nearest target in range, and show the ring there. The snapped delta is
979
+ // what gets stored, so onPointerUp's commit reuses it unchanged.
980
+ if (s.mode === 'endpoint' && s.snap && s.handle) {
981
+ const base = endpointBase(ctx.document, s.id, s.elementKind, s.handle);
982
+ if (base) {
983
+ const r = snapDelta(base, delta, s.snap.targets, s.snap.radiusDoc);
984
+ delta = r.delta;
985
+ ctx.snap?.setIndicator(r.hit ? { x: r.hit.x, y: r.hit.y } : null);
986
+ }
987
+ }
948
988
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
949
989
  if (patch)
950
990
  ctx.preview(patch);
951
991
  return { ...s, delta };
952
992
  },
953
993
  onPointerUp(_event, ctx, state) {
994
+ ctx.snap?.setIndicator(null);
954
995
  const editId = pendingTextEditId;
955
996
  pendingTextEditId = null;
956
997
  const deselectId = pendingBackgroundDeselectId;
@@ -984,6 +1025,7 @@ export const createSelectTool = (options = {}) => ({
984
1025
  onCancel(_state, ctx) {
985
1026
  pendingTextEditId = null;
986
1027
  pendingBackgroundDeselectId = null;
1028
+ ctx.snap?.setIndicator(null);
987
1029
  ctx.preview({ ops: [] });
988
1030
  },
989
1031
  hitTest(element, p) {
@@ -1,5 +1,15 @@
1
1
  import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
2
  import { annotationKindFor, shapePointsFromDrag, } from '../shapeGeometry.js';
3
+ import { collectSnapTargetsFlat, snapPoint } from '../snapGeometry.js';
4
+ const snapStateForPress = (ctx, kind) => kind === 'line' && ctx.snap?.enabled
5
+ ? {
6
+ snap: {
7
+ targets: collectSnapTargetsFlat(ctx.document),
8
+ radiusDoc: ctx.snap.radiusPx / ctx.viewport.state.zoom,
9
+ },
10
+ }
11
+ : {};
12
+ const snapWorld = (s, world) => s.snap ? snapPoint(world, s.snap.targets, s.snap.radiusDoc) : null;
3
13
  let counter = 0;
4
14
  const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
15
  const DEFAULT_LABELS = {
@@ -61,12 +71,14 @@ export const createShapeTool = (options = {}) => {
61
71
  dash,
62
72
  },
63
73
  onPointerDown(event, ctx) {
74
+ const snap = snapStateForPress(ctx, kind);
75
+ const startWorld = snapWorld(snap, event.world)?.point ?? event.world;
64
76
  return {
65
77
  kind: 'shape-drawing',
66
78
  shape: buildShapeFromDrag({
67
79
  kind,
68
- a: event.world,
69
- b: event.world,
80
+ a: startWorld,
81
+ b: startWorld,
70
82
  color,
71
83
  width,
72
84
  cap,
@@ -74,22 +86,28 @@ export const createShapeTool = (options = {}) => {
74
86
  dash,
75
87
  layerId: firstLayerId(ctx.document),
76
88
  }),
77
- startWorld: event.world,
89
+ startWorld,
78
90
  startScreen: event.screen,
79
91
  moved: false,
92
+ ...snap,
80
93
  };
81
94
  },
82
95
  onPointerMove(event, ctx, state) {
83
96
  const s = state;
84
97
  if (s?.kind !== 'shape-drawing')
85
98
  return s;
99
+ const snapped = snapWorld(s, event.world);
100
+ if (s.snap) {
101
+ ctx.snap?.setIndicator(snapped?.hit ? { x: snapped.hit.x, y: snapped.hit.y } : null);
102
+ }
103
+ const end = snapped?.point ?? event.world;
86
104
  const next = {
87
105
  ...s,
88
106
  moved: true,
89
107
  shape: {
90
108
  ...s.shape,
91
109
  geometry: {
92
- points: shapePointsFromDrag(kind, s.startWorld, event.world),
110
+ points: shapePointsFromDrag(kind, s.startWorld, end),
93
111
  },
94
112
  },
95
113
  };
@@ -100,6 +118,7 @@ export const createShapeTool = (options = {}) => {
100
118
  const s = state;
101
119
  if (s?.kind !== 'shape-drawing')
102
120
  return;
121
+ ctx.snap?.setIndicator(null);
103
122
  const dx = event.screen.x - s.startScreen.x;
104
123
  const dy = event.screen.y - s.startScreen.y;
105
124
  if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
@@ -107,15 +126,18 @@ export const createShapeTool = (options = {}) => {
107
126
  ctx.preview({ ops: [] });
108
127
  return;
109
128
  }
129
+ // Same snap as the last move, so the commit equals the preview.
130
+ const end = snapWorld(s, event.world)?.point ?? event.world;
110
131
  const shape = {
111
132
  ...s.shape,
112
133
  geometry: {
113
- points: shapePointsFromDrag(kind, s.startWorld, event.world),
134
+ points: shapePointsFromDrag(kind, s.startWorld, end),
114
135
  },
115
136
  };
116
137
  ctx.commit({ ops: [{ op: 'addShape', shape }] });
117
138
  },
118
139
  onCancel(_state, ctx) {
140
+ ctx.snap?.setIndicator(null);
119
141
  ctx.preview({ ops: [] });
120
142
  },
121
143
  };
@@ -64,6 +64,7 @@ export interface UseAnnotationCanvasStateProps {
64
64
  measurements?: Measurement[];
65
65
  pickMeasurement?: () => Promise<MeasurementRef | null>;
66
66
  requestTextInput?: RequestTextInput;
67
+ snapToGeometry?: boolean;
67
68
  width: number;
68
69
  height: number;
69
70
  initialViewport?: ViewportState;
@@ -94,6 +95,7 @@ export interface AnnotationCanvasStateApi {
94
95
  fileTileScaleFactor: number;
95
96
  penDrawingStroke: AnnotationStroke | null;
96
97
  customPreviewState: ToolState;
98
+ snapIndicator: Vec2 | null;
97
99
  dispatchPointerDown(event: CanvasPointerEvent): void;
98
100
  dispatchPointerMove(event: CanvasPointerEvent): void;
99
101
  dispatchPointerUp(event: CanvasPointerEvent): void;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
3
  import { resolveHeaderTileScaleFactor, resolveFileTileScaleFactor, resolveTileScaleFactor, stampTileDims, STAMP_TILE_SIZE, tileDocSizeForFactor, tileScaleFromDocSize, } from './stampLayout.js';
4
+ import { SNAP_RADIUS_PX } from './snapGeometry.js';
4
5
  import { createViewportApi, fitRectToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, DEFAULT_ZOOM_LIMITS, } from './viewport.js';
5
6
  import { computeZoomFloor, pdfFirstPageDocRect } from './pdfDocument.js';
6
7
  import { backgroundLayerDocRect, backgroundLayersOf, backgroundLayersUnionRect, } from './backgroundLayers.js';
@@ -113,7 +114,7 @@ const defaultLeaderLenDoc = (docWidth) => Math.min(800, Math.max(240, docWidth *
113
114
  // inners share this hook; each wraps it with platform-specific event
114
115
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
115
116
  export const useAnnotationCanvasState = (props) => {
116
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
117
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, snapToGeometry, width, height, initialViewport, tileScalePlatform, animateViewport, imperativeRef, } = props;
117
118
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
118
119
  const [toolState, setToolState] = useState(undefined);
119
120
  // Synchronous mirror for the DISPATCHERS. The native tap gesture
@@ -128,6 +129,8 @@ export const useAnnotationCanvasState = (props) => {
128
129
  toolStateRef.current = next;
129
130
  setToolState(next);
130
131
  };
132
+ // Snap ring position for JS-path drags (see AnnotationCanvasStateApi).
133
+ const [snapIndicator, setSnapIndicator] = useState(null);
131
134
  const [previewPatch, setPreviewPatch] = useState(null);
132
135
  const undoStackRef = useRef([]);
133
136
  const redoStackRef = useRef([]);
@@ -228,6 +231,11 @@ export const useAnnotationCanvasState = (props) => {
228
231
  tileScaleFactor,
229
232
  headerTileScaleFactor,
230
233
  fileTileScaleFactor,
234
+ snap: {
235
+ enabled: snapToGeometry === true,
236
+ radiusPx: SNAP_RADIUS_PX,
237
+ setIndicator: setSnapIndicator,
238
+ },
231
239
  preview(patch) {
232
240
  setPreviewPatch(patch);
233
241
  },
@@ -328,6 +336,7 @@ export const useAnnotationCanvasState = (props) => {
328
336
  onSelectionChange,
329
337
  pickMeasurement,
330
338
  requestTextInput,
339
+ snapToGeometry,
331
340
  ]);
332
341
  // Live ctx for imperative handle methods (which are created in an effect and
333
342
  // would otherwise capture a stale ctx/viewport).
@@ -353,6 +362,7 @@ export const useAnnotationCanvasState = (props) => {
353
362
  activePointerIdRef.current = null;
354
363
  updateToolState(undefined);
355
364
  setPreviewPatch(null);
365
+ setSnapIndicator(null);
356
366
  // updateToolState is a stable-in-effect helper (ref + setState); the
357
367
  // effect only re-runs on a tool identity change.
358
368
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -390,6 +400,9 @@ export const useAnnotationCanvasState = (props) => {
390
400
  activeTool.onPointerUp?.(event, ctx, toolStateRef.current);
391
401
  activePointerIdRef.current = null;
392
402
  updateToolState(undefined);
403
+ // Belt-and-braces: tools clear their own ring on release, but a tool
404
+ // that forgets must not leave a stale ring on the canvas.
405
+ setSnapIndicator(null);
393
406
  },
394
407
  // eslint-disable-next-line react-hooks/exhaustive-deps
395
408
  [activeTool, ctx]);
@@ -410,6 +423,7 @@ export const useAnnotationCanvasState = (props) => {
410
423
  const prevToolState = toolStateRef.current;
411
424
  updateToolState(undefined);
412
425
  setPreviewPatch(null);
426
+ setSnapIndicator(null);
413
427
  if (activeTool)
414
428
  activeTool.onCancel?.(prevToolState, ctx);
415
429
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -903,6 +917,7 @@ export const useAnnotationCanvasState = (props) => {
903
917
  fileTileScaleFactor,
904
918
  penDrawingStroke,
905
919
  customPreviewState: toolState,
920
+ snapIndicator,
906
921
  dispatchPointerDown,
907
922
  dispatchPointerMove,
908
923
  dispatchPointerUp,
package/dist/exports.d.ts CHANGED
@@ -28,7 +28,7 @@ export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
28
28
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
29
29
  export type { AnnotationCanvasHandle } from './annotation/canvas/useAnnotationCanvasState.js';
30
30
  export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './annotation/canvas/AnnotationCanvasInner.js';
31
- export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
31
+ export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, SnapContext, ToolState, } from './annotation/canvas/Tool.js';
32
32
  export type { FileRef, MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
33
33
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
34
34
  export type { CanvasBackgroundSlotArgs, RenderCanvasBackground, } from './annotation/canvas/backgroundSlot.js';
@@ -52,3 +52,4 @@ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeTool
52
52
  export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, type ResizeGeometry, type TextBounds, } from './annotation/canvas/textGeometry.js';
53
53
  export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, type NormalizedRect, type RectCorner, } from './annotation/canvas/measurementGeometry.js';
54
54
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
55
+ export { SNAP_RADIUS_PX, SNAP_RING_PX, SNAP_RING_WIDTH_PX, SNAP_KIND_POINT, SNAP_KIND_MIDPOINT, SNAP_STRIDE, collectSnapTargets, collectSnapTargetsFlat, encodeSnapTargets, snapNearest, snapDelta, snapPoint, type SnapHit, type SnapKind, type SnapTarget, } from './annotation/canvas/snapGeometry.js';
package/dist/exports.js CHANGED
@@ -56,3 +56,4 @@ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './anno
56
56
  export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, } from './annotation/canvas/textGeometry.js';
57
57
  export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
58
58
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
59
+ export { SNAP_RADIUS_PX, SNAP_RING_PX, SNAP_RING_WIDTH_PX, SNAP_KIND_POINT, SNAP_KIND_MIDPOINT, SNAP_STRIDE, collectSnapTargets, collectSnapTargetsFlat, encodeSnapTargets, snapNearest, snapDelta, snapPoint, } from './annotation/canvas/snapGeometry.js';
@@ -96,6 +96,7 @@ export interface Template extends Timestamps {
96
96
  formulas?: Formula[];
97
97
  sharedOrgIds?: string[];
98
98
  starred?: boolean;
99
+ thumbnailUrl?: string;
99
100
  }
100
101
  export interface SelectedTemplate extends FirestoreDoc {
101
102
  count: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.19.6",
3
+ "version": "1.20.1",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",