@reekon-tools/boldr-utils 1.6.8 → 1.6.10

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.
@@ -5,6 +5,7 @@ import type { MeasurementRef } from './measurementPicker.js';
5
5
  import type { Tool } from './Tool.js';
6
6
  import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
7
7
  import { type ViewportState } from './viewport.js';
8
+ import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
8
9
  export type { AnnotationCanvasHandle };
9
10
  export interface AnnotationCanvasInnerProps {
10
11
  canvas: AnnotationCanvasState;
@@ -19,6 +20,7 @@ export interface AnnotationCanvasInnerProps {
19
20
  decimalTolerance?: DecimalTolerance;
20
21
  resolveImageUrl?: (storagePath: string) => Promise<string>;
21
22
  pickMeasurement?: () => Promise<MeasurementRef | null>;
23
+ renderMeasurementStamp?: RenderMeasurementStamp;
22
24
  stampFontSource?: unknown;
23
25
  stampValueFontSize?: number;
24
26
  stampLabelFontSize?: number;
@@ -1,8 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFont } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
6
+ import { STAMP_TILE_SIZE } from './stampLayout.js';
6
7
  const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
7
8
  export const AnnotationCanvasInner = (props) => {
8
9
  const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, gestures, width, height, style, activeToolId, tools, } = props;
@@ -161,19 +162,58 @@ export const AnnotationCanvasInner = (props) => {
161
162
  ...style,
162
163
  };
163
164
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
164
- return (_jsx("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: AnnotationCanvasSkia({
165
- width,
166
- height,
167
- effectiveCanvas: state.effectiveCanvas,
168
- worldTransform: state.worldTransform,
169
- measurementsById: state.measurementsById,
170
- fallbackUnit,
171
- fractionalTolerance,
172
- decimalTolerance,
173
- resolveImageUrl,
174
- valueFont,
175
- labelFont,
176
- penDrawingStroke: state.penDrawingStroke,
177
- customPreview,
178
- }) }));
165
+ const { renderMeasurementStamp, selection } = props;
166
+ return (_jsxs("div", { ref: containerRef, style: containerStyle, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, onPointerCancel: handlePointerCancel, onWheel: handleWheel, onContextMenu: handleContextMenu, children: [AnnotationCanvasSkia({
167
+ width,
168
+ height,
169
+ effectiveCanvas: state.effectiveCanvas,
170
+ worldTransform: state.worldTransform,
171
+ measurementsById: state.measurementsById,
172
+ fallbackUnit,
173
+ fractionalTolerance,
174
+ decimalTolerance,
175
+ resolveImageUrl,
176
+ valueFont,
177
+ labelFont,
178
+ hideMeasurementStamps: !!renderMeasurementStamp,
179
+ penDrawingStroke: state.penDrawingStroke,
180
+ customPreview,
181
+ }), renderMeasurementStamp && (_jsx("div", { style: {
182
+ position: 'absolute',
183
+ inset: 0,
184
+ pointerEvents: 'none',
185
+ }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
186
+ const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
187
+ const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
188
+ const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
189
+ const isSelected = selection?.ids.includes(placed.id) ?? false;
190
+ return (_jsxs("div", { style: {
191
+ position: 'absolute',
192
+ left: 0,
193
+ top: 0,
194
+ width: size,
195
+ height: size,
196
+ transform: `translate(${cx - size / 2}px, ${cy - size / 2}px)`,
197
+ }, children: [renderMeasurementStamp({
198
+ placed,
199
+ measurement: state.measurementsById.get(placed.measurementId) ?? null,
200
+ selected: isSelected,
201
+ size,
202
+ zoom: state.viewport.zoom,
203
+ }), isSelected && (_jsx("div", { role: "button", "aria-label": "Remove measurement", onPointerDown: (e) => {
204
+ e.stopPropagation();
205
+ state.ctx.commit({
206
+ ops: [{ op: 'removeMeasurement', id: placed.id }],
207
+ });
208
+ state.ctx.setSelection(null);
209
+ }, style: {
210
+ position: 'absolute',
211
+ top: -10,
212
+ right: -10,
213
+ width: 40,
214
+ height: 40,
215
+ cursor: 'pointer',
216
+ pointerEvents: 'auto',
217
+ } }))] }, placed.id));
218
+ }) }))] }));
179
219
  };
@@ -1,5 +1,6 @@
1
1
  import { type MutableRefObject } from 'react';
2
2
  import { type ViewStyle } from 'react-native';
3
+ import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
3
4
  import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
4
5
  import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
5
6
  import type { MeasurementRef } from './measurementPicker.js';
@@ -20,6 +21,7 @@ export interface AnnotationCanvasInnerProps {
20
21
  decimalTolerance?: DecimalTolerance;
21
22
  resolveImageUrl?: (storagePath: string) => Promise<string>;
22
23
  pickMeasurement?: () => Promise<MeasurementRef | null>;
24
+ renderMeasurementStamp?: RenderMeasurementStamp;
23
25
  stampFontSource?: unknown;
24
26
  stampValueFontSize?: number;
25
27
  stampLabelFontSize?: number;
@@ -1,8 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useFont } from '@shopify/react-native-skia';
3
3
  import { useMemo, useRef } from 'react';
4
- import { View } from 'react-native';
4
+ import { StyleSheet, TouchableOpacity, View } from 'react-native';
5
5
  import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
6
+ import { STAMP_TILE_SIZE } from './stampLayout.js';
6
7
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
7
8
  import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
8
9
  // Native fingerprint: one finger drives the active tool, two fingers
@@ -84,19 +85,54 @@ export const AnnotationCanvasInner = (props) => {
84
85
  }, [state]);
85
86
  const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
86
87
  const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
87
- return (_jsx(GestureHandlerRootView, { style: [{ width, height }, style], children: _jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
88
- width,
89
- height,
90
- effectiveCanvas: state.effectiveCanvas,
91
- worldTransform: state.worldTransform,
92
- measurementsById: state.measurementsById,
93
- fallbackUnit,
94
- fractionalTolerance,
95
- decimalTolerance,
96
- resolveImageUrl,
97
- valueFont,
98
- labelFont,
99
- penDrawingStroke: state.penDrawingStroke,
100
- customPreview,
101
- }) }) }) }));
88
+ const { renderMeasurementStamp, selection } = props;
89
+ return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia({
90
+ width,
91
+ height,
92
+ effectiveCanvas: state.effectiveCanvas,
93
+ worldTransform: state.worldTransform,
94
+ measurementsById: state.measurementsById,
95
+ fallbackUnit,
96
+ fractionalTolerance,
97
+ decimalTolerance,
98
+ resolveImageUrl,
99
+ valueFont,
100
+ labelFont,
101
+ hideMeasurementStamps: !!renderMeasurementStamp,
102
+ penDrawingStroke: state.penDrawingStroke,
103
+ customPreview,
104
+ }) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
105
+ const size = STAMP_TILE_SIZE * (placed.scale ?? 1);
106
+ const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
107
+ const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
108
+ const isSelected = selection?.ids.includes(placed.id) ?? false;
109
+ return (_jsxs(View, { pointerEvents: "box-none", style: {
110
+ position: 'absolute',
111
+ left: 0,
112
+ top: 0,
113
+ width: size,
114
+ height: size,
115
+ transform: [
116
+ { translateX: cx - size / 2 },
117
+ { translateY: cy - size / 2 },
118
+ ],
119
+ }, children: [_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: renderMeasurementStamp({
120
+ placed,
121
+ measurement: state.measurementsById.get(placed.measurementId) ?? null,
122
+ selected: isSelected,
123
+ size,
124
+ zoom: state.viewport.zoom,
125
+ }) }), isSelected && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", hitSlop: 10, onPress: () => {
126
+ state.ctx.commit({
127
+ ops: [{ op: 'removeMeasurement', id: placed.id }],
128
+ });
129
+ state.ctx.setSelection(null);
130
+ }, style: {
131
+ position: 'absolute',
132
+ top: -8,
133
+ right: -8,
134
+ width: 36,
135
+ height: 36,
136
+ } }))] }, placed.id));
137
+ }) }))] }));
102
138
  };
@@ -20,7 +20,8 @@ export interface AnnotationCanvasSkiaProps {
20
20
  resolveImageUrl?: (storagePath: string) => Promise<string>;
21
21
  valueFont: SkFont | null;
22
22
  labelFont: SkFont | null;
23
+ hideMeasurementStamps?: boolean;
23
24
  penDrawingStroke: AnnotationStroke | null;
24
25
  customPreview?: ReactNode;
25
26
  }
26
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, penDrawingStroke, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
27
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, hideMeasurementStamps, penDrawingStroke, customPreview, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
@@ -16,4 +16,5 @@ import { StrokeElement } from './elements/StrokeElement.js';
16
16
  // since the function-call pattern works identically on native we use it
17
17
  // in both Inners for consistency. Don't add hooks here; this is a plain
18
18
  // JSX-returning helper, not a component.
19
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, penDrawingStroke, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _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 })), effectiveCanvas.strokes.map((stroke) => (_jsx(StrokeElement, { stroke: stroke }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(ShapeElement, { shape: shape, font: valueFont }, shape.id))), effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampElement, { placed: placed, measurement: measurementsById.get(placed.measurementId) ?? null, fallbackUnit: fallbackUnit ?? Units.Millimeters, fractionalTolerance: fractionalTolerance, decimalTolerance: decimalTolerance, valueFont: valueFont, labelFont: labelFont }, placed.id))), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), customPreview] }) }));
19
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, measurementsById, fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, valueFont, labelFont, hideMeasurementStamps, penDrawingStroke, customPreview, }) => (_jsx(Canvas, { style: { width, height }, children: _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 })), effectiveCanvas.strokes.map((stroke) => (_jsx(StrokeElement, { stroke: stroke }, stroke.id))), effectiveCanvas.shapes.map((shape) => (_jsx(ShapeElement, { shape: shape, font: valueFont }, shape.id))), !hideMeasurementStamps &&
20
+ effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampElement, { placed: placed, measurement: measurementsById.get(placed.measurementId) ?? null, fallbackUnit: fallbackUnit ?? Units.Millimeters, fractionalTolerance: fractionalTolerance, decimalTolerance: decimalTolerance, valueFont: valueFont, labelFont: labelFont }, placed.id))), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), customPreview] }) }));
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { PlacedMeasurementRef } from '../types/annotation.js';
3
+ import type { Measurement } from '../types/firestore.js';
4
+ export interface MeasurementStampRenderArgs {
5
+ placed: PlacedMeasurementRef;
6
+ measurement: Measurement | null;
7
+ selected: boolean;
8
+ size: number;
9
+ zoom: number;
10
+ }
11
+ export type RenderMeasurementStamp = (args: MeasurementStampRenderArgs) => ReactNode;
@@ -0,0 +1 @@
1
+ export {};
@@ -2,3 +2,4 @@ export declare const STAMP_WIDTH = 120;
2
2
  export declare const STAMP_HEIGHT = 44;
3
3
  export declare const STAMP_PADDING_X = 10;
4
4
  export declare const STAMP_PADDING_Y = 6;
5
+ export declare const STAMP_TILE_SIZE = 120;
@@ -6,3 +6,9 @@ export const STAMP_WIDTH = 120;
6
6
  export const STAMP_HEIGHT = 44;
7
7
  export const STAMP_PADDING_X = 10;
8
8
  export const STAMP_PADDING_Y = 6;
9
+ // Constant SCREEN-space edge length of a placed measurement rendered as a
10
+ // square tile (the overlay path — see measurementStampOverlay.ts). The tile
11
+ // is a fixed-size pin: its on-screen size is this times `placed.scale` and
12
+ // does NOT change with zoom (only its position tracks the canvas). Also drives
13
+ // the select-tool hit box, which converts it back to doc space via the zoom.
14
+ export const STAMP_TILE_SIZE = 120;
@@ -1,4 +1,4 @@
1
- import { STAMP_HEIGHT, STAMP_WIDTH } from '../stampLayout.js';
1
+ import { STAMP_TILE_SIZE } from '../stampLayout.js';
2
2
  const HIT_PADDING = 6;
3
3
  // Hit-test in doc-space. Crude but fast — good enough for v1; tools can
4
4
  // override via `hitTest` for more precision later.
@@ -12,16 +12,16 @@ const hitStroke = (stroke, p) => {
12
12
  }
13
13
  return false;
14
14
  };
15
- const hitMeasurement = (m, p) => {
16
- // Stamp rectangle is fixed-size, centered on the anchor and scaled by
17
- // `placed.scale`. Match the hit box exactly to that rectangle (plus a
18
- // small padding so the click target feels generous).
15
+ const hitMeasurement = (m, p, zoom = 1) => {
16
+ // The stamp renders as a constant *screen*-size square centered on the
17
+ // anchor, so its doc-space footprint shrinks as you zoom in. Convert the
18
+ // screen-space half-extent (+ padding) back to doc space via the zoom so
19
+ // the hit box always matches what's drawn.
19
20
  const scale = m.scale ?? 1;
20
- const halfW = (STAMP_WIDTH * scale) / 2 + HIT_PADDING;
21
- const halfH = (STAMP_HEIGHT * scale) / 2 + HIT_PADDING;
21
+ const half = ((STAMP_TILE_SIZE * scale) / 2 + HIT_PADDING) / zoom;
22
22
  const dx = Math.abs(p.x - m.anchor.x);
23
23
  const dy = Math.abs(p.y - m.anchor.y);
24
- return dx <= halfW && dy <= halfH;
24
+ return dx <= half && dy <= half;
25
25
  };
26
26
  const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
27
27
  const abx = bx - ax;
@@ -35,11 +35,11 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
35
35
  const dy = py - cy;
36
36
  return dx * dx + dy * dy;
37
37
  };
38
- const findHit = (doc, world) => {
38
+ const findHit = (doc, world, zoom) => {
39
39
  // Hit-test in z-order (top first): measurements > shapes > strokes.
40
40
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
41
41
  const m = doc.placedMeasurements[i];
42
- if (hitMeasurement(m, world))
42
+ if (hitMeasurement(m, world, zoom))
43
43
  return { id: m.id, kind: 'measurement' };
44
44
  }
45
45
  for (let i = doc.shapes.length - 1; i >= 0; i--) {
@@ -135,7 +135,7 @@ export const createSelectTool = () => ({
135
135
  label: 'Select',
136
136
  cursor: 'default',
137
137
  onPointerDown(event, ctx) {
138
- const hit = findHit(ctx.document, event.world);
138
+ const hit = findHit(ctx.document, event.world, ctx.viewport.state.zoom);
139
139
  if (!hit) {
140
140
  ctx.setSelection(null);
141
141
  return { kind: 'idle' };
@@ -10,6 +10,7 @@ export interface AnnotationCanvasHandle {
10
10
  canRedo(): boolean;
11
11
  zoomToFit(): void;
12
12
  resetView(): void;
13
+ placeMeasurementAtCenter(ref: MeasurementRef): void;
13
14
  }
14
15
  export interface UseAnnotationCanvasStateProps {
15
16
  canvas: AnnotationCanvasState;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { applyPatch, invertPatch, } from '../types/annotation.js';
2
+ import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../types/annotation.js';
3
3
  import { createViewportApi, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
4
  // Platform-agnostic state machine for the annotation canvas. Web and native
5
5
  // inners share this hook; each wraps it with platform-specific event
@@ -54,6 +54,10 @@ export const useAnnotationCanvasState = (props) => {
54
54
  onSelectionChange,
55
55
  pickMeasurement,
56
56
  ]);
57
+ // Live ctx for imperative handle methods (which are created in an effect and
58
+ // would otherwise capture a stale ctx/viewport).
59
+ const ctxRef = useRef(ctx);
60
+ ctxRef.current = ctx;
57
61
  const dispatchPointerDown = useCallback((event) => {
58
62
  if (!activeTool)
59
63
  return;
@@ -136,6 +140,30 @@ export const useAnnotationCanvasState = (props) => {
136
140
  resetView() {
137
141
  setViewport(DEFAULT_VIEWPORT);
138
142
  },
143
+ placeMeasurementAtCenter(ref) {
144
+ const c = ctxRef.current;
145
+ const anchor = c.viewport.screenToWorld({
146
+ x: width / 2,
147
+ y: height / 2,
148
+ });
149
+ const placed = {
150
+ id: `measurement-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`,
151
+ layerId: c.document.layers[0]?.id ?? DEFAULT_LAYER_ID,
152
+ measurementPath: ref.measurementPath,
153
+ measurementId: ref.measurementId,
154
+ groupId: ref.groupId,
155
+ anchor,
156
+ labelOverride: ref.label,
157
+ unitOverride: ref.unit,
158
+ showLabel: true,
159
+ showValue: true,
160
+ createdAt: Date.now(),
161
+ };
162
+ c.commit({ ops: [{ op: 'addMeasurement', measurement: placed }] });
163
+ // Select it so the consumer can immediately move it (the consumer is
164
+ // responsible for switching to its move/select tool).
165
+ c.setSelection({ ids: [placed.id] });
166
+ },
139
167
  };
140
168
  return () => {
141
169
  if (imperativeRef)
@@ -39,7 +39,7 @@ export interface UploadedImageRef {
39
39
  bytes?: number;
40
40
  }
41
41
  export type AnnotationFile = Extract<FileUpload, {
42
- type: FileUploadType.Annotation;
42
+ type: FileUploadType.Canvas;
43
43
  }>;
44
44
  export interface AnnotationFileSummary {
45
45
  id: string;
@@ -1,4 +1,4 @@
1
- import { FileUploadType, } from '../types/firestore.js';
1
+ import { FileUploadType } from '../types/firestore.js';
2
2
  import { isFieldOp, } from './AnnotationDataProvider.js';
3
3
  const scopeKey = (s) => `${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
4
4
  const jobKey = (s) => `${s.orgId}/${s.projectId}/${s.jobId}`;
@@ -69,7 +69,7 @@ export class InMemoryAnnotationProvider {
69
69
  thumbnailUrl: seed.thumbnailUrl ?? null,
70
70
  createdAt: seed.createdAt ?? new Date(),
71
71
  createdBy: seed.createdBy,
72
- type: FileUploadType.Annotation,
72
+ type: FileUploadType.Canvas,
73
73
  fileData: seed.fileData ?? { fileType: 'sketch' },
74
74
  };
75
75
  this.getBucket(scope).set(id, file);
@@ -0,0 +1,3 @@
1
+ import { type AnnotationCanvasState, type AnnotationViewport } from '../types/annotation.js';
2
+ import type { AnnotationFile } from './AnnotationDataProvider.js';
3
+ export declare const hydrateCanvasState: (file: AnnotationFile | null, fallbackViewport?: Partial<AnnotationViewport>) => AnnotationCanvasState;
@@ -0,0 +1,26 @@
1
+ import { createEmptyCanvasState, } from '../types/annotation.js';
2
+ // Bring a persisted canvas forward to the current schema. Keyed on
3
+ // `schemaVersion` so each client migrates identically and the provider can
4
+ // stay a dumb transport. Identity for v1 today; add a `case 1: return
5
+ // upgradeV1toV2(raw)` arm when the schema bumps.
6
+ const migrateCanvasState = (raw) => {
7
+ switch (raw.schemaVersion) {
8
+ case 1:
9
+ return raw;
10
+ default:
11
+ // Forward-compat: a doc written by a newer client. Trust it as-is
12
+ // rather than risk a lossy downgrade-write.
13
+ return raw;
14
+ }
15
+ };
16
+ // Turn a loaded annotation file into a working canvas state ready to feed the
17
+ // canvas. Missing/legacy docs (no `fileData.canvas`) start from an empty
18
+ // canvas sized by `fallbackViewport`. This is the single entry point for going
19
+ // from `useAnnotationDoc` data to an `AnnotationCanvasState` — consumers should
20
+ // not call `createEmptyCanvasState` directly.
21
+ export const hydrateCanvasState = (file, fallbackViewport) => {
22
+ const persisted = file?.fileData.canvas;
23
+ if (!persisted)
24
+ return createEmptyCanvasState(fallbackViewport);
25
+ return migrateCanvasState(persisted);
26
+ };
@@ -0,0 +1,33 @@
1
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../types/annotation.js';
2
+ import type { ImageBlob, JobGroupScope } from '../AnnotationDataProvider.js';
3
+ export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
4
+ export interface UseAnnotationCanvasDocOptions {
5
+ scope: JobGroupScope | null;
6
+ fileId: string | null;
7
+ fallbackViewport?: Partial<AnnotationViewport>;
8
+ debounceMs?: number;
9
+ createSeed?: {
10
+ name?: string;
11
+ fileType?: 'sketch' | 'document';
12
+ isLabel?: boolean;
13
+ };
14
+ onFileCreated?: (fileId: string) => void;
15
+ onSaveError?: (error: unknown) => void;
16
+ captureThumbnail?: () => Promise<ImageBlob | null>;
17
+ debugLogging?: boolean;
18
+ }
19
+ export interface UseAnnotationCanvasDocResult {
20
+ canvas: AnnotationCanvasState;
21
+ onCommit: (patch: AnnotationDocumentPatch) => void;
22
+ loading: boolean;
23
+ error: Error | null;
24
+ saveStatus: SaveStatus;
25
+ save: () => Promise<void>;
26
+ ensureFileId: () => Promise<string>;
27
+ setBackgroundImage: (blob: ImageBlob, dims: {
28
+ width: number;
29
+ height: number;
30
+ }, fit?: BackgroundFit) => Promise<void>;
31
+ clearBackgroundImage: () => Promise<void>;
32
+ }
33
+ export declare const useAnnotationCanvasDoc: (options: UseAnnotationCanvasDocOptions) => UseAnnotationCanvasDocResult;
@@ -0,0 +1,314 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { applyPatch, createEmptyCanvasState, } from '../../types/annotation.js';
3
+ import { FileUploadType } from '../../types/firestore.js';
4
+ import { useAnnotationDoc } from './useAnnotationDoc.js';
5
+ import { useAnnotationMutations } from './useAnnotationMutations.js';
6
+ import { hydrateCanvasState } from '../canvasPersistence.js';
7
+ // Stable placeholder so useAnnotationMutations (which requires a non-null
8
+ // scope) can be called unconditionally. Never used to write — flushes are
9
+ // guarded on a real scope.
10
+ const EMPTY_SCOPE = {
11
+ orgId: '',
12
+ projectId: '',
13
+ jobId: '',
14
+ groupId: '',
15
+ };
16
+ // Build the persisted fileData, omitting `isLabel` when undefined so the write
17
+ // contains no undefined values (Firestore rejects them on RN).
18
+ const buildFileData = (fileType, isLabel, canvas) => ({
19
+ fileType,
20
+ ...(isLabel !== undefined ? { isLabel } : {}),
21
+ canvas,
22
+ });
23
+ // Orchestrates load + auto-save for the annotation canvas. Hydrates the working
24
+ // state from the persisted doc, applies commits optimistically, and persists
25
+ // (debounced) through the data provider — creating the file on first save when
26
+ // no `fileId` was supplied. Composes the existing low-level hooks so it stays
27
+ // decoupled from any specific Firebase SDK.
28
+ export const useAnnotationCanvasDoc = (options) => {
29
+ const { scope, fileId, fallbackViewport, debounceMs = 800, createSeed, onFileCreated, onSaveError, captureThumbnail, debugLogging = false, } = options;
30
+ const { data, loading, error } = useAnnotationDoc(scope, fileId);
31
+ const { create, update, uploadImage, deleteImage } = useAnnotationMutations(scope ?? EMPTY_SCOPE);
32
+ const [working, setWorking] = useState(null);
33
+ const [saveStatus, setSaveStatus] = useState('idle');
34
+ // Refs let the debounced/unmount flush read the latest values without
35
+ // re-creating timers on every keystroke.
36
+ const workingRef = useRef(null);
37
+ const dataRef = useRef(data);
38
+ const statusRef = useRef('idle');
39
+ const timerRef = useRef(null);
40
+ const createRef = useRef(create);
41
+ const updateRef = useRef(update);
42
+ const fileIdRef = useRef(fileId);
43
+ // Id of a file this hook created on first save (when opened with no fileId).
44
+ // Used for subsequent updates and to recognize the caller echoing it back
45
+ // into `fileId`.
46
+ const createdIdRef = useRef(null);
47
+ const createSeedRef = useRef(createSeed);
48
+ const onFileCreatedRef = useRef(onFileCreated);
49
+ const onSaveErrorRef = useRef(onSaveError);
50
+ const captureThumbnailRef = useRef(captureThumbnail);
51
+ const uploadImageRef = useRef(uploadImage);
52
+ // Guards against overlapping thumbnail captures (each save fires one).
53
+ const thumbnailSavingRef = useRef(false);
54
+ const debugRef = useRef(debugLogging);
55
+ // JSON of the canvas we last wrote, to recognize (and ignore) the snapshot
56
+ // echo of our own write when reconciling incoming remote changes.
57
+ const lastSavedJsonRef = useRef(null);
58
+ // Guards against two creates racing if flushes overlap.
59
+ const creatingRef = useRef(false);
60
+ workingRef.current = working;
61
+ dataRef.current = data;
62
+ statusRef.current = saveStatus;
63
+ createRef.current = create;
64
+ updateRef.current = update;
65
+ createSeedRef.current = createSeed;
66
+ onFileCreatedRef.current = onFileCreated;
67
+ onSaveErrorRef.current = onSaveError;
68
+ captureThumbnailRef.current = captureThumbnail;
69
+ uploadImageRef.current = uploadImage;
70
+ debugRef.current = debugLogging;
71
+ const setStatus = useCallback((next) => {
72
+ statusRef.current = next;
73
+ setSaveStatus(next);
74
+ }, []);
75
+ // Fire-and-forget thumbnail capture + upload, run after each successful save.
76
+ // Stable identity (reads refs) so it doesn't churn `flush`'s deps. Skips when
77
+ // no capturer is supplied or a capture is already in flight.
78
+ const saveThumbnail = useCallback(async (fileId) => {
79
+ const capture = captureThumbnailRef.current;
80
+ if (!capture || thumbnailSavingRef.current)
81
+ return;
82
+ thumbnailSavingRef.current = true;
83
+ try {
84
+ const blob = await capture();
85
+ if (blob)
86
+ await uploadImageRef.current(fileId, 'thumbnail', blob);
87
+ }
88
+ catch (e) {
89
+ console.warn('[useAnnotationCanvasDoc] thumbnail save failed', e);
90
+ }
91
+ finally {
92
+ thumbnailSavingRef.current = false;
93
+ }
94
+ }, []);
95
+ // Reset working state when the target document changes so the next snapshot
96
+ // hydrates the new file rather than leaking the previous one. Skip the reset
97
+ // when the new fileId is one we just created (the caller echoing it back) —
98
+ // our working state is already correct and must not be wiped.
99
+ useEffect(() => {
100
+ if (fileIdRef.current === fileId)
101
+ return;
102
+ fileIdRef.current = fileId;
103
+ if (fileId !== null && fileId === createdIdRef.current)
104
+ return;
105
+ if (timerRef.current) {
106
+ clearTimeout(timerRef.current);
107
+ timerRef.current = null;
108
+ }
109
+ createdIdRef.current = null;
110
+ lastSavedJsonRef.current = null;
111
+ setWorking(null);
112
+ setStatus('idle');
113
+ }, [fileId, setStatus]);
114
+ // Hydrate / reconcile from the persisted doc.
115
+ useEffect(() => {
116
+ // First load — always hydrate.
117
+ if (workingRef.current === null) {
118
+ setWorking(hydrateCanvasState(data, fallbackViewport));
119
+ lastSavedJsonRef.current = data?.fileData.canvas
120
+ ? JSON.stringify(data.fileData.canvas)
121
+ : null;
122
+ return;
123
+ }
124
+ // A write of ours is queued or in flight — ignore the snapshot; it is
125
+ // either the echo of our write or about to be superseded by it.
126
+ if (statusRef.current === 'saving' || timerRef.current !== null)
127
+ return;
128
+ // Clean locally: accept a genuine remote change, but ignore the echo of
129
+ // our own last write (same content).
130
+ const incoming = data?.fileData.canvas;
131
+ if (!incoming)
132
+ return;
133
+ const incomingJson = JSON.stringify(incoming);
134
+ if (incomingJson === lastSavedJsonRef.current)
135
+ return;
136
+ lastSavedJsonRef.current = incomingJson;
137
+ setWorking(hydrateCanvasState(data, fallbackViewport));
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [data]);
140
+ const flush = useCallback(async () => {
141
+ if (timerRef.current) {
142
+ clearTimeout(timerRef.current);
143
+ timerRef.current = null;
144
+ }
145
+ const canvas = workingRef.current;
146
+ // Nothing to persist, or no real target scope yet.
147
+ if (!canvas || !scope)
148
+ return;
149
+ if (creatingRef.current)
150
+ return;
151
+ const json = JSON.stringify(canvas);
152
+ const id = fileIdRef.current ?? createdIdRef.current;
153
+ const mode = id ? 'update' : 'create';
154
+ const debug = debugRef.current;
155
+ if (debug) {
156
+ console.log('[useAnnotationCanvasDoc] save attempt', {
157
+ mode,
158
+ fileId: id,
159
+ scope,
160
+ bytes: json.length,
161
+ strokes: canvas.strokes.length,
162
+ shapes: canvas.shapes.length,
163
+ measurements: canvas.placedMeasurements.length,
164
+ });
165
+ }
166
+ // Round-trip through JSON to drop `undefined` keys (e.g. an empty canvas's
167
+ // viewport.backgroundImage/backgroundFit). Firestore — RN in particular —
168
+ // rejects undefined field values unless ignoreUndefinedProperties is set.
169
+ const canvasPayload = JSON.parse(json);
170
+ setStatus('saving');
171
+ try {
172
+ if (!id) {
173
+ // First save with no file — create the doc seeded with the canvas.
174
+ creatingRef.current = true;
175
+ const seed = createSeedRef.current;
176
+ const newId = await createRef.current({
177
+ type: FileUploadType.Canvas,
178
+ ...(seed?.name !== undefined ? { name: seed.name } : {}),
179
+ fileData: buildFileData(seed?.fileType ?? 'sketch', seed?.isLabel, canvasPayload),
180
+ });
181
+ creatingRef.current = false;
182
+ createdIdRef.current = newId;
183
+ if (debug) {
184
+ console.log('[useAnnotationCanvasDoc] created file', newId);
185
+ }
186
+ onFileCreatedRef.current?.(newId);
187
+ }
188
+ else {
189
+ const doc = dataRef.current;
190
+ await updateRef.current(id, {
191
+ fileData: buildFileData(doc?.fileData.fileType ??
192
+ createSeedRef.current?.fileType ??
193
+ 'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload),
194
+ });
195
+ if (debug) {
196
+ console.log('[useAnnotationCanvasDoc] updated file', id);
197
+ }
198
+ }
199
+ lastSavedJsonRef.current = json;
200
+ // Refresh the file's thumbnail to match what was just saved (fire and
201
+ // forget — never blocks or fails the save).
202
+ const savedId = fileIdRef.current ?? createdIdRef.current;
203
+ if (savedId)
204
+ void saveThumbnail(savedId);
205
+ // If new edits landed mid-flight, stay dirty and let the next debounce
206
+ // (or unmount) flush them.
207
+ const latest = workingRef.current;
208
+ if (latest && JSON.stringify(latest) !== json) {
209
+ setStatus('dirty');
210
+ }
211
+ else {
212
+ setStatus('saved');
213
+ }
214
+ }
215
+ catch (e) {
216
+ creatingRef.current = false;
217
+ // Always log save failures with context — these are otherwise invisible
218
+ // (the canvas keeps working from local state).
219
+ console.error(`[useAnnotationCanvasDoc] ${mode} failed`, { fileId: id, scope, bytes: json.length }, e);
220
+ onSaveErrorRef.current?.(e);
221
+ setStatus('error');
222
+ }
223
+ }, [scope, setStatus, saveThumbnail]);
224
+ const onCommit = useCallback((patch) => {
225
+ setWorking((prev) => (prev ? applyPatch(prev, patch) : prev));
226
+ setStatus('dirty');
227
+ if (timerRef.current)
228
+ clearTimeout(timerRef.current);
229
+ timerRef.current = setTimeout(() => {
230
+ timerRef.current = null;
231
+ void flush();
232
+ }, debounceMs);
233
+ }, [debounceMs, flush, setStatus]);
234
+ const ensureFileId = useCallback(async () => {
235
+ const existing = fileIdRef.current ?? createdIdRef.current;
236
+ if (existing)
237
+ 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.
240
+ if (!workingRef.current) {
241
+ const seeded = createEmptyCanvasState(fallbackViewport);
242
+ workingRef.current = seeded;
243
+ setWorking(seeded);
244
+ }
245
+ await flush();
246
+ const id = fileIdRef.current ?? createdIdRef.current;
247
+ if (!id) {
248
+ throw new Error('Unable to create annotation file before background upload');
249
+ }
250
+ return id;
251
+ }, [flush, fallbackViewport]);
252
+ const setBackgroundImage = useCallback(async (blob, dims, fit = 'contain') => {
253
+ const id = await ensureFileId();
254
+ const ref = await uploadImage(id, 'background', blob);
255
+ onCommit({
256
+ ops: [
257
+ {
258
+ op: 'setViewport',
259
+ patch: {
260
+ backgroundImage: {
261
+ storagePath: ref.storagePath,
262
+ downloadUrl: ref.downloadUrl,
263
+ widthPx: dims.width,
264
+ heightPx: dims.height,
265
+ },
266
+ backgroundFit: fit,
267
+ },
268
+ },
269
+ ],
270
+ });
271
+ }, [ensureFileId, uploadImage, onCommit]);
272
+ const clearBackgroundImage = useCallback(async () => {
273
+ const bg = workingRef.current?.viewport.backgroundImage;
274
+ const id = fileIdRef.current ?? createdIdRef.current;
275
+ if (bg && id) {
276
+ try {
277
+ await deleteImage(id, bg.storagePath);
278
+ }
279
+ catch (e) {
280
+ // Non-fatal: still clear the viewport reference even if the storage
281
+ // object is already gone.
282
+ console.warn('[useAnnotationCanvasDoc] failed to delete background image', e);
283
+ }
284
+ }
285
+ onCommit({
286
+ ops: [
287
+ {
288
+ op: 'setViewport',
289
+ patch: { backgroundImage: undefined, backgroundFit: undefined },
290
+ },
291
+ ],
292
+ });
293
+ }, [deleteImage, onCommit]);
294
+ // Flush any pending edits on unmount.
295
+ useEffect(() => () => {
296
+ if (timerRef.current) {
297
+ clearTimeout(timerRef.current);
298
+ timerRef.current = null;
299
+ void flush();
300
+ }
301
+ }, [flush]);
302
+ const canvas = useMemo(() => working ?? createEmptyCanvasState(fallbackViewport), [working, fallbackViewport]);
303
+ return {
304
+ canvas,
305
+ onCommit,
306
+ loading,
307
+ error,
308
+ saveStatus,
309
+ save: flush,
310
+ ensureFileId,
311
+ setBackgroundImage,
312
+ clearBackgroundImage,
313
+ };
314
+ };
package/dist/exports.d.ts CHANGED
@@ -13,13 +13,16 @@ export { AnnotationDataProviderContext, useAnnotationData, type AnnotationDataPr
13
13
  export { useAnnotationDoc, type UseAnnotationDocResult, } from './data/hooks/useAnnotationDoc.js';
14
14
  export { useAnnotationList, type UseAnnotationListResult, } from './data/hooks/useAnnotationList.js';
15
15
  export { useAnnotationMutations, type AnnotationMutations, } from './data/hooks/useAnnotationMutations.js';
16
+ export { useAnnotationCanvasDoc, type SaveStatus, type UseAnnotationCanvasDocOptions, type UseAnnotationCanvasDocResult, } from './data/hooks/useAnnotationCanvasDoc.js';
17
+ export { hydrateCanvasState } from './data/canvasPersistence.js';
16
18
  export { InMemoryAnnotationProvider } from './data/InMemoryAnnotationProvider.js';
17
- export type { AnnotationCanvasHandle, } from './canvas/useAnnotationCanvasState.js';
19
+ export type { AnnotationCanvasHandle } from './canvas/useAnnotationCanvasState.js';
18
20
  export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './canvas/AnnotationCanvasInner.js';
19
21
  export type { CanvasPointerEvent, Tool, ToolContext, ToolState, } from './canvas/Tool.js';
20
22
  export type { MeasurementRef, PickMeasurement, } from './canvas/measurementPicker.js';
23
+ export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './canvas/measurementStampOverlay.js';
21
24
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './canvas/viewport.js';
22
- export { createPenTool, type PenToolOptions, } from './canvas/tools/penTool.js';
25
+ export { createPenTool, type PenToolOptions } from './canvas/tools/penTool.js';
23
26
  export { createSelectTool } from './canvas/tools/selectTool.js';
24
27
  export { createMeasurementStampTool, type MeasurementStampToolOptions, } from './canvas/tools/measurementStampTool.js';
25
- export { createPanTool, type PanToolOptions, } from './canvas/tools/panTool.js';
28
+ export { createPanTool, type PanToolOptions } from './canvas/tools/panTool.js';
package/dist/exports.js CHANGED
@@ -18,9 +18,11 @@ export { AnnotationDataProviderContext, useAnnotationData, } from './data/Annota
18
18
  export { useAnnotationDoc, } from './data/hooks/useAnnotationDoc.js';
19
19
  export { useAnnotationList, } from './data/hooks/useAnnotationList.js';
20
20
  export { useAnnotationMutations, } from './data/hooks/useAnnotationMutations.js';
21
+ export { useAnnotationCanvasDoc, } from './data/hooks/useAnnotationCanvasDoc.js';
22
+ export { hydrateCanvasState } from './data/canvasPersistence.js';
21
23
  export { InMemoryAnnotationProvider } from './data/InMemoryAnnotationProvider.js';
22
24
  export { createViewportApi, fitToScreen, panBy, zoomAt, DEFAULT_VIEWPORT, } from './canvas/viewport.js';
23
- export { createPenTool, } from './canvas/tools/penTool.js';
25
+ export { createPenTool } from './canvas/tools/penTool.js';
24
26
  export { createSelectTool } from './canvas/tools/selectTool.js';
25
27
  export { createMeasurementStampTool, } from './canvas/tools/measurementStampTool.js';
26
- export { createPanTool, } from './canvas/tools/panTool.js';
28
+ export { createPanTool } from './canvas/tools/panTool.js';
@@ -100,6 +100,7 @@ export interface SelectedTemplate extends FirestoreDoc {
100
100
  }
101
101
  export declare enum FileUploadType {
102
102
  Annotation = "annotation",
103
+ Canvas = "canvas",
103
104
  Template = "template",
104
105
  Background = "background",
105
106
  LayoutGroup = "layoutGroup",
@@ -148,6 +149,7 @@ interface FileUploadBase {
148
149
  id: string;
149
150
  name: string;
150
151
  thumbnailUrl: string | null;
152
+ folderPath?: string;
151
153
  createdBy?: {
152
154
  userId: string;
153
155
  firstName?: string;
@@ -158,6 +160,9 @@ interface FileUploadBase {
158
160
  export type FileUpload = FileUploadBase & ({
159
161
  type: FileUploadType.Annotation;
160
162
  fileData: AnnotationFileData;
163
+ } | {
164
+ type: FileUploadType.Canvas;
165
+ fileData: AnnotationFileData;
161
166
  } | {
162
167
  type: FileUploadType.Calculator;
163
168
  fileData: CalculatorFileData;
@@ -18,6 +18,9 @@ export var TemplateType;
18
18
  export var FileUploadType;
19
19
  (function (FileUploadType) {
20
20
  FileUploadType["Annotation"] = "annotation";
21
+ // v2 Skia photo-annotation canvas. Distinct from the legacy PSPDFKit
22
+ // `Annotation` docs so file-open routing sends it to the canvas editor.
23
+ FileUploadType["Canvas"] = "canvas";
21
24
  FileUploadType["Template"] = "template";
22
25
  FileUploadType["Background"] = "background";
23
26
  FileUploadType["LayoutGroup"] = "layoutGroup";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",