@reekon-tools/boldr-utils 1.6.30 → 1.6.32

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 (27) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +21 -2
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  4. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +4 -3
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.js +3 -3
  6. package/dist/annotation/canvas/elements/BackgroundImageElement.js +67 -12
  7. package/dist/annotation/canvas/tools/measurementTool.d.ts +6 -1
  8. package/dist/annotation/canvas/tools/measurementTool.js +34 -1
  9. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +1 -0
  10. package/dist/annotation/canvas/useAnnotationCanvasState.js +35 -28
  11. package/dist/annotation/data/AnnotationDataProvider.d.ts +20 -9
  12. package/dist/annotation/data/AnnotationDataProvider.js +8 -0
  13. package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +10 -10
  14. package/dist/annotation/data/InMemoryAnnotationProvider.js +30 -4
  15. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +2 -2
  16. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +10 -3
  17. package/dist/annotation/data/hooks/useAnnotationDoc.d.ts +2 -2
  18. package/dist/annotation/data/hooks/useAnnotationDoc.js +3 -8
  19. package/dist/annotation/data/hooks/useAnnotationList.d.ts +2 -2
  20. package/dist/annotation/data/hooks/useAnnotationList.js +3 -1
  21. package/dist/annotation/data/hooks/useAnnotationMutations.d.ts +3 -3
  22. package/dist/annotation/data/hooks/useAnnotationMutations.js +9 -5
  23. package/dist/exports.d.ts +1 -1
  24. package/dist/exports.js +1 -1
  25. package/dist/types/annotation.d.ts +1 -0
  26. package/dist/types/firestore.d.ts +6 -1
  27. package/package.json +1 -1
@@ -37,6 +37,7 @@ export interface AnnotationCanvasInnerProps {
37
37
  style?: CSSProperties;
38
38
  magnifierTopOffset?: number;
39
39
  imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
40
+ snapshotRef?: MutableRefObject<(() => Uint8Array | null) | null>;
40
41
  }
41
42
  export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
42
43
  export interface GestureConfig {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
2
+ import { Skia, useCanvasRef, useFont, useTypeface, } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useMemo, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
@@ -21,7 +21,25 @@ const LONG_PRESS_MS = 500;
21
21
  const LONG_PRESS_SLOP_PX = 10;
22
22
  const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
23
23
  export const AnnotationCanvasInner = (props) => {
24
- const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
24
+ const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, snapshotRef, } = props;
25
+ // Snapshot plumbing for thumbnail capture: hold the <Canvas> ref and publish
26
+ // a capture fn on the consumer's snapshotRef. encodeToBytes() yields PNG.
27
+ const canvasRef = useCanvasRef();
28
+ useEffect(() => {
29
+ if (!snapshotRef)
30
+ return;
31
+ snapshotRef.current = () => {
32
+ const image = canvasRef.current?.makeImageSnapshot();
33
+ if (!image)
34
+ return null;
35
+ const bytes = image.encodeToBytes();
36
+ image.dispose?.();
37
+ return bytes ?? null;
38
+ };
39
+ return () => {
40
+ snapshotRef.current = null;
41
+ };
42
+ }, [snapshotRef, canvasRef]);
25
43
  const wheelMode = gestures?.wheel ?? 'auto';
26
44
  const panTriggers = gestures?.panTriggers ?? DEFAULT_PAN_TRIGGERS;
27
45
  const allowSpacePan = panTriggers.includes('space');
@@ -276,6 +294,7 @@ export const AnnotationCanvasInner = (props) => {
276
294
  effectiveCanvas: state.effectiveCanvas,
277
295
  worldTransform: state.worldTransform,
278
296
  resolveImageUrl,
297
+ canvasRef,
279
298
  valueFont,
280
299
  textFontMgr,
281
300
  penDrawingStroke: state.penDrawingStroke,
@@ -38,5 +38,6 @@ export interface AnnotationCanvasInnerProps {
38
38
  style?: ViewStyle;
39
39
  magnifierTopOffset?: number;
40
40
  imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
41
+ snapshotRef?: MutableRefObject<(() => Uint8Array | null) | null>;
41
42
  }
42
43
  export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,5 @@
1
- import { type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
- import type { ReactNode } from 'react';
1
+ import { type CanvasRef, type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
+ import type { ReactNode, RefObject } from 'react';
3
3
  import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
4
  type AnimatedPoint = {
5
5
  x: number;
@@ -88,6 +88,7 @@ export interface AnnotationCanvasSkiaProps {
88
88
  };
89
89
  customPreview?: ReactNode;
90
90
  wrapContent?: (content: ReactNode) => ReactNode;
91
+ canvasRef?: RefObject<CanvasRef | null>;
91
92
  }
92
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
93
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
93
94
  export {};
@@ -15,7 +15,7 @@ import { StrokeElement } from './elements/StrokeElement.js';
15
15
  // against the document; the rectangle border stays thinner (it outlines a
16
16
  // region rather than pointing at one).
17
17
  const MEASUREMENT_LINE_COLOR = '#0066FF';
18
- const MEASUREMENT_LINE_WIDTH = 6;
18
+ const MEASUREMENT_LINE_WIDTH = 16;
19
19
  const MEASUREMENT_RECT_WIDTH = 2;
20
20
  // Endpoint handle color — constant selection chrome, independent of the (now
21
21
  // editable) line color so the handles stay legible on any line color.
@@ -78,7 +78,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
78
78
  // since the function-call pattern works identically on native we use it
79
79
  // in both Inners for consistency. Don't add hooks here; this is a plain
80
80
  // JSX-returning helper, not a component.
81
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }) => {
81
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }) => {
82
82
  const world = (_jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl, skImage: backgroundSkImage })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
83
83
  // Line/arrow shapes support endpoint editing. When selected they show
84
84
  // grab handles at both ends; during an endpoint drag the line renders
@@ -220,5 +220,5 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
220
220
  }
221
221
  return null;
222
222
  })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }));
223
- return (_jsx(Canvas, { style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
223
+ return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
224
224
  };
@@ -1,14 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Image, useImage } from '@shopify/react-native-skia';
2
+ import { Group, Image, ImageSVG, Skia, useImage, } from '@shopify/react-native-skia';
3
3
  import { memo, useEffect, useRef, useState } from 'react';
4
4
  import { imageDocRect } from '../viewport.js';
5
- // Resolve a background image's (possibly expired) storage URL and decode it to
6
- // an SkImage. Exported so a consumer can hoist the decode ABOVE the canvas and
7
- // share one SkImage across multiple canvases (e.g. the main canvas + a
8
- // magnifier overlay) — otherwise each canvas mounts its own loader and flashes
9
- // a blank while it re-resolves + re-decodes. Returns null until loaded, or when
10
- // `image` is null/undefined. Hooks run unconditionally regardless of `image`.
11
- export const useBackgroundSkImage = (image, resolveUrl) => {
5
+ // Resolve a background image's (possibly expired) storage URL. Shared by the
6
+ // raster and SVG loaders below.
7
+ const useBackgroundUrl = (image, resolveUrl) => {
12
8
  const [url, setUrl] = useState(image?.downloadUrl ?? '');
13
9
  // Hold resolveUrl in a ref so re-resolution is keyed only on the image
14
10
  // identity, not the function's. Consumers commonly pass an inline closure
@@ -33,17 +29,76 @@ export const useBackgroundSkImage = (image, resolveUrl) => {
33
29
  return () => {
34
30
  cancelled = true;
35
31
  };
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
33
  }, [image?.downloadUrl, image?.storagePath]);
34
+ return url;
35
+ };
36
+ // Null-safe SVG loader. NOT RN Skia's useSVG: that hook throws synchronously
37
+ // on a null/undefined source (despite its DataSourceParam type), so it can't
38
+ // be called unconditionally from a component that usually renders raster
39
+ // backgrounds. An empty url resolves to null (nothing to draw yet).
40
+ const useBackgroundSvg = (url) => {
41
+ const [svg, setSvg] = useState(null);
42
+ useEffect(() => {
43
+ if (!url) {
44
+ setSvg(null);
45
+ return;
46
+ }
47
+ let cancelled = false;
48
+ (async () => {
49
+ try {
50
+ const res = await fetch(url);
51
+ const text = await res.text();
52
+ const next = Skia.SVG.MakeFromString(text);
53
+ if (!cancelled)
54
+ setSvg(next);
55
+ }
56
+ catch (e) {
57
+ console.warn('[BackgroundImageElement] failed to load SVG background', e);
58
+ }
59
+ })();
60
+ return () => {
61
+ cancelled = true;
62
+ };
63
+ }, [url]);
64
+ return svg;
65
+ };
66
+ // Resolve a background image's URL and decode it to an SkImage. Exported so a
67
+ // consumer can hoist the decode ABOVE the canvas and share one SkImage across
68
+ // multiple canvases (e.g. the main canvas + a magnifier overlay) — otherwise
69
+ // each canvas mounts its own loader and flashes a blank while it re-resolves +
70
+ // re-decodes. Returns null until loaded, or when `image` is null/undefined.
71
+ // Hooks run unconditionally regardless of `image`. Raster only — SVG
72
+ // backgrounds self-load in the element below (parsing an SVG is cheap).
73
+ export const useBackgroundSkImage = (image, resolveUrl) => {
74
+ const url = useBackgroundUrl(image, resolveUrl);
37
75
  return useImage(url || null);
38
76
  };
39
77
  export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, }) => {
40
- // Self-load only when no pre-decoded image is provided; the hook still runs
41
- // unconditionally (with a null image, so it no-ops) to satisfy hook rules.
78
+ const isSvg = image.format === 'svg';
79
+ // Self-load only when no pre-decoded image is provided; the hooks still run
80
+ // unconditionally (with a null source, so they no-op) to satisfy hook rules.
42
81
  const provided = skImageProp !== undefined;
43
- const loaded = useBackgroundSkImage(provided ? null : image, resolveUrl);
82
+ const loaded = useBackgroundSkImage(provided || isSvg ? null : image, resolveUrl);
83
+ const svgUrl = useBackgroundUrl(isSvg ? image : null, resolveUrl);
84
+ const svg = useBackgroundSvg(isSvg ? svgUrl : '');
44
85
  const skImage = provided ? skImageProp : loaded;
86
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
87
+ if (isSvg) {
88
+ if (!svg)
89
+ return null;
90
+ // Draw the SVG at its stamped intrinsic size inside a scale transform
91
+ // (rather than relying on ImageSVG's own fitting, which only applies to
92
+ // sources with a viewBox). The vector source re-rasterizes per frame, so
93
+ // it stays crisp at any canvas zoom.
94
+ return (_jsx(Group, { transform: [
95
+ { translateX: dims.x },
96
+ { translateY: dims.y },
97
+ { scaleX: dims.width / image.widthPx },
98
+ { scaleY: dims.height / image.heightPx },
99
+ ], children: _jsx(ImageSVG, { svg: svg, x: 0, y: 0, width: image.widthPx, height: image.heightPx }) }));
100
+ }
45
101
  if (!skImage)
46
102
  return null;
47
- const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
48
103
  return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
49
104
  });
@@ -1,4 +1,4 @@
1
- import type { PlacedMeasurementRef } from '../../../types/annotation.js';
1
+ import type { PlacedMeasurementRef, StrokeCap } from '../../../types/annotation.js';
2
2
  import type { Tool } from '../Tool.js';
3
3
  export type MeasurementToolPlacement = 'line' | 'rectangle' | 'none';
4
4
  export interface MeasurementToolOptions {
@@ -11,5 +11,10 @@ export interface MeasurementToolOptions {
11
11
  selectToolId?: string;
12
12
  onAutoSwitch?: (toToolId: string) => void;
13
13
  onPlaced?: (measurement: PlacedMeasurementRef) => void;
14
+ color?: string;
15
+ width?: number;
16
+ cap?: StrokeCap;
17
+ startCap?: StrokeCap;
18
+ dash?: boolean;
14
19
  }
15
20
  export declare const createMeasurementTool: (options?: MeasurementToolOptions) => Tool;
@@ -1,5 +1,27 @@
1
1
  import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
2
  import { DEFAULT_LINE_POS, recomputeAnchor, rectCenter, } from '../measurementGeometry.js';
3
+ // The `line*` style fields for a placed measurement, built from the tool's
4
+ // style options. Undefined fields are dropped (never emitted) so the render
5
+ // defaults apply and Firestore never sees an `undefined` value.
6
+ const lineStyleFields = (placement, style) => {
7
+ if (placement === 'none')
8
+ return {};
9
+ const out = {};
10
+ if (style.color !== undefined)
11
+ out.lineColor = style.color;
12
+ if (style.width !== undefined)
13
+ out.lineWidth = style.width;
14
+ if (style.dash !== undefined)
15
+ out.lineDash = style.dash;
16
+ // End/start caps are only meaningful on the open `line` path.
17
+ if (placement === 'line') {
18
+ if (style.cap !== undefined)
19
+ out.lineCap = style.cap;
20
+ if (style.startCap !== undefined)
21
+ out.lineStartCap = style.startCap;
22
+ }
23
+ return out;
24
+ };
3
25
  let counter = 0;
4
26
  const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
27
  const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
@@ -8,13 +30,14 @@ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
8
30
  // placeAnnotationAtCenter so a drawn annotation is identical to a (legacy)
9
31
  // center-placed one once committed.
10
32
  const buildMeasurement = (opts) => {
11
- const { id, layerId, placement, linePos, a, b } = opts;
33
+ const { id, layerId, placement, linePos, a, b, style } = opts;
12
34
  const base = {
13
35
  id,
14
36
  layerId,
15
37
  showLabel: true,
16
38
  showValue: true,
17
39
  createdAt: Date.now(),
40
+ ...lineStyleFields(placement, style),
18
41
  };
19
42
  if (placement === 'rectangle') {
20
43
  const rect = { a, b };
@@ -51,6 +74,13 @@ export const createMeasurementTool = (options = {}) => {
51
74
  const minDragPx = options.minDragPx ?? 4;
52
75
  const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
53
76
  const selectToolId = options.selectToolId ?? 'select';
77
+ const style = {
78
+ color: options.color,
79
+ width: options.width,
80
+ cap: options.cap,
81
+ startCap: options.startCap,
82
+ dash: options.dash,
83
+ };
54
84
  const place = (ctx, measurement) => {
55
85
  ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
56
86
  options.onPlaced?.(measurement);
@@ -78,6 +108,7 @@ export const createMeasurementTool = (options = {}) => {
78
108
  linePos,
79
109
  a: event.world,
80
110
  b: event.world,
111
+ style,
81
112
  }));
82
113
  },
83
114
  };
@@ -108,6 +139,7 @@ export const createMeasurementTool = (options = {}) => {
108
139
  linePos,
109
140
  a: s.startWorld,
110
141
  b: event.world,
142
+ style,
111
143
  });
112
144
  ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
113
145
  return { ...s, moved: true };
@@ -130,6 +162,7 @@ export const createMeasurementTool = (options = {}) => {
130
162
  linePos,
131
163
  a: s.startWorld,
132
164
  b: event.world,
165
+ style,
133
166
  }));
134
167
  },
135
168
  onCancel(_state, ctx) {
@@ -16,6 +16,7 @@ export interface AnnotationCanvasHandle {
16
16
  }): void;
17
17
  setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
18
  centerOnLine(id: AnnotationElementId): void;
19
+ setLinePos(id: AnnotationElementId, linePos: number): void;
19
20
  removeMeasurementValue(id: AnnotationElementId): void;
20
21
  associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
21
22
  bindColumn(id: AnnotationElementId, binding: {
@@ -195,6 +195,37 @@ export const useAnnotationCanvasState = (props) => {
195
195
  useEffect(() => {
196
196
  if (!imperativeRef)
197
197
  return;
198
+ // Shared body for centerOnLine / setLinePos: ensure the annotation is a
199
+ // 'line' (synthesizing a default horizontal line around the anchor if it
200
+ // has none) and pin its tile at `linePos` along that line.
201
+ const applyLinePos = (id, linePos) => {
202
+ const c = ctxRef.current;
203
+ const m = c.document.placedMeasurements.find((x) => x.id === id);
204
+ if (!m)
205
+ return;
206
+ let line = m.line;
207
+ if (!line) {
208
+ const len = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
209
+ line = {
210
+ a: { x: m.anchor.x - len / 2, y: m.anchor.y },
211
+ b: { x: m.anchor.x + len / 2, y: m.anchor.y },
212
+ };
213
+ }
214
+ c.commit({
215
+ ops: [
216
+ {
217
+ op: 'updateMeasurement',
218
+ id,
219
+ patch: {
220
+ placement: 'line',
221
+ line,
222
+ linePos,
223
+ anchor: recomputeAnchor(line, 'line', linePos, m.anchor),
224
+ },
225
+ },
226
+ ],
227
+ });
228
+ };
198
229
  imperativeRef.current = {
199
230
  undo() {
200
231
  const entry = undoStackRef.current.pop();
@@ -340,34 +371,10 @@ export const useAnnotationCanvasState = (props) => {
340
371
  });
341
372
  },
342
373
  centerOnLine(id) {
343
- const c = ctxRef.current;
344
- const m = c.document.placedMeasurements.find((x) => x.id === id);
345
- if (!m)
346
- return;
347
- // Same default-line synthesis as setAnnotationType('line') so centering
348
- // a floating tile also gives it a line to sit on.
349
- let line = m.line;
350
- if (!line) {
351
- const len = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
352
- line = {
353
- a: { x: m.anchor.x - len / 2, y: m.anchor.y },
354
- b: { x: m.anchor.x + len / 2, y: m.anchor.y },
355
- };
356
- }
357
- c.commit({
358
- ops: [
359
- {
360
- op: 'updateMeasurement',
361
- id,
362
- patch: {
363
- placement: 'line',
364
- line,
365
- linePos: 0.5,
366
- anchor: recomputeAnchor(line, 'line', 0.5, m.anchor),
367
- },
368
- },
369
- ],
370
- });
374
+ applyLinePos(id, 0.5);
375
+ },
376
+ setLinePos(id, linePos) {
377
+ applyLinePos(id, linePos);
371
378
  },
372
379
  removeMeasurementValue(id) {
373
380
  const c = ctxRef.current;
@@ -7,6 +7,14 @@ export interface JobScope {
7
7
  export interface JobGroupScope extends JobScope {
8
8
  groupId: string;
9
9
  }
10
+ export interface TemplateScope {
11
+ kind: 'template';
12
+ orgId?: string;
13
+ templateId: string;
14
+ }
15
+ export type AnnotationScope = JobGroupScope | TemplateScope;
16
+ export declare const isTemplateScope: (s: AnnotationScope) => s is TemplateScope;
17
+ export declare const annotationScopeKey: (s: AnnotationScope | null) => string;
10
18
  export type Unsubscribe = () => void;
11
19
  export type FieldOp = {
12
20
  kind: 'serverTimestamp';
@@ -26,6 +34,9 @@ export declare const isFieldOp: (v: unknown) => v is FieldOp;
26
34
  export type Patch<T> = {
27
35
  [K in keyof T]?: T[K] | FieldOp;
28
36
  };
37
+ export type AnnotationFilePatch = Patch<AnnotationFile> & {
38
+ [dotPath: `fileData.${string}`]: unknown;
39
+ };
29
40
  export interface ImageBlob {
30
41
  data: Blob | ArrayBuffer | string;
31
42
  contentType: string;
@@ -51,15 +62,15 @@ export interface AnnotationFileSummary {
51
62
  createdBy?: AnnotationFile['createdBy'];
52
63
  }
53
64
  export interface AnnotationDataProvider {
54
- create(scope: JobGroupScope, seed: Partial<AnnotationFile>): Promise<string>;
55
- get(scope: JobGroupScope, fileId: string): Promise<AnnotationFile | null>;
56
- update(scope: JobGroupScope, fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
57
- delete(scope: JobGroupScope, fileId: string): Promise<void>;
58
- subscribe(scope: JobGroupScope, fileId: string, onNext: (doc: AnnotationFile | null) => void, onError?: (err: Error) => void): Unsubscribe;
59
- list(scope: JobGroupScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
65
+ create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
66
+ get(scope: AnnotationScope, fileId: string): Promise<AnnotationFile | null>;
67
+ update(scope: AnnotationScope, fileId: string, patch: AnnotationFilePatch): Promise<void>;
68
+ delete(scope: AnnotationScope, fileId: string): Promise<void>;
69
+ subscribe(scope: AnnotationScope, fileId: string, onNext: (doc: AnnotationFile | null) => void, onError?: (err: Error) => void): Unsubscribe;
70
+ list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
60
71
  subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
61
72
  subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
62
- uploadImage(scope: JobGroupScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
63
- getImageUrl(scope: JobGroupScope, fileId: string, storagePath: string): Promise<string>;
64
- deleteImage(scope: JobGroupScope, fileId: string, storagePath: string): Promise<void>;
73
+ uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
74
+ getImageUrl(scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
75
+ deleteImage(scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
65
76
  }
@@ -1,3 +1,11 @@
1
+ export const isTemplateScope = (s) => 'kind' in s && s.kind === 'template';
2
+ // Stable string identity for a scope, for React hook dep arrays — scope
3
+ // objects are typically rebuilt every render.
4
+ export const annotationScopeKey = (s) => s == null
5
+ ? ''
6
+ : isTemplateScope(s)
7
+ ? `template/${s.orgId ?? ''}/${s.templateId}`
8
+ : `job/${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
1
9
  export const isFieldOp = (v) => !!v &&
2
10
  typeof v === 'object' &&
3
11
  'kind' in v &&
@@ -1,5 +1,5 @@
1
1
  import { type Measurement } from '../../types/firestore.js';
2
- import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
2
+ import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type ImageBlob, type JobGroupScope, type JobScope, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
3
3
  export declare class InMemoryAnnotationProvider implements AnnotationDataProvider {
4
4
  private docs;
5
5
  private measurements;
@@ -10,17 +10,17 @@ export declare class InMemoryAnnotationProvider implements AnnotationDataProvide
10
10
  private jobMeasurementListeners;
11
11
  private nextId;
12
12
  setMeasurements(scope: JobGroupScope, measurements: Measurement[]): void;
13
- create(scope: JobGroupScope, seed: Partial<AnnotationFile>): Promise<string>;
14
- get(scope: JobGroupScope, fileId: string): Promise<AnnotationFile | null>;
15
- update(scope: JobGroupScope, fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
16
- delete(scope: JobGroupScope, fileId: string): Promise<void>;
17
- subscribe(scope: JobGroupScope, fileId: string, onNext: (doc: AnnotationFile | null) => void): Unsubscribe;
18
- list(scope: JobGroupScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
13
+ create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
14
+ get(scope: AnnotationScope, fileId: string): Promise<AnnotationFile | null>;
15
+ update(scope: AnnotationScope, fileId: string, patch: AnnotationFilePatch): Promise<void>;
16
+ delete(scope: AnnotationScope, fileId: string): Promise<void>;
17
+ subscribe(scope: AnnotationScope, fileId: string, onNext: (doc: AnnotationFile | null) => void): Unsubscribe;
18
+ list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
19
19
  subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
20
20
  subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
21
- uploadImage(scope: JobGroupScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
22
- getImageUrl(_scope: JobGroupScope, fileId: string, storagePath: string): Promise<string>;
23
- deleteImage(_scope: JobGroupScope, fileId: string, storagePath: string): Promise<void>;
21
+ uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
22
+ getImageUrl(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
23
+ deleteImage(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
24
24
  private getBucket;
25
25
  private notifyDoc;
26
26
  private notifyList;
@@ -1,7 +1,9 @@
1
1
  import { FileUploadType } from '../../types/firestore.js';
2
- import { isFieldOp, } from './AnnotationDataProvider.js';
3
- const scopeKey = (s) => `${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
4
- const jobKey = (s) => `${s.orgId}/${s.projectId}/${s.jobId}`;
2
+ import { annotationScopeKey, isFieldOp, } from './AnnotationDataProvider.js';
3
+ const scopeKey = annotationScopeKey;
4
+ // Prefix of annotationScopeKey for job scopes, so collectJobMeasurements can
5
+ // match group keys by startsWith.
6
+ const jobKey = (s) => `job/${s.orgId}/${s.projectId}/${s.jobId}`;
5
7
  const summarize = (file) => ({
6
8
  id: file.id,
7
9
  name: file.name,
@@ -37,6 +39,30 @@ const resolveFieldOp = (op) => {
37
39
  return undefined;
38
40
  }
39
41
  };
42
+ // Firestore-style dotted field paths: 'fileData.canvas' merges into the
43
+ // nested map (creating intermediate maps as needed) instead of replacing the
44
+ // whole top-level field — mirrors what the real SDKs do with dotted keys.
45
+ const applyDotPaths = (base, resolved) => {
46
+ const next = { ...base };
47
+ for (const [key, value] of Object.entries(resolved)) {
48
+ if (!key.includes('.')) {
49
+ next[key] = value;
50
+ continue;
51
+ }
52
+ const parts = key.split('.');
53
+ let target = next;
54
+ for (let i = 0; i < parts.length - 1; i++) {
55
+ const existing = target[parts[i]];
56
+ target[parts[i]] =
57
+ existing && typeof existing === 'object' && !Array.isArray(existing)
58
+ ? { ...existing }
59
+ : {};
60
+ target = target[parts[i]];
61
+ }
62
+ target[parts[parts.length - 1]] = value;
63
+ }
64
+ return next;
65
+ };
40
66
  // Simple test/dev provider. Stores documents and image blobs in memory and
41
67
  // fans out subscription notifications synchronously. Not designed for
42
68
  // performance — designed for predictable behavior in tests and Storybook.
@@ -86,7 +112,7 @@ export class InMemoryAnnotationProvider {
86
112
  if (!prev)
87
113
  throw new Error(`Annotation file ${fileId} not found`);
88
114
  const resolved = applyFieldOps(patch);
89
- const next = { ...prev, ...resolved };
115
+ const next = applyDotPaths(prev, resolved);
90
116
  bucket.set(fileId, next);
91
117
  this.notifyDoc(scope, fileId);
92
118
  this.notifyList(scope);
@@ -1,8 +1,8 @@
1
1
  import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../../types/annotation.js';
2
- import type { ImageBlob, JobGroupScope } from '../AnnotationDataProvider.js';
2
+ import type { AnnotationScope, ImageBlob } from '../AnnotationDataProvider.js';
3
3
  export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
4
4
  export interface UseAnnotationCanvasDocOptions {
5
- scope: JobGroupScope | null;
5
+ scope: AnnotationScope | null;
6
6
  fileId: string | null;
7
7
  fallbackViewport?: Partial<AnnotationViewport>;
8
8
  debounceMs?: number;
@@ -223,10 +223,17 @@ export const useAnnotationCanvasDoc = (options) => {
223
223
  }
224
224
  else {
225
225
  const doc = dataRef.current;
226
+ const fileType = doc?.fileData.fileType ?? createSeedRef.current?.fileType ?? 'sketch';
227
+ const isLabel = doc?.fileData.isLabel ?? createSeedRef.current?.isLabel;
228
+ // Dotted field paths so the write MERGES into fileData instead of
229
+ // replacing the map — the doc may carry sibling fileData keys the
230
+ // canvas doesn't own (a calculator file's columns/tableConfig/
231
+ // isCompleted, …) which a whole-map write would silently delete.
226
232
  await updateRef.current(id, {
227
- fileData: buildFileData(doc?.fileData.fileType ??
228
- createSeedRef.current?.fileType ??
229
- 'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload, canvasRev),
233
+ 'fileData.fileType': fileType,
234
+ ...(isLabel !== undefined ? { 'fileData.isLabel': isLabel } : {}),
235
+ 'fileData.canvas': canvasPayload,
236
+ 'fileData.canvasRev': canvasRev,
230
237
  });
231
238
  if (debug) {
232
239
  console.log('[useAnnotationCanvasDoc] updated file', id);
@@ -1,7 +1,7 @@
1
- import type { AnnotationFile, JobGroupScope } from '../AnnotationDataProvider.js';
1
+ import { type AnnotationFile, type AnnotationScope } from '../AnnotationDataProvider.js';
2
2
  export interface UseAnnotationDocResult {
3
3
  data: AnnotationFile | null;
4
4
  loading: boolean;
5
5
  error: Error | null;
6
6
  }
7
- export declare const useAnnotationDoc: (scope: JobGroupScope | null, fileId: string | null) => UseAnnotationDocResult;
7
+ export declare const useAnnotationDoc: (scope: AnnotationScope | null, fileId: string | null) => UseAnnotationDocResult;
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useAnnotationData } from '../AnnotationDataContext.js';
3
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
3
4
  export const useAnnotationDoc = (scope, fileId) => {
4
5
  const provider = useAnnotationData();
5
6
  const [data, setData] = useState(null);
@@ -21,13 +22,7 @@ export const useAnnotationDoc = (scope, fileId) => {
21
22
  setLoading(false);
22
23
  });
23
24
  return unsubscribe;
24
- }, [
25
- provider,
26
- scope?.orgId,
27
- scope?.projectId,
28
- scope?.jobId,
29
- scope?.groupId,
30
- fileId,
31
- ]);
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [provider, annotationScopeKey(scope), fileId]);
32
27
  return { data, loading, error };
33
28
  };
@@ -1,7 +1,7 @@
1
- import type { AnnotationFileSummary, JobGroupScope } from '../AnnotationDataProvider.js';
1
+ import { type AnnotationFileSummary, type AnnotationScope } from '../AnnotationDataProvider.js';
2
2
  export interface UseAnnotationListResult {
3
3
  files: AnnotationFileSummary[];
4
4
  loading: boolean;
5
5
  error: Error | null;
6
6
  }
7
- export declare const useAnnotationList: (scope: JobGroupScope | null) => UseAnnotationListResult;
7
+ export declare const useAnnotationList: (scope: AnnotationScope | null) => UseAnnotationListResult;
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useAnnotationData } from '../AnnotationDataContext.js';
3
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
3
4
  export const useAnnotationList = (scope) => {
4
5
  const provider = useAnnotationData();
5
6
  const [files, setFiles] = useState([]);
@@ -21,6 +22,7 @@ export const useAnnotationList = (scope) => {
21
22
  setLoading(false);
22
23
  });
23
24
  return unsubscribe;
24
- }, [provider, scope?.orgId, scope?.projectId, scope?.jobId, scope?.groupId]);
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [provider, annotationScopeKey(scope)]);
25
27
  return { files, loading, error };
26
28
  };
@@ -1,9 +1,9 @@
1
- import type { AnnotationFile, ImageBlob, JobGroupScope, Patch, UploadedImageRef } from '../AnnotationDataProvider.js';
1
+ import { type AnnotationFile, type AnnotationFilePatch, type AnnotationScope, type ImageBlob, type UploadedImageRef } from '../AnnotationDataProvider.js';
2
2
  export interface AnnotationMutations {
3
3
  create(seed: Partial<AnnotationFile>): Promise<string>;
4
- update(fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
4
+ update(fileId: string, patch: AnnotationFilePatch): Promise<void>;
5
5
  remove(fileId: string): Promise<void>;
6
6
  uploadImage(fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
7
7
  deleteImage(fileId: string, storagePath: string): Promise<void>;
8
8
  }
9
- export declare const useAnnotationMutations: (scope: JobGroupScope) => AnnotationMutations;
9
+ export declare const useAnnotationMutations: (scope: AnnotationScope) => AnnotationMutations;
@@ -1,11 +1,15 @@
1
1
  import { useCallback, useMemo } from 'react';
2
2
  import { useAnnotationData } from '../AnnotationDataContext.js';
3
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
3
4
  export const useAnnotationMutations = (scope) => {
4
5
  const provider = useAnnotationData();
5
- const create = useCallback((seed) => provider.create(scope, seed), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
6
- const update = useCallback((fileId, patch) => provider.update(scope, fileId, patch), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
7
- const remove = useCallback((fileId) => provider.delete(scope, fileId), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
8
- const uploadImage = useCallback((fileId, role, blob) => provider.uploadImage(scope, fileId, role, blob), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
9
- const deleteImage = useCallback((fileId, storagePath) => provider.deleteImage(scope, fileId, storagePath), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
6
+ const scopeKey = annotationScopeKey(scope);
7
+ /* eslint-disable react-hooks/exhaustive-deps */
8
+ const create = useCallback((seed) => provider.create(scope, seed), [provider, scopeKey]);
9
+ const update = useCallback((fileId, patch) => provider.update(scope, fileId, patch), [provider, scopeKey]);
10
+ const remove = useCallback((fileId) => provider.delete(scope, fileId), [provider, scopeKey]);
11
+ const uploadImage = useCallback((fileId, role, blob) => provider.uploadImage(scope, fileId, role, blob), [provider, scopeKey]);
12
+ const deleteImage = useCallback((fileId, storagePath) => provider.deleteImage(scope, fileId, storagePath), [provider, scopeKey]);
13
+ /* eslint-enable react-hooks/exhaustive-deps */
10
14
  return useMemo(() => ({ create, update, remove, uploadImage, deleteImage }), [create, update, remove, uploadImage, deleteImage]);
11
15
  };
package/dist/exports.d.ts CHANGED
@@ -9,7 +9,7 @@ export * from './types/annotation.js';
9
9
  export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, generateToleranceGradient, createDefaultToleranceThresholds, DEFAULT_TOLERANCE_COLORS, type ToleranceThreshold, type ToleranceConfig, } from './utils/tolerance.js';
10
10
  export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
11
11
  export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
12
- export { isFieldOp, type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
12
+ export { annotationScopeKey, isFieldOp, isTemplateScope, type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type TemplateScope, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
13
13
  export { AnnotationDataProviderContext, useAnnotationData, type AnnotationDataProviderProps, } from './annotation/data/AnnotationDataContext.js';
14
14
  export { useAnnotationDoc, type UseAnnotationDocResult, } from './annotation/data/hooks/useAnnotationDoc.js';
15
15
  export { useAnnotationList, type UseAnnotationListResult, } from './annotation/data/hooks/useAnnotationList.js';
package/dist/exports.js CHANGED
@@ -14,7 +14,7 @@ export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, gen
14
14
  export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
15
15
  export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
16
16
  // Annotation data layer (SDK-neutral; apps provide their own provider).
17
- export { isFieldOp, } from './annotation/data/AnnotationDataProvider.js';
17
+ export { annotationScopeKey, isFieldOp, isTemplateScope, } from './annotation/data/AnnotationDataProvider.js';
18
18
  export { AnnotationDataProviderContext, useAnnotationData, } from './annotation/data/AnnotationDataContext.js';
19
19
  export { useAnnotationDoc, } from './annotation/data/hooks/useAnnotationDoc.js';
20
20
  export { useAnnotationList, } from './annotation/data/hooks/useAnnotationList.js';
@@ -93,6 +93,7 @@ export interface AnnotationBackgroundImage {
93
93
  downloadUrl: string;
94
94
  widthPx: number;
95
95
  heightPx: number;
96
+ format?: 'raster' | 'svg';
96
97
  }
97
98
  export interface AnnotationViewport {
98
99
  width: number;
@@ -126,6 +126,11 @@ export interface CalculatorFileData {
126
126
  diagramFileId: string | null;
127
127
  columns: Record<string, any>;
128
128
  isCompleted: boolean;
129
+ canvas?: AnnotationCanvasState;
130
+ canvasRev?: {
131
+ clientId: string;
132
+ seq: number;
133
+ };
129
134
  }
130
135
  export interface LayoutGroupFileData {
131
136
  layoutCount?: number;
@@ -184,7 +189,7 @@ export type FileUpload = FileUploadBase & ({
184
189
  fileData?: undefined;
185
190
  } | {
186
191
  type: FileUploadType.Template;
187
- fileData?: undefined;
192
+ fileData?: AnnotationFileData;
188
193
  } | {
189
194
  type: FileUploadType.Label;
190
195
  fileData: Label;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.30",
3
+ "version": "1.6.32",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",