@reekon-tools/boldr-utils 1.19.6 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +10 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +214 -29
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +7 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +11 -2
  7. package/dist/annotation/canvas/Tool.d.ts +6 -0
  8. package/dist/annotation/canvas/snapGeometry.d.ts +32 -0
  9. package/dist/annotation/canvas/snapGeometry.js +168 -0
  10. package/dist/annotation/canvas/tools/measurementTool.js +38 -4
  11. package/dist/annotation/canvas/tools/selectTool.js +43 -1
  12. package/dist/annotation/canvas/tools/shapeTool.js +27 -5
  13. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -0
  14. package/dist/annotation/canvas/useAnnotationCanvasState.js +16 -1
  15. package/dist/exports.d.ts +2 -1
  16. package/dist/exports.js +1 -0
  17. package/package.json +1 -1
  18. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  19. package/dist/canvas/AnnotationCanvas.js +0 -10
  20. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  21. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  22. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  23. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  24. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  25. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  26. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  27. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  28. package/dist/canvas/Tool.d.ts +0 -38
  29. package/dist/canvas/Tool.js +0 -1
  30. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  31. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  32. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  33. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  34. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  35. package/dist/canvas/elements/ShapeElement.js +0 -62
  36. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  37. package/dist/canvas/elements/StrokeElement.js +0 -18
  38. package/dist/canvas/measurementPicker.d.ts +0 -10
  39. package/dist/canvas/measurementPicker.js +0 -1
  40. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  41. package/dist/canvas/measurementStampOverlay.js +0 -1
  42. package/dist/canvas/pointerAdapter.d.ts +0 -3
  43. package/dist/canvas/pointerAdapter.js +0 -19
  44. package/dist/canvas/stampLayout.d.ts +0 -5
  45. package/dist/canvas/stampLayout.js +0 -14
  46. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  47. package/dist/canvas/tools/measurementStampTool.js +0 -37
  48. package/dist/canvas/tools/panTool.d.ts +0 -5
  49. package/dist/canvas/tools/panTool.js +0 -25
  50. package/dist/canvas/tools/penTool.d.ts +0 -13
  51. package/dist/canvas/tools/penTool.js +0 -68
  52. package/dist/canvas/tools/selectTool.d.ts +0 -2
  53. package/dist/canvas/tools/selectTool.js +0 -182
  54. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  55. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  56. package/dist/canvas/viewport.d.ts +0 -16
  57. package/dist/canvas/viewport.js +0 -54
  58. package/dist/data/AnnotationDataContext.d.ts +0 -8
  59. package/dist/data/AnnotationDataContext.js +0 -11
  60. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  61. package/dist/data/AnnotationDataProvider.js +0 -4
  62. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  63. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  64. package/dist/data/canvasPersistence.d.ts +0 -3
  65. package/dist/data/canvasPersistence.js +0 -26
  66. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  67. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  68. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  69. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  70. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  71. package/dist/data/hooks/useAnnotationList.js +0 -26
  72. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  73. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  74. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  75. package/dist/hooks/useParseMeasurement.js +0 -14
  76. package/dist/utils/evaluateFormula.d.ts +0 -20
  77. package/dist/utils/evaluateFormula.js +0 -31
@@ -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';
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.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",
@@ -1,11 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.js';
3
- export type { AnnotationCanvasHandle, GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
- export interface CanvasKitOpts {
5
- locateFile?: (file: string) => string;
6
- }
7
- export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
8
- fallback?: ReactNode;
9
- canvasKitOpts?: CanvasKitOpts;
10
- };
11
- export declare const AnnotationCanvas: ({ fallback, canvasKitOpts, ...rest }: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,10 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web/index.js';
3
- // Web-only entry point: lazy-loads CanvasKit wasm via WithSkiaWeb. Imperative
4
- // API (undo/redo/zoom) is exposed through the `imperativeRef` prop rather
5
- // than React refs, since WithSkiaWeb can't forward refs through its lazy
6
- // component boundary. The native build sits in `AnnotationCanvas.native.tsx`
7
- // and skips WithSkiaWeb entirely (no wasm to load).
8
- export const AnnotationCanvas = ({ fallback, canvasKitOpts, ...rest }) => (_jsx(WithSkiaWeb, { getComponent: () => import('./AnnotationCanvasInner.js').then((m) => ({
9
- default: m.AnnotationCanvasInner,
10
- })), fallback: fallback ?? null, componentProps: rest, opts: canvasKitOpts }));
@@ -1,8 +0,0 @@
1
- import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.native.js';
2
- export type { AnnotationCanvasHandle, } from './useAnnotationCanvasState.js';
3
- export type { GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
- export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
5
- fallback?: unknown;
6
- canvasKitOpts?: unknown;
7
- };
8
- export declare const AnnotationCanvas: (props: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,6 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { AnnotationCanvasInner } from './AnnotationCanvasInner.native.js';
3
- export const AnnotationCanvas = (props) => {
4
- const { fallback: _f, canvasKitOpts: _o, ...rest } = props;
5
- return _jsx(AnnotationCanvasInner, { ...rest });
6
- };
@@ -1,39 +0,0 @@
1
- import { type CSSProperties, type MutableRefObject } from 'react';
2
- import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
3
- import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
4
- import type { MeasurementRef } from './measurementPicker.js';
5
- import type { Tool } from './Tool.js';
6
- import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
7
- import { type ViewportState } from './viewport.js';
8
- import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
9
- export type { AnnotationCanvasHandle };
10
- export interface AnnotationCanvasInnerProps {
11
- canvas: AnnotationCanvasState;
12
- onCommit(patch: AnnotationDocumentPatch): void;
13
- tools: Tool[];
14
- activeToolId: string;
15
- selection: Selection | null;
16
- onSelectionChange(selection: Selection | null): void;
17
- measurements?: Measurement[];
18
- fallbackUnit?: Units;
19
- fractionalTolerance?: FractionalTolerance;
20
- decimalTolerance?: DecimalTolerance;
21
- resolveImageUrl?: (storagePath: string) => Promise<string>;
22
- pickMeasurement?: () => Promise<MeasurementRef | null>;
23
- renderMeasurementStamp?: RenderMeasurementStamp;
24
- stampFontSource?: unknown;
25
- stampValueFontSize?: number;
26
- stampLabelFontSize?: number;
27
- gestures?: GestureConfig;
28
- width: number;
29
- height: number;
30
- initialViewport?: ViewportState;
31
- style?: CSSProperties;
32
- imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
33
- }
34
- export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
35
- export interface GestureConfig {
36
- wheel?: 'zoom' | 'pan' | 'auto';
37
- panTriggers?: PanTrigger[];
38
- }
39
- export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;