@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
@@ -1,7 +1,8 @@
1
- import { backgroundLayerDocRect, backgroundLayerIsLocked, backgroundLayersOf, isBackgroundLayerLocked, } from '../backgroundLayers.js';
1
+ import { MAX_BACKGROUND_DOC_EXTENT, MIN_BACKGROUND_DOC_EXTENT, backgroundCropBars, backgroundLayerDocRect, backgroundLayerFullDocRect, backgroundLayerIsLocked, backgroundLayersOf, isBackgroundLayerLocked, } from '../backgroundLayers.js';
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) => {
@@ -468,12 +495,8 @@ const shapeCornerPatch = (doc, id, corner, delta) => {
468
495
  // other, but never moves or scales:
469
496
  // the drag/resize hit-tests below refuse it, and a gesture on it behaves like
470
497
  // empty canvas (pan) while leaving the selection in place. ---
471
- // Doc-space clamp on a resized background's larger doc dimension. The floor
472
- // keeps a corner drag from collapsing a layer below grabbable size; the
473
- // ceiling keeps the doc rect (which feeds fit math and the web SVG raster
474
- // planner) from exploding to numerically silly sizes.
475
- const MIN_BACKGROUND_DOC_EXTENT = 16;
476
- const MAX_BACKGROUND_DOC_EXTENT = 65536;
498
+ // Doc-space size clamps live in backgroundLayers.ts (the native crop preview
499
+ // worklet shares the floor).
477
500
  const backgroundLayerById = (doc, id) => backgroundLayersOf(doc.viewport).find((l) => l.id === id) ?? null;
478
501
  const pointInBackgroundLayer = (layer, p) => {
479
502
  const r = backgroundLayerDocRect(layer);
@@ -511,19 +534,30 @@ const findSelectedBackgroundHit = (doc, world, selectedIds) => {
511
534
  }
512
535
  return null;
513
536
  };
514
- // Corner-scale geometry for a background layer: pivot = the doc rect's
515
- // top-left — the layer's transform origin, so scaling about it multiplies
516
- // scaleX/scaleY while x/y stay put — and handle = the EXACT bottom-right
517
- // corner (the selection chrome draws it un-padded, unlike the padded text
518
- // box; the grab tolerance lives in the hit radius, not the geometry).
519
- const backgroundResizeGeometry = (layer) => {
537
+ // Corner-scale geometry for a background layer: handle = the grabbed corner
538
+ // of its VISIBLE (cropped) doc rect, pivot = the opposite corner, which stays
539
+ // put — so the patch scales scaleX/scaleY AND repositions transform.x/y about
540
+ // the pivot (unlike text, whose top-left anchor is always the pivot). The
541
+ // chrome draws all four handles un-padded on the exact rect corners; the grab
542
+ // tolerance lives in the hit radius, not the geometry.
543
+ const BACKGROUND_RESIZE_CORNERS = [
544
+ 'tl',
545
+ 'tr',
546
+ 'bl',
547
+ 'br',
548
+ ];
549
+ const backgroundResizeGeometry = (layer, corner) => {
520
550
  const r = backgroundLayerDocRect(layer);
521
551
  const maxDim = Math.max(r.width, r.height);
522
552
  if (!(maxDim > 0))
523
553
  return null;
554
+ const cornerPoint = (c) => ({
555
+ x: c === 'tl' || c === 'bl' ? r.x : r.x + r.width,
556
+ y: c === 'tl' || c === 'tr' ? r.y : r.y + r.height,
557
+ });
524
558
  return {
525
- pivot: { x: r.x, y: r.y },
526
- handle: { x: r.x + r.width, y: r.y + r.height },
559
+ pivot: cornerPoint(oppositeRectCorner(corner)),
560
+ handle: cornerPoint(corner),
527
561
  minScale: MIN_BACKGROUND_DOC_EXTENT / maxDim,
528
562
  maxScale: MAX_BACKGROUND_DOC_EXTENT / maxDim,
529
563
  };
@@ -532,41 +566,51 @@ const backgroundResizeGeometry = (layer) => {
532
566
  // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
533
567
  // Resize geometry when the (selected) text shape's or background layer's
534
568
  // corner handle is under `world`, else null. A text handle sits on the padded
535
- // selection box's bottom-right corner, a background handle on its exact doc
536
- // rect corner (see AnnotationCanvasSkia); grab radius matches the measurement
537
- // endpoint handles either way.
569
+ // selection box's bottom-right corner; a background offers all FOUR exact doc
570
+ // rect corners, each scaling about its opposite (see AnnotationCanvasSkia);
571
+ // grab radius matches the measurement endpoint handles either way.
538
572
  const findResizeHandleHit = (doc, id, world, zoom) => {
573
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
574
+ const within = (geom) => {
575
+ const dx = world.x - geom.handle.x;
576
+ const dy = world.y - geom.handle.y;
577
+ return dx * dx + dy * dy <= r2;
578
+ };
539
579
  const s = doc.shapes.find((x) => x.id === id);
540
- // A locked background cannot scale, so its corner offers no handle (the
580
+ if (s) {
581
+ const geom = textResizeGeometry(s);
582
+ return geom && within(geom) ? geom : null;
583
+ }
584
+ // A locked background cannot scale, so its corners offer no handles (the
541
585
  // chrome draws none either — see AnnotationCanvasSkia).
542
- const layer = s || isBackgroundLayerLocked(doc.viewport, id)
543
- ? null
544
- : backgroundLayerById(doc, id);
545
- const geom = s
546
- ? textResizeGeometry(s)
547
- : layer
548
- ? backgroundResizeGeometry(layer)
549
- : null;
550
- if (!geom)
586
+ if (isBackgroundLayerLocked(doc.viewport, id))
551
587
  return null;
552
- const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
553
- const dx = world.x - geom.handle.x;
554
- const dy = world.y - geom.handle.y;
555
- return dx * dx + dy * dy <= r2 ? geom : null;
588
+ const layer = backgroundLayerById(doc, id);
589
+ if (!layer)
590
+ return null;
591
+ for (const corner of BACKGROUND_RESIZE_CORNERS) {
592
+ const geom = backgroundResizeGeometry(layer, corner);
593
+ if (geom && within(geom))
594
+ return geom;
595
+ }
596
+ return null;
556
597
  };
557
598
  // Scale the element by the drag (clamped to the geometry's scale range, so it
558
- // matches the native live preview exactly). The pivot is untouched: a text
559
- // shape keeps its anchor and bakes the scale into fontSize; a background keeps
560
- // transform.x/y — its top-left IS the scale pivot — and bakes the scale into
561
- // scaleX/scaleY (both axes, so the aspect ratio is preserved even on layers
562
- // synthesized from a legacy 'stretch' fit).
563
- const resizePatch = (doc, id, delta) => {
599
+ // matches the native live preview exactly). A text shape keeps its top-left
600
+ // anchor (the pivot) and bakes the scale into fontSize. A background scales
601
+ // uniformly about `geom.pivot` — the corner OPPOSITE the grabbed one — so
602
+ // scaleX/scaleY multiply AND transform.x/y move to keep the pivot fixed
603
+ // (both axes scale together, preserving the aspect ratio even on layers
604
+ // synthesized from a legacy 'stretch' fit). `geom` is the grab-start geometry
605
+ // the hit-test chose; without it (defensive) the bottom-right corner's
606
+ // geometry is assumed, which reproduces the pre-four-corner behavior.
607
+ const resizePatch = (doc, id, delta, grabGeom) => {
564
608
  const s = doc.shapes.find((x) => x.id === id);
565
609
  if (!s) {
566
610
  const layer = backgroundLayerById(doc, id);
567
611
  if (!layer || isBackgroundLayerLocked(doc.viewport, id))
568
612
  return null;
569
- const geom = backgroundResizeGeometry(layer);
613
+ const geom = grabGeom ?? backgroundResizeGeometry(layer, 'br');
570
614
  if (!geom)
571
615
  return null;
572
616
  const scale = resizeScaleFromDrag(geom, delta);
@@ -577,7 +621,8 @@ const resizePatch = (doc, id, delta) => {
577
621
  id,
578
622
  patch: {
579
623
  transform: {
580
- ...layer.transform,
624
+ x: geom.pivot.x + (layer.transform.x - geom.pivot.x) * scale,
625
+ y: geom.pivot.y + (layer.transform.y - geom.pivot.y) * scale,
581
626
  scaleX: layer.transform.scaleX * scale,
582
627
  scaleY: layer.transform.scaleY * scale,
583
628
  },
@@ -597,6 +642,82 @@ const resizePatch = (doc, id, delta) => {
597
642
  ],
598
643
  };
599
644
  };
645
+ // --- Background crop (the four edge bars on a selected, unlocked layer) ----
646
+ // Which crop bar of the given (selected) background layer is under `world`,
647
+ // or null. Bars exist only on an unlocked RASTER layer (an SVG layer's
648
+ // doc-rect math — the web rasterizer — never sees a crop). The bar rect is
649
+ // the drawn gizmo (backgroundCropBars) padded by the standard screen-constant
650
+ // grab tolerance.
651
+ const findBackgroundCropBarHit = (doc, id, world, zoom) => {
652
+ if (isBackgroundLayerLocked(doc.viewport, id))
653
+ return null;
654
+ const layer = backgroundLayerById(doc, id);
655
+ if (!layer || layer.format === 'svg')
656
+ return null;
657
+ const pad = HANDLE_GRAB_PX / zoom;
658
+ for (const bar of backgroundCropBars(layer)) {
659
+ if (world.x >= bar.x - pad &&
660
+ world.x <= bar.x + bar.width + pad &&
661
+ world.y >= bar.y - pad &&
662
+ world.y <= bar.y + bar.height + pad) {
663
+ return bar.edge;
664
+ }
665
+ }
666
+ return null;
667
+ };
668
+ // New VISIBLE doc rect for a crop-bar drag: the grabbed edge follows the
669
+ // delta, clamped so the rect never leaves the full image's bounds (dragging
670
+ // back out un-crops — the bytes were never touched) and never collapses below
671
+ // the same doc-extent floor a corner resize enforces. The native live-preview
672
+ // worklet in AnnotationCanvasInner.native.tsx is a WORKLET TWIN of this math
673
+ // — keep them in sync.
674
+ export const cropDragVisibleRect = (start, full, edge, delta) => {
675
+ let { x, y, width, height } = start;
676
+ if (edge === 'top') {
677
+ const newY = Math.min(Math.max(start.y + delta.y, full.y), start.y + start.height - MIN_BACKGROUND_DOC_EXTENT);
678
+ height = start.y + start.height - newY;
679
+ y = newY;
680
+ }
681
+ else if (edge === 'bottom') {
682
+ height = Math.min(Math.max(start.height + delta.y, MIN_BACKGROUND_DOC_EXTENT), full.y + full.height - start.y);
683
+ }
684
+ else if (edge === 'left') {
685
+ const newX = Math.min(Math.max(start.x + delta.x, full.x), start.x + start.width - MIN_BACKGROUND_DOC_EXTENT);
686
+ width = start.x + start.width - newX;
687
+ x = newX;
688
+ }
689
+ else {
690
+ width = Math.min(Math.max(start.width + delta.x, MIN_BACKGROUND_DOC_EXTENT), full.x + full.width - start.x);
691
+ }
692
+ return { x, y, width, height };
693
+ };
694
+ // Patch that commits a crop-bar drag: the new visible rect (clamped exactly
695
+ // like the live preview) converted back to an intrinsic-pixel crop window.
696
+ const backgroundCropPatch = (doc, id, edge, delta) => {
697
+ const layer = backgroundLayerById(doc, id);
698
+ if (!layer || isBackgroundLayerLocked(doc.viewport, id))
699
+ return null;
700
+ const { scaleX, scaleY } = layer.transform;
701
+ if (!(scaleX > 0) || !(scaleY > 0))
702
+ return null;
703
+ const visible = cropDragVisibleRect(backgroundLayerDocRect(layer), backgroundLayerFullDocRect(layer), edge, delta);
704
+ return {
705
+ ops: [
706
+ {
707
+ op: 'updateBackgroundImage',
708
+ id,
709
+ patch: {
710
+ crop: {
711
+ x: (visible.x - layer.transform.x) / scaleX,
712
+ y: (visible.y - layer.transform.y) / scaleY,
713
+ width: visible.width / scaleX,
714
+ height: visible.height / scaleY,
715
+ },
716
+ },
717
+ },
718
+ ],
719
+ };
720
+ };
600
721
  // Patch for the current drag mode (web pointer path), from a world-space delta.
601
722
  const dragPatch = (s, doc, delta, zoom) => {
602
723
  if (s.mode === 'endpoint' && s.handle) {
@@ -609,7 +730,10 @@ const dragPatch = (s, doc, delta, zoom) => {
609
730
  if (s.mode === 'slide')
610
731
  return slidePatch(doc, s.id, delta, zoom);
611
732
  if (s.mode === 'resize')
612
- return resizePatch(doc, s.id, delta);
733
+ return resizePatch(doc, s.id, delta, s.geom);
734
+ if (s.mode === 'bg-crop' && s.cropEdge) {
735
+ return backgroundCropPatch(doc, s.id, s.cropEdge, delta);
736
+ }
613
737
  if (s.mode === 'rect-corner' && s.corner) {
614
738
  return rectCornerPatch(doc, s.id, s.corner, delta);
615
739
  }
@@ -643,6 +767,8 @@ export const createSelectTool = (options = {}) => ({
643
767
  buildResizePatch: resizePatch,
644
768
  hitTestBackground: findBackgroundHit,
645
769
  hitTestSelectedBackground: findSelectedBackgroundHit,
770
+ hitTestBackgroundCropBar: findBackgroundCropBarHit,
771
+ buildBackgroundCropPatch: backgroundCropPatch,
646
772
  hitTestRectCorner: findRectCornerHit,
647
773
  buildRectCornerPatch: rectCornerPatch,
648
774
  hitTestShapeCorner: findShapeCornerHit,
@@ -678,6 +804,7 @@ export const createSelectTool = (options = {}) => ({
678
804
  handle,
679
805
  start: world,
680
806
  delta: { x: 0, y: 0 },
807
+ ...snapStateForGrab(ctx, selId, zoom),
681
808
  };
682
809
  }
683
810
  const shapeHandle = findShapeHandleHit(ctx.document, selId, world, zoom);
@@ -691,9 +818,11 @@ export const createSelectTool = (options = {}) => ({
691
818
  handle: shapeHandle,
692
819
  start: world,
693
820
  delta: { x: 0, y: 0 },
821
+ ...snapStateForGrab(ctx, selId, zoom),
694
822
  };
695
823
  }
696
- if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
824
+ const resizeGeom = findResizeHandleHit(ctx.document, selId, world, zoom);
825
+ if (resizeGeom) {
697
826
  return {
698
827
  kind: 'dragging',
699
828
  id: selId,
@@ -704,6 +833,22 @@ export const createSelectTool = (options = {}) => ({
704
833
  ? 'background'
705
834
  : 'shape',
706
835
  mode: 'resize',
836
+ // The grabbed corner's geometry — a background scales about the
837
+ // OPPOSITE corner, so the patch must reuse what the grab chose.
838
+ geom: resizeGeom,
839
+ start: world,
840
+ delta: { x: 0, y: 0 },
841
+ };
842
+ }
843
+ // Crop bar on a selected, unlocked background layer.
844
+ const cropEdge = findBackgroundCropBarHit(ctx.document, selId, world, zoom);
845
+ if (cropEdge) {
846
+ return {
847
+ kind: 'dragging',
848
+ id: selId,
849
+ elementKind: 'background',
850
+ mode: 'bg-crop',
851
+ cropEdge,
707
852
  start: world,
708
853
  delta: { x: 0, y: 0 },
709
854
  };
@@ -819,7 +964,7 @@ export const createSelectTool = (options = {}) => ({
819
964
  }
820
965
  if (s?.kind !== 'dragging')
821
966
  return s;
822
- const delta = {
967
+ let delta = {
823
968
  x: event.world.x - s.start.x,
824
969
  y: event.world.y - s.start.y,
825
970
  };
@@ -829,12 +974,24 @@ export const createSelectTool = (options = {}) => ({
829
974
  pendingTextEditId = null;
830
975
  pendingBackgroundDeselectId = null;
831
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
+ }
832
988
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
833
989
  if (patch)
834
990
  ctx.preview(patch);
835
991
  return { ...s, delta };
836
992
  },
837
993
  onPointerUp(_event, ctx, state) {
994
+ ctx.snap?.setIndicator(null);
838
995
  const editId = pendingTextEditId;
839
996
  pendingTextEditId = null;
840
997
  const deselectId = pendingBackgroundDeselectId;
@@ -868,6 +1025,7 @@ export const createSelectTool = (options = {}) => ({
868
1025
  onCancel(_state, ctx) {
869
1026
  pendingTextEditId = null;
870
1027
  pendingBackgroundDeselectId = null;
1028
+ ctx.snap?.setIndicator(null);
871
1029
  ctx.preview({ ops: [] });
872
1030
  },
873
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([]);
@@ -194,7 +197,9 @@ export const useAnnotationCanvasState = (props) => {
194
197
  // equals the measurement factor (resolveHeaderTileScaleFactor's fallback) —
195
198
  // the pre-split one-knob behavior.
196
199
  let headerTileScaleFactor = resolveHeaderTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
197
- // Object tiles: the third knob, same fallback (Asana 1217570500895545).
200
+ // Object tiles share the measurement factor — their own knob was retired
201
+ // (see resolveFileTileScaleFactor), so this always equals tileScaleFactor
202
+ // and rides the same calibration below.
198
203
  let fileTileScaleFactor = resolveFileTileScaleFactor(effectiveCanvas, tileScalePlatformResolved);
199
204
  if (tileScalePlatformResolved === 'mobile' &&
200
205
  effectiveCanvas.tileScaleFactorMobile == null &&
@@ -226,6 +231,11 @@ export const useAnnotationCanvasState = (props) => {
226
231
  tileScaleFactor,
227
232
  headerTileScaleFactor,
228
233
  fileTileScaleFactor,
234
+ snap: {
235
+ enabled: snapToGeometry === true,
236
+ radiusPx: SNAP_RADIUS_PX,
237
+ setIndicator: setSnapIndicator,
238
+ },
229
239
  preview(patch) {
230
240
  setPreviewPatch(patch);
231
241
  },
@@ -326,6 +336,7 @@ export const useAnnotationCanvasState = (props) => {
326
336
  onSelectionChange,
327
337
  pickMeasurement,
328
338
  requestTextInput,
339
+ snapToGeometry,
329
340
  ]);
330
341
  // Live ctx for imperative handle methods (which are created in an effect and
331
342
  // would otherwise capture a stale ctx/viewport).
@@ -351,6 +362,7 @@ export const useAnnotationCanvasState = (props) => {
351
362
  activePointerIdRef.current = null;
352
363
  updateToolState(undefined);
353
364
  setPreviewPatch(null);
365
+ setSnapIndicator(null);
354
366
  // updateToolState is a stable-in-effect helper (ref + setState); the
355
367
  // effect only re-runs on a tool identity change.
356
368
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -388,6 +400,9 @@ export const useAnnotationCanvasState = (props) => {
388
400
  activeTool.onPointerUp?.(event, ctx, toolStateRef.current);
389
401
  activePointerIdRef.current = null;
390
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);
391
406
  },
392
407
  // eslint-disable-next-line react-hooks/exhaustive-deps
393
408
  [activeTool, ctx]);
@@ -408,6 +423,7 @@ export const useAnnotationCanvasState = (props) => {
408
423
  const prevToolState = toolStateRef.current;
409
424
  updateToolState(undefined);
410
425
  setPreviewPatch(null);
426
+ setSnapIndicator(null);
411
427
  if (activeTool)
412
428
  activeTool.onCancel?.(prevToolState, ctx);
413
429
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -901,6 +917,7 @@ export const useAnnotationCanvasState = (props) => {
901
917
  fileTileScaleFactor,
902
918
  penDrawingStroke,
903
919
  customPreviewState: toolState,
920
+ snapIndicator,
904
921
  dispatchPointerDown,
905
922
  dispatchPointerMove,
906
923
  dispatchPointerUp,
@@ -429,7 +429,11 @@ export const useAnnotationCanvasDoc = (options) => {
429
429
  ? backgroundLayersOf(viewport).find((l) => l.id === id)
430
430
  : undefined;
431
431
  const fileId = fileIdRef.current ?? createdIdRef.current;
432
- if (layer && fileId) {
432
+ // A LINKED layer (an expanded file tile — see fileTileSwap.ts) does not
433
+ // own its Storage object: its storagePath points into the SOURCE file's
434
+ // folder, and deleting it would destroy that file's photo. Remove the
435
+ // layer only.
436
+ if (layer && fileId && !layer.fileId) {
433
437
  try {
434
438
  await deleteImage(fileId, layer.storagePath);
435
439
  }
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';
@@ -37,6 +37,7 @@ export { GROUP_HEADER_STYLE, describeGroupHeader, groupHeaderSignature, type Gro
37
37
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, DEFAULT_ZOOM_LIMITS, type ViewportApi, type ViewportState, type ZoomLimits, } from './annotation/canvas/viewport.js';
38
38
  export { DEFAULT_PDF_PAGE_GAP, DEFAULT_MIN_ZOOM, DEFAULT_MAX_ZOOM, makePdfDocumentId, createPdfDocument, pdfPageDocRects, pdfDocumentExtent, pdfFirstPageDocRect, computeZoomFloor, type PdfDocRect, } from './annotation/canvas/pdfDocument.js';
39
39
  export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerIsLocked, isBackgroundLayerLocked, defaultBackgroundLayerLocked, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
40
+ export { fileTileImageSource, expandFileTileToBackground, collapseBackgroundToFileTile, type FileTileImageSource, } from './annotation/canvas/fileTileSwap.js';
40
41
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
41
42
  export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
42
43
  export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './annotation/canvas/tools/measurementStampTool.js';
@@ -51,3 +52,4 @@ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeTool
51
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';
52
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';
53
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
@@ -41,6 +41,7 @@ export { GROUP_HEADER_STYLE, describeGroupHeader, groupHeaderSignature, } from '
41
41
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, DEFAULT_ZOOM_LIMITS, } from './annotation/canvas/viewport.js';
42
42
  export { DEFAULT_PDF_PAGE_GAP, DEFAULT_MIN_ZOOM, DEFAULT_MAX_ZOOM, makePdfDocumentId, createPdfDocument, pdfPageDocRects, pdfDocumentExtent, pdfFirstPageDocRect, computeZoomFloor, } from './annotation/canvas/pdfDocument.js';
43
43
  export { LEGACY_BACKGROUND_LAYER_ID, BACKGROUND_PLACEMENT_GAP_RATIO, backgroundLayersOf, backgroundLayerIsLocked, isBackgroundLayerLocked, defaultBackgroundLayerLocked, backgroundLayerDocRect, backgroundLayersUnionRect, placeNewBackgroundLayer, } from './annotation/canvas/backgroundLayers.js';
44
+ export { fileTileImageSource, expandFileTileToBackground, collapseBackgroundToFileTile, } from './annotation/canvas/fileTileSwap.js';
44
45
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
45
46
  export { createSelectTool, } from './annotation/canvas/tools/selectTool.js';
46
47
  export { createMeasurementStampTool, } from './annotation/canvas/tools/measurementStampTool.js';
@@ -55,3 +56,4 @@ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './anno
55
56
  export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, } from './annotation/canvas/textGeometry.js';
56
57
  export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
57
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';
@@ -135,6 +135,12 @@ export interface BackgroundLayerTransform {
135
135
  scaleX: number;
136
136
  scaleY: number;
137
137
  }
138
+ export interface BackgroundLayerCrop {
139
+ x: number;
140
+ y: number;
141
+ width: number;
142
+ height: number;
143
+ }
138
144
  export interface AnnotationBackgroundLayer {
139
145
  id: AnnotationElementId;
140
146
  storagePath: string;
@@ -143,6 +149,10 @@ export interface AnnotationBackgroundLayer {
143
149
  heightPx: number;
144
150
  format?: 'raster' | 'svg';
145
151
  transform: BackgroundLayerTransform;
152
+ crop?: BackgroundLayerCrop;
153
+ fileId?: string;
154
+ filePath?: string;
155
+ fileGroupId?: string;
146
156
  locked?: boolean;
147
157
  }
148
158
  export interface AnnotationPdfPageSize {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.19.5",
3
+ "version": "1.20.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",