@reekon-tools/boldr-utils 1.6.12 → 1.6.14

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 (41) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +5 -2
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +58 -6
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +5 -2
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +514 -59
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +31 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
  8. package/dist/annotation/canvas/Tool.d.ts +27 -0
  9. package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
  10. package/dist/annotation/canvas/elements/ShapeElement.js +68 -9
  11. package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
  12. package/dist/annotation/canvas/measurementGeometry.d.ts +21 -0
  13. package/dist/annotation/canvas/measurementGeometry.js +98 -3
  14. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  15. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  16. package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
  17. package/dist/annotation/canvas/strokeGeometry.js +8 -0
  18. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  19. package/dist/annotation/canvas/textGeometry.js +110 -0
  20. package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
  21. package/dist/annotation/canvas/tools/panTool.js +38 -5
  22. package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
  23. package/dist/annotation/canvas/tools/penTool.js +8 -2
  24. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  25. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  26. package/dist/annotation/canvas/tools/selectTool.js +148 -51
  27. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  28. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  29. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  30. package/dist/annotation/canvas/tools/textTool.js +78 -0
  31. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
  32. package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -6
  33. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  34. package/dist/annotation/data/coalescedRunner.js +48 -0
  35. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +118 -38
  36. package/dist/exports.d.ts +9 -4
  37. package/dist/exports.js +8 -3
  38. package/dist/formulas/calculateFormula.js +1 -3
  39. package/dist/types/annotation.d.ts +9 -0
  40. package/dist/types/firestore.d.ts +4 -0
  41. package/package.json +1 -1
@@ -1,5 +1,7 @@
1
1
  import { STAMP_TILE_SIZE } from '../stampLayout.js';
2
- import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, } from '../measurementGeometry.js';
2
+ import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
+ import { hitShapeOutline } from '../shapeGeometry.js';
4
+ import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
3
5
  const HIT_PADDING = 6;
4
6
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
5
7
  // override via `hitTest` for more precision later.
@@ -13,9 +15,9 @@ const hitStroke = (stroke, p) => {
13
15
  }
14
16
  return false;
15
17
  };
16
- // Screen-space grab tolerance (px) for a measurement-annotation line, converted
17
- // to doc space via zoom (the line itself is a thin world-space stroke).
18
- const LINE_GRAB_PX = 12;
18
+ // Screen-space grab tolerance (px) added around a geometric shape's outline
19
+ // (line/arrow/rect/ellipse/polygon), converted to doc space via zoom.
20
+ const SHAPE_GRAB_PX = 12;
19
21
  // Screen-px radius of the center snap detent when sliding a tile along its line.
20
22
  // Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
21
23
  // slide worklet inlines the same value — keep them in sync.
@@ -23,27 +25,6 @@ const SLIDE_SNAP_PX = 12;
23
25
  // Screen-px grab radius for a line-annotation endpoint handle (converted to doc
24
26
  // space via zoom). A bit larger than the drawn handle so it's easy to grab.
25
27
  const HANDLE_GRAB_PX = 22;
26
- const hitMeasurement = (m, p, zoom = 1) => {
27
- // The stamp renders as a constant *screen*-size square centered on the
28
- // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
29
- // screen-space half-extent (+ padding) back to doc space via the zoom so
30
- // the hit box always matches what's drawn.
31
- const scale = m.scale ?? 1;
32
- const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
33
- const dx = Math.abs(p.x - m.anchor.x);
34
- const dy = Math.abs(p.y - m.anchor.y);
35
- if (dx <= half && dy <= half)
36
- return true;
37
- // Measurement annotation: also grab anywhere along the line body.
38
- if (m.line) {
39
- const r = LINE_GRAB_PX / zoom;
40
- if (segmentDistanceSq(p.x, p.y, m.line.a.x, m.line.a.y, m.line.b.x, m.line.b.y) <=
41
- r * r) {
42
- return true;
43
- }
44
- }
45
- return false;
46
- };
47
28
  const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
48
29
  const abx = bx - ax;
49
30
  const aby = by - ay;
@@ -60,34 +41,32 @@ const findHit = (doc, world, zoom) => {
60
41
  // Hit-test in z-order (top first): measurements > shapes > strokes.
61
42
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
62
43
  const m = doc.placedMeasurements[i];
63
- if (hitMeasurement(m, world, zoom))
44
+ if (hitPlacedMeasurement(m, world, zoom)) {
64
45
  return { id: m.id, kind: 'measurement' };
46
+ }
65
47
  }
66
48
  for (let i = doc.shapes.length - 1; i >= 0; i--) {
67
- // Default shape hit test: bounding box of the shape's points + padding.
68
49
  const s = doc.shapes[i];
69
- const pts = s.geometry.points;
70
- if (pts.length === 0)
50
+ if (s.kind === 'text') {
51
+ // Text shapes store only their top-left anchor, so their hit box comes
52
+ // from the estimated text bounds.
53
+ const b = textShapeBounds(s);
54
+ if (!b)
55
+ continue;
56
+ if (world.x >= b.minX - HIT_PADDING &&
57
+ world.x <= b.maxX + HIT_PADDING &&
58
+ world.y >= b.minY - HIT_PADDING &&
59
+ world.y <= b.maxY + HIT_PADDING) {
60
+ return { id: s.id, kind: 'shape' };
61
+ }
71
62
  continue;
72
- let minX = pts[0].x;
73
- let maxX = pts[0].x;
74
- let minY = pts[0].y;
75
- let maxY = pts[0].y;
76
- for (let j = 1; j < pts.length; j++) {
77
- const p = pts[j];
78
- if (p.x < minX)
79
- minX = p.x;
80
- if (p.x > maxX)
81
- maxX = p.x;
82
- if (p.y < minY)
83
- minY = p.y;
84
- if (p.y > maxY)
85
- maxY = p.y;
86
63
  }
87
- if (world.x >= minX - HIT_PADDING &&
88
- world.x <= maxX + HIT_PADDING &&
89
- world.y >= minY - HIT_PADDING &&
90
- world.y <= maxY + HIT_PADDING) {
64
+ // Geometric shapes hit on their painted outline, not their bounding box —
65
+ // a long diagonal line's box would swallow taps nowhere near the ink, and
66
+ // a shape's interior stays transparent to hits so elements inside remain
67
+ // selectable. Grab radius = half the stroke + a screen-constant pad.
68
+ const tol = (s.style.strokeWidth ?? 2) / 2 + SHAPE_GRAB_PX / zoom;
69
+ if (hitShapeOutline(s, world, tol)) {
91
70
  return { id: s.id, kind: 'shape' };
92
71
  }
93
72
  }
@@ -111,6 +90,8 @@ const translatePatch = (elementKind, id, doc, delta) => {
111
90
  const patch = { anchor: tx(m.anchor) };
112
91
  if (m.line)
113
92
  patch.line = { a: tx(m.line.a), b: tx(m.line.b) };
93
+ if (m.rect)
94
+ patch.rect = { a: tx(m.rect.a), b: tx(m.rect.b) };
114
95
  if (m.leader)
115
96
  patch.leader = { from: tx(m.leader.from), to: tx(m.leader.to) };
116
97
  return { op: 'updateMeasurement', id, patch };
@@ -174,7 +155,9 @@ const slidePatch = (doc, id, delta, zoom) => {
174
155
  const snapT = SLIDE_SNAP_PX / zoom / Math.sqrt(lenSq);
175
156
  t = snapLinePos(t, snapT);
176
157
  const anchor = lerp(m.line.a, m.line.b, t);
177
- return { ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }] };
158
+ return {
159
+ ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
160
+ };
178
161
  };
179
162
  // Which endpoint handle of a (selected) line annotation is under `world`.
180
163
  // Prefers the nearer endpoint when both are within range.
@@ -207,6 +190,85 @@ const endpointPatch = (doc, id, handle, delta) => {
207
190
  const anchor = recomputeAnchor(line, 'line', linePosOf(m), m.anchor);
208
191
  return { ops: [{ op: 'updateMeasurement', id, patch: { line, anchor } }] };
209
192
  };
193
+ // Which corner handle of a (selected) rectangle annotation is under `world`.
194
+ // Prefers the nearest corner when several are within range (small rects).
195
+ const findRectCornerHit = (doc, id, world, zoom) => {
196
+ const m = doc.placedMeasurements.find((x) => x.id === id);
197
+ if (!m || !m.rect || placementOf(m) !== 'rectangle')
198
+ return null;
199
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
200
+ let best = null;
201
+ for (const corner of ['tl', 'tr', 'bl', 'br']) {
202
+ const p = rectCornerPoint(m.rect, corner);
203
+ const d = (world.x - p.x) ** 2 + (world.y - p.y) ** 2;
204
+ if (d <= r2 && (!best || d < best.d))
205
+ best = { corner, d };
206
+ }
207
+ if (!best)
208
+ return null;
209
+ return {
210
+ corner: best.corner,
211
+ moving: rectCornerPoint(m.rect, best.corner),
212
+ fixed: rectCornerPoint(m.rect, oppositeRectCorner(best.corner)),
213
+ };
214
+ };
215
+ // Drag one rect corner by a world delta (opposite corner fixed) and re-center
216
+ // the anchor, keeping the tile locked to the rect's center.
217
+ const rectCornerPatch = (doc, id, corner, delta) => {
218
+ const m = doc.placedMeasurements.find((x) => x.id === id);
219
+ if (!m || !m.rect || placementOf(m) !== 'rectangle')
220
+ return null;
221
+ const moving = rectCornerPoint(m.rect, corner);
222
+ const rect = {
223
+ a: rectCornerPoint(m.rect, oppositeRectCorner(corner)),
224
+ b: { x: moving.x + delta.x, y: moving.y + delta.y },
225
+ };
226
+ return {
227
+ ops: [
228
+ {
229
+ op: 'updateMeasurement',
230
+ id,
231
+ patch: { rect, anchor: rectCenter(rect) },
232
+ },
233
+ ],
234
+ };
235
+ };
236
+ // --- Text-shape resize (corner-scale about the top-left anchor; shared by the
237
+ // native UI-thread drag via DragSelectionConfig AND the web pointer handlers) ---
238
+ // Resize geometry when the (selected) text shape's corner handle is under
239
+ // `world`, else null. The handle is drawn on the padded selection box's
240
+ // bottom-right corner (see AnnotationCanvasSkia); grab radius matches the
241
+ // measurement endpoint handles.
242
+ const findResizeHandleHit = (doc, id, world, zoom) => {
243
+ const s = doc.shapes.find((x) => x.id === id);
244
+ if (!s)
245
+ return null;
246
+ const geom = textResizeGeometry(s);
247
+ if (!geom)
248
+ return null;
249
+ const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
250
+ const dx = world.x - geom.handle.x;
251
+ const dy = world.y - geom.handle.y;
252
+ return dx * dx + dy * dy <= r2 ? geom : null;
253
+ };
254
+ // Scale the text shape's fontSize by the drag (clamped to the geometry's
255
+ // scale range, so it matches the native live preview exactly). The anchor —
256
+ // the scale pivot — is untouched.
257
+ const resizePatch = (doc, id, delta) => {
258
+ const s = doc.shapes.find((x) => x.id === id);
259
+ if (!s)
260
+ return null;
261
+ const geom = textResizeGeometry(s);
262
+ if (!geom)
263
+ return null;
264
+ const scale = resizeScaleFromDrag(geom, delta);
265
+ const fontSize = Math.round((s.style.fontSize ?? DEFAULT_TEXT_FONT_SIZE) * scale * 10) / 10;
266
+ return {
267
+ ops: [
268
+ { op: 'updateShape', id, patch: { style: { ...s.style, fontSize } } },
269
+ ],
270
+ };
271
+ };
210
272
  // Patch for the current drag mode (web pointer path), from a world-space delta.
211
273
  const dragPatch = (s, doc, delta, zoom) => {
212
274
  if (s.mode === 'endpoint' && s.handle) {
@@ -214,6 +276,11 @@ const dragPatch = (s, doc, delta, zoom) => {
214
276
  }
215
277
  if (s.mode === 'slide')
216
278
  return slidePatch(doc, s.id, delta, zoom);
279
+ if (s.mode === 'resize')
280
+ return resizePatch(doc, s.id, delta);
281
+ if (s.mode === 'rect-corner' && s.corner) {
282
+ return rectCornerPatch(doc, s.id, s.corner, delta);
283
+ }
217
284
  const op = translatePatch(s.elementKind, s.id, doc, delta);
218
285
  return op ? { ops: [op] } : null;
219
286
  };
@@ -234,6 +301,10 @@ export const createSelectTool = () => ({
234
301
  buildSlidePatch: slidePatch,
235
302
  hitTestHandle: findHandleHit,
236
303
  buildEndpointPatch: endpointPatch,
304
+ hitTestResizeHandle: findResizeHandleHit,
305
+ buildResizePatch: resizePatch,
306
+ hitTestRectCorner: findRectCornerHit,
307
+ buildRectCornerPatch: rectCornerPatch,
237
308
  },
238
309
  // Web pointer path. Mirrors the native UI-thread drag using the same shared
239
310
  // helpers: an endpoint handle on the selected annotation resizes the line;
@@ -241,7 +312,7 @@ export const createSelectTool = () => ({
241
312
  onPointerDown(event, ctx) {
242
313
  const { world } = event;
243
314
  const zoom = ctx.viewport.state.zoom;
244
- // Endpoint handles show only on the selected annotation — check first.
315
+ // Endpoint/resize handles show only on the selected element — check first.
245
316
  const selId = ctx.selection?.ids[0];
246
317
  if (selId) {
247
318
  const handle = findHandleHit(ctx.document, selId, world, zoom);
@@ -257,6 +328,29 @@ export const createSelectTool = () => ({
257
328
  delta: { x: 0, y: 0 },
258
329
  };
259
330
  }
331
+ if (findResizeHandleHit(ctx.document, selId, world, zoom)) {
332
+ return {
333
+ kind: 'dragging',
334
+ id: selId,
335
+ elementKind: 'shape',
336
+ mode: 'resize',
337
+ start: world,
338
+ delta: { x: 0, y: 0 },
339
+ };
340
+ }
341
+ const rectCorner = findRectCornerHit(ctx.document, selId, world, zoom);
342
+ if (rectCorner) {
343
+ ctx.setSelection({ ids: [selId] });
344
+ return {
345
+ kind: 'dragging',
346
+ id: selId,
347
+ elementKind: 'measurement',
348
+ mode: 'rect-corner',
349
+ corner: rectCorner.corner,
350
+ start: world,
351
+ delta: { x: 0, y: 0 },
352
+ };
353
+ }
260
354
  }
261
355
  const hit = findHit(ctx.document, world, zoom);
262
356
  if (!hit) {
@@ -281,7 +375,10 @@ export const createSelectTool = () => ({
281
375
  const s = state;
282
376
  if (s?.kind !== 'dragging')
283
377
  return s;
284
- const delta = { x: event.world.x - s.start.x, y: event.world.y - s.start.y };
378
+ const delta = {
379
+ x: event.world.x - s.start.x,
380
+ y: event.world.y - s.start.y,
381
+ };
285
382
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
286
383
  if (patch)
287
384
  ctx.preview(patch);
@@ -302,7 +399,7 @@ export const createSelectTool = () => ({
302
399
  },
303
400
  hitTest(element, p) {
304
401
  if (element.kind === 'measurement')
305
- return hitMeasurement(element, p);
402
+ return hitPlacedMeasurement(element, p);
306
403
  if (element.kind === 'stroke')
307
404
  return hitStroke(element, p);
308
405
  return false;
@@ -0,0 +1,25 @@
1
+ import type { AnnotationShape, StrokeCap, Vec2 } from '../../../types/annotation.js';
2
+ import { type ShapeToolKind } from '../shapeGeometry.js';
3
+ import type { Tool } from '../Tool.js';
4
+ export interface ShapeToolOptions {
5
+ kind?: ShapeToolKind;
6
+ id?: string;
7
+ label?: string;
8
+ color?: string;
9
+ width?: number;
10
+ cap?: StrokeCap;
11
+ dash?: boolean;
12
+ minDragPx?: number;
13
+ }
14
+ export declare const buildShapeFromDrag: (opts: {
15
+ kind: ShapeToolKind;
16
+ a: Vec2;
17
+ b: Vec2;
18
+ color: string;
19
+ width: number;
20
+ cap?: StrokeCap;
21
+ dash?: boolean;
22
+ layerId: string;
23
+ id?: string;
24
+ }) => AnnotationShape;
25
+ export declare const createShapeTool: (options?: ShapeToolOptions) => Tool;
@@ -0,0 +1,111 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { annotationKindFor, shapePointsFromDrag, } from '../shapeGeometry.js';
3
+ let counter = 0;
4
+ const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
+ const DEFAULT_LABELS = {
6
+ line: 'Line',
7
+ rect: 'Rectangle',
8
+ triangle: 'Triangle',
9
+ ellipse: 'Circle',
10
+ };
11
+ // Assemble the committed AnnotationShape for a drag from `a` to `b`. Shared
12
+ // by the web pointer handlers below AND the native UI-thread gesture (which
13
+ // rubber-bands on shared values and calls this once on release) — one source
14
+ // of truth for kind mapping, styling, and the no-undefined-keys rule
15
+ // (Firestore rejects undefined values, so optional style keys are spread in
16
+ // only when set).
17
+ export const buildShapeFromDrag = (opts) => ({
18
+ id: opts.id ?? makeId('shape'),
19
+ layerId: opts.layerId,
20
+ kind: annotationKindFor(opts.kind),
21
+ geometry: { points: shapePointsFromDrag(opts.kind, opts.a, opts.b) },
22
+ style: {
23
+ stroke: opts.color,
24
+ strokeWidth: opts.width,
25
+ ...(opts.dash && { dash: true }),
26
+ // Caps only mean something on an open line; 'round' is the implicit
27
+ // default so it stays un-persisted.
28
+ ...(opts.kind === 'line' &&
29
+ opts.cap &&
30
+ opts.cap !== 'round' && { cap: opts.cap }),
31
+ },
32
+ createdAt: Date.now(),
33
+ });
34
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
35
+ // Drag-to-draw shape tool (line/arrow/rect/triangle/circle). Press-drag
36
+ // rubber-bands the shape from the press point; release commits it as an
37
+ // AnnotationShape. On native the drag runs on the UI thread via the
38
+ // `shapeDraw` config; the pointer handlers below are the web/parity
39
+ // implementation. Skia-free, like every tool factory.
40
+ export const createShapeTool = (options = {}) => {
41
+ const kind = options.kind ?? 'line';
42
+ const color = options.color ?? '#111827';
43
+ const width = options.width ?? 2;
44
+ const cap = options.cap;
45
+ const dash = options.dash ?? false;
46
+ const minDragPx = options.minDragPx ?? 4;
47
+ return {
48
+ id: options.id ?? kind,
49
+ label: options.label ?? DEFAULT_LABELS[kind],
50
+ cursor: 'crosshair',
51
+ // Drives UI-thread rubber-banding on native (see ShapeDrawConfig).
52
+ shapeDraw: { kind, color, width, ...(cap && { cap }), dash },
53
+ onPointerDown(event, ctx) {
54
+ return {
55
+ kind: 'shape-drawing',
56
+ shape: buildShapeFromDrag({
57
+ kind,
58
+ a: event.world,
59
+ b: event.world,
60
+ color,
61
+ width,
62
+ cap,
63
+ dash,
64
+ layerId: firstLayerId(ctx.document),
65
+ }),
66
+ startWorld: event.world,
67
+ startScreen: event.screen,
68
+ moved: false,
69
+ };
70
+ },
71
+ onPointerMove(event, ctx, state) {
72
+ const s = state;
73
+ if (s?.kind !== 'shape-drawing')
74
+ return s;
75
+ const next = {
76
+ ...s,
77
+ moved: true,
78
+ shape: {
79
+ ...s.shape,
80
+ geometry: {
81
+ points: shapePointsFromDrag(kind, s.startWorld, event.world),
82
+ },
83
+ },
84
+ };
85
+ ctx.preview({ ops: [{ op: 'addShape', shape: next.shape }] });
86
+ return next;
87
+ },
88
+ onPointerUp(event, ctx, state) {
89
+ const s = state;
90
+ if (s?.kind !== 'shape-drawing')
91
+ return;
92
+ const dx = event.screen.x - s.startScreen.x;
93
+ const dy = event.screen.y - s.startScreen.y;
94
+ if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
95
+ // Accidental tap — discard the rubber-band, commit nothing.
96
+ ctx.preview({ ops: [] });
97
+ return;
98
+ }
99
+ const shape = {
100
+ ...s.shape,
101
+ geometry: {
102
+ points: shapePointsFromDrag(kind, s.startWorld, event.world),
103
+ },
104
+ };
105
+ ctx.commit({ ops: [{ op: 'addShape', shape }] });
106
+ },
107
+ onCancel(_state, ctx) {
108
+ ctx.preview({ ops: [] });
109
+ },
110
+ };
111
+ };
@@ -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
1
  import type { Measurement } from '../../types/firestore.js';
2
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;
@@ -27,6 +27,7 @@ export interface UseAnnotationCanvasStateProps {
27
27
  onSelectionChange(selection: Selection | null): void;
28
28
  measurements?: Measurement[];
29
29
  pickMeasurement?: () => Promise<MeasurementRef | null>;
30
+ requestTextInput?: RequestTextInput;
30
31
  width: number;
31
32
  height: number;
32
33
  initialViewport?: ViewportState;
@@ -1,12 +1,12 @@
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 { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
- import { recomputeAnchor, DEFAULT_LINE_POS } from './measurementGeometry.js';
4
+ import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
5
  // Platform-agnostic state machine for the annotation canvas. Web and native
6
6
  // inners share this hook; each wraps it with platform-specific event
7
7
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
8
8
  export const useAnnotationCanvasState = (props) => {
9
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, width, height, initialViewport, imperativeRef, } = props;
9
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, imperativeRef, } = props;
10
10
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
11
11
  const [toolState, setToolState] = useState(undefined);
12
12
  const [previewPatch, setPreviewPatch] = useState(null);
@@ -41,6 +41,11 @@ export const useAnnotationCanvasState = (props) => {
41
41
  requestPickMeasurement() {
42
42
  return pickMeasurement ? pickMeasurement() : Promise.resolve(null);
43
43
  },
44
+ requestTextInput(options) {
45
+ return requestTextInput
46
+ ? requestTextInput(options)
47
+ : Promise.resolve(null);
48
+ },
44
49
  applyPan(deltaScreen) {
45
50
  setViewport((v) => panBy(v, deltaScreen));
46
51
  },
@@ -54,11 +59,30 @@ export const useAnnotationCanvasState = (props) => {
54
59
  onCommit,
55
60
  onSelectionChange,
56
61
  pickMeasurement,
62
+ requestTextInput,
57
63
  ]);
58
64
  // Live ctx for imperative handle methods (which are created in an effect and
59
65
  // would otherwise capture a stale ctx/viewport).
60
66
  const ctxRef = useRef(ctx);
61
67
  ctxRef.current = ctx;
68
+ // Tool hand-over. When the active tool's identity changes — a real tool
69
+ // switch OR the consumer rebuilding the tools array (e.g. a style change
70
+ // recreates every factory) — give the outgoing instance a chance to wind
71
+ // down multi-gesture work (the polygon tool commits its in-progress
72
+ // vertices), then drop any leftover gesture state/preview so the incoming
73
+ // tool starts clean. Keyed on object identity, not id: a rebuilt instance
74
+ // has fresh closure state, so the old instance must still wind down.
75
+ const prevToolRef = useRef(null);
76
+ useEffect(() => {
77
+ const prev = prevToolRef.current;
78
+ prevToolRef.current = activeTool;
79
+ if (!prev || prev === activeTool)
80
+ return;
81
+ prev.onDeactivate?.(ctxRef.current);
82
+ activePointerIdRef.current = null;
83
+ setToolState(undefined);
84
+ setPreviewPatch(null);
85
+ }, [activeTool]);
62
86
  const dispatchPointerDown = useCallback((event) => {
63
87
  if (!activeTool)
64
88
  return;
@@ -90,11 +114,15 @@ export const useAnnotationCanvasState = (props) => {
90
114
  setToolState(undefined);
91
115
  }, [activeTool, ctx, toolState]);
92
116
  const dispatchPointerCancel = useCallback(() => {
93
- if (activeTool)
94
- activeTool.onCancel?.(toolState, ctx);
117
+ // Clear FIRST, then let the tool react: state updates batch, so a tool
118
+ // whose onCancel re-emits a preview (the polygon tool keeps its placed
119
+ // vertices visible across an interrupting two-finger pan) wins over the
120
+ // clear instead of being clobbered by it.
95
121
  activePointerIdRef.current = null;
96
122
  setToolState(undefined);
97
123
  setPreviewPatch(null);
124
+ if (activeTool)
125
+ activeTool.onCancel?.(toolState, ctx);
98
126
  }, [activeTool, ctx, toolState]);
99
127
  const pan = useCallback((deltaScreen) => {
100
128
  setViewport((v) => panBy(v, deltaScreen));
@@ -199,8 +227,30 @@ export const useAnnotationCanvasState = (props) => {
199
227
  const m = c.document.placedMeasurements.find((x) => x.id === id);
200
228
  if (!m)
201
229
  return;
202
- if (type === 'rectangle')
203
- return; // not supported in v1
230
+ if (type === 'rectangle') {
231
+ // Reuse the existing rect if the annotation had one; otherwise
232
+ // synthesize a square centered on the anchor (same size clamp as
233
+ // the default line) so the tile doesn't jump.
234
+ const side = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
235
+ const rect = m.rect ?? {
236
+ a: { x: m.anchor.x - side / 2, y: m.anchor.y - side / 2 },
237
+ b: { x: m.anchor.x + side / 2, y: m.anchor.y + side / 2 },
238
+ };
239
+ c.commit({
240
+ ops: [
241
+ {
242
+ op: 'updateMeasurement',
243
+ id,
244
+ patch: {
245
+ placement: 'rectangle',
246
+ rect,
247
+ anchor: rectCenter(rect),
248
+ },
249
+ },
250
+ ],
251
+ });
252
+ return;
253
+ }
204
254
  if (type === 'line') {
205
255
  let line = m.line;
206
256
  if (!line) {
@@ -0,0 +1 @@
1
+ export declare const createCoalescedRunner: (task: () => Promise<void>) => (() => Promise<void>);