@reekon-tools/boldr-utils 1.6.12 → 1.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +4 -2
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +19 -5
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +4 -2
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +150 -8
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +16 -1
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +38 -9
  7. package/dist/annotation/canvas/Tool.d.ts +17 -0
  8. package/dist/annotation/canvas/elements/BackgroundImageElement.js +4 -1
  9. package/dist/annotation/canvas/elements/ShapeElement.js +28 -2
  10. package/dist/annotation/canvas/elements/StrokeElement.js +8 -3
  11. package/dist/annotation/canvas/measurementGeometry.d.ts +20 -0
  12. package/dist/annotation/canvas/measurementGeometry.js +38 -1
  13. package/dist/annotation/canvas/strokeGeometry.d.ts +1 -0
  14. package/dist/annotation/canvas/strokeGeometry.js +8 -0
  15. package/dist/annotation/canvas/textGeometry.d.ts +24 -0
  16. package/dist/annotation/canvas/textGeometry.js +110 -0
  17. package/dist/annotation/canvas/tools/penTool.d.ts +1 -0
  18. package/dist/annotation/canvas/tools/penTool.js +3 -1
  19. package/dist/annotation/canvas/tools/selectTool.js +155 -19
  20. package/dist/annotation/canvas/tools/textTool.d.ts +12 -0
  21. package/dist/annotation/canvas/tools/textTool.js +78 -0
  22. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +2 -1
  23. package/dist/annotation/canvas/useAnnotationCanvasState.js +30 -4
  24. package/dist/annotation/data/coalescedRunner.d.ts +1 -0
  25. package/dist/annotation/data/coalescedRunner.js +48 -0
  26. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +35 -14
  27. package/dist/exports.d.ts +4 -2
  28. package/dist/exports.js +3 -1
  29. package/dist/types/annotation.d.ts +7 -0
  30. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
3
  import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
- import { recomputeAnchor, DEFAULT_LINE_POS } from './measurementGeometry.js';
4
+ import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
5
  // Platform-agnostic state machine for the annotation canvas. Web and native
6
6
  // inners share this hook; each wraps it with platform-specific event
7
7
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
8
8
  export const useAnnotationCanvasState = (props) => {
9
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, width, height, initialViewport, imperativeRef, } = props;
9
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, imperativeRef, } = props;
10
10
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
11
11
  const [toolState, setToolState] = useState(undefined);
12
12
  const [previewPatch, setPreviewPatch] = useState(null);
@@ -41,6 +41,9 @@ export const useAnnotationCanvasState = (props) => {
41
41
  requestPickMeasurement() {
42
42
  return pickMeasurement ? pickMeasurement() : Promise.resolve(null);
43
43
  },
44
+ requestTextInput(options) {
45
+ return requestTextInput ? requestTextInput(options) : Promise.resolve(null);
46
+ },
44
47
  applyPan(deltaScreen) {
45
48
  setViewport((v) => panBy(v, deltaScreen));
46
49
  },
@@ -54,6 +57,7 @@ export const useAnnotationCanvasState = (props) => {
54
57
  onCommit,
55
58
  onSelectionChange,
56
59
  pickMeasurement,
60
+ requestTextInput,
57
61
  ]);
58
62
  // Live ctx for imperative handle methods (which are created in an effect and
59
63
  // would otherwise capture a stale ctx/viewport).
@@ -199,8 +203,30 @@ export const useAnnotationCanvasState = (props) => {
199
203
  const m = c.document.placedMeasurements.find((x) => x.id === id);
200
204
  if (!m)
201
205
  return;
202
- if (type === 'rectangle')
203
- return; // not supported in v1
206
+ if (type === 'rectangle') {
207
+ // Reuse the existing rect if the annotation had one; otherwise
208
+ // synthesize a square centered on the anchor (same size clamp as
209
+ // the default line) so the tile doesn't jump.
210
+ const side = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
211
+ const rect = m.rect ?? {
212
+ a: { x: m.anchor.x - side / 2, y: m.anchor.y - side / 2 },
213
+ b: { x: m.anchor.x + side / 2, y: m.anchor.y + side / 2 },
214
+ };
215
+ c.commit({
216
+ ops: [
217
+ {
218
+ op: 'updateMeasurement',
219
+ id,
220
+ patch: {
221
+ placement: 'rectangle',
222
+ rect,
223
+ anchor: rectCenter(rect),
224
+ },
225
+ },
226
+ ],
227
+ });
228
+ return;
229
+ }
204
230
  if (type === 'line') {
205
231
  let line = m.line;
206
232
  if (!line) {
@@ -0,0 +1 @@
1
+ export declare const createCoalescedRunner: (task: () => Promise<void>) => (() => Promise<void>);
@@ -0,0 +1,48 @@
1
+ // A single-flight async runner with trailing coalescing.
2
+ //
3
+ // Wraps an async `task` so that overlapping callers don't run it concurrently
4
+ // and don't drop work:
5
+ // • While a run is in flight, every additional call returns that same
6
+ // in-flight promise (single-flight) instead of starting a second run.
7
+ // • If any call arrives during a run, exactly ONE trailing run is scheduled
8
+ // after the current one finishes — so work that arrived mid-run is never
9
+ // lost. The trailing run re-invokes `task`, which is expected to re-read the
10
+ // latest state each time (it takes no arguments by design).
11
+ // • The returned promise resolves only once the chain is fully drained, so an
12
+ // `await`-ing caller can rely on all queued work having completed.
13
+ // • A task rejection is propagated to the awaiting callers of that run but
14
+ // does NOT wedge the runner: `inFlight` is always cleared, and a pending
15
+ // trailing run still fires, so the runner self-heals after a transient
16
+ // failure.
17
+ //
18
+ // This is the concurrency primitive behind the annotation autosave/flush: it
19
+ // guarantees a create-on-first-save can't race a concurrent flush (the old
20
+ // boolean-guard approach silently dropped the second writer).
21
+ export const createCoalescedRunner = (task) => {
22
+ let inFlight = null;
23
+ let pending = false;
24
+ const run = () => {
25
+ if (inFlight) {
26
+ // A run is already underway — remember that more work arrived and join
27
+ // the in-flight promise rather than starting a second concurrent run.
28
+ pending = true;
29
+ return inFlight;
30
+ }
31
+ inFlight = (async () => {
32
+ try {
33
+ await task();
34
+ }
35
+ finally {
36
+ inFlight = null;
37
+ if (pending) {
38
+ // Work arrived mid-run: drain it with a single trailing run, which
39
+ // re-reads the latest state. Recurses until quiescent.
40
+ pending = false;
41
+ await run();
42
+ }
43
+ }
44
+ })();
45
+ return inFlight;
46
+ };
47
+ return run;
48
+ };
@@ -3,6 +3,7 @@ import { applyPatch, createEmptyCanvasState, } from '../../../types/annotation.j
3
3
  import { FileUploadType } from '../../../types/firestore.js';
4
4
  import { useAnnotationDoc } from './useAnnotationDoc.js';
5
5
  import { useAnnotationMutations } from './useAnnotationMutations.js';
6
+ import { createCoalescedRunner } from '../coalescedRunner.js';
6
7
  import { hydrateCanvasState } from '../canvasPersistence.js';
7
8
  // Stable placeholder so useAnnotationMutations (which requires a non-null
8
9
  // scope) can be called unconditionally. Never used to write — flushes are
@@ -55,8 +56,6 @@ export const useAnnotationCanvasDoc = (options) => {
55
56
  // JSON of the canvas we last wrote, to recognize (and ignore) the snapshot
56
57
  // echo of our own write when reconciling incoming remote changes.
57
58
  const lastSavedJsonRef = useRef(null);
58
- // Guards against two creates racing if flushes overlap.
59
- const creatingRef = useRef(false);
60
59
  workingRef.current = working;
61
60
  dataRef.current = data;
62
61
  statusRef.current = saveStatus;
@@ -137,17 +136,17 @@ export const useAnnotationCanvasDoc = (options) => {
137
136
  setWorking(hydrateCanvasState(data, fallbackViewport));
138
137
  // eslint-disable-next-line react-hooks/exhaustive-deps
139
138
  }, [data]);
140
- const flush = useCallback(async () => {
141
- if (timerRef.current) {
142
- clearTimeout(timerRef.current);
143
- timerRef.current = null;
144
- }
139
+ // One pass of the persist logic: snapshots the latest working state and
140
+ // creates-or-updates the doc. NOT called directly — it runs through the
141
+ // single-flight `flush` below, which guarantees two passes never overlap (so
142
+ // a create-on-first-save can't race a concurrent flush) and re-invokes this
143
+ // to drain any work that arrived mid-pass. Because it re-reads `workingRef`
144
+ // each call, a trailing pass always persists the newest canvas.
145
+ const runFlush = useCallback(async () => {
145
146
  const canvas = workingRef.current;
146
147
  // Nothing to persist, or no real target scope yet.
147
148
  if (!canvas || !scope)
148
149
  return;
149
- if (creatingRef.current)
150
- return;
151
150
  const json = JSON.stringify(canvas);
152
151
  const id = fileIdRef.current ?? createdIdRef.current;
153
152
  const mode = id ? 'update' : 'create';
@@ -171,14 +170,12 @@ export const useAnnotationCanvasDoc = (options) => {
171
170
  try {
172
171
  if (!id) {
173
172
  // First save with no file — create the doc seeded with the canvas.
174
- creatingRef.current = true;
175
173
  const seed = createSeedRef.current;
176
174
  const newId = await createRef.current({
177
175
  type: FileUploadType.Canvas,
178
176
  ...(seed?.name !== undefined ? { name: seed.name } : {}),
179
177
  fileData: buildFileData(seed?.fileType ?? 'sketch', seed?.isLabel, canvasPayload),
180
178
  });
181
- creatingRef.current = false;
182
179
  createdIdRef.current = newId;
183
180
  if (debug) {
184
181
  console.log('[useAnnotationCanvasDoc] created file', newId);
@@ -213,14 +210,34 @@ export const useAnnotationCanvasDoc = (options) => {
213
210
  }
214
211
  }
215
212
  catch (e) {
216
- creatingRef.current = false;
217
213
  // Always log save failures with context — these are otherwise invisible
218
214
  // (the canvas keeps working from local state).
219
215
  console.error(`[useAnnotationCanvasDoc] ${mode} failed`, { fileId: id, scope, bytes: json.length }, e);
220
216
  onSaveErrorRef.current?.(e);
221
217
  setStatus('error');
218
+ // Swallowed (not re-thrown): autosave/unmount call this fire-and-forget,
219
+ // and the canvas keeps working from local state. Callers that need to know
220
+ // a create succeeded (ensureFileId) detect it via the absence of an id
221
+ // after the flush drains, not via a rejection.
222
222
  }
223
223
  }, [scope, setStatus, saveThumbnail]);
224
+ // Stable indirection so the coalesced runner (created once) always calls the
225
+ // latest `runFlush` without being re-created when its deps change.
226
+ const runFlushRef = useRef(runFlush);
227
+ runFlushRef.current = runFlush;
228
+ // Single-flight runner: concurrent callers join the in-flight pass; work that
229
+ // arrives mid-pass triggers exactly one trailing pass. Created once.
230
+ const flushRunner = useMemo(() => createCoalescedRunner(() => runFlushRef.current()), []);
231
+ // Public flush: cancel any pending debounce, then run (or join) a persist
232
+ // pass. Resolves once the chain is fully drained, so `await flush()` is safe
233
+ // for create-before-upload and explicit Save.
234
+ const flush = useCallback(() => {
235
+ if (timerRef.current) {
236
+ clearTimeout(timerRef.current);
237
+ timerRef.current = null;
238
+ }
239
+ return flushRunner();
240
+ }, [flushRunner]);
224
241
  const onCommit = useCallback((patch) => {
225
242
  setWorking((prev) => (prev ? applyPatch(prev, patch) : prev));
226
243
  setStatus('dirty');
@@ -235,8 +252,12 @@ export const useAnnotationCanvasDoc = (options) => {
235
252
  const existing = fileIdRef.current ?? createdIdRef.current;
236
253
  if (existing)
237
254
  return existing;
238
- // No file yet — seed an empty canvas if nothing has hydrated, then flush
239
- // so the existing create branch mints the doc.
255
+ // No file yet — seed an empty canvas if nothing has hydrated, then flush so
256
+ // the create branch mints the doc. `flush` is single-flight: if the
257
+ // debounced autosave is already creating, this call JOINS that create and
258
+ // waits for it to drain (rather than racing it and dropping the upload, the
259
+ // old bug). After the drain, the id is present unless the create truly
260
+ // failed — so the throw below is now a genuine error, not a race.
240
261
  if (!workingRef.current) {
241
262
  const seeded = createEmptyCanvasState(fallbackViewport);
242
263
  workingRef.current = seeded;
package/dist/exports.d.ts CHANGED
@@ -18,7 +18,7 @@ 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, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
21
+ export type { CanvasPointerEvent, RequestTextInput, 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';
@@ -26,5 +26,7 @@ export { createPenTool, type PenToolOptions } from './annotation/canvas/tools/pe
26
26
  export { createSelectTool } from './annotation/canvas/tools/selectTool.js';
27
27
  export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './annotation/canvas/tools/measurementStampTool.js';
28
28
  export { createPanTool, type PanToolOptions } from './annotation/canvas/tools/panTool.js';
29
- export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
29
+ export { createTextTool, type TextToolOptions, } from './annotation/canvas/tools/textTool.js';
30
+ 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';
30
32
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
package/dist/exports.js CHANGED
@@ -26,5 +26,7 @@ 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
28
  export { createPanTool } from './annotation/canvas/tools/panTool.js';
29
- export { recomputeAnchor, projectToLinePos, snapLinePos, lerp, lineLength, placementOf, linePosOf, DEFAULT_LINE_POS, } from './annotation/canvas/measurementGeometry.js';
29
+ export { createTextTool, } from './annotation/canvas/tools/textTool.js';
30
+ 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';
30
32
  export { toSkiaStrokeCap, arrowheadTriangle, arrowheadLength, } from './annotation/canvas/strokeGeometry.js';
@@ -13,6 +13,7 @@ export interface AnnotationStroke {
13
13
  color: string;
14
14
  width: number;
15
15
  cap?: StrokeCap;
16
+ dash?: boolean;
16
17
  points: number[];
17
18
  pressure?: number[];
18
19
  createdAt: number;
@@ -24,6 +25,7 @@ export interface AnnotationShapeStyle {
24
25
  strokeWidth?: number;
25
26
  fontSize?: number;
26
27
  fontFamily?: string;
28
+ dash?: boolean;
27
29
  }
28
30
  export interface AnnotationShape {
29
31
  id: AnnotationElementId;
@@ -51,9 +53,14 @@ export interface PlacedMeasurementRef {
51
53
  b: Vec2;
52
54
  };
53
55
  linePos?: number;
56
+ rect?: {
57
+ a: Vec2;
58
+ b: Vec2;
59
+ };
54
60
  lineColor?: string;
55
61
  lineWidth?: number;
56
62
  lineCap?: StrokeCap;
63
+ lineDash?: boolean;
57
64
  leader?: {
58
65
  from: Vec2;
59
66
  to: Vec2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.12",
3
+ "version": "1.6.13",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",