@reekon-tools/boldr-utils 1.19.5 → 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 (88) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +2 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +12 -1
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +2 -0
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +329 -35
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +28 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +99 -14
  7. package/dist/annotation/canvas/Tool.d.ts +10 -1
  8. package/dist/annotation/canvas/backgroundLayers.d.ts +20 -1
  9. package/dist/annotation/canvas/backgroundLayers.js +94 -3
  10. package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +2 -1
  11. package/dist/annotation/canvas/elements/BackgroundImageElement.js +10 -2
  12. package/dist/annotation/canvas/fileTileSwap.d.ts +12 -0
  13. package/dist/annotation/canvas/fileTileSwap.js +87 -0
  14. package/dist/annotation/canvas/snapGeometry.d.ts +32 -0
  15. package/dist/annotation/canvas/snapGeometry.js +168 -0
  16. package/dist/annotation/canvas/stampLayout.d.ts +1 -1
  17. package/dist/annotation/canvas/stampLayout.js +10 -8
  18. package/dist/annotation/canvas/tools/measurementTool.js +38 -4
  19. package/dist/annotation/canvas/tools/selectTool.d.ts +18 -0
  20. package/dist/annotation/canvas/tools/selectTool.js +201 -43
  21. package/dist/annotation/canvas/tools/shapeTool.js +27 -5
  22. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
  23. package/dist/annotation/canvas/useAnnotationCanvasState.js +19 -2
  24. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +5 -1
  25. package/dist/exports.d.ts +3 -1
  26. package/dist/exports.js +2 -0
  27. package/dist/types/annotation.d.ts +10 -0
  28. package/package.json +1 -1
  29. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  30. package/dist/canvas/AnnotationCanvas.js +0 -10
  31. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  32. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  33. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  34. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  35. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  36. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  37. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  38. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  39. package/dist/canvas/Tool.d.ts +0 -38
  40. package/dist/canvas/Tool.js +0 -1
  41. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  42. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  43. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  44. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  45. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  46. package/dist/canvas/elements/ShapeElement.js +0 -62
  47. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  48. package/dist/canvas/elements/StrokeElement.js +0 -18
  49. package/dist/canvas/measurementPicker.d.ts +0 -10
  50. package/dist/canvas/measurementPicker.js +0 -1
  51. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  52. package/dist/canvas/measurementStampOverlay.js +0 -1
  53. package/dist/canvas/pointerAdapter.d.ts +0 -3
  54. package/dist/canvas/pointerAdapter.js +0 -19
  55. package/dist/canvas/stampLayout.d.ts +0 -5
  56. package/dist/canvas/stampLayout.js +0 -14
  57. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  58. package/dist/canvas/tools/measurementStampTool.js +0 -37
  59. package/dist/canvas/tools/panTool.d.ts +0 -5
  60. package/dist/canvas/tools/panTool.js +0 -25
  61. package/dist/canvas/tools/penTool.d.ts +0 -13
  62. package/dist/canvas/tools/penTool.js +0 -68
  63. package/dist/canvas/tools/selectTool.d.ts +0 -2
  64. package/dist/canvas/tools/selectTool.js +0 -182
  65. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  66. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  67. package/dist/canvas/viewport.d.ts +0 -16
  68. package/dist/canvas/viewport.js +0 -54
  69. package/dist/data/AnnotationDataContext.d.ts +0 -8
  70. package/dist/data/AnnotationDataContext.js +0 -11
  71. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  72. package/dist/data/AnnotationDataProvider.js +0 -4
  73. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  74. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  75. package/dist/data/canvasPersistence.d.ts +0 -3
  76. package/dist/data/canvasPersistence.js +0 -26
  77. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  78. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  79. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  80. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  81. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  82. package/dist/data/hooks/useAnnotationList.js +0 -26
  83. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  84. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  85. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  86. package/dist/hooks/useParseMeasurement.js +0 -14
  87. package/dist/utils/evaluateFormula.d.ts +0 -20
  88. package/dist/utils/evaluateFormula.js +0 -31
@@ -7,10 +7,11 @@ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useShare
7
7
  import { stampTileDims } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
- import { backgroundLayersOf, backgroundLayerDocRect, } from './backgroundLayers.js';
10
+ import { MIN_BACKGROUND_DOC_EXTENT, backgroundLayersOf, backgroundLayerDocRect, backgroundLayerFullDocRect, } from './backgroundLayers.js';
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, 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;
@@ -661,6 +741,9 @@ export const AnnotationCanvasInner = (props) => {
661
741
  // goes through buildResizePatch, which clamps identically.
662
742
  const [resizingId, setResizingId] = useState(null);
663
743
  const resizeCtx = useSharedValue({ px: 0, py: 0, hx: 0, hy: 0, minS: 1, maxS: 1 });
744
+ // The grab-start geometry rides along to the commit: a background offers
745
+ // four corner handles, each scaling about its opposite corner, so the patch
746
+ // must reuse whichever geometry the grab's hit-test chose.
664
747
  const resizeTargetRef = useRef(null);
665
748
  const resizeTransform = useDerivedValue(() => {
666
749
  'worklet';
@@ -684,6 +767,60 @@ export const AnnotationCanvasInner = (props) => {
684
767
  { translateY: -c.py },
685
768
  ];
686
769
  });
770
+ // Background crop-bar drag. `bgCropId` (React state) gates which layer
771
+ // clips to the live rect; `bgCropCtx` carries the grabbed edge (0 top ·
772
+ // 1 bottom · 2 left · 3 right), the visible doc rect at grab-start and the
773
+ // full (uncropped) image's doc rect it may expand back out to — so the
774
+ // preview is a pure UI-thread rect rebuild driven by dragX/dragY. The
775
+ // commit on release goes through buildBackgroundCropPatch, which clamps
776
+ // identically.
777
+ const [bgCropId, setBgCropId] = useState(null);
778
+ const bgCropTargetRef = useRef(null);
779
+ const bgCropCtx = useSharedValue({ edge: 0, x: 0, y: 0, w: 0, h: 0, bx: 0, by: 0, bw: 0, bh: 0 });
780
+ const liveBgCropRect = useDerivedValue(() => {
781
+ 'worklet';
782
+ // WORKLET TWIN of selectTool's cropDragVisibleRect — keep in sync.
783
+ const c = bgCropCtx.value;
784
+ let x = c.x;
785
+ let y = c.y;
786
+ let w = c.w;
787
+ let h = c.h;
788
+ if (c.edge === 0) {
789
+ const newY = Math.min(Math.max(c.y + dragY.value, c.by), c.y + c.h - MIN_BACKGROUND_DOC_EXTENT);
790
+ h = c.y + c.h - newY;
791
+ y = newY;
792
+ }
793
+ else if (c.edge === 1) {
794
+ h = Math.min(Math.max(c.h + dragY.value, MIN_BACKGROUND_DOC_EXTENT), c.by + c.bh - c.y);
795
+ }
796
+ else if (c.edge === 2) {
797
+ const newX = Math.min(Math.max(c.x + dragX.value, c.bx), c.x + c.w - MIN_BACKGROUND_DOC_EXTENT);
798
+ w = c.x + c.w - newX;
799
+ x = newX;
800
+ }
801
+ else {
802
+ w = Math.min(Math.max(c.w + dragX.value, MIN_BACKGROUND_DOC_EXTENT), c.bx + c.bw - c.x);
803
+ }
804
+ return { x, y, width: w, height: h };
805
+ });
806
+ // Per-component slices for the live outline (Skia props take one animated
807
+ // value each; the clip takes the whole rect).
808
+ const liveBgCropX = useDerivedValue(() => {
809
+ 'worklet';
810
+ return liveBgCropRect.value.x;
811
+ });
812
+ const liveBgCropY = useDerivedValue(() => {
813
+ 'worklet';
814
+ return liveBgCropRect.value.y;
815
+ });
816
+ const liveBgCropW = useDerivedValue(() => {
817
+ 'worklet';
818
+ return liveBgCropRect.value.width;
819
+ });
820
+ const liveBgCropH = useDerivedValue(() => {
821
+ 'worklet';
822
+ return liveBgCropRect.value.height;
823
+ });
687
824
  // Geometric-shape corner resize (rect/ellipse/polygon). `shapeResizeId`
688
825
  // (React state) gates which shape renders live; `shapeResizeCtx` carries the
689
826
  // fixed (opposite) corner and the grabbed corner's start position (from
@@ -842,9 +979,9 @@ export const AnnotationCanvasInner = (props) => {
842
979
  else if (loupeCrossMode === 'endpoint') {
843
980
  // Measurement-line and shape line/arrow endpoint drags both stage
844
981
  // their line in epCtx; the grabbed end follows the finger.
845
- const c = epCtx.value;
846
- wx = (c.handle === 0 ? c.ax : c.bx) + dragX.value;
847
- wy = (c.handle === 0 ? c.ay : c.by) + dragY.value;
982
+ const e = epLive.value;
983
+ wx = e.x;
984
+ wy = e.y;
848
985
  }
849
986
  else if (loupeCrossMode === 'rect-corner') {
850
987
  const c = rectCtx.value;
@@ -1345,9 +1482,29 @@ export const AnnotationCanvasInner = (props) => {
1345
1482
  .maxPointers(1)
1346
1483
  .onBegin((e) => {
1347
1484
  'worklet';
1348
- const wx = e.x / zoom.value + panX.value;
1349
- const wy = e.y / zoom.value + panY.value;
1350
- 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
+ };
1351
1508
  magTouchX.value = e.x;
1352
1509
  magTouchY.value = e.y;
1353
1510
  runOnJS(setMagnifying)(true);
@@ -1359,12 +1516,23 @@ export const AnnotationCanvasInner = (props) => {
1359
1516
  return;
1360
1517
  magTouchX.value = e.x;
1361
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
+ }
1362
1528
  liveShape.value = {
1363
1529
  active: true,
1364
1530
  ax: s.ax,
1365
1531
  ay: s.ay,
1366
- bx: e.x / zoom.value + panX.value,
1367
- by: e.y / zoom.value + panY.value,
1532
+ bx,
1533
+ by,
1534
+ snapR: s.snapR,
1535
+ snapped: hit !== null,
1368
1536
  };
1369
1537
  })
1370
1538
  .onEnd(() => {
@@ -1377,7 +1545,15 @@ export const AnnotationCanvasInner = (props) => {
1377
1545
  const dxPx = (s.bx - s.ax) * zoom.value;
1378
1546
  const dyPx = (s.by - s.ay) * zoom.value;
1379
1547
  if (dxPx * dxPx + dyPx * dyPx < minDragSq) {
1380
- 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
+ };
1381
1557
  return;
1382
1558
  }
1383
1559
  // Hand off in THIS worklet tick (see the stroke onEnd): the live
@@ -1397,14 +1573,30 @@ export const AnnotationCanvasInner = (props) => {
1397
1573
  by: s.by,
1398
1574
  },
1399
1575
  ];
1400
- 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
+ };
1401
1585
  runOnJS(commitShape)(id, { x: s.ax, y: s.ay }, { x: s.bx, y: s.by });
1402
1586
  })
1403
1587
  .onFinalize(() => {
1404
1588
  'worklet';
1405
1589
  // Cancel path (no onEnd): drop the in-flight rubber-band. After a
1406
1590
  // normal end the slot is already inactive, so this is a no-op.
1407
- 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
+ };
1408
1600
  runOnJS(setMagnifying)(false);
1409
1601
  });
1410
1602
  };
@@ -1430,6 +1622,16 @@ export const AnnotationCanvasInner = (props) => {
1430
1622
  if (handle) {
1431
1623
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
1432
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
+ }
1433
1635
  epCtx.value = {
1434
1636
  ax: m.line.a.x,
1435
1637
  ay: m.line.a.y,
@@ -1437,6 +1639,7 @@ export const AnnotationCanvasInner = (props) => {
1437
1639
  by: m.line.b.y,
1438
1640
  t0: m.linePos ?? 0.5,
1439
1641
  handle: handle === 'a' ? 0 : 1,
1642
+ snapR,
1440
1643
  };
1441
1644
  epTargetRef.current = { id: selId, handle };
1442
1645
  setEpDragId(selId);
@@ -1452,6 +1655,12 @@ export const AnnotationCanvasInner = (props) => {
1452
1655
  const a = s?.geometry.points[0];
1453
1656
  const b = s?.geometry.points[1];
1454
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
+ }
1455
1664
  epCtx.value = {
1456
1665
  ax: a.x,
1457
1666
  ay: a.y,
@@ -1459,6 +1668,7 @@ export const AnnotationCanvasInner = (props) => {
1459
1668
  by: b.y,
1460
1669
  t0: 0.5,
1461
1670
  handle: shapeHandle === 'a' ? 0 : 1,
1671
+ snapR,
1462
1672
  };
1463
1673
  shapeEpTargetRef.current = { id: selId, handle: shapeHandle };
1464
1674
  setShapeEpDragId(selId);
@@ -1477,11 +1687,42 @@ export const AnnotationCanvasInner = (props) => {
1477
1687
  minS: resizeGeom.minScale,
1478
1688
  maxS: resizeGeom.maxScale,
1479
1689
  };
1480
- resizeTargetRef.current = { id: selId };
1690
+ resizeTargetRef.current = { id: selId, geom: resizeGeom };
1481
1691
  setResizingId(selId);
1482
1692
  setMagnifying(true);
1483
1693
  return;
1484
1694
  }
1695
+ // Crop bar on a selected, unlocked background layer — the edge
1696
+ // windows the image (non-destructively) instead of scaling it.
1697
+ const cropEdge = cfg.hitTestBackgroundCropBar?.(st.ctx.document, selId, world, zoomNow);
1698
+ if (cropEdge) {
1699
+ const cropLayer = backgroundLayersOf(st.ctx.document.viewport).find((l) => l.id === selId);
1700
+ if (cropLayer) {
1701
+ const visible = backgroundLayerDocRect(cropLayer);
1702
+ const full = backgroundLayerFullDocRect(cropLayer);
1703
+ bgCropCtx.value = {
1704
+ edge: cropEdge === 'top'
1705
+ ? 0
1706
+ : cropEdge === 'bottom'
1707
+ ? 1
1708
+ : cropEdge === 'left'
1709
+ ? 2
1710
+ : 3,
1711
+ x: visible.x,
1712
+ y: visible.y,
1713
+ w: visible.width,
1714
+ h: visible.height,
1715
+ bx: full.x,
1716
+ by: full.y,
1717
+ bw: full.width,
1718
+ bh: full.height,
1719
+ };
1720
+ bgCropTargetRef.current = { id: selId, edge: cropEdge };
1721
+ setBgCropId(selId);
1722
+ setMagnifying(true);
1723
+ return;
1724
+ }
1725
+ }
1485
1726
  // Rectangle-annotation corner handle — also selected-element-only.
1486
1727
  const rectCornerHit = cfg.hitTestRectCorner?.(st.ctx.document, selId, world, zoomNow);
1487
1728
  if (rectCornerHit) {
@@ -1580,10 +1821,7 @@ export const AnnotationCanvasInner = (props) => {
1580
1821
  const rT = resizeTargetRef.current;
1581
1822
  if (rT) {
1582
1823
  if (dx !== 0 || dy !== 0) {
1583
- const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, {
1584
- x: dx,
1585
- y: dy,
1586
- });
1824
+ const patch = cfg.buildResizePatch?.(st.ctx.document, rT.id, { x: dx, y: dy }, rT.geom);
1587
1825
  if (patch)
1588
1826
  st.ctx.commit(patch);
1589
1827
  }
@@ -1591,6 +1829,19 @@ export const AnnotationCanvasInner = (props) => {
1591
1829
  setResizingId(null);
1592
1830
  return;
1593
1831
  }
1832
+ // Crop commit: window the layer to the live rect's final edges
1833
+ // (clamped in buildBackgroundCropPatch exactly like the live preview).
1834
+ const cropT = bgCropTargetRef.current;
1835
+ if (cropT) {
1836
+ if (dx !== 0 || dy !== 0) {
1837
+ const patch = cfg.buildBackgroundCropPatch?.(st.ctx.document, cropT.id, cropT.edge, { x: dx, y: dy });
1838
+ if (patch)
1839
+ st.ctx.commit(patch);
1840
+ }
1841
+ bgCropTargetRef.current = null;
1842
+ setBgCropId(null);
1843
+ return;
1844
+ }
1594
1845
  // Rect-corner commit: drag the grabbed corner by the world delta
1595
1846
  // (opposite corner fixed); anchor re-centers in the same patch.
1596
1847
  const rectT = rectTargetRef.current;
@@ -1678,6 +1929,7 @@ export const AnnotationCanvasInner = (props) => {
1678
1929
  resizeTargetRef.current = null;
1679
1930
  rectTargetRef.current = null;
1680
1931
  shapeResizeTargetRef.current = null;
1932
+ bgCropTargetRef.current = null;
1681
1933
  setDraggingId(null);
1682
1934
  setSlidingId(null);
1683
1935
  setEpDragId(null);
@@ -1685,6 +1937,7 @@ export const AnnotationCanvasInner = (props) => {
1685
1937
  setResizingId(null);
1686
1938
  setRectDragId(null);
1687
1939
  setShapeResizeId(null);
1940
+ setBgCropId(null);
1688
1941
  };
1689
1942
  return Gesture.Pan()
1690
1943
  .minPointers(1)
@@ -1694,6 +1947,10 @@ export const AnnotationCanvasInner = (props) => {
1694
1947
  dragEnded.value = false;
1695
1948
  dragX.value = 0;
1696
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 };
1697
1954
  // The loupe (turned on by beginSelectDrag only for precision sub-
1698
1955
  // drags) follows the live finger, not the hit-test origin.
1699
1956
  magTouchX.value = e.x;
@@ -1717,12 +1974,27 @@ export const AnnotationCanvasInner = (props) => {
1717
1974
  .onEnd(() => {
1718
1975
  'worklet';
1719
1976
  dragEnded.value = true;
1720
- 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);
1721
1991
  })
1722
1992
  .onFinalize(() => {
1723
1993
  'worklet';
1724
1994
  if (!dragEnded.value)
1725
1995
  runOnJS(cancelSelectDrag)();
1996
+ // Also closes the snap-ring gate on the cancel path.
1997
+ dragEnded.value = true;
1726
1998
  });
1727
1999
  };
1728
2000
  // Plane tool: a hybrid pan. Handle drags run on the UI thread (shared
@@ -1922,6 +2194,8 @@ export const AnnotationCanvasInner = (props) => {
1922
2194
  epCtx,
1923
2195
  resizeCtx,
1924
2196
  rectCtx,
2197
+ snapCfg,
2198
+ snapTargets,
1925
2199
  ]);
1926
2200
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
1927
2201
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
@@ -1950,6 +2224,7 @@ export const AnnotationCanvasInner = (props) => {
1950
2224
  worldTransform,
1951
2225
  resolveImageUrl,
1952
2226
  deferBackgroundToResolver,
2227
+ hideLinkBadges,
1953
2228
  backgroundSkImages,
1954
2229
  // Shared with the loupe overlay below (it re-runs this same scene through a
1955
2230
  // magnifying transform), so the lens shows the substrate magnified instead
@@ -1998,6 +2273,23 @@ export const AnnotationCanvasInner = (props) => {
1998
2273
  shapeEndpointDragId: shapeEpDragId,
1999
2274
  liveLineP1,
2000
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,
2285
+ bgCropId,
2286
+ liveBgCrop: {
2287
+ x: liveBgCropX,
2288
+ y: liveBgCropY,
2289
+ width: liveBgCropW,
2290
+ height: liveBgCropH,
2291
+ },
2292
+ liveBgCropClip: liveBgCropRect,
2001
2293
  rectDragId,
2002
2294
  liveRect: {
2003
2295
  x: liveRectX,
@@ -2048,7 +2340,7 @@ export const AnnotationCanvasInner = (props) => {
2048
2340
  // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
2049
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
2050
2342
  ? (state.measurementsById.get(placed.measurementId) ?? null)
2051
- : (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
2052
2344
  ? () => {
2053
2345
  const defaultRemove = () => {
2054
2346
  const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
@@ -2075,7 +2367,7 @@ export const AnnotationCanvasInner = (props) => {
2075
2367
  measurementLineScale: 1 / LOUPE_MAGNIFICATION,
2076
2368
  }) }))] }));
2077
2369
  };
2078
- 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, }) => {
2079
2371
  // Square tile or wide group-header pill — one footprint source of truth
2080
2372
  // shared with the hit-test (stampTileDims).
2081
2373
  const { width, height } = stampTileDims(placed, tileScaleFactor, tileViewportScale, headerTileScaleFactor ?? tileScaleFactor, fileTileScaleFactor ?? tileScaleFactor);
@@ -2151,13 +2443,15 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
2151
2443
  worldY = c.ay + aby * t;
2152
2444
  }
2153
2445
  else if (endpointDragging) {
2154
- // Tile stays at its linePos along the LIVE line: fold the drag delta into
2155
- // 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.
2156
2449
  const c = epCtx.value;
2157
- const ax = c.handle === 0 ? c.ax + dragX.value : c.ax;
2158
- const ay = c.handle === 0 ? c.ay + dragY.value : c.ay;
2159
- const bx = c.handle === 1 ? c.bx + dragX.value : c.bx;
2160
- 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;
2161
2455
  worldX = ax + (bx - ax) * c.t0;
2162
2456
  worldY = ay + (by - ay) * c.t0;
2163
2457
  }
@@ -24,6 +24,7 @@ export interface AnnotationCanvasSkiaProps {
24
24
  viewport?: ViewportState;
25
25
  resolveImageUrl?: (storagePath: string) => Promise<string>;
26
26
  deferBackgroundToResolver?: boolean;
27
+ hideLinkBadges?: boolean;
27
28
  backgroundSkImages?: Record<string, SkImage | null>;
28
29
  backgroundContent?: ReactNode;
29
30
  valueFont: SkFont | null;
@@ -74,6 +75,26 @@ export interface AnnotationCanvasSkiaProps {
74
75
  width: AnimatedNumber;
75
76
  height: AnimatedNumber;
76
77
  };
78
+ bgCropId?: string | null;
79
+ liveBgCrop?: {
80
+ x: AnimatedNumber;
81
+ y: AnimatedNumber;
82
+ width: AnimatedNumber;
83
+ height: AnimatedNumber;
84
+ };
85
+ liveBgCropClip?: {
86
+ x: number;
87
+ y: number;
88
+ width: number;
89
+ height: number;
90
+ } | {
91
+ value: {
92
+ x: number;
93
+ y: number;
94
+ width: number;
95
+ height: number;
96
+ };
97
+ };
77
98
  shapeResizeId?: string | null;
78
99
  shapeResizePath?: SkPath | {
79
100
  value: SkPath;
@@ -90,6 +111,12 @@ export interface AnnotationCanvasSkiaProps {
90
111
  handleRingWidth?: number | {
91
112
  value: number;
92
113
  };
114
+ snapIndicator?: {
115
+ c: AnimatedPoint;
116
+ r: AnimatedNumber;
117
+ ringWidth: AnimatedNumber;
118
+ opacity?: AnimatedNumber;
119
+ } | null;
93
120
  showPlaneHandles?: boolean;
94
121
  planeDragId?: string | null;
95
122
  planeDragIndex?: number | null;
@@ -106,5 +133,5 @@ export interface AnnotationCanvasSkiaProps {
106
133
  wrapContent?: (content: ReactNode) => ReactNode;
107
134
  canvasRef?: RefObject<CanvasRef | null>;
108
135
  }
109
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, deferBackgroundToResolver, backgroundSkImages, backgroundContent, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, 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;
110
137
  export {};