@reekon-tools/boldr-utils 1.6.13 → 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 (29) hide show
  1. package/dist/annotation/canvas/AnnotationCanvas.native.d.ts +2 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  3. package/dist/annotation/canvas/AnnotationCanvasInner.js +49 -11
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +368 -55
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
  7. package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
  8. package/dist/annotation/canvas/Tool.d.ts +10 -0
  9. package/dist/annotation/canvas/elements/ShapeElement.js +41 -8
  10. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
  11. package/dist/annotation/canvas/measurementGeometry.js +60 -2
  12. package/dist/annotation/canvas/shapeGeometry.d.ts +5 -0
  13. package/dist/annotation/canvas/shapeGeometry.js +116 -0
  14. package/dist/annotation/canvas/tools/panTool.d.ts +1 -0
  15. package/dist/annotation/canvas/tools/panTool.js +38 -5
  16. package/dist/annotation/canvas/tools/penTool.js +5 -1
  17. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  18. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  19. package/dist/annotation/canvas/tools/selectTool.js +34 -73
  20. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  21. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  22. package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
  23. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
  24. package/dist/exports.d.ts +7 -4
  25. package/dist/exports.js +6 -3
  26. package/dist/formulas/calculateFormula.js +1 -3
  27. package/dist/types/annotation.d.ts +2 -0
  28. package/dist/types/firestore.d.ts +4 -0
  29. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { STAMP_TILE_SIZE } from '../stampLayout.js';
2
- import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, } from '../measurementGeometry.js';
2
+ import { placementOf, linePosOf, snapLinePos, lerp, recomputeAnchor, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, } from '../measurementGeometry.js';
3
+ import { hitShapeOutline } from '../shapeGeometry.js';
3
4
  import { DEFAULT_TEXT_FONT_SIZE, resizeScaleFromDrag, textResizeGeometry, textShapeBounds, } from '../textGeometry.js';
4
5
  const HIT_PADDING = 6;
5
6
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
@@ -14,9 +15,9 @@ const hitStroke = (stroke, p) => {
14
15
  }
15
16
  return false;
16
17
  };
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;
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;
20
21
  // Screen-px radius of the center snap detent when sliding a tile along its line.
21
22
  // Converted to t-space per line via (SNAP_PX / zoom) / lineLength. The native
22
23
  // slide worklet inlines the same value — keep them in sync.
@@ -24,39 +25,6 @@ const SLIDE_SNAP_PX = 12;
24
25
  // Screen-px grab radius for a line-annotation endpoint handle (converted to doc
25
26
  // space via zoom). A bit larger than the drawn handle so it's easy to grab.
26
27
  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
28
  const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
61
29
  const abx = bx - ax;
62
30
  const aby = by - ay;
@@ -73,48 +41,32 @@ const findHit = (doc, world, zoom) => {
73
41
  // Hit-test in z-order (top first): measurements > shapes > strokes.
74
42
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
75
43
  const m = doc.placedMeasurements[i];
76
- if (hitMeasurement(m, world, zoom))
44
+ if (hitPlacedMeasurement(m, world, zoom)) {
77
45
  return { id: m.id, kind: 'measurement' };
46
+ }
78
47
  }
79
48
  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
49
  const s = doc.shapes[i];
84
- let minX;
85
- let maxX;
86
- let minY;
87
- let maxY;
88
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.
89
53
  const b = textShapeBounds(s);
90
54
  if (!b)
91
55
  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;
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' };
112
61
  }
62
+ continue;
113
63
  }
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) {
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)) {
118
70
  return { id: s.id, kind: 'shape' };
119
71
  }
120
72
  }
@@ -203,7 +155,9 @@ const slidePatch = (doc, id, delta, zoom) => {
203
155
  const snapT = SLIDE_SNAP_PX / zoom / Math.sqrt(lenSq);
204
156
  t = snapLinePos(t, snapT);
205
157
  const anchor = lerp(m.line.a, m.line.b, t);
206
- return { ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }] };
158
+ return {
159
+ ops: [{ op: 'updateMeasurement', id, patch: { linePos: t, anchor } }],
160
+ };
207
161
  };
208
162
  // Which endpoint handle of a (selected) line annotation is under `world`.
209
163
  // Prefers the nearer endpoint when both are within range.
@@ -271,7 +225,11 @@ const rectCornerPatch = (doc, id, corner, delta) => {
271
225
  };
272
226
  return {
273
227
  ops: [
274
- { op: 'updateMeasurement', id, patch: { rect, anchor: rectCenter(rect) } },
228
+ {
229
+ op: 'updateMeasurement',
230
+ id,
231
+ patch: { rect, anchor: rectCenter(rect) },
232
+ },
275
233
  ],
276
234
  };
277
235
  };
@@ -417,7 +375,10 @@ export const createSelectTool = () => ({
417
375
  const s = state;
418
376
  if (s?.kind !== 'dragging')
419
377
  return s;
420
- 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
+ };
421
382
  const patch = dragPatch(s, ctx.document, delta, ctx.viewport.state.zoom);
422
383
  if (patch)
423
384
  ctx.preview(patch);
@@ -438,7 +399,7 @@ export const createSelectTool = () => ({
438
399
  },
439
400
  hitTest(element, p) {
440
401
  if (element.kind === 'measurement')
441
- return hitMeasurement(element, p);
402
+ return hitPlacedMeasurement(element, p);
442
403
  if (element.kind === 'stroke')
443
404
  return hitStroke(element, p);
444
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
+ };
@@ -42,7 +42,9 @@ export const useAnnotationCanvasState = (props) => {
42
42
  return pickMeasurement ? pickMeasurement() : Promise.resolve(null);
43
43
  },
44
44
  requestTextInput(options) {
45
- return requestTextInput ? requestTextInput(options) : Promise.resolve(null);
45
+ return requestTextInput
46
+ ? requestTextInput(options)
47
+ : Promise.resolve(null);
46
48
  },
47
49
  applyPan(deltaScreen) {
48
50
  setViewport((v) => panBy(v, deltaScreen));
@@ -63,6 +65,24 @@ export const useAnnotationCanvasState = (props) => {
63
65
  // would otherwise capture a stale ctx/viewport).
64
66
  const ctxRef = useRef(ctx);
65
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]);
66
86
  const dispatchPointerDown = useCallback((event) => {
67
87
  if (!activeTool)
68
88
  return;
@@ -94,11 +114,15 @@ export const useAnnotationCanvasState = (props) => {
94
114
  setToolState(undefined);
95
115
  }, [activeTool, ctx, toolState]);
96
116
  const dispatchPointerCancel = useCallback(() => {
97
- if (activeTool)
98
- 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.
99
121
  activePointerIdRef.current = null;
100
122
  setToolState(undefined);
101
123
  setPreviewPatch(null);
124
+ if (activeTool)
125
+ activeTool.onCancel?.(toolState, ctx);
102
126
  }, [activeTool, ctx, toolState]);
103
127
  const pan = useCallback((deltaScreen) => {
104
128
  setViewport((v) => panBy(v, deltaScreen));
@@ -16,11 +16,16 @@ const EMPTY_SCOPE = {
16
16
  };
17
17
  // Build the persisted fileData, omitting `isLabel` when undefined so the write
18
18
  // contains no undefined values (Firestore rejects them on RN).
19
- const buildFileData = (fileType, isLabel, canvas) => ({
19
+ const buildFileData = (fileType, isLabel, canvas, canvasRev) => ({
20
20
  fileType,
21
21
  ...(isLabel !== undefined ? { isLabel } : {}),
22
22
  canvas,
23
+ canvasRev,
23
24
  });
25
+ // Random id for this editing session, used to stamp writes (see canvasRev in
26
+ // AnnotationFileData). Uniqueness only needs to hold across the handful of
27
+ // clients that ever touch one annotation doc.
28
+ const makeClientId = () => `${Date.now().toString(36)}-${Math.floor(Math.random() * 0x100000000).toString(36)}`;
24
29
  // Orchestrates load + auto-save for the annotation canvas. Hydrates the working
25
30
  // state from the persisted doc, applies commits optimistically, and persists
26
31
  // (debounced) through the data provider — creating the file on first save when
@@ -52,10 +57,30 @@ export const useAnnotationCanvasDoc = (options) => {
52
57
  const uploadImageRef = useRef(uploadImage);
53
58
  // Guards against overlapping thumbnail captures (each save fires one).
54
59
  const thumbnailSavingRef = useRef(false);
60
+ // Set by the public `save()` so the next successful flush refreshes the
61
+ // thumbnail; debounced autosaves leave it false (capture is too costly to
62
+ // run mid-session — see captureThumbnail). Consumed (cleared) by each flush
63
+ // pass that attempts a write, so a failed explicit save drops the request
64
+ // instead of leaking the capture into some later autosave.
65
+ const thumbnailRequestedRef = useRef(false);
66
+ // In-flight capture+upload from the latest flush, so `save()` can await it —
67
+ // callers that navigate away right after saving must not unmount the view
68
+ // mid-capture.
69
+ const thumbnailPromiseRef = useRef(undefined);
55
70
  const debugRef = useRef(debugLogging);
56
- // JSON of the canvas we last wrote, to recognize (and ignore) the snapshot
57
- // echo of our own write when reconciling incoming remote changes.
58
- const lastSavedJsonRef = useRef(null);
71
+ // This session's identity + write counter, stamped into `fileData.canvasRev`
72
+ // on every flush. An incoming snapshot carrying our clientId is the echo of
73
+ // our own write — state we already hold — and must not be re-applied (doing
74
+ // so replaces every object identity in the working canvas, busting all
75
+ // memoization downstream).
76
+ const clientIdRef = useRef(null);
77
+ if (clientIdRef.current === null)
78
+ clientIdRef.current = makeClientId();
79
+ const writeSeqRef = useRef(0);
80
+ // Monotonic local-edit counter: bumped on every commit. A flush snapshots it
81
+ // before writing and compares after, so "did edits land mid-flight?" is an
82
+ // integer comparison instead of re-serializing the whole document.
83
+ const editSeqRef = useRef(0);
59
84
  workingRef.current = working;
60
85
  dataRef.current = data;
61
86
  statusRef.current = saveStatus;
@@ -106,7 +131,6 @@ export const useAnnotationCanvasDoc = (options) => {
106
131
  timerRef.current = null;
107
132
  }
108
133
  createdIdRef.current = null;
109
- lastSavedJsonRef.current = null;
110
134
  setWorking(null);
111
135
  setStatus('idle');
112
136
  }, [fileId, setStatus]);
@@ -115,24 +139,22 @@ export const useAnnotationCanvasDoc = (options) => {
115
139
  // First load — always hydrate.
116
140
  if (workingRef.current === null) {
117
141
  setWorking(hydrateCanvasState(data, fallbackViewport));
118
- lastSavedJsonRef.current = data?.fileData.canvas
119
- ? JSON.stringify(data.fileData.canvas)
120
- : null;
121
142
  return;
122
143
  }
123
144
  // A write of ours is queued or in flight — ignore the snapshot; it is
124
145
  // either the echo of our write or about to be superseded by it.
125
146
  if (statusRef.current === 'saving' || timerRef.current !== null)
126
147
  return;
127
- // Clean locally: accept a genuine remote change, but ignore the echo of
128
- // our own last write (same content).
129
148
  const incoming = data?.fileData.canvas;
130
149
  if (!incoming)
131
150
  return;
132
- const incomingJson = JSON.stringify(incoming);
133
- if (incomingJson === lastSavedJsonRef.current)
151
+ // Ignore the echo of this session's own writes: the doc carries the
152
+ // canvasRev we stamped, so its content is (at most as new as) what we
153
+ // already hold. Only a doc written by ANOTHER client is a genuine remote
154
+ // change worth re-hydrating — which replaces all element identities, so
155
+ // it must never happen on the routine save → echo round-trip.
156
+ if (data?.fileData.canvasRev?.clientId === clientIdRef.current)
134
157
  return;
135
- lastSavedJsonRef.current = incomingJson;
136
158
  setWorking(hydrateCanvasState(data, fallbackViewport));
137
159
  // eslint-disable-next-line react-hooks/exhaustive-deps
138
160
  }, [data]);
@@ -148,6 +170,17 @@ export const useAnnotationCanvasDoc = (options) => {
148
170
  if (!canvas || !scope)
149
171
  return;
150
172
  const json = JSON.stringify(canvas);
173
+ // Edits up to this point are covered by this write; anything committed
174
+ // while the write is in flight bumps editSeqRef past this snapshot.
175
+ const editSeqAtFlush = editSeqRef.current;
176
+ // Claim the thumbnail request for THIS pass: honored on success, dropped
177
+ // on failure (the next explicit save re-requests it).
178
+ const wantThumbnail = thumbnailRequestedRef.current;
179
+ thumbnailRequestedRef.current = false;
180
+ const canvasRev = {
181
+ clientId: clientIdRef.current,
182
+ seq: ++writeSeqRef.current,
183
+ };
151
184
  const id = fileIdRef.current ?? createdIdRef.current;
152
185
  const mode = id ? 'update' : 'create';
153
186
  const debug = debugRef.current;
@@ -167,6 +200,7 @@ export const useAnnotationCanvasDoc = (options) => {
167
200
  // rejects undefined field values unless ignoreUndefinedProperties is set.
168
201
  const canvasPayload = JSON.parse(json);
169
202
  setStatus('saving');
203
+ let createdThisPass = false;
170
204
  try {
171
205
  if (!id) {
172
206
  // First save with no file — create the doc seeded with the canvas.
@@ -174,9 +208,10 @@ export const useAnnotationCanvasDoc = (options) => {
174
208
  const newId = await createRef.current({
175
209
  type: FileUploadType.Canvas,
176
210
  ...(seed?.name !== undefined ? { name: seed.name } : {}),
177
- fileData: buildFileData(seed?.fileType ?? 'sketch', seed?.isLabel, canvasPayload),
211
+ fileData: buildFileData(seed?.fileType ?? 'sketch', seed?.isLabel, canvasPayload, canvasRev),
178
212
  });
179
213
  createdIdRef.current = newId;
214
+ createdThisPass = true;
180
215
  if (debug) {
181
216
  console.log('[useAnnotationCanvasDoc] created file', newId);
182
217
  }
@@ -187,22 +222,27 @@ export const useAnnotationCanvasDoc = (options) => {
187
222
  await updateRef.current(id, {
188
223
  fileData: buildFileData(doc?.fileData.fileType ??
189
224
  createSeedRef.current?.fileType ??
190
- 'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload),
225
+ 'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload, canvasRev),
191
226
  });
192
227
  if (debug) {
193
228
  console.log('[useAnnotationCanvasDoc] updated file', id);
194
229
  }
195
230
  }
196
- lastSavedJsonRef.current = json;
197
- // Refresh the file's thumbnail to match what was just saved (fire and
198
- // forget never blocks or fails the save).
231
+ // Refresh the thumbnail when an explicit save() asked for it, or when
232
+ // this pass CREATED the file every file gets at least one thumbnail
233
+ // so the grid never shows a blank tile for a canvas the user drew and
234
+ // then backed out of without an explicit save. Routine debounced
235
+ // autosaves skip it: capture means a full view snapshot + encode on the
236
+ // JS thread, which would jank the very drawing session that triggered
237
+ // the save. Fire-and-forget for the flush itself; save() awaits the
238
+ // stashed promise so explicit savers can navigate safely after.
199
239
  const savedId = fileIdRef.current ?? createdIdRef.current;
200
- if (savedId)
201
- void saveThumbnail(savedId);
240
+ if (savedId && (wantThumbnail || createdThisPass)) {
241
+ thumbnailPromiseRef.current = saveThumbnail(savedId);
242
+ }
202
243
  // If new edits landed mid-flight, stay dirty and let the next debounce
203
244
  // (or unmount) flush them.
204
- const latest = workingRef.current;
205
- if (latest && JSON.stringify(latest) !== json) {
245
+ if (editSeqRef.current !== editSeqAtFlush) {
206
246
  setStatus('dirty');
207
247
  }
208
248
  else {
@@ -239,7 +279,18 @@ export const useAnnotationCanvasDoc = (options) => {
239
279
  return flushRunner();
240
280
  }, [flushRunner]);
241
281
  const onCommit = useCallback((patch) => {
242
- setWorking((prev) => (prev ? applyPatch(prev, patch) : prev));
282
+ editSeqRef.current += 1;
283
+ setWorking((prev) => {
284
+ if (!prev)
285
+ return prev;
286
+ const next = applyPatch(prev, patch);
287
+ // Keep the flush snapshot in lockstep with editSeqRef: a flush that
288
+ // starts before React re-renders must not pair this commit's seq bump
289
+ // with the pre-commit canvas (it would mark the edit 'saved' without
290
+ // ever writing it).
291
+ workingRef.current = next;
292
+ return next;
293
+ });
243
294
  setStatus('dirty');
244
295
  if (timerRef.current)
245
296
  clearTimeout(timerRef.current);
@@ -248,6 +299,14 @@ export const useAnnotationCanvasDoc = (options) => {
248
299
  void flush();
249
300
  }, debounceMs);
250
301
  }, [debounceMs, flush, setStatus]);
302
+ // Explicit save (Save button, "done" actions): also refreshes the file's
303
+ // thumbnail, which autosaves deliberately skip. Resolves only after the
304
+ // capture+upload too, so a caller may unmount the view right after.
305
+ const save = useCallback(async () => {
306
+ thumbnailRequestedRef.current = true;
307
+ await flush();
308
+ await thumbnailPromiseRef.current;
309
+ }, [flush]);
251
310
  const ensureFileId = useCallback(async () => {
252
311
  const existing = fileIdRef.current ?? createdIdRef.current;
253
312
  if (existing)
@@ -327,7 +386,7 @@ export const useAnnotationCanvasDoc = (options) => {
327
386
  loading,
328
387
  error,
329
388
  saveStatus,
330
- save: flush,
389
+ save,
331
390
  ensureFileId,
332
391
  setBackgroundImage,
333
392
  clearBackgroundImage,
package/dist/exports.d.ts CHANGED
@@ -18,15 +18,18 @@ export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
18
18
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
19
19
  export type { AnnotationCanvasHandle } from './annotation/canvas/useAnnotationCanvasState.js';
20
20
  export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './annotation/canvas/AnnotationCanvasInner.js';
21
- export type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
21
+ export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
22
22
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
23
23
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
24
24
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
25
- export { createPenTool, type PenToolOptions } from './annotation/canvas/tools/penTool.js';
25
+ export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
26
26
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
27
27
  export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './annotation/canvas/tools/measurementStampTool.js';
28
- export { createPanTool, type PanToolOptions } from './annotation/canvas/tools/panTool.js';
28
+ export { createPanTool, type PanToolOptions, } from './annotation/canvas/tools/panTool.js';
29
29
  export { createTextTool, type TextToolOptions, } from './annotation/canvas/tools/textTool.js';
30
+ export { createShapeTool, buildShapeFromDrag, type ShapeToolOptions, } from './annotation/canvas/tools/shapeTool.js';
31
+ export { createPolygonTool, type PolygonToolOptions, } from './annotation/canvas/tools/polygonTool.js';
32
+ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeToolKind, } from './annotation/canvas/shapeGeometry.js';
30
33
  export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, type ResizeGeometry, type TextBounds, } from './annotation/canvas/textGeometry.js';
31
- export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, DEFAULT_LINE_POS, type NormalizedRect, type RectCorner, } from './annotation/canvas/measurementGeometry.js';
34
+ 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';
32
35
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
package/dist/exports.js CHANGED
@@ -22,11 +22,14 @@ export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCa
22
22
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
23
23
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
24
24
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
25
- export { createPenTool } from './annotation/canvas/tools/penTool.js';
25
+ export { createPenTool, } from './annotation/canvas/tools/penTool.js';
26
26
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
27
27
  export { createMeasurementStampTool, } from './annotation/canvas/tools/measurementStampTool.js';
28
- export { createPanTool } from './annotation/canvas/tools/panTool.js';
28
+ export { createPanTool, } from './annotation/canvas/tools/panTool.js';
29
29
  export { createTextTool, } from './annotation/canvas/tools/textTool.js';
30
+ export { createShapeTool, buildShapeFromDrag, } from './annotation/canvas/tools/shapeTool.js';
31
+ export { createPolygonTool, } from './annotation/canvas/tools/polygonTool.js';
32
+ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './annotation/canvas/shapeGeometry.js';
30
33
  export { DEFAULT_TEXT_FONT_SIZE, MIN_TEXT_FONT_SIZE, MAX_TEXT_FONT_SIZE, textShapeBounds, textResizeGeometry, } from './annotation/canvas/textGeometry.js';
31
- export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
34
+ export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
32
35
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
@@ -105,9 +105,7 @@ export const calculateFormula = (formula, formulas, columns, tableConfig, measur
105
105
  // Validate that all required inputs are filled out
106
106
  const missingInputs = [];
107
107
  for (const [variable, mapping] of Object.entries(currentMappings)) {
108
- const mappingObj = typeof mapping === 'string'
109
- ? { id: mapping, type: 'column' }
110
- : mapping;
108
+ const mappingObj = typeof mapping === 'string' ? { id: mapping, type: 'column' } : mapping;
111
109
  // Only check column references (formula references are handled separately)
112
110
  if (mappingObj.type === 'column' || !mappingObj.type) {
113
111
  const columnId = mappingObj.id;
@@ -26,6 +26,7 @@ export interface AnnotationShapeStyle {
26
26
  fontSize?: number;
27
27
  fontFamily?: string;
28
28
  dash?: boolean;
29
+ cap?: StrokeCap;
29
30
  }
30
31
  export interface AnnotationShape {
31
32
  id: AnnotationElementId;
@@ -34,6 +35,7 @@ export interface AnnotationShape {
34
35
  geometry: {
35
36
  points: Vec2[];
36
37
  rotation?: number;
38
+ closed?: boolean;
37
39
  };
38
40
  style: AnnotationShapeStyle;
39
41
  text?: string;
@@ -113,6 +113,10 @@ export interface AnnotationFileData {
113
113
  fileType: 'sketch' | 'document';
114
114
  isLabel?: boolean;
115
115
  canvas?: AnnotationCanvasState;
116
+ canvasRev?: {
117
+ clientId: string;
118
+ seq: number;
119
+ };
116
120
  }
117
121
  export interface CalculatorFileData {
118
122
  templateId: string;