@reekon-tools/boldr-utils 1.6.11 → 1.6.13

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 (89) hide show
  1. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.d.ts +5 -3
  2. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.js +36 -17
  3. package/dist/{canvas → annotation/canvas}/AnnotationCanvasInner.native.d.ts +5 -3
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +810 -0
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +61 -0
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +158 -0
  7. package/dist/annotation/canvas/Tool.d.ts +77 -0
  8. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.d.ts +2 -2
  9. package/dist/{canvas → annotation/canvas}/elements/BackgroundImageElement.js +17 -7
  10. package/dist/annotation/canvas/elements/ShapeElement.d.ts +7 -0
  11. package/dist/{canvas → annotation/canvas}/elements/ShapeElement.js +33 -5
  12. package/dist/annotation/canvas/elements/StrokeElement.d.ts +7 -0
  13. package/dist/annotation/canvas/elements/StrokeElement.js +45 -0
  14. package/dist/annotation/canvas/measurementGeometry.d.ts +43 -0
  15. package/dist/annotation/canvas/measurementGeometry.js +111 -0
  16. package/dist/{canvas → annotation/canvas}/measurementPicker.d.ts +1 -1
  17. package/dist/{canvas → annotation/canvas}/measurementStampOverlay.d.ts +2 -2
  18. package/dist/annotation/canvas/stampLayout.d.ts +1 -0
  19. package/dist/annotation/canvas/stampLayout.js +11 -0
  20. package/dist/annotation/canvas/strokeGeometry.d.ts +5 -0
  21. package/dist/annotation/canvas/strokeGeometry.js +41 -0
  22. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  23. package/dist/annotation/canvas/textGeometry.js +110 -0
  24. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.d.ts +1 -1
  25. package/dist/{canvas → annotation/canvas}/tools/measurementStampTool.js +1 -1
  26. package/dist/{canvas → annotation/canvas}/tools/panTool.js +3 -0
  27. package/dist/{canvas → annotation/canvas}/tools/penTool.d.ts +3 -1
  28. package/dist/{canvas → annotation/canvas}/tools/penTool.js +34 -5
  29. package/dist/annotation/canvas/tools/selectTool.js +446 -0
  30. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  31. package/dist/annotation/canvas/tools/textTool.js +78 -0
  32. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.d.ts +11 -3
  33. package/dist/{canvas → annotation/canvas}/useAnnotationCanvasState.js +142 -2
  34. package/dist/{canvas → annotation/canvas}/viewport.d.ts +1 -1
  35. package/dist/{data → annotation/data}/AnnotationDataProvider.d.ts +1 -1
  36. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.d.ts +1 -1
  37. package/dist/{data → annotation/data}/InMemoryAnnotationProvider.js +1 -1
  38. package/dist/{data → annotation/data}/canvasPersistence.d.ts +1 -1
  39. package/dist/{data → annotation/data}/canvasPersistence.js +1 -1
  40. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  41. package/dist/annotation/data/coalescedRunner.js +48 -0
  42. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.d.ts +1 -1
  43. package/dist/{data → annotation/data}/hooks/useAnnotationCanvasDoc.js +37 -16
  44. package/dist/exports.d.ts +23 -19
  45. package/dist/exports.js +18 -14
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +2 -2
  48. package/dist/index.native.d.ts +1 -1
  49. package/dist/index.native.js +1 -1
  50. package/dist/types/annotation.d.ts +22 -3
  51. package/dist/types/firestore.d.ts +0 -1
  52. package/dist/{hooks → utils}/useParseMeasurement.js +1 -1
  53. package/package.json +1 -1
  54. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  55. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  56. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  57. package/dist/canvas/Tool.d.ts +0 -38
  58. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  59. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  60. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  61. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  62. package/dist/canvas/elements/StrokeElement.js +0 -18
  63. package/dist/canvas/stampLayout.d.ts +0 -5
  64. package/dist/canvas/stampLayout.js +0 -14
  65. package/dist/canvas/tools/selectTool.js +0 -182
  66. package/dist/utils/evaluateFormula.d.ts +0 -20
  67. package/dist/utils/evaluateFormula.js +0 -31
  68. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.d.ts +0 -0
  69. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.js +0 -0
  70. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.d.ts +0 -0
  71. /package/dist/{canvas → annotation/canvas}/AnnotationCanvas.native.js +0 -0
  72. /package/dist/{canvas → annotation/canvas}/Tool.js +0 -0
  73. /package/dist/{canvas → annotation/canvas}/measurementPicker.js +0 -0
  74. /package/dist/{canvas → annotation/canvas}/measurementStampOverlay.js +0 -0
  75. /package/dist/{canvas → annotation/canvas}/pointerAdapter.d.ts +0 -0
  76. /package/dist/{canvas → annotation/canvas}/pointerAdapter.js +0 -0
  77. /package/dist/{canvas → annotation/canvas}/tools/panTool.d.ts +0 -0
  78. /package/dist/{canvas → annotation/canvas}/tools/selectTool.d.ts +0 -0
  79. /package/dist/{canvas → annotation/canvas}/viewport.js +0 -0
  80. /package/dist/{data → annotation/data}/AnnotationDataContext.d.ts +0 -0
  81. /package/dist/{data → annotation/data}/AnnotationDataContext.js +0 -0
  82. /package/dist/{data → annotation/data}/AnnotationDataProvider.js +0 -0
  83. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.d.ts +0 -0
  84. /package/dist/{data → annotation/data}/hooks/useAnnotationDoc.js +0 -0
  85. /package/dist/{data → annotation/data}/hooks/useAnnotationList.d.ts +0 -0
  86. /package/dist/{data → annotation/data}/hooks/useAnnotationList.js +0 -0
  87. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.d.ts +0 -0
  88. /package/dist/{data → annotation/data}/hooks/useAnnotationMutations.js +0 -0
  89. /package/dist/{hooks → utils}/useParseMeasurement.d.ts +0 -0
@@ -0,0 +1,446 @@
1
+ import { STAMP_TILE_SIZE } from '../stampLayout.js';
2
+ import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, } from '../measurementGeometry.js';
3
+ import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
4
+ const HIT_PADDING = 6;
5
+ // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
6
+ // override via `hitTest` for more precision later.
7
+ const hitStroke = (stroke, p) => {
8
+ const r = stroke.width / 2 + HIT_PADDING;
9
+ const r2 = r * r;
10
+ for (let i = 0; i < stroke.points.length - 2; i += 2) {
11
+ if (segmentDistanceSq(p.x, p.y, stroke.points[i], stroke.points[i + 1], stroke.points[i + 2], stroke.points[i + 3]) <= r2) {
12
+ return true;
13
+ }
14
+ }
15
+ return false;
16
+ };
17
+ // Screen-space grab tolerance (px) for a measurement-annotation line, converted
18
+ // to doc space via zoom (the line itself is a thin world-space stroke).
19
+ const LINE_GRAB_PX = 12;
20
+ // Screen-px radius of the center snap detent when sliding a tile along its line.
21
+ // Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
22
+ // slide worklet inlines the same value — keep them in sync.
23
+ const SLIDE_SNAP_PX = 12;
24
+ // Screen-px grab radius for a line-annotation endpoint handle (converted to doc
25
+ // space via zoom). A bit larger than the drawn handle so it's easy to grab.
26
+ const HANDLE_GRAB_PX = 22;
27
+ const hitMeasurement = (m, p, zoom = 1) => {
28
+ // The stamp renders as a constant *screen*-size square centered on the
29
+ // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
30
+ // screen-space half-extent (+ padding) back to doc space via the zoom so
31
+ // the hit box always matches what's drawn.
32
+ const scale = m.scale ?? 1;
33
+ const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
34
+ const dx = Math.abs(p.x - m.anchor.x);
35
+ const dy = Math.abs(p.y - m.anchor.y);
36
+ if (dx <= half && dy <= half)
37
+ return true;
38
+ // Measurement annotation: also grab anywhere along the line body.
39
+ if (m.line) {
40
+ const r = LINE_GRAB_PX / zoom;
41
+ if (segmentDistanceSq(p.x, p.y, m.line.a.x, m.line.a.y, m.line.b.x, m.line.b.y) <=
42
+ r * r) {
43
+ return true;
44
+ }
45
+ }
46
+ // Rectangle annotation: grab anywhere along the border (the interior stays
47
+ // transparent to hits so elements behind the rect remain selectable).
48
+ if (m.rect && placementOf(m) === 'rectangle') {
49
+ const n = normalizeRect(m.rect);
50
+ const r2 = (LINE_GRAB_PX / zoom) ** 2;
51
+ if (segmentDistanceSq(p.x, p.y, n.minX, n.minY, n.maxX, n.minY) <= r2 ||
52
+ segmentDistanceSq(p.x, p.y, n.maxX, n.minY, n.maxX, n.maxY) <= r2 ||
53
+ segmentDistanceSq(p.x, p.y, n.maxX, n.maxY, n.minX, n.maxY) <= r2 ||
54
+ segmentDistanceSq(p.x, p.y, n.minX, n.maxY, n.minX, n.minY) <= r2) {
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ };
60
+ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
61
+ const abx = bx - ax;
62
+ const aby = by - ay;
63
+ const lenSq = abx * abx + aby * aby;
64
+ let t = lenSq === 0 ? 0 : ((px - ax) * abx + (py - ay) * aby) / lenSq;
65
+ t = Math.max(0, Math.min(1, t));
66
+ const cx = ax + t * abx;
67
+ const cy = ay + t * aby;
68
+ const dx = px - cx;
69
+ const dy = py - cy;
70
+ return dx * dx + dy * dy;
71
+ };
72
+ const findHit = (doc, world, zoom) => {
73
+ // Hit-test in z-order (top first): measurements > shapes > strokes.
74
+ for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
75
+ const m = doc.placedMeasurements[i];
76
+ if (hitMeasurement(m, world, zoom))
77
+ return { id: m.id, kind: 'measurement' };
78
+ }
79
+ for (let i = doc.shapes.length - 1; i >= 0; i--) {
80
+ // Default shape hit test: bounding box of the shape's points + padding.
81
+ // Text shapes store only their top-left anchor, so their box comes from
82
+ // the estimated text bounds instead.
83
+ const s = doc.shapes[i];
84
+ let minX;
85
+ let maxX;
86
+ let minY;
87
+ let maxY;
88
+ if (s.kind === 'text') {
89
+ const b = textShapeBounds(s);
90
+ if (!b)
91
+ continue;
92
+ ({ minX, maxX, minY, maxY } = b);
93
+ }
94
+ else {
95
+ const pts = s.geometry.points;
96
+ if (pts.length === 0)
97
+ continue;
98
+ minX = pts[0].x;
99
+ maxX = pts[0].x;
100
+ minY = pts[0].y;
101
+ maxY = pts[0].y;
102
+ for (let j = 1; j < pts.length; j++) {
103
+ const p = pts[j];
104
+ if (p.x < minX)
105
+ minX = p.x;
106
+ if (p.x > maxX)
107
+ maxX = p.x;
108
+ if (p.y < minY)
109
+ minY = p.y;
110
+ if (p.y > maxY)
111
+ maxY = p.y;
112
+ }
113
+ }
114
+ if (world.x >= minX - HIT_PADDING &&
115
+ world.x <= maxX + HIT_PADDING &&
116
+ world.y >= minY - HIT_PADDING &&
117
+ world.y <= maxY + HIT_PADDING) {
118
+ return { id: s.id, kind: 'shape' };
119
+ }
120
+ }
121
+ for (let i = doc.strokes.length - 1; i >= 0; i--) {
122
+ const s = doc.strokes[i];
123
+ if (hitStroke(s, world))
124
+ return { id: s.id, kind: 'stroke' };
125
+ }
126
+ return null;
127
+ };
128
+ const translatePatch = (elementKind, id, doc, delta) => {
129
+ if (elementKind === 'measurement') {
130
+ const m = doc.placedMeasurements.find((x) => x.id === id);
131
+ if (!m)
132
+ return null;
133
+ // Group move: translate the whole annotation uniformly. `anchor` stays
134
+ // consistent with the line (lerp is affine, so translating both endpoints
135
+ // translates the anchor by the same delta). Only emit keys that exist so
136
+ // the patch never carries `undefined` (Firestore rejects undefined values).
137
+ const tx = (p) => ({ x: p.x + delta.x, y: p.y + delta.y });
138
+ const patch = { anchor: tx(m.anchor) };
139
+ if (m.line)
140
+ patch.line = { a: tx(m.line.a), b: tx(m.line.b) };
141
+ if (m.rect)
142
+ patch.rect = { a: tx(m.rect.a), b: tx(m.rect.b) };
143
+ if (m.leader)
144
+ patch.leader = { from: tx(m.leader.from), to: tx(m.leader.to) };
145
+ return { op: 'updateMeasurement', id, patch };
146
+ }
147
+ if (elementKind === 'shape') {
148
+ const s = doc.shapes.find((x) => x.id === id);
149
+ if (!s)
150
+ return null;
151
+ return {
152
+ op: 'updateShape',
153
+ id,
154
+ patch: {
155
+ geometry: {
156
+ ...s.geometry,
157
+ points: s.geometry.points.map((p) => ({
158
+ x: p.x + delta.x,
159
+ y: p.y + delta.y,
160
+ })),
161
+ },
162
+ },
163
+ };
164
+ }
165
+ const stroke = doc.strokes.find((x) => x.id === id);
166
+ if (!stroke)
167
+ return null;
168
+ const points = stroke.points.slice();
169
+ for (let i = 0; i < points.length; i += 2) {
170
+ points[i] = points[i] + delta.x;
171
+ points[i + 1] = points[i + 1] + delta.y;
172
+ }
173
+ return { op: 'updateStroke', id, patch: { points } };
174
+ };
175
+ // --- Measurement-annotation grab logic (shared by the native UI-thread drag
176
+ // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
177
+ // Grabbing the tile of a line annotation slides it along the line; everything
178
+ // else (bare stamps, grabs on the line body) is a group move.
179
+ const classifyGrab = (doc, id, world, zoom) => {
180
+ const m = doc.placedMeasurements.find((x) => x.id === id);
181
+ if (!m)
182
+ return 'move';
183
+ const scale = m.scale ?? 1;
184
+ const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
185
+ const onTile = Math.abs(world.x - m.anchor.x) <= half &&
186
+ Math.abs(world.y - m.anchor.y) <= half;
187
+ return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
188
+ };
189
+ // Slide the tile along its line by a world-space drag delta (relative to
190
+ // grab-start), clamped to [0,1] with a center snap, and rewrite anchor. The
191
+ // native slide worklet in AnnotationCanvasInner.native.tsx mirrors this math.
192
+ const slidePatch = (doc, id, delta, zoom) => {
193
+ const m = doc.placedMeasurements.find((x) => x.id === id);
194
+ if (!m || !m.line || placementOf(m) !== 'line')
195
+ return null;
196
+ const abx = m.line.b.x - m.line.a.x;
197
+ const aby = m.line.b.y - m.line.a.y;
198
+ const lenSq = abx * abx + aby * aby;
199
+ if (lenSq === 0)
200
+ return null;
201
+ let t = linePosOf(m) + (delta.x * abx + delta.y * aby) / lenSq;
202
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
203
+ const snapT = SLIDE_SNAP_PX / zoom / Math.sqrt(lenSq);
204
+ t = snapLinePos(t, snapT);
205
+ const anchor = lerp(m.line.a, m.line.b, t);
206
+ return { ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }] };
207
+ };
208
+ // Which endpoint handle of a (selected) line annotation is under `world`.
209
+ // Prefers the nearer endpoint when both are within range.
210
+ const findHandleHit = (doc, id, world, zoom) => {
211
+ const m = doc.placedMeasurements.find((x) => x.id === id);
212
+ if (!m || !m.line || placementOf(m) !== 'line')
213
+ return null;
214
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
215
+ const da = (world.x - m.line.a.x) ** 2 + (world.y - m.line.a.y) ** 2;
216
+ const db = (world.x - m.line.b.x) ** 2 + (world.y - m.line.b.y) ** 2;
217
+ if (da <= r2 && da <= db)
218
+ return 'a';
219
+ if (db <= r2)
220
+ return 'b';
221
+ return null;
222
+ };
223
+ // Move one endpoint by a world delta (resize/rotate); keep the tile at its
224
+ // linePos by rewriting anchor.
225
+ const endpointPatch = (doc, id, handle, delta) => {
226
+ const m = doc.placedMeasurements.find((x) => x.id === id);
227
+ if (!m || !m.line || placementOf(m) !== 'line')
228
+ return null;
229
+ const a = handle === 'a'
230
+ ? { x: m.line.a.x + delta.x, y: m.line.a.y + delta.y }
231
+ : m.line.a;
232
+ const b = handle === 'b'
233
+ ? { x: m.line.b.x + delta.x, y: m.line.b.y + delta.y }
234
+ : m.line.b;
235
+ const line = { a, b };
236
+ const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
237
+ return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
238
+ };
239
+ // Which corner handle of a (selected) rectangle annotation is under `world`.
240
+ // Prefers the nearest corner when several are within range (small rects).
241
+ const findRectCornerHit = (doc, id, world, zoom) => {
242
+ const m = doc.placedMeasurements.find((x) => x.id === id);
243
+ if (!m || !m.rect || placementOf(m) !== 'rectangle')
244
+ return null;
245
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
246
+ let best = null;
247
+ for (const corner of ['tl', 'tr', 'bl', 'br']) {
248
+ const p = rectCornerPoint(m.rect, corner);
249
+ const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
250
+ if (d <= r2 && (!best || d < best.d))
251
+ best = { corner, d };
252
+ }
253
+ if (!best)
254
+ return null;
255
+ return {
256
+ corner: best.corner,
257
+ moving: rectCornerPoint(m.rect, best.corner),
258
+ fixed: rectCornerPoint(m.rect, oppositeRectCorner(best.corner)),
259
+ };
260
+ };
261
+ // Drag one rect corner by a world delta (opposite corner fixed) and re-center
262
+ // the anchor, keeping the tile locked to the rect's center.
263
+ const rectCornerPatch = (doc, id, corner, delta) => {
264
+ const m = doc.placedMeasurements.find((x) => x.id === id);
265
+ if (!m || !m.rect || placementOf(m) !== 'rectangle')
266
+ return null;
267
+ const moving = rectCornerPoint(m.rect, corner);
268
+ const rect = {
269
+ a: rectCornerPoint(m.rect, oppositeRectCorner(corner)),
270
+ b: { x: moving.x + delta.x, y: moving.y + delta.y },
271
+ };
272
+ return {
273
+ ops: [
274
+ { op: 'updateMeasurement', id, patch: { rect, anchor: rectCenter(rect) } },
275
+ ],
276
+ };
277
+ };
278
+ // --- Text-shape resize (corner-scale about the top-left anchor; shared by the
279
+ // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
280
+ // Resize geometry when the (selected) text shape's corner handle is under
281
+ // `world`, else null. The handle is drawn on the padded selection box's
282
+ // bottom-right corner (see AnnotationCanvasSkia); grab radius matches the
283
+ // measurement endpoint handles.
284
+ const findResizeHandleHit = (doc, id, world, zoom) => {
285
+ const s = doc.shapes.find((x) => x.id === id);
286
+ if (!s)
287
+ return null;
288
+ const geom = textResizeGeometry(s);
289
+ if (!geom)
290
+ return null;
291
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
292
+ const dx = world.x - geom.handle.x;
293
+ const dy = world.y - geom.handle.y;
294
+ return dx * dx + dy * dy <= r2 ? geom : null;
295
+ };
296
+ // Scale the text shape's fontSize by the drag (clamped to the geometry's
297
+ // scale range, so it matches the native live preview exactly). The anchor —
298
+ // the scale pivot — is untouched.
299
+ const resizePatch = (doc, id, delta) => {
300
+ const s = doc.shapes.find((x) => x.id === id);
301
+ if (!s)
302
+ return null;
303
+ const geom = textResizeGeometry(s);
304
+ if (!geom)
305
+ return null;
306
+ const scale = resizeScaleFromDrag(geom, delta);
307
+ const fontSize = Math.round((s.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE) * scale * 10) / 10;
308
+ return {
309
+ ops: [
310
+ { op: 'updateShape', id, patch: { style: { ...s.style, fontSize } } },
311
+ ],
312
+ };
313
+ };
314
+ // Patch for the current drag mode (web pointer path), from a world-space delta.
315
+ const dragPatch = (s, doc, delta, zoom) => {
316
+ if (s.mode === 'endpoint' && s.handle) {
317
+ return endpointPatch(doc, s.id, s.handle, delta);
318
+ }
319
+ if (s.mode === 'slide')
320
+ return slidePatch(doc, s.id, delta, zoom);
321
+ if (s.mode === 'resize')
322
+ return resizePatch(doc, s.id, delta);
323
+ if (s.mode === 'rect-corner' && s.corner) {
324
+ return rectCornerPatch(doc, s.id, s.corner, delta);
325
+ }
326
+ const op = translatePatch(s.elementKind, s.id, doc, delta);
327
+ return op ? { ops: [op] } : null;
328
+ };
329
+ export const createSelectTool = () => ({
330
+ id: 'select',
331
+ label: 'Select',
332
+ cursor: 'default',
333
+ // Native drags the hit element on the UI thread (see DragSelectionConfig),
334
+ // reusing the same hit-test and translate logic the pointer handlers below
335
+ // use for web — one source of truth.
336
+ dragSelection: {
337
+ hitTest: (doc, world, zoom) => findHit(doc, world, zoom),
338
+ buildTranslatePatch: (doc, id, kind, delta) => {
339
+ const op = translatePatch(kind, id, doc, delta);
340
+ return op ? { ops: [op] } : null;
341
+ },
342
+ classifyMeasurementGrab: classifyGrab,
343
+ buildSlidePatch: slidePatch,
344
+ hitTestHandle: findHandleHit,
345
+ buildEndpointPatch: endpointPatch,
346
+ hitTestResizeHandle: findResizeHandleHit,
347
+ buildResizePatch: resizePatch,
348
+ hitTestRectCorner: findRectCornerHit,
349
+ buildRectCornerPatch: rectCornerPatch,
350
+ },
351
+ // Web pointer path. Mirrors the native UI-thread drag using the same shared
352
+ // helpers: an endpoint handle on the selected annotation resizes the line;
353
+ // grabbing a line-annotation tile slides it; everything else group-moves.
354
+ onPointerDown(event, ctx) {
355
+ const { world } = event;
356
+ const zoom = ctx.viewport.state.zoom;
357
+ // Endpoint/resize handles show only on the selected element — check first.
358
+ const selId = ctx.selection?.ids[0];
359
+ if (selId) {
360
+ const handle = findHandleHit(ctx.document, selId, world, zoom);
361
+ if (handle) {
362
+ ctx.setSelection({ ids: [selId] });
363
+ return {
364
+ kind: 'dragging',
365
+ id: selId,
366
+ elementKind: 'measurement',
367
+ mode: 'endpoint',
368
+ handle,
369
+ start: world,
370
+ delta: { x: 0, y: 0 },
371
+ };
372
+ }
373
+ if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
374
+ return {
375
+ kind: 'dragging',
376
+ id: selId,
377
+ elementKind: 'shape',
378
+ mode: 'resize',
379
+ start: world,
380
+ delta: { x: 0, y: 0 },
381
+ };
382
+ }
383
+ const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
384
+ if (rectCorner) {
385
+ ctx.setSelection({ ids: [selId] });
386
+ return {
387
+ kind: 'dragging',
388
+ id: selId,
389
+ elementKind: 'measurement',
390
+ mode: 'rect-corner',
391
+ corner: rectCorner.corner,
392
+ start: world,
393
+ delta: { x: 0, y: 0 },
394
+ };
395
+ }
396
+ }
397
+ const hit = findHit(ctx.document, world, zoom);
398
+ if (!hit) {
399
+ ctx.setSelection(null);
400
+ return { kind: 'idle' };
401
+ }
402
+ ctx.setSelection({ ids: [hit.id] });
403
+ const mode = hit.kind === 'measurement' &&
404
+ classifyGrab(ctx.document, hit.id, world, zoom) === 'slide'
405
+ ? 'slide'
406
+ : 'move';
407
+ return {
408
+ kind: 'dragging',
409
+ id: hit.id,
410
+ elementKind: hit.kind,
411
+ mode,
412
+ start: world,
413
+ delta: { x: 0, y: 0 },
414
+ };
415
+ },
416
+ onPointerMove(event, ctx, state) {
417
+ const s = state;
418
+ if (s?.kind !== 'dragging')
419
+ return s;
420
+ const delta = { x: event.world.x - s.start.x, y: event.world.y - s.start.y };
421
+ const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
422
+ if (patch)
423
+ ctx.preview(patch);
424
+ return { ...s, delta };
425
+ },
426
+ onPointerUp(_event, ctx, state) {
427
+ const s = state;
428
+ if (s?.kind !== 'dragging')
429
+ return;
430
+ if (s.delta.x === 0 && s.delta.y === 0)
431
+ return;
432
+ const patch = dragPatch(s, ctx.document, s.delta, ctx.viewport.state.zoom);
433
+ if (patch)
434
+ ctx.commit(patch);
435
+ },
436
+ onCancel(_state, ctx) {
437
+ ctx.preview({ ops: [] });
438
+ },
439
+ hitTest(element, p) {
440
+ if (element.kind === 'measurement')
441
+ return hitMeasurement(element, p);
442
+ if (element.kind === 'stroke')
443
+ return hitStroke(element, p);
444
+ return false;
445
+ },
446
+ });
@@ -0,0 +1,12 @@
1
+ import type { AnnotationShape } from '../../../types/annotation.js';
2
+ import type { Tool } from '../Tool.js';
3
+ export interface TextToolOptions {
4
+ color?: string;
5
+ fontSize?: number;
6
+ dash?: boolean;
7
+ autoSwitchToSelect?: boolean;
8
+ onPlaced?: (shape: AnnotationShape) => void;
9
+ onAutoSwitch?: (toToolId: string) => void;
10
+ selectToolId?: string;
11
+ }
12
+ export declare const createTextTool: (options?: TextToolOptions) => Tool;
@@ -0,0 +1,78 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
3
+ let counter = 0;
4
+ const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
6
+ // Topmost text shape under a world point, for tap-to-edit.
7
+ const findTextShapeAt = (doc, world) => {
8
+ for (let i = doc.shapes.length - 1; i >= 0; i--) {
9
+ const s = doc.shapes[i];
10
+ if (s.kind === 'text' && hitTestTextShape(s, world))
11
+ return s;
12
+ }
13
+ return null;
14
+ };
15
+ // Tap-to-type. Tapping empty canvas opens the consumer's text input and
16
+ // commits a new text shape at the tap point (top-left anchored); tapping an
17
+ // existing text shape re-opens the input pre-filled to edit it (clearing the
18
+ // text deletes the shape). Both paths leave the shape selected and (by
19
+ // default) switch back to select so it can be dragged/resized immediately —
20
+ // the same flow as the measurement stamp tool. Skia-free, like every tool.
21
+ export const createTextTool = (options = {}) => {
22
+ const color = options.color ?? '#111827';
23
+ const fontSize = options.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
24
+ const dash = options.dash ?? false;
25
+ const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
26
+ const selectToolId = options.selectToolId ?? 'select';
27
+ return {
28
+ id: 'text',
29
+ label: 'Text',
30
+ cursor: 'text',
31
+ onPointerUp(event, ctx) {
32
+ const existing = findTextShapeAt(ctx.document, event.world);
33
+ if (existing) {
34
+ void ctx
35
+ .requestTextInput({ initialText: existing.text })
36
+ .then((text) => {
37
+ if (text === null)
38
+ return;
39
+ if (text === '') {
40
+ ctx.commit({ ops: [{ op: 'removeShape', id: existing.id }] });
41
+ ctx.setSelection(null);
42
+ return;
43
+ }
44
+ if (text !== existing.text) {
45
+ ctx.commit({
46
+ ops: [{ op: 'updateShape', id: existing.id, patch: { text } }],
47
+ });
48
+ }
49
+ ctx.setSelection({ ids: [existing.id] });
50
+ if (autoSwitchToSelect)
51
+ options.onAutoSwitch?.(selectToolId);
52
+ });
53
+ return;
54
+ }
55
+ const anchor = event.world;
56
+ void ctx.requestTextInput().then((text) => {
57
+ if (!text)
58
+ return;
59
+ const shape = {
60
+ id: makeId(),
61
+ layerId: firstLayerId(ctx.document),
62
+ kind: 'text',
63
+ geometry: { points: [anchor] },
64
+ // `...(dash && ...)` keeps the key absent (not `false`) for solid
65
+ // text, matching the stroke convention (absent === solid).
66
+ style: { stroke: color, fontSize, ...(dash && { dash: true }) },
67
+ text,
68
+ createdAt: Date.now(),
69
+ };
70
+ ctx.commit({ ops: [{ op: 'addShape', shape }] });
71
+ ctx.setSelection({ ids: [shape.id] });
72
+ options.onPlaced?.(shape);
73
+ if (autoSwitchToSelect)
74
+ options.onAutoSwitch?.(selectToolId);
75
+ });
76
+ },
77
+ };
78
+ };
@@ -1,7 +1,7 @@
1
- import type { Measurement } from '../types/firestore.js';
2
- import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationStroke, type Selection, type Vec2 } from '../types/annotation.js';
1
+ import type { Measurement } from '../../types/firestore.js';
2
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type Vec2 } from '../../types/annotation.js';
3
3
  import type { MeasurementRef } from './measurementPicker.js';
4
- import type { CanvasPointerEvent, Tool, ToolContext, ToolState } from './Tool.js';
4
+ import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
5
5
  import { type ViewportState } from './viewport.js';
6
6
  export interface AnnotationCanvasHandle {
7
7
  undo(): void;
@@ -11,6 +11,12 @@ export interface AnnotationCanvasHandle {
11
11
  zoomToFit(): void;
12
12
  resetView(): void;
13
13
  placeMeasurementAtCenter(ref: MeasurementRef): void;
14
+ placeAnnotationAtCenter(opts?: {
15
+ defaultLengthDoc?: number;
16
+ }): void;
17
+ setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
+ associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
19
+ deleteSelected(): void;
14
20
  }
15
21
  export interface UseAnnotationCanvasStateProps {
16
22
  canvas: AnnotationCanvasState;
@@ -21,6 +27,7 @@ export interface UseAnnotationCanvasStateProps {
21
27
  onSelectionChange(selection: Selection | null): void;
22
28
  measurements?: Measurement[];
23
29
  pickMeasurement?: () => Promise<MeasurementRef | null>;
30
+ requestTextInput?: RequestTextInput;
24
31
  width: number;
25
32
  height: number;
26
33
  initialViewport?: ViewportState;
@@ -50,5 +57,6 @@ export interface AnnotationCanvasStateApi {
50
57
  dispatchPointerCancel(): void;
51
58
  pan(deltaScreen: Vec2): void;
52
59
  zoom(focalScreen: Vec2, nextZoom: number): void;
60
+ setViewport(next: ViewportState): void;
53
61
  }
54
62
  export declare const useAnnotationCanvasState: (props: UseAnnotationCanvasStateProps) => AnnotationCanvasStateApi;