@reekon-tools/boldr-utils 1.6.13 → 1.6.15

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 (37) 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 +51 -13
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  5. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +370 -57
  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 +115 -38
  10. package/dist/annotation/canvas/measurementGeometry.d.ts +1 -0
  11. package/dist/annotation/canvas/measurementGeometry.js +61 -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/stampLayout.d.ts +4 -0
  15. package/dist/annotation/canvas/stampLayout.js +25 -9
  16. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  17. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  18. package/dist/annotation/canvas/tools/measurementTool.d.ts +15 -0
  19. package/dist/annotation/canvas/tools/measurementTool.js +133 -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.js +5 -1
  23. package/dist/annotation/canvas/tools/polygonTool.d.ts +11 -0
  24. package/dist/annotation/canvas/tools/polygonTool.js +162 -0
  25. package/dist/annotation/canvas/tools/selectTool.js +37 -76
  26. package/dist/annotation/canvas/tools/shapeTool.d.ts +25 -0
  27. package/dist/annotation/canvas/tools/shapeTool.js +111 -0
  28. package/dist/annotation/canvas/tools/textTool.d.ts +3 -1
  29. package/dist/annotation/canvas/tools/textTool.js +28 -3
  30. package/dist/annotation/canvas/useAnnotationCanvasState.js +27 -3
  31. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +83 -24
  32. package/dist/exports.d.ts +8 -4
  33. package/dist/exports.js +7 -3
  34. package/dist/formulas/calculateFormula.js +1 -3
  35. package/dist/types/annotation.d.ts +4 -0
  36. package/dist/types/firestore.d.ts +4 -0
  37. package/package.json +1 -1
@@ -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
+ };
@@ -1,9 +1,11 @@
1
- import type { AnnotationShape } from '../../../types/annotation.js';
1
+ import type { AnnotationShape, AnnotationTextDecoration } from '../../../types/annotation.js';
2
2
  import type { Tool } from '../Tool.js';
3
3
  export interface TextToolOptions {
4
+ id?: string;
4
5
  color?: string;
5
6
  fontSize?: number;
6
7
  dash?: boolean;
8
+ decoration?: AnnotationTextDecoration;
7
9
  autoSwitchToSelect?: boolean;
8
10
  onPlaced?: (shape: AnnotationShape) => void;
9
11
  onAutoSwitch?: (toToolId: string) => void;
@@ -2,6 +2,10 @@ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
2
  import { DEFAULT_TEXT_FONT_SIZE, hitTestTextShape } from '../textGeometry.js';
3
3
  let counter = 0;
4
4
  const makeId = () => `text-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
+ // Screen-px a press may travel and still count as a tap. Beyond this the
6
+ // gesture is a drag (e.g. an attempt to pan with the text tool active) and must
7
+ // NOT open the text sheet — the cause of the stray "weird popup" on screen.
8
+ const TAP_SLOP_PX = 10;
5
9
  const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
6
10
  // Topmost text shape under a world point, for tap-to-edit.
7
11
  const findTextShapeAt = (doc, world) => {
@@ -22,13 +26,29 @@ export const createTextTool = (options = {}) => {
22
26
  const color = options.color ?? '#111827';
23
27
  const fontSize = options.fontSize ?? DEFAULT_TEXT_FONT_SIZE;
24
28
  const dash = options.dash ?? false;
29
+ const decoration = options.decoration;
25
30
  const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
26
31
  const selectToolId = options.selectToolId ?? 'select';
27
32
  return {
28
- id: 'text',
33
+ id: options.id ?? 'text',
29
34
  label: 'Text',
30
35
  cursor: 'text',
31
- onPointerUp(event, ctx) {
36
+ // Arm the press so onPointerUp can tell a tap from a drag. The native tap
37
+ // gesture and the generic tool-pan both dispatch a down before the up.
38
+ onPointerDown(event) {
39
+ return { kind: 'text-armed', startScreen: event.screen };
40
+ },
41
+ onPointerUp(event, ctx, state) {
42
+ // A press that traveled beyond the tap slop is a drag, not a tap — never
43
+ // open the text sheet for it. (If the press wasn't armed, fall back to
44
+ // treating it as a tap so the tool still works.)
45
+ const armed = state;
46
+ if (armed?.kind === 'text-armed') {
47
+ const dx = event.screen.x - armed.startScreen.x;
48
+ const dy = event.screen.y - armed.startScreen.y;
49
+ if (dx * dx + dy * dy > TAP_SLOP_PX * TAP_SLOP_PX)
50
+ return;
51
+ }
32
52
  const existing = findTextShapeAt(ctx.document, event.world);
33
53
  if (existing) {
34
54
  void ctx
@@ -63,7 +83,12 @@ export const createTextTool = (options = {}) => {
63
83
  geometry: { points: [anchor] },
64
84
  // `...(dash && ...)` keeps the key absent (not `false`) for solid
65
85
  // text, matching the stroke convention (absent === solid).
66
- style: { stroke: color, fontSize, ...(dash && { dash: true }) },
86
+ style: {
87
+ stroke: color,
88
+ fontSize,
89
+ ...(dash && { dash: true }),
90
+ ...(decoration && { textDecoration: decoration }),
91
+ },
67
92
  text,
68
93
  createdAt: Date.now(),
69
94
  };
@@ -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,19 @@ 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 { createMeasurementTool, type MeasurementToolOptions, type MeasurementToolPlacement, } from './annotation/canvas/tools/measurementTool.js';
33
+ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, type ShapeToolKind, } from './annotation/canvas/shapeGeometry.js';
30
34
  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';
35
+ 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
36
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
package/dist/exports.js CHANGED
@@ -22,11 +22,15 @@ 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 { createMeasurementTool, } from './annotation/canvas/tools/measurementTool.js';
33
+ export { annotationKindFor, hitShapeOutline, shapePointsFromDrag, } from './annotation/canvas/shapeGeometry.js';
30
34
  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';
35
+ export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, normalizeRect, rectCenter, rectCornerPoint, oppositeRectCorner, hitPlacedMeasurement, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
32
36
  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;
@@ -19,6 +19,7 @@ export interface AnnotationStroke {
19
19
  createdAt: number;
20
20
  }
21
21
  export type AnnotationShapeKind = 'rect' | 'ellipse' | 'line' | 'arrow' | 'polygon' | 'text';
22
+ export type AnnotationTextDecoration = 'underline' | 'squiggle' | 'highlight';
22
23
  export interface AnnotationShapeStyle {
23
24
  stroke?: string;
24
25
  fill?: string;
@@ -26,6 +27,8 @@ export interface AnnotationShapeStyle {
26
27
  fontSize?: number;
27
28
  fontFamily?: string;
28
29
  dash?: boolean;
30
+ textDecoration?: AnnotationTextDecoration;
31
+ cap?: StrokeCap;
29
32
  }
30
33
  export interface AnnotationShape {
31
34
  id: AnnotationElementId;
@@ -34,6 +37,7 @@ export interface AnnotationShape {
34
37
  geometry: {
35
38
  points: Vec2[];
36
39
  rotation?: number;
40
+ closed?: boolean;
37
41
  };
38
42
  style: AnnotationShapeStyle;
39
43
  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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.13",
3
+ "version": "1.6.15",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",