@reekon-tools/boldr-utils 1.15.1 → 1.15.3

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 (81) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +5 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +10 -3
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +2 -1
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.js +17 -3
  5. package/dist/annotation/canvas/Tool.d.ts +1 -0
  6. package/dist/annotation/canvas/elements/PlaneElement.d.ts +18 -0
  7. package/dist/annotation/canvas/elements/PlaneElement.js +115 -0
  8. package/dist/annotation/canvas/planeGeometry.d.ts +48 -0
  9. package/dist/annotation/canvas/planeGeometry.js +507 -0
  10. package/dist/annotation/canvas/tools/measurementTool.d.ts +1 -0
  11. package/dist/annotation/canvas/tools/measurementTool.js +13 -0
  12. package/dist/annotation/canvas/tools/planeTool.d.ts +13 -0
  13. package/dist/annotation/canvas/tools/planeTool.js +391 -0
  14. package/dist/annotation/canvas/tools/selectTool.js +9 -0
  15. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +3 -1
  16. package/dist/annotation/canvas/useAnnotationCanvasState.js +22 -1
  17. package/dist/canvas/AnnotationCanvas.d.ts +11 -0
  18. package/dist/canvas/AnnotationCanvas.js +10 -0
  19. package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
  20. package/dist/canvas/AnnotationCanvas.native.js +6 -0
  21. package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
  22. package/dist/canvas/AnnotationCanvasInner.js +219 -0
  23. package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
  24. package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
  25. package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
  26. package/dist/canvas/AnnotationCanvasSkia.js +20 -0
  27. package/dist/canvas/Tool.d.ts +38 -0
  28. package/dist/canvas/Tool.js +1 -0
  29. package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
  30. package/dist/canvas/elements/BackgroundImageElement.js +37 -0
  31. package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
  32. package/dist/canvas/elements/MeasurementStampElement.js +30 -0
  33. package/dist/canvas/elements/ShapeElement.d.ts +7 -0
  34. package/dist/canvas/elements/ShapeElement.js +62 -0
  35. package/dist/canvas/elements/StrokeElement.d.ts +7 -0
  36. package/dist/canvas/elements/StrokeElement.js +18 -0
  37. package/dist/canvas/measurementPicker.d.ts +10 -0
  38. package/dist/canvas/measurementPicker.js +1 -0
  39. package/dist/canvas/measurementStampOverlay.d.ts +11 -0
  40. package/dist/canvas/measurementStampOverlay.js +1 -0
  41. package/dist/canvas/pointerAdapter.d.ts +3 -0
  42. package/dist/canvas/pointerAdapter.js +19 -0
  43. package/dist/canvas/stampLayout.d.ts +5 -0
  44. package/dist/canvas/stampLayout.js +14 -0
  45. package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
  46. package/dist/canvas/tools/measurementStampTool.js +37 -0
  47. package/dist/canvas/tools/panTool.d.ts +5 -0
  48. package/dist/canvas/tools/panTool.js +25 -0
  49. package/dist/canvas/tools/penTool.d.ts +13 -0
  50. package/dist/canvas/tools/penTool.js +68 -0
  51. package/dist/canvas/tools/selectTool.d.ts +2 -0
  52. package/dist/canvas/tools/selectTool.js +182 -0
  53. package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
  54. package/dist/canvas/useAnnotationCanvasState.js +210 -0
  55. package/dist/canvas/viewport.d.ts +16 -0
  56. package/dist/canvas/viewport.js +54 -0
  57. package/dist/data/AnnotationDataContext.d.ts +8 -0
  58. package/dist/data/AnnotationDataContext.js +11 -0
  59. package/dist/data/AnnotationDataProvider.d.ts +65 -0
  60. package/dist/data/AnnotationDataProvider.js +4 -0
  61. package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
  62. package/dist/data/InMemoryAnnotationProvider.js +197 -0
  63. package/dist/data/canvasPersistence.d.ts +3 -0
  64. package/dist/data/canvasPersistence.js +26 -0
  65. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
  66. package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
  67. package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
  68. package/dist/data/hooks/useAnnotationDoc.js +33 -0
  69. package/dist/data/hooks/useAnnotationList.d.ts +7 -0
  70. package/dist/data/hooks/useAnnotationList.js +26 -0
  71. package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
  72. package/dist/data/hooks/useAnnotationMutations.js +11 -0
  73. package/dist/exports.d.ts +2 -0
  74. package/dist/exports.js +2 -0
  75. package/dist/hooks/useParseMeasurement.d.ts +4 -0
  76. package/dist/hooks/useParseMeasurement.js +14 -0
  77. package/dist/types/annotation.d.ts +37 -0
  78. package/dist/types/annotation.js +30 -0
  79. package/dist/utils/evaluateFormula.d.ts +20 -0
  80. package/dist/utils/evaluateFormula.js +31 -0
  81. package/package.json +1 -1
@@ -0,0 +1,391 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { isQuadConvexNonDegenerate, normalizeQuadOrder, planeEdgeLine, } from '../planeGeometry.js';
3
+ import { linePosOf, recomputeAnchor, DEFAULT_LINE_POS, } from '../measurementGeometry.js';
4
+ import { stampTileDims } from '../stampLayout.js';
5
+ import { FormulaColors } from '../../../theme/colors.js';
6
+ // Screen-px grab radius for an existing plane's corner/endpoint handles while
7
+ // the tool is active (matches the drawn handle affordance scale).
8
+ const HANDLE_GRAB_PX = 18;
9
+ // Screen-px radius of the placed-corner dots drawn while marking a quad.
10
+ const VERTEX_DOT_PX = 6;
11
+ // Preview chrome color — matches PlaneElement's calibration purple.
12
+ const PLANE_COLOR = FormulaColors.purple;
13
+ let counter = 0;
14
+ const makeId = (prefix) => `${prefix}-${Date.now().toString(36)}-${(counter++).toString(36)}`;
15
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
16
+ const distSq = (a, b) => {
17
+ const dx = a.x - b.x;
18
+ const dy = a.y - b.y;
19
+ return dx * dx + dy * dy;
20
+ };
21
+ // The document's (single, v1) committed calibration this tool can edit.
22
+ const committedPlane = (doc) => doc.planes?.[0] ?? null;
23
+ // The grabbable points of a committed plane: quad corners, or the reference
24
+ // line's endpoints.
25
+ const planeHandles = (plane) => plane.mode === 'perspective'
26
+ ? plane.corners
27
+ : [plane.refLine.a, plane.refLine.b];
28
+ // The committed plane with one handle moved to `p`.
29
+ const withHandleMoved = (plane, index, p) => {
30
+ if (plane.mode === 'perspective') {
31
+ const corners = plane.corners.map((c, i) => (i === index ? p : c));
32
+ return { ...plane, corners };
33
+ }
34
+ return {
35
+ ...plane,
36
+ refLine: index === 0 ? { ...plane.refLine, a: p } : { ...plane.refLine, b: p },
37
+ };
38
+ };
39
+ // The dimension slots a mode carries a tile for.
40
+ const edgesForMode = (mode) => mode === 'perspective' ? ['top', 'right', 'bottom', 'left'] : ['ref'];
41
+ // A fresh dimension tile magnetized to one plane edge (see the planeEdge doc
42
+ // on PlacedMeasurementRef).
43
+ const buildDimensionTile = (doc, plane, edge) => {
44
+ const line = planeEdgeLine(plane, edge);
45
+ if (!line)
46
+ return null;
47
+ return {
48
+ id: makeId('plane-dim'),
49
+ layerId: firstLayerId(doc),
50
+ placement: 'line',
51
+ line,
52
+ linePos: DEFAULT_LINE_POS,
53
+ anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, line.a),
54
+ planeId: plane.id,
55
+ planeEdge: edge,
56
+ // The tile's line IS the plane edge PlaneElement already draws — style it
57
+ // as the same purple stroke (and width) so it disappears into the outline
58
+ // instead of painting the default measurement blue on top of it.
59
+ lineColor: PLANE_COLOR,
60
+ lineWidth: 14,
61
+ showLabel: true,
62
+ showValue: true,
63
+ createdAt: Date.now(),
64
+ };
65
+ };
66
+ // Ops that pin every dimension tile of `plane` back onto its (just-moved)
67
+ // edge, preserving each tile's slid position along it. Emitted alongside the
68
+ // setPlane in the same patch, so a corner drag is one undo step.
69
+ const syncDimensionTileOps = (doc, plane) => {
70
+ const ops = [];
71
+ for (const m of doc.placedMeasurements) {
72
+ if (m.planeId !== plane.id || !m.planeEdge)
73
+ continue;
74
+ const line = planeEdgeLine(plane, m.planeEdge);
75
+ if (!line)
76
+ continue;
77
+ ops.push({
78
+ op: 'updateMeasurement',
79
+ id: m.id,
80
+ patch: {
81
+ line,
82
+ anchor: recomputeAnchor(line, 'line', linePosOf(m), m.anchor),
83
+ },
84
+ });
85
+ }
86
+ return ops;
87
+ };
88
+ // Ops that remove a calibration AND its dimension tiles — the replace flow
89
+ // when new geometry is marked while a plane exists (v1: one per canvas).
90
+ const removePlaneOps = (doc, plane) => [
91
+ ...doc.placedMeasurements
92
+ .filter((m) => m.planeId === plane.id)
93
+ .map((m) => ({ op: 'removeMeasurement', id: m.id })),
94
+ { op: 'removePlane', id: plane.id },
95
+ ];
96
+ // Plane-calibration tool. Two jobs, both Skia-free and JS-dispatched on native
97
+ // (like polygonTool/measurementTool):
98
+ // - MARK new geometry: scale mode rubber-bands a reference line
99
+ // (measurementTool's drag pattern); perspective mode places 4 corners by
100
+ // tapping (polygonTool's pattern). Completion COMMITS the plane at once —
101
+ // geometry only, dimensions blank — together with a dimension tile per
102
+ // edge, replacing any existing plane (and its tiles) in the same undoable
103
+ // patch. The user then enters real-world sizes through the tiles.
104
+ // - EDIT the committed plane: while active, grabbing a corner/endpoint drags
105
+ // it with a live preview (the grid and dimension tiles follow through the
106
+ // preview patch) and commits one setPlane (+tile sync) per drag — a single
107
+ // undo step. A drag that would make the quad non-convex snaps back on
108
+ // release.
109
+ // In-progress marks render through preview patches (line/polygon draft shapes
110
+ // + vertex dots), so no platform-specific preview plumbing is needed.
111
+ export const createPlaneTool = (options) => {
112
+ const mode = options.mode;
113
+ const minDragPx = options.minDragPx ?? 4;
114
+ // In-progress quad corners (span gestures — the canvas clears ToolState per
115
+ // pointer-up, so they live in the factory closure like polygonTool's).
116
+ let vertices = [];
117
+ let draftId = null;
118
+ // Preview of the in-progress mark: the polyline/line so far plus a dot per
119
+ // placed vertex. Preview-only shapes riding the preview patch — never
120
+ // committed.
121
+ const previewOps = (doc, zoom, rubber) => {
122
+ const pts = rubber ? [...vertices, rubber] : [...vertices];
123
+ const ops = [];
124
+ if (pts.length >= 2) {
125
+ ops.push({
126
+ op: 'addShape',
127
+ shape: {
128
+ id: `${draftId}-draft`,
129
+ layerId: firstLayerId(doc),
130
+ kind: 'polygon',
131
+ geometry: { points: pts, closed: pts.length >= 4 },
132
+ style: { stroke: PLANE_COLOR, strokeWidth: 6 / zoom, dash: true },
133
+ createdAt: 0,
134
+ },
135
+ });
136
+ }
137
+ pts.forEach((v, i) => {
138
+ const r = VERTEX_DOT_PX / zoom;
139
+ ops.push({
140
+ op: 'addShape',
141
+ shape: {
142
+ id: `${draftId}-v${i}`,
143
+ layerId: firstLayerId(doc),
144
+ kind: 'ellipse',
145
+ geometry: {
146
+ points: [
147
+ { x: v.x - r, y: v.y - r },
148
+ { x: v.x + r, y: v.y + r },
149
+ ],
150
+ },
151
+ style: { fill: PLANE_COLOR, stroke: '#FFFFFF', strokeWidth: r / 3 },
152
+ createdAt: 0,
153
+ },
154
+ });
155
+ });
156
+ return ops;
157
+ };
158
+ const reset = () => {
159
+ vertices = [];
160
+ draftId = null;
161
+ };
162
+ // Commit freshly marked geometry: the plane (dimensions blank until the
163
+ // user enters them) plus its dimension tiles, replacing any existing plane
164
+ // — one undoable patch.
165
+ const commitPlane = (ctx, geometry) => {
166
+ const doc = ctx.document;
167
+ const plane = geometry.mode === 'scale'
168
+ ? {
169
+ id: makeId('plane'),
170
+ mode: 'scale',
171
+ refLine: geometry.refLine,
172
+ createdAt: Date.now(),
173
+ }
174
+ : {
175
+ id: makeId('plane'),
176
+ mode: 'perspective',
177
+ corners: geometry.corners,
178
+ createdAt: Date.now(),
179
+ };
180
+ const existing = committedPlane(doc);
181
+ const ops = [
182
+ ...(existing ? removePlaneOps(doc, existing) : []),
183
+ { op: 'setPlane', plane },
184
+ ...edgesForMode(plane.mode).flatMap((edge) => {
185
+ const tile = buildDimensionTile(doc, plane, edge);
186
+ return tile
187
+ ? [{ op: 'addMeasurement', measurement: tile }]
188
+ : [];
189
+ }),
190
+ ];
191
+ reset();
192
+ ctx.preview({ ops: [] });
193
+ ctx.commit({ ops });
194
+ options.onPlaced?.(plane);
195
+ };
196
+ // Which committed-plane DIMENSION tile a tap landed on, if any — the tile
197
+ // BOX only (deliberately not the edge line, which spans the whole side and
198
+ // would turn every edge tap into value entry). Same footprint the overlay
199
+ // draws and the select tool grabs (stampTileDims + the ctx scale factors).
200
+ const hitDimensionTile = (ctx, world) => {
201
+ const plane = committedPlane(ctx.document);
202
+ if (!plane)
203
+ return null;
204
+ const zoom = ctx.viewport.state.zoom;
205
+ for (let i = ctx.document.placedMeasurements.length - 1; i >= 0; i--) {
206
+ const m = ctx.document.placedMeasurements[i];
207
+ if (m.planeId !== plane.id || !m.planeEdge)
208
+ continue;
209
+ const dims = stampTileDims(m, ctx.tileScaleFactor, ctx.tileViewportScale, ctx.headerTileScaleFactor, ctx.fileTileScaleFactor);
210
+ const halfW = (dims.width / 2 + 6) / zoom;
211
+ const halfH = (dims.height / 2 + 6) / zoom;
212
+ if (Math.abs(world.x - m.anchor.x) <= halfW &&
213
+ Math.abs(world.y - m.anchor.y) <= halfH) {
214
+ return m;
215
+ }
216
+ }
217
+ return null;
218
+ };
219
+ // Which committed-plane handle a press landed on, if any.
220
+ const hitHandle = (ctx, world) => {
221
+ const plane = committedPlane(ctx.document);
222
+ if (!plane)
223
+ return null;
224
+ const tol = HANDLE_GRAB_PX / ctx.viewport.state.zoom;
225
+ const tolSq = tol * tol;
226
+ const handles = planeHandles(plane);
227
+ for (let i = 0; i < handles.length; i++) {
228
+ if (distSq(world, handles[i]) <= tolSq)
229
+ return i;
230
+ }
231
+ return null;
232
+ };
233
+ return {
234
+ id: options.id ?? `plane-${mode}`,
235
+ label: options.label ??
236
+ (mode === 'perspective' ? 'Perspective plane' : 'Scale reference'),
237
+ cursor: 'crosshair',
238
+ planeEditing: true,
239
+ onPointerDown(event, ctx) {
240
+ // Editing an existing plane wins over starting new geometry — but only
241
+ // when nothing is mid-mark (a quad in progress keeps collecting taps).
242
+ if (vertices.length === 0) {
243
+ const handleIndex = hitHandle(ctx, event.world);
244
+ if (handleIndex != null) {
245
+ return { kind: 'plane-handle-drag', handleIndex, moved: false };
246
+ }
247
+ }
248
+ if (mode === 'scale') {
249
+ return {
250
+ kind: 'plane-scale-drawing',
251
+ startWorld: event.world,
252
+ startScreen: event.screen,
253
+ moved: false,
254
+ };
255
+ }
256
+ return { kind: 'plane-quad-pointing' };
257
+ },
258
+ onPointerMove(event, ctx, state) {
259
+ const s = state;
260
+ if (!s)
261
+ return s;
262
+ if (s.kind === 'plane-handle-drag') {
263
+ const plane = committedPlane(ctx.document);
264
+ if (!plane)
265
+ return s;
266
+ const moved = withHandleMoved(plane, s.handleIndex, event.world);
267
+ // Dimension tiles ride along in the same preview so the whole
268
+ // calibration — outline, grid, tiles — tracks the finger.
269
+ ctx.preview({
270
+ ops: [
271
+ { op: 'setPlane', plane: moved },
272
+ ...syncDimensionTileOps(ctx.document, moved),
273
+ ],
274
+ });
275
+ return { ...s, moved: true };
276
+ }
277
+ // New geometry can't be marked while a calibration exists (delete it
278
+ // first) — suppress the rubber-band so nothing appears to happen and
279
+ // the pointer-up path explains why.
280
+ if (committedPlane(ctx.document))
281
+ return s;
282
+ if (s.kind === 'plane-scale-drawing') {
283
+ if (!draftId)
284
+ draftId = makeId('plane-draft');
285
+ vertices = [s.startWorld];
286
+ ctx.preview({
287
+ ops: previewOps(ctx.document, ctx.viewport.state.zoom, event.world),
288
+ });
289
+ vertices = [];
290
+ return { ...s, moved: true };
291
+ }
292
+ // Quad marking: rubber the next edge from the placed corners.
293
+ if (vertices.length > 0) {
294
+ ctx.preview({
295
+ ops: previewOps(ctx.document, ctx.viewport.state.zoom, event.world),
296
+ });
297
+ }
298
+ return s;
299
+ },
300
+ onPointerUp(event, ctx, state) {
301
+ const s = state;
302
+ if (!s)
303
+ return;
304
+ if (s.kind === 'plane-handle-drag') {
305
+ ctx.preview({ ops: [] });
306
+ if (!s.moved)
307
+ return;
308
+ const plane = committedPlane(ctx.document);
309
+ if (!plane)
310
+ return;
311
+ const next = withHandleMoved(plane, s.handleIndex, event.world);
312
+ // A corner drag that breaks the quad snaps back instead of committing
313
+ // a plane the homography can't invert.
314
+ if (next.mode === 'perspective' &&
315
+ !isQuadConvexNonDegenerate(next.corners)) {
316
+ options.onInvalidQuad?.();
317
+ return;
318
+ }
319
+ ctx.commit({
320
+ ops: [
321
+ { op: 'setPlane', plane: next },
322
+ ...syncDimensionTileOps(ctx.document, next),
323
+ ],
324
+ });
325
+ return;
326
+ }
327
+ // While a calibration exists, a TAP on one of its dimension tiles
328
+ // selects it and opens value entry (the host's onDimensionTileTap) —
329
+ // dimensions stay editable without leaving calibration mode. Any other
330
+ // attempt to mark new geometry is blocked: delete the plane first.
331
+ // (A real drag — scale mode with `moved` — is never a tile tap.)
332
+ if (committedPlane(ctx.document)) {
333
+ const isTap = s.kind !== 'plane-scale-drawing' || !s.moved;
334
+ const tile = isTap ? hitDimensionTile(ctx, event.world) : null;
335
+ if (tile) {
336
+ ctx.setSelection({ ids: [tile.id] });
337
+ options.onDimensionTileTap?.(tile);
338
+ return;
339
+ }
340
+ options.onExistingPlane?.();
341
+ return;
342
+ }
343
+ if (s.kind === 'plane-scale-drawing') {
344
+ ctx.preview({ ops: [] });
345
+ const dx = event.screen.x - s.startScreen.x;
346
+ const dy = event.screen.y - s.startScreen.y;
347
+ if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx)
348
+ return;
349
+ commitPlane(ctx, {
350
+ mode: 'scale',
351
+ refLine: { a: s.startWorld, b: event.world },
352
+ });
353
+ return;
354
+ }
355
+ // Quad marking: each tap places a corner; the 4th completes.
356
+ if (!draftId)
357
+ draftId = makeId('plane-draft');
358
+ vertices = [...vertices, { x: event.world.x, y: event.world.y }];
359
+ if (vertices.length < 4) {
360
+ ctx.preview({
361
+ ops: previewOps(ctx.document, ctx.viewport.state.zoom),
362
+ });
363
+ return;
364
+ }
365
+ const corners = normalizeQuadOrder(vertices);
366
+ if (!corners) {
367
+ // Drop the offending 4th corner; the first three stay marked.
368
+ vertices = vertices.slice(0, 3);
369
+ ctx.preview({
370
+ ops: previewOps(ctx.document, ctx.viewport.state.zoom),
371
+ });
372
+ options.onInvalidQuad?.();
373
+ return;
374
+ }
375
+ commitPlane(ctx, { mode: 'perspective', corners });
376
+ },
377
+ // Gesture interrupted (a second finger landed and the viewport pan took
378
+ // over): keep the placed corners and re-establish their preview — the
379
+ // canvas clears the preview patch before calling this.
380
+ onCancel(_state, ctx) {
381
+ if (vertices.length === 0)
382
+ return;
383
+ ctx.preview({ ops: previewOps(ctx.document, ctx.viewport.state.zoom) });
384
+ },
385
+ // Switching tools mid-mark discards the unfinished geometry — unlike a
386
+ // polygon, a partial calibration has no committable meaning.
387
+ onDeactivate() {
388
+ reset();
389
+ },
390
+ };
391
+ };
@@ -209,6 +209,11 @@ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFact
209
209
  const halfH = (dims.height / 2 + HIT_PADDING) / zoom;
210
210
  const onTile = Math.abs(world.x - m.anchor.x) <= halfW &&
211
211
  Math.abs(world.y - m.anchor.y) <= halfH;
212
+ // A plane-dimension tile is locked to its calibration edge: EVERY grab
213
+ // slides it along the edge line — a group move would pull it off the plane
214
+ // geometry it annotates (the plane tool owns moving the geometry itself).
215
+ if (m.planeEdge && placementOf(m) === 'line' && m.line)
216
+ return 'slide';
212
217
  return onTile && placementOf(m) === 'line' && m.line ? 'slide' : 'move';
213
218
  };
214
219
  // Slide the tile along its line by a world-space drag delta (relative to
@@ -238,6 +243,10 @@ const findHandleHit = (doc, id, world, zoom) => {
238
243
  const m = doc.placedMeasurements.find((x) => x.id === id);
239
244
  if (!m || !m.line || placementOf(m) !== 'line')
240
245
  return null;
246
+ // A plane-dimension tile's line IS the calibration edge — no endpoint
247
+ // editing here; the plane tool drags the plane's corners instead.
248
+ if (m.planeEdge)
249
+ return null;
241
250
  const r2 = (HANDLE_GRAB_PX / zoom) ** 2;
242
251
  const da = (world.x - m.line.a.x) ** 2 + (world.y - m.line.a.y) ** 2;
243
252
  const db = (world.x - m.line.b.x) ** 2 + (world.y - m.line.b.y) ** 2;
@@ -1,5 +1,5 @@
1
1
  import type { Measurement } from '../../types/firestore.js';
2
- import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type TileScalePlatform, type Vec2 } from '../../types/annotation.js';
2
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type PlaneCalibration, type Selection, type TileScalePlatform, type Vec2 } from '../../types/annotation.js';
3
3
  import type { FileRef, MeasurementRef } from './measurementPicker.js';
4
4
  import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
5
5
  import { type ViewportState } from './viewport.js';
@@ -51,6 +51,8 @@ export interface AnnotationCanvasHandle {
51
51
  placeFileAtCenter(ref: FileRef): AnnotationElementId;
52
52
  setGroupHeaderCollapsed(id: AnnotationElementId, collapsed: boolean): void;
53
53
  deleteSelected(): void;
54
+ setPlane(plane: PlaneCalibration): void;
55
+ removePlane(id: string): void;
54
56
  }
55
57
  export interface UseAnnotationCanvasStateProps {
56
58
  canvas: AnnotationCanvasState;
@@ -772,6 +772,10 @@ export const useAnnotationCanvasState = (props) => {
772
772
  // Build one remove op per selected element, dispatched by which
773
773
  // collection owns the id. A single multi-op commit makes the whole
774
774
  // deletion one undo step (inverse re-adds each element).
775
+ // Plane DIMENSION tiles (planeEdge) are exempt: they are the
776
+ // calibration's entry affordances and live and die with it
777
+ // (removePlane / the plane tool's replace flow) — their value is
778
+ // replaced by re-entering it, never by deleting the tile.
775
779
  const ops = [
776
780
  ...doc.strokes
777
781
  .filter((s) => idSet.has(s.id))
@@ -780,7 +784,7 @@ export const useAnnotationCanvasState = (props) => {
780
784
  .filter((s) => idSet.has(s.id))
781
785
  .map((s) => ({ op: 'removeShape', id: s.id })),
782
786
  ...doc.placedMeasurements
783
- .filter((m) => idSet.has(m.id))
787
+ .filter((m) => idSet.has(m.id) && !m.planeEdge)
784
788
  .map((m) => ({ op: 'removeMeasurement', id: m.id })),
785
789
  ];
786
790
  if (ops.length === 0)
@@ -788,6 +792,23 @@ export const useAnnotationCanvasState = (props) => {
788
792
  c.commit({ ops });
789
793
  c.setSelection(null);
790
794
  },
795
+ setPlane(plane) {
796
+ ctxRef.current.commit({ ops: [{ op: 'setPlane', plane }] });
797
+ },
798
+ removePlane(id) {
799
+ // The calibration's dimension tiles (planeId) go with it — orphaned
800
+ // entry tiles would keep rendering with nothing to write to. One
801
+ // undoable patch.
802
+ const c = ctxRef.current;
803
+ c.commit({
804
+ ops: [
805
+ ...c.document.placedMeasurements
806
+ .filter((m) => m.planeId === id)
807
+ .map((m) => ({ op: 'removeMeasurement', id: m.id })),
808
+ { op: 'removePlane', id },
809
+ ],
810
+ });
811
+ },
791
812
  };
792
813
  return () => {
793
814
  if (imperativeRef)
@@ -0,0 +1,11 @@
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;
@@ -0,0 +1,10 @@
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 }));
@@ -0,0 +1,8 @@
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;
@@ -0,0 +1,6 @@
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
+ };
@@ -0,0 +1,39 @@
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;