@reekon-tools/boldr-utils 1.25.0 → 1.26.0
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.
- package/dist/canvas/AnnotationCanvas.d.ts +11 -0
- package/dist/canvas/AnnotationCanvas.js +10 -0
- package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
- package/dist/canvas/AnnotationCanvas.native.js +6 -0
- package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
- package/dist/canvas/AnnotationCanvasInner.js +219 -0
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
- package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
- package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
- package/dist/canvas/AnnotationCanvasSkia.js +20 -0
- package/dist/canvas/Tool.d.ts +38 -0
- package/dist/canvas/Tool.js +1 -0
- package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
- package/dist/canvas/elements/BackgroundImageElement.js +37 -0
- package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
- package/dist/canvas/elements/MeasurementStampElement.js +30 -0
- package/dist/canvas/elements/ShapeElement.d.ts +7 -0
- package/dist/canvas/elements/ShapeElement.js +62 -0
- package/dist/canvas/elements/StrokeElement.d.ts +7 -0
- package/dist/canvas/elements/StrokeElement.js +18 -0
- package/dist/canvas/measurementPicker.d.ts +10 -0
- package/dist/canvas/measurementPicker.js +1 -0
- package/dist/canvas/measurementStampOverlay.d.ts +11 -0
- package/dist/canvas/measurementStampOverlay.js +1 -0
- package/dist/canvas/pointerAdapter.d.ts +3 -0
- package/dist/canvas/pointerAdapter.js +19 -0
- package/dist/canvas/stampLayout.d.ts +5 -0
- package/dist/canvas/stampLayout.js +14 -0
- package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
- package/dist/canvas/tools/measurementStampTool.js +37 -0
- package/dist/canvas/tools/panTool.d.ts +5 -0
- package/dist/canvas/tools/panTool.js +25 -0
- package/dist/canvas/tools/penTool.d.ts +13 -0
- package/dist/canvas/tools/penTool.js +68 -0
- package/dist/canvas/tools/selectTool.d.ts +2 -0
- package/dist/canvas/tools/selectTool.js +182 -0
- package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
- package/dist/canvas/useAnnotationCanvasState.js +210 -0
- package/dist/canvas/viewport.d.ts +16 -0
- package/dist/canvas/viewport.js +54 -0
- package/dist/data/AnnotationDataContext.d.ts +8 -0
- package/dist/data/AnnotationDataContext.js +11 -0
- package/dist/data/AnnotationDataProvider.d.ts +65 -0
- package/dist/data/AnnotationDataProvider.js +4 -0
- package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
- package/dist/data/InMemoryAnnotationProvider.js +197 -0
- package/dist/data/canvasPersistence.d.ts +3 -0
- package/dist/data/canvasPersistence.js +26 -0
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
- package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
- package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
- package/dist/data/hooks/useAnnotationDoc.js +33 -0
- package/dist/data/hooks/useAnnotationList.d.ts +7 -0
- package/dist/data/hooks/useAnnotationList.js +26 -0
- package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
- package/dist/data/hooks/useAnnotationMutations.js +11 -0
- package/dist/export/buildJobCsv.js +2 -3
- package/dist/exports.d.ts +1 -1
- package/dist/exports.js +1 -1
- package/dist/formulas/calculateFormula.d.ts +1 -1
- package/dist/formulas/calculateFormula.js +22 -24
- package/dist/formulas/evaluateFormula.d.ts +10 -0
- package/dist/formulas/evaluateFormula.js +17 -2
- package/dist/hooks/useParseMeasurement.d.ts +4 -0
- package/dist/hooks/useParseMeasurement.js +14 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +6 -2
- package/dist/types/firestore.d.ts +8 -0
- package/dist/utils/evaluateFormula.d.ts +20 -0
- package/dist/utils/evaluateFormula.js +31 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AnnotationFile, JobGroupScope } from '../AnnotationDataProvider.js';
|
|
2
|
+
export interface UseAnnotationDocResult {
|
|
3
|
+
data: AnnotationFile | null;
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: Error | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const useAnnotationDoc: (scope: JobGroupScope | null, fileId: string | null) => UseAnnotationDocResult;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
3
|
+
export const useAnnotationDoc = (scope, fileId) => {
|
|
4
|
+
const provider = useAnnotationData();
|
|
5
|
+
const [data, setData] = useState(null);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!scope || !fileId) {
|
|
10
|
+
setData(null);
|
|
11
|
+
setLoading(false);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
setLoading(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
const unsubscribe = provider.subscribe(scope, fileId, (doc) => {
|
|
17
|
+
setData(doc);
|
|
18
|
+
setLoading(false);
|
|
19
|
+
}, (err) => {
|
|
20
|
+
setError(err);
|
|
21
|
+
setLoading(false);
|
|
22
|
+
});
|
|
23
|
+
return unsubscribe;
|
|
24
|
+
}, [
|
|
25
|
+
provider,
|
|
26
|
+
scope?.orgId,
|
|
27
|
+
scope?.projectId,
|
|
28
|
+
scope?.jobId,
|
|
29
|
+
scope?.groupId,
|
|
30
|
+
fileId,
|
|
31
|
+
]);
|
|
32
|
+
return { data, loading, error };
|
|
33
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AnnotationFileSummary, JobGroupScope } from '../AnnotationDataProvider.js';
|
|
2
|
+
export interface UseAnnotationListResult {
|
|
3
|
+
files: AnnotationFileSummary[];
|
|
4
|
+
loading: boolean;
|
|
5
|
+
error: Error | null;
|
|
6
|
+
}
|
|
7
|
+
export declare const useAnnotationList: (scope: JobGroupScope | null) => UseAnnotationListResult;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
3
|
+
export const useAnnotationList = (scope) => {
|
|
4
|
+
const provider = useAnnotationData();
|
|
5
|
+
const [files, setFiles] = useState([]);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (!scope) {
|
|
10
|
+
setFiles([]);
|
|
11
|
+
setLoading(false);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
setLoading(true);
|
|
15
|
+
setError(null);
|
|
16
|
+
const unsubscribe = provider.list(scope, (next) => {
|
|
17
|
+
setFiles(next);
|
|
18
|
+
setLoading(false);
|
|
19
|
+
}, (err) => {
|
|
20
|
+
setError(err);
|
|
21
|
+
setLoading(false);
|
|
22
|
+
});
|
|
23
|
+
return unsubscribe;
|
|
24
|
+
}, [provider, scope?.orgId, scope?.projectId, scope?.jobId, scope?.groupId]);
|
|
25
|
+
return { files, loading, error };
|
|
26
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AnnotationFile, ImageBlob, JobGroupScope, Patch, UploadedImageRef } from '../AnnotationDataProvider.js';
|
|
2
|
+
export interface AnnotationMutations {
|
|
3
|
+
create(seed: Partial<AnnotationFile>): Promise<string>;
|
|
4
|
+
update(fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
|
|
5
|
+
remove(fileId: string): Promise<void>;
|
|
6
|
+
uploadImage(fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
|
|
7
|
+
deleteImage(fileId: string, storagePath: string): Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare const useAnnotationMutations: (scope: JobGroupScope) => AnnotationMutations;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import { useAnnotationData } from '../AnnotationDataContext.js';
|
|
3
|
+
export const useAnnotationMutations = (scope) => {
|
|
4
|
+
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]);
|
|
10
|
+
return useMemo(() => ({ create, update, remove, uploadImage, deleteImage }), [create, update, remove, uploadImage, deleteImage]);
|
|
11
|
+
};
|
|
@@ -15,8 +15,7 @@ const objectCell = (obj) => obj.url ? hyperlinkCell(obj.url, obj.name) : escapeC
|
|
|
15
15
|
const toExportDate = (value) => {
|
|
16
16
|
if (value instanceof Date)
|
|
17
17
|
return value;
|
|
18
|
-
if (value &&
|
|
19
|
-
typeof value.toDate === 'function') {
|
|
18
|
+
if (value && typeof value.toDate === 'function') {
|
|
20
19
|
return value.toDate();
|
|
21
20
|
}
|
|
22
21
|
return null;
|
|
@@ -30,7 +29,7 @@ const dateCell = (value) => {
|
|
|
30
29
|
? `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|
31
30
|
: '';
|
|
32
31
|
};
|
|
33
|
-
const userCell = (user) =>
|
|
32
|
+
const userCell = (user) => user ? [user.firstName, user.lastName].filter(Boolean).join(' ') : '';
|
|
34
33
|
// Pure CSV assembly for the job export, shared by rock-pro-app and
|
|
35
34
|
// rock-desktop so the two platforms emit identical files. With no objects
|
|
36
35
|
// selected and the legacy measurement/label/notes columns on, the
|
package/dist/exports.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
|
|
1
|
+
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
|
|
2
2
|
export { calculateFormula } from './formulas/calculateFormula.js';
|
|
3
3
|
export { convertMicrometers } from './utils/micrometersToUnit.js';
|
|
4
4
|
export { parseMeasurement } from './utils/parseMeasurement.js';
|
package/dist/exports.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// platform-specific `AnnotationCanvas` wrapper lives here. The two index
|
|
3
3
|
// files (index.ts, index.native.ts) re-export from this and add their own
|
|
4
4
|
// AnnotationCanvas import.
|
|
5
|
-
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, } from './formulas/evaluateFormula.js';
|
|
5
|
+
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, } from './formulas/evaluateFormula.js';
|
|
6
6
|
export { calculateFormula } from './formulas/calculateFormula.js';
|
|
7
7
|
export { convertMicrometers } from './utils/micrometersToUnit.js';
|
|
8
8
|
export { parseMeasurement } from './utils/parseMeasurement.js';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import { ColumnConfig, DecimalTolerance, FractionalTolerance, Formula, Measurement, Units } from '../types/firestore.js';
|
|
2
|
-
export declare const calculateFormula: (formula: Formula, formulas: Formula[], columns: Record<string, string>, tableConfig: ColumnConfig[], measurements: Measurement[], unit: Units,
|
|
2
|
+
export declare const calculateFormula: (formula: Formula, formulas: Formula[], columns: Record<string, string>, tableConfig: ColumnConfig[], measurements: Measurement[], unit: Units, _fractionalTolerance?: FractionalTolerance, _decimalTolerance?: DecimalTolerance) => string | number | null;
|
|
@@ -1,25 +1,15 @@
|
|
|
1
|
-
import { ColumnType, } from '../types/firestore.js';
|
|
2
|
-
import { evaluateFormula } from './evaluateFormula.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
else if (parts[0].includes('/')) {
|
|
14
|
-
const [num, den] = parts[0].split('/').map(Number);
|
|
15
|
-
fraction = num / den;
|
|
16
|
-
}
|
|
17
|
-
else {
|
|
18
|
-
whole = parseFloat(parts[0]);
|
|
19
|
-
}
|
|
20
|
-
return whole + fraction;
|
|
21
|
-
}
|
|
22
|
-
export const calculateFormula = (formula, formulas, columns, tableConfig, measurements, unit, fractionalTolerance, decimalTolerance) => {
|
|
1
|
+
import { ColumnType, Units, } from '../types/firestore.js';
|
|
2
|
+
import { evaluateFormula, micrometersToUnitNumber } from './evaluateFormula.js';
|
|
3
|
+
const VALID_UNITS = new Set(Object.values(Units));
|
|
4
|
+
// A measurement operand resolves in ITS column's locked unit when one is set;
|
|
5
|
+
// an unrecognized/legacy override falls back to the caller's unit — never to
|
|
6
|
+
// normalizeUnitForMathJS's silent 'mm' default, which would be a wrong answer
|
|
7
|
+
// rather than a visible error.
|
|
8
|
+
const resolveColumnUnit = (override, fallback) => (override && VALID_UNITS.has(override) ? override : fallback);
|
|
9
|
+
export const calculateFormula = (formula, formulas, columns, tableConfig, measurements, unit,
|
|
10
|
+
// Unused since measurement values stopped round-tripping through the display
|
|
11
|
+
// string — kept so the 8-arg call sites in the apps don't break.
|
|
12
|
+
_fractionalTolerance, _decimalTolerance) => {
|
|
23
13
|
// Convert Firestore Map to plain object if necessary
|
|
24
14
|
let variableToColumnMap = {};
|
|
25
15
|
if (formula.variableToColumnMap) {
|
|
@@ -45,8 +35,16 @@ export const calculateFormula = (formula, formulas, columns, tableConfig, measur
|
|
|
45
35
|
const measurement = measurements.find((m) => m.id === measurementId);
|
|
46
36
|
if (!measurement)
|
|
47
37
|
continue;
|
|
48
|
-
|
|
49
|
-
|
|
38
|
+
// Straight um -> display-unit number. The old path rendered the display
|
|
39
|
+
// STRING (convertMicrometers) and re-parsed it, which rounded the value
|
|
40
|
+
// to the display tolerance and returned NaN / dropped the inches for the
|
|
41
|
+
// composite feet-inches formats (`5' 3 1/2"`).
|
|
42
|
+
//
|
|
43
|
+
// Each operand resolves in its own column's unit (lock ?? user unit),
|
|
44
|
+
// so a locked column contributes the number its cell displays. With
|
|
45
|
+
// mixed locks the expression is numeric over display-space values by
|
|
46
|
+
// design; the RESULT (and typed constants) stay in the caller's unit.
|
|
47
|
+
valueMap[columnId] = micrometersToUnitNumber(measurement.value, resolveColumnUnit(column.unitOverride, unit));
|
|
50
48
|
}
|
|
51
49
|
else if (column.type === ColumnType.Angle) {
|
|
52
50
|
const measurement = measurements.find((m) => m.id === measurementId);
|
|
@@ -17,6 +17,16 @@ export interface FormulaEvaluationOptions {
|
|
|
17
17
|
formulas?: FormulaDefinition[];
|
|
18
18
|
scope?: Record<string, number>;
|
|
19
19
|
}
|
|
20
|
+
export declare function normalizeUnitForMathJS(unit: Units | string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Convert a stored micrometer value to the number the formula scope expects
|
|
23
|
+
* for `unit` — the same conversion nested-formula results go through. This is
|
|
24
|
+
* the ONLY sanctioned way to put a measurement into a valueMap: converting via
|
|
25
|
+
* the display STRING (convertMicrometers + a string parser) both rounds the
|
|
26
|
+
* value to the display tolerance and falls apart on composite formats like
|
|
27
|
+
* `5' 3 1/2"`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function micrometersToUnitNumber(micrometers: number, unit: Units): number;
|
|
20
30
|
export declare function evaluateFormula({ expression, mappings, valueMap, unit, formulas, scope, }: FormulaEvaluationOptions): number | string | null;
|
|
21
31
|
export declare function createFormulaScope({ mappings, valueMap, unit, }: {
|
|
22
32
|
mappings: Record<string, string>;
|
|
@@ -7,7 +7,7 @@ const compile = math.compile.bind(math);
|
|
|
7
7
|
const mathUnit = math.unit.bind(math);
|
|
8
8
|
// Cache for evaluated formulas to improve performance
|
|
9
9
|
const formulaCache = new Map();
|
|
10
|
-
function normalizeUnitForMathJS(unit) {
|
|
10
|
+
export function normalizeUnitForMathJS(unit) {
|
|
11
11
|
switch (unit) {
|
|
12
12
|
case Units.Centimeters:
|
|
13
13
|
case 'cm':
|
|
@@ -30,6 +30,17 @@ function normalizeUnitForMathJS(unit) {
|
|
|
30
30
|
return 'mm'; // fallback
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Convert a stored micrometer value to the number the formula scope expects
|
|
35
|
+
* for `unit` — the same conversion nested-formula results go through. This is
|
|
36
|
+
* the ONLY sanctioned way to put a measurement into a valueMap: converting via
|
|
37
|
+
* the display STRING (convertMicrometers + a string parser) both rounds the
|
|
38
|
+
* value to the display tolerance and falls apart on composite formats like
|
|
39
|
+
* `5' 3 1/2"`.
|
|
40
|
+
*/
|
|
41
|
+
export function micrometersToUnitNumber(micrometers, unit) {
|
|
42
|
+
return mathUnit(micrometers, 'um').toNumber(normalizeUnitForMathJS(unit));
|
|
43
|
+
}
|
|
33
44
|
// Normalize mapping to EnhancedMapping format
|
|
34
45
|
function normalizeMapping(mapping) {
|
|
35
46
|
if (typeof mapping === 'string') {
|
|
@@ -112,7 +123,11 @@ export function evaluateFormula({ expression, mappings, valueMap, unit, formulas
|
|
|
112
123
|
return 'Error: valueMap is required';
|
|
113
124
|
}
|
|
114
125
|
const evaluationStack = new Set(); // For circular dependency detection
|
|
115
|
-
|
|
126
|
+
// Local cache for this evaluation session. Deliberately per-invocation:
|
|
127
|
+
// valueMap is rebuilt (with any column unit locks applied) on every
|
|
128
|
+
// calculateFormula call, so the `${formulaId}_${unit}` key needs no lock
|
|
129
|
+
// signature — a stale entry cannot outlive the call that created it.
|
|
130
|
+
const cache = new Map();
|
|
116
131
|
function evaluateNestedFormula(formulaId, currentExpression, currentMappings) {
|
|
117
132
|
// Check for circular dependency
|
|
118
133
|
if (evaluationStack.has(formulaId)) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { parseMeasurement } from '../utils/parseMeasurement.js';
|
|
3
|
+
export const useParseMeasurement = () => {
|
|
4
|
+
const [error, setError] = useState(null);
|
|
5
|
+
const parseMeasurementInput = (input, defaultUnit = 'mm') => {
|
|
6
|
+
setError(null);
|
|
7
|
+
const result = parseMeasurement(input, defaultUnit);
|
|
8
|
+
if (result === null) {
|
|
9
|
+
setError('Invalid measurement. Please provide a valid number and unit.');
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
};
|
|
13
|
+
return { parseMeasurementInput, error };
|
|
14
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from './exports.js';
|
|
2
|
-
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
|
|
2
|
+
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
|
|
3
3
|
export { calculateFormula } from './formulas/calculateFormula.js';
|
|
4
4
|
export { convertMicrometers } from './utils/micrometersToUnit.js';
|
|
5
5
|
export { parseMeasurement } from './utils/parseMeasurement.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// WithSkiaWeb. Metro/RN consumers resolve `index.native.ts` instead via
|
|
4
4
|
// the `react-native` condition in package.json `exports`.
|
|
5
5
|
export * from './exports.js';
|
|
6
|
-
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, } from './formulas/evaluateFormula.js';
|
|
6
|
+
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, } from './formulas/evaluateFormula.js';
|
|
7
7
|
export { calculateFormula } from './formulas/calculateFormula.js';
|
|
8
8
|
export { convertMicrometers } from './utils/micrometersToUnit.js';
|
|
9
9
|
export { parseMeasurement } from './utils/parseMeasurement.js';
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
|
|
1
|
+
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, type EnhancedMapping, type FormulaDefinition, type FormulaEvaluationOptions, } from './formulas/evaluateFormula.js';
|
|
2
2
|
export { calculateFormula } from './formulas/calculateFormula.js';
|
|
3
3
|
export { convertMicrometers } from './utils/micrometersToUnit.js';
|
|
4
4
|
export { parseMeasurement } from './utils/parseMeasurement.js';
|
|
@@ -18,5 +18,5 @@ export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, }
|
|
|
18
18
|
export { toSelectedValues, formatSelectedValues, toStoredValue, } from './utils/selectValues.js';
|
|
19
19
|
export * from './calculator/index.js';
|
|
20
20
|
export * from './export/pdf/index.js';
|
|
21
|
-
export { applyGroupFormulaUm, groupFormulaExpression,
|
|
21
|
+
export { applyGroupFormulaUm, groupFormulaExpression, unitNumberToMicrometers, type GroupFormulaFrame, } from './formulas/groupFormula.js';
|
|
22
22
|
export { MEASUREMENT_LINE_COLOR, MEASUREMENT_LINE_WIDTH, MEASUREMENT_RECT_WIDTH, } from './annotation/canvas/measurementLineStyle.js';
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// root barrel, whose module graph statically pulls in `react` and
|
|
4
4
|
// `@shopify/react-native-skia`. Only modules with no React/Skia anywhere
|
|
5
5
|
// in their import graph may be re-exported from this file.
|
|
6
|
-
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, } from './formulas/evaluateFormula.js';
|
|
6
|
+
export { evaluateFormula, createFormulaScope, createEnhancedFormulaScope, clearFormulaCache, micrometersToUnitNumber, } from './formulas/evaluateFormula.js';
|
|
7
7
|
export { calculateFormula } from './formulas/calculateFormula.js';
|
|
8
8
|
export { convertMicrometers } from './utils/micrometersToUnit.js';
|
|
9
9
|
export { parseMeasurement } from './utils/parseMeasurement.js';
|
|
@@ -25,5 +25,9 @@ export * from './calculator/index.js';
|
|
|
25
25
|
// Canvas → annotated PDF export (Cloud Function renderer). Pure pdf-lib; no
|
|
26
26
|
// React or Skia anywhere in its import graph.
|
|
27
27
|
export * from './export/pdf/index.js';
|
|
28
|
-
|
|
28
|
+
// groupFormula's own micrometersToUnitNumber (integer constants, NaN on
|
|
29
|
+
// unknown units) stays module-local: the canonical export of that name is the
|
|
30
|
+
// mathjs-exact one from evaluateFormula.js above, and no consumer imported
|
|
31
|
+
// the groupFormula variant.
|
|
32
|
+
export { applyGroupFormulaUm, groupFormulaExpression, unitNumberToMicrometers, } from './formulas/groupFormula.js';
|
|
29
33
|
export { MEASUREMENT_LINE_COLOR, MEASUREMENT_LINE_WIDTH, MEASUREMENT_RECT_WIDTH, } from './annotation/canvas/measurementLineStyle.js';
|