@reekon-tools/boldr-utils 1.9.0 → 1.9.2

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 (69) hide show
  1. package/dist/calculator/validate.js +33 -8
  2. package/dist/canvas/AnnotationCanvas.d.ts +11 -0
  3. package/dist/canvas/AnnotationCanvas.js +10 -0
  4. package/dist/canvas/AnnotationCanvas.native.d.ts +8 -0
  5. package/dist/canvas/AnnotationCanvas.native.js +6 -0
  6. package/dist/canvas/AnnotationCanvasInner.d.ts +39 -0
  7. package/dist/canvas/AnnotationCanvasInner.js +219 -0
  8. package/dist/canvas/AnnotationCanvasInner.native.d.ts +35 -0
  9. package/dist/canvas/AnnotationCanvasInner.native.js +138 -0
  10. package/dist/canvas/AnnotationCanvasSkia.d.ts +27 -0
  11. package/dist/canvas/AnnotationCanvasSkia.js +20 -0
  12. package/dist/canvas/Tool.d.ts +38 -0
  13. package/dist/canvas/Tool.js +1 -0
  14. package/dist/canvas/elements/BackgroundImageElement.d.ts +9 -0
  15. package/dist/canvas/elements/BackgroundImageElement.js +37 -0
  16. package/dist/canvas/elements/MeasurementStampElement.d.ts +13 -0
  17. package/dist/canvas/elements/MeasurementStampElement.js +30 -0
  18. package/dist/canvas/elements/ShapeElement.d.ts +7 -0
  19. package/dist/canvas/elements/ShapeElement.js +62 -0
  20. package/dist/canvas/elements/StrokeElement.d.ts +7 -0
  21. package/dist/canvas/elements/StrokeElement.js +18 -0
  22. package/dist/canvas/measurementPicker.d.ts +10 -0
  23. package/dist/canvas/measurementPicker.js +1 -0
  24. package/dist/canvas/measurementStampOverlay.d.ts +11 -0
  25. package/dist/canvas/measurementStampOverlay.js +1 -0
  26. package/dist/canvas/pointerAdapter.d.ts +3 -0
  27. package/dist/canvas/pointerAdapter.js +19 -0
  28. package/dist/canvas/stampLayout.d.ts +5 -0
  29. package/dist/canvas/stampLayout.js +14 -0
  30. package/dist/canvas/tools/measurementStampTool.d.ts +9 -0
  31. package/dist/canvas/tools/measurementStampTool.js +37 -0
  32. package/dist/canvas/tools/panTool.d.ts +5 -0
  33. package/dist/canvas/tools/panTool.js +25 -0
  34. package/dist/canvas/tools/penTool.d.ts +13 -0
  35. package/dist/canvas/tools/penTool.js +68 -0
  36. package/dist/canvas/tools/selectTool.d.ts +2 -0
  37. package/dist/canvas/tools/selectTool.js +182 -0
  38. package/dist/canvas/useAnnotationCanvasState.d.ts +54 -0
  39. package/dist/canvas/useAnnotationCanvasState.js +210 -0
  40. package/dist/canvas/viewport.d.ts +16 -0
  41. package/dist/canvas/viewport.js +54 -0
  42. package/dist/data/AnnotationDataContext.d.ts +8 -0
  43. package/dist/data/AnnotationDataContext.js +11 -0
  44. package/dist/data/AnnotationDataProvider.d.ts +65 -0
  45. package/dist/data/AnnotationDataProvider.js +4 -0
  46. package/dist/data/InMemoryAnnotationProvider.d.ts +30 -0
  47. package/dist/data/InMemoryAnnotationProvider.js +197 -0
  48. package/dist/data/canvasPersistence.d.ts +3 -0
  49. package/dist/data/canvasPersistence.js +26 -0
  50. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +33 -0
  51. package/dist/data/hooks/useAnnotationCanvasDoc.js +314 -0
  52. package/dist/data/hooks/useAnnotationDoc.d.ts +7 -0
  53. package/dist/data/hooks/useAnnotationDoc.js +33 -0
  54. package/dist/data/hooks/useAnnotationList.d.ts +7 -0
  55. package/dist/data/hooks/useAnnotationList.js +26 -0
  56. package/dist/data/hooks/useAnnotationMutations.d.ts +9 -0
  57. package/dist/data/hooks/useAnnotationMutations.js +11 -0
  58. package/dist/hooks/useParseMeasurement.d.ts +4 -0
  59. package/dist/hooks/useParseMeasurement.js +14 -0
  60. package/dist/types/firestore.d.ts +1 -0
  61. package/dist/utils/evaluateFormula.d.ts +20 -0
  62. package/dist/utils/evaluateFormula.js +31 -0
  63. package/package.json +1 -1
  64. package/dist/theme/index.d.ts +0 -3
  65. package/dist/theme/index.js +0 -3
  66. package/dist/theme/preset.d.ts +0 -120
  67. package/dist/theme/preset.js +0 -25
  68. package/dist/theme/semantic.d.ts +0 -136
  69. package/dist/theme/semantic.js +0 -116
@@ -321,6 +321,10 @@ export const validateCalculatorDefinition = (input) => {
321
321
  push('variable-unit-dimension-mismatch', unitPath, `Unit "${unit}" is not a ${dim} unit (variable ${variable} maps to "${mapped.name}")`);
322
322
  }
323
323
  }
324
+ // The unit the EXPRESSION produces, which is what `resultUnit` claims to
325
+ // be. Hoisted out of the `target` branch because the number-target check
326
+ // further down needs it too.
327
+ const inferred = inferExpressionUnit(def.fields, eq);
324
328
  if (target) {
325
329
  const dim = fieldDimension(target);
326
330
  if (eq.resultUnit != null) {
@@ -331,11 +335,16 @@ export const validateCalculatorDefinition = (input) => {
331
335
  push('result-unit-dimension-mismatch', `${path}.resultUnit`, `Result unit "${eq.resultUnit}" is not a ${dim} unit (target "${target.name}")`);
332
336
  }
333
337
  }
334
- // The unit the EXPRESSION produces, which is what `resultUnit` claims to
335
- // be. Dimension checks against the target field can't see a same-
336
- // dimension scale error: three inch variables multiplied are in³, so
337
- // `cu_ft` passes every check above while reporting 1728× the truth.
338
- const inferred = inferExpressionUnit(def.fields, eq);
338
+ // Dimension checks against the target field can't see a same-dimension
339
+ // scale error: three inch variables multiplied are in³, so `cu_ft`
340
+ // passes every check above while reporting 1728× the truth.
341
+ //
342
+ // Deliberately NOT applied to a dimensionless target. Literals carry no
343
+ // units here, so `area / 33` infers as an area whether the area arrives
344
+ // in µm² or ft² — the inference cannot tell a broken count from a
345
+ // correct one, and every legitimate count written as
346
+ // `area_in_ft² / 33_ft²` would be flagged. The canonical-leak check
347
+ // below is the one that separates them.
339
348
  if (inferred.ok && dim !== 'none') {
340
349
  if (inferred.dimension !== dim) {
341
350
  push('expression-dimension-mismatch', `${path}.expression`, `Expression produces ${describeDimension(inferred.dimension)} but "${target.name}" is ${describeDimension(dim)} — check the variable units and the expression`);
@@ -365,14 +374,30 @@ export const validateCalculatorDefinition = (input) => {
365
374
  }
366
375
  }
367
376
  // The canonical-leak trap: a number target consuming a unit-carrying
368
- // field without a variable unit receives raw µm-scale values.
369
- if (target?.kind === ColumnType.Number) {
377
+ // field without a variable unit receives raw µm-scale values. Reported
378
+ // per variable, because the fix is per variable ("pick a unit for A").
379
+ //
380
+ // Gated on the inference: unit-carrying variables are fine as long as
381
+ // their dimensions CANCEL, which is the common shape for a count —
382
+ // `ceil(area / area)` sheets, `(12 * rise) / run` pitch. Flagging those
383
+ // put a permanent warning on two correct calculators, and a check that
384
+ // cries wolf on correct authoring is one authors learn to scroll past.
385
+ //
386
+ // An ERROR, not a warning. It was a warning so that pre-annotation docs
387
+ // kept validating, but "keeps validating" is how the roofing estimator
388
+ // shipped `Bundles = A / 33` over a canonical µm² area and showed
389
+ // 184,990,000,000 — the right answer times µm²-per-ft². There is no
390
+ // authoring where a µm-scale value in a unitless field is intended, and
391
+ // nothing gates on `ok`: mobile only console.warns in __DEV__, and the
392
+ // web editor shows the banner without blocking save.
393
+ if (target?.kind === ColumnType.Number &&
394
+ (!inferred.ok || inferred.dimension !== 'none')) {
370
395
  for (const [variable, fieldId] of Object.entries(eq.variableToFieldId)) {
371
396
  const mapped = fieldById.get(fieldId);
372
397
  if (!mapped || fieldDimension(mapped) === 'none')
373
398
  continue;
374
399
  if (eq.variableUnits?.[variable] == null) {
375
- push('canonical-value-into-number-target', `${path}.variableToFieldId.${variable}`, `"${target.name}" is a number field but variable ${variable} ("${mapped.name}") has no display unit its canonical (µm-scale) value will be used. Pick a unit for ${variable}.`, 'warning');
400
+ push('canonical-value-into-number-target', `${path}.variableToFieldId.${variable}`, `"${target.name}" is a number field, so it shows the raw result — but variable ${variable} ("${mapped.name}") has no display unit, so it arrives as a canonical µm-scale value and the result will be off by a large factor. Pick a unit for ${variable}.`);
376
401
  }
377
402
  }
378
403
  }
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.js';
3
+ export type { AnnotationCanvasHandle, GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
+ export interface CanvasKitOpts {
5
+ locateFile?: (file: string) => string;
6
+ }
7
+ export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
8
+ fallback?: ReactNode;
9
+ canvasKitOpts?: CanvasKitOpts;
10
+ };
11
+ export declare const AnnotationCanvas: ({ fallback, canvasKitOpts, ...rest }: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web/index.js';
3
+ // Web-only entry point: lazy-loads CanvasKit wasm via WithSkiaWeb. Imperative
4
+ // API (undo/redo/zoom) is exposed through the `imperativeRef` prop rather
5
+ // than React refs, since WithSkiaWeb can't forward refs through its lazy
6
+ // component boundary. The native build sits in `AnnotationCanvas.native.tsx`
7
+ // and skips WithSkiaWeb entirely (no wasm to load).
8
+ export const AnnotationCanvas = ({ fallback, canvasKitOpts, ...rest }) => (_jsx(WithSkiaWeb, { getComponent: () => import('./AnnotationCanvasInner.js').then((m) => ({
9
+ default: m.AnnotationCanvasInner,
10
+ })), fallback: fallback ?? null, componentProps: rest, opts: canvasKitOpts }));
@@ -0,0 +1,8 @@
1
+ import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.native.js';
2
+ export type { AnnotationCanvasHandle, } from './useAnnotationCanvasState.js';
3
+ export type { GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
+ export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
5
+ fallback?: unknown;
6
+ canvasKitOpts?: unknown;
7
+ };
8
+ export declare const AnnotationCanvas: (props: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { AnnotationCanvasInner } from './AnnotationCanvasInner.native.js';
3
+ export const AnnotationCanvas = (props) => {
4
+ const { fallback: _f, canvasKitOpts: _o, ...rest } = props;
5
+ return _jsx(AnnotationCanvasInner, { ...rest });
6
+ };
@@ -0,0 +1,39 @@
1
+ import { type CSSProperties, type MutableRefObject } from 'react';
2
+ import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
3
+ import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
4
+ import type { MeasurementRef } from './measurementPicker.js';
5
+ import type { Tool } from './Tool.js';
6
+ import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
7
+ import { type ViewportState } from './viewport.js';
8
+ import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
9
+ export type { AnnotationCanvasHandle };
10
+ export interface AnnotationCanvasInnerProps {
11
+ canvas: AnnotationCanvasState;
12
+ onCommit(patch: AnnotationDocumentPatch): void;
13
+ tools: Tool[];
14
+ activeToolId: string;
15
+ selection: Selection | null;
16
+ onSelectionChange(selection: Selection | null): void;
17
+ measurements?: Measurement[];
18
+ fallbackUnit?: Units;
19
+ fractionalTolerance?: FractionalTolerance;
20
+ decimalTolerance?: DecimalTolerance;
21
+ resolveImageUrl?: (storagePath: string) => Promise<string>;
22
+ pickMeasurement?: () => Promise<MeasurementRef | null>;
23
+ renderMeasurementStamp?: RenderMeasurementStamp;
24
+ stampFontSource?: unknown;
25
+ stampValueFontSize?: number;
26
+ stampLabelFontSize?: number;
27
+ gestures?: GestureConfig;
28
+ width: number;
29
+ height: number;
30
+ initialViewport?: ViewportState;
31
+ style?: CSSProperties;
32
+ imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
33
+ }
34
+ export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
35
+ export interface GestureConfig {
36
+ wheel?: 'zoom' | 'pan' | 'auto';
37
+ panTriggers?: PanTrigger[];
38
+ }
39
+ export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,219 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFont } from '@shopify/react-native-skia';
3
+ import { useCallback, useEffect, useRef, } from 'react';
4
+ import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
+ import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
6
+ import { STAMP_TILE_SIZE } from './stampLayout.js';
7
+ const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
8
+ export const AnnotationCanvasInner = (props) => {
9
+ const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, gestures, width, height, style, activeToolId, tools, } = props;
10
+ const wheelMode = gestures?.wheel ?? 'auto';
11
+ const panTriggers = gestures?.panTriggers ?? DEFAULT_PAN_TRIGGERS;
12
+ const allowSpacePan = panTriggers.includes('space');
13
+ const allowMiddlePan = panTriggers.includes('middleMouse');
14
+ const allowRightPan = panTriggers.includes('rightMouse');
15
+ const valueFont = useFont(stampFontSource, stampValueFontSize);
16
+ const labelFont = useFont(stampFontSource, stampLabelFontSize);
17
+ const state = useAnnotationCanvasState(props);
18
+ const containerRef = useRef(null);
19
+ const panGestureRef = useRef(null);
20
+ const spaceDownRef = useRef(false);
21
+ const activeTool = tools.find((t) => t.id === activeToolId) ?? null;
22
+ const toCanvasPointer = useCallback((event) => {
23
+ const rect = containerRef.current?.getBoundingClientRect();
24
+ const screen = {
25
+ x: event.clientX - (rect?.left ?? 0),
26
+ y: event.clientY - (rect?.top ?? 0),
27
+ };
28
+ return {
29
+ pointerId: event.pointerId,
30
+ screen,
31
+ world: state.ctx.viewport.screenToWorld(screen),
32
+ pressure: event.pressure || undefined,
33
+ shiftKey: event.shiftKey,
34
+ altKey: event.altKey,
35
+ metaKey: event.metaKey,
36
+ ctrlKey: event.ctrlKey,
37
+ };
38
+ }, [state.ctx.viewport]);
39
+ const isPanTriggerDown = useCallback((event) => {
40
+ if (allowMiddlePan && event.button === 1)
41
+ return true;
42
+ if (allowRightPan && event.button === 2)
43
+ return true;
44
+ if (allowSpacePan && event.button === 0 && spaceDownRef.current)
45
+ return true;
46
+ return false;
47
+ }, [allowMiddlePan, allowRightPan, allowSpacePan]);
48
+ const handlePointerDown = useCallback((event) => {
49
+ const rect = containerRef.current?.getBoundingClientRect();
50
+ const screen = {
51
+ x: event.clientX - (rect?.left ?? 0),
52
+ y: event.clientY - (rect?.top ?? 0),
53
+ };
54
+ if (isPanTriggerDown(event)) {
55
+ panGestureRef.current = { pointerId: event.pointerId, lastScreen: screen };
56
+ event.currentTarget.setPointerCapture(event.pointerId);
57
+ event.preventDefault();
58
+ return;
59
+ }
60
+ event.currentTarget.setPointerCapture(event.pointerId);
61
+ state.dispatchPointerDown(toCanvasPointer(event));
62
+ }, [state, toCanvasPointer, isPanTriggerDown]);
63
+ const handlePointerMove = useCallback((event) => {
64
+ const pan = panGestureRef.current;
65
+ if (pan && event.pointerId === pan.pointerId) {
66
+ const rect = containerRef.current?.getBoundingClientRect();
67
+ const screen = {
68
+ x: event.clientX - (rect?.left ?? 0),
69
+ y: event.clientY - (rect?.top ?? 0),
70
+ };
71
+ state.pan({
72
+ x: screen.x - pan.lastScreen.x,
73
+ y: screen.y - pan.lastScreen.y,
74
+ });
75
+ panGestureRef.current = { pointerId: pan.pointerId, lastScreen: screen };
76
+ return;
77
+ }
78
+ state.dispatchPointerMove(toCanvasPointer(event));
79
+ }, [state, toCanvasPointer]);
80
+ const handlePointerUp = useCallback((event) => {
81
+ const pan = panGestureRef.current;
82
+ if (pan && event.pointerId === pan.pointerId) {
83
+ panGestureRef.current = null;
84
+ return;
85
+ }
86
+ state.dispatchPointerUp(toCanvasPointer(event));
87
+ }, [state, toCanvasPointer]);
88
+ const handlePointerCancel = useCallback(() => {
89
+ panGestureRef.current = null;
90
+ state.dispatchPointerCancel();
91
+ }, [state]);
92
+ const handleWheel = useCallback((event) => {
93
+ const rect = containerRef.current?.getBoundingClientRect();
94
+ const focal = {
95
+ x: event.clientX - (rect?.left ?? 0),
96
+ y: event.clientY - (rect?.top ?? 0),
97
+ };
98
+ if (event.ctrlKey || event.metaKey) {
99
+ const factor = Math.exp(-event.deltaY / 200);
100
+ state.zoom(focal, state.ctx.viewport.state.zoom * factor);
101
+ return;
102
+ }
103
+ if (wheelMode === 'zoom') {
104
+ const factor = Math.exp(-event.deltaY / 300);
105
+ state.zoom(focal, state.ctx.viewport.state.zoom * factor);
106
+ return;
107
+ }
108
+ if (event.shiftKey) {
109
+ state.pan({ x: event.deltaY, y: event.deltaX });
110
+ return;
111
+ }
112
+ state.pan({ x: event.deltaX, y: event.deltaY });
113
+ }, [state, wheelMode]);
114
+ const handleContextMenu = useCallback((event) => {
115
+ if (allowRightPan)
116
+ event.preventDefault();
117
+ }, [allowRightPan]);
118
+ useEffect(() => {
119
+ const el = containerRef.current;
120
+ if (!el)
121
+ return;
122
+ const stop = (e) => {
123
+ if (e.target === el || el.contains(e.target)) {
124
+ e.preventDefault();
125
+ }
126
+ };
127
+ el.addEventListener('wheel', stop, { passive: false });
128
+ return () => el.removeEventListener('wheel', stop);
129
+ }, []);
130
+ useEffect(() => {
131
+ if (!allowSpacePan)
132
+ return;
133
+ const onKeyDown = (e) => {
134
+ if (e.code === 'Space' && !e.repeat) {
135
+ spaceDownRef.current = true;
136
+ if (containerRef.current)
137
+ containerRef.current.style.cursor = 'grab';
138
+ }
139
+ };
140
+ const onKeyUp = (e) => {
141
+ if (e.code === 'Space') {
142
+ spaceDownRef.current = false;
143
+ if (containerRef.current)
144
+ containerRef.current.style.cursor = '';
145
+ }
146
+ };
147
+ window.addEventListener('keydown', onKeyDown);
148
+ window.addEventListener('keyup', onKeyUp);
149
+ return () => {
150
+ window.removeEventListener('keydown', onKeyDown);
151
+ window.removeEventListener('keyup', onKeyUp);
152
+ };
153
+ }, [allowSpacePan]);
154
+ const containerStyle = {
155
+ position: 'relative',
156
+ width,
157
+ height,
158
+ overflow: 'hidden',
159
+ touchAction: 'none',
160
+ userSelect: 'none',
161
+ cursor: activeTool?.cursor ?? 'default',
162
+ ...style,
163
+ };
164
+ const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
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
+ }) }))] }));
219
+ };
@@ -0,0 +1,35 @@
1
+ import { type MutableRefObject } from 'react';
2
+ import { type ViewStyle } from 'react-native';
3
+ import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
4
+ import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
5
+ import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
6
+ import type { MeasurementRef } from './measurementPicker.js';
7
+ import type { Tool } from './Tool.js';
8
+ import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
9
+ import type { ViewportState } from './viewport.js';
10
+ export type { AnnotationCanvasHandle };
11
+ export interface AnnotationCanvasInnerProps {
12
+ canvas: AnnotationCanvasState;
13
+ onCommit(patch: AnnotationDocumentPatch): void;
14
+ tools: Tool[];
15
+ activeToolId: string;
16
+ selection: Selection | null;
17
+ onSelectionChange(selection: Selection | null): void;
18
+ measurements?: Measurement[];
19
+ fallbackUnit?: Units;
20
+ fractionalTolerance?: FractionalTolerance;
21
+ decimalTolerance?: DecimalTolerance;
22
+ resolveImageUrl?: (storagePath: string) => Promise<string>;
23
+ pickMeasurement?: () => Promise<MeasurementRef | null>;
24
+ renderMeasurementStamp?: RenderMeasurementStamp;
25
+ stampFontSource?: unknown;
26
+ stampValueFontSize?: number;
27
+ stampLabelFontSize?: number;
28
+ gestures?: unknown;
29
+ width: number;
30
+ height: number;
31
+ initialViewport?: ViewportState;
32
+ style?: ViewStyle;
33
+ imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
34
+ }
35
+ export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,138 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFont } from '@shopify/react-native-skia';
3
+ import { useMemo, useRef } from 'react';
4
+ import { StyleSheet, TouchableOpacity, View } from 'react-native';
5
+ import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
6
+ import { STAMP_TILE_SIZE } from './stampLayout.js';
7
+ import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
8
+ import { useAnnotationCanvasState, } from './useAnnotationCanvasState.js';
9
+ // Native fingerprint: one finger drives the active tool, two fingers
10
+ // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
11
+ // like measurement-stamp (which only listen to onPointerUp) work via tap.
12
+ export const AnnotationCanvasInner = (props) => {
13
+ const { fallbackUnit, fractionalTolerance, decimalTolerance, resolveImageUrl, stampFontSource, stampValueFontSize = 14, stampLabelFontSize = 11, width, height, style, } = props;
14
+ const valueFont = useFont(stampFontSource, stampValueFontSize);
15
+ const labelFont = useFont(stampFontSource, stampLabelFontSize);
16
+ const state = useAnnotationCanvasState(props);
17
+ // Per-gesture refs so we always emit a matching down/move/up sequence.
18
+ const pointerIdRef = useRef(1);
19
+ const inFlightRef = useRef(null);
20
+ const pinchStartZoomRef = useRef(1);
21
+ const buildEvent = (pointerId, screen) => ({
22
+ pointerId,
23
+ screen,
24
+ world: state.ctx.viewport.screenToWorld(screen),
25
+ });
26
+ const gesture = useMemo(() => {
27
+ const toolPan = Gesture.Pan()
28
+ .minPointers(1)
29
+ .maxPointers(1)
30
+ .runOnJS(true)
31
+ .onBegin((e) => {
32
+ const id = pointerIdRef.current++;
33
+ const screen = { x: e.x, y: e.y };
34
+ inFlightRef.current = { id, lastScreen: screen };
35
+ state.dispatchPointerDown(buildEvent(id, screen));
36
+ })
37
+ .onUpdate((e) => {
38
+ const f = inFlightRef.current;
39
+ if (!f)
40
+ return;
41
+ const screen = { x: e.x, y: e.y };
42
+ f.lastScreen = screen;
43
+ state.dispatchPointerMove(buildEvent(f.id, screen));
44
+ })
45
+ .onEnd((e) => {
46
+ const f = inFlightRef.current;
47
+ if (!f)
48
+ return;
49
+ state.dispatchPointerUp(buildEvent(f.id, { x: e.x, y: e.y }));
50
+ inFlightRef.current = null;
51
+ })
52
+ .onFinalize(() => {
53
+ if (inFlightRef.current) {
54
+ state.dispatchPointerCancel();
55
+ inFlightRef.current = null;
56
+ }
57
+ });
58
+ const tap = Gesture.Tap()
59
+ .maxDuration(250)
60
+ .runOnJS(true)
61
+ .onEnd((e) => {
62
+ const id = pointerIdRef.current++;
63
+ const screen = { x: e.x, y: e.y };
64
+ // Synthesize a down+up sequence so tools that only listen to
65
+ // onPointerUp (e.g. measurement stamp) still fire.
66
+ state.dispatchPointerDown(buildEvent(id, screen));
67
+ state.dispatchPointerUp(buildEvent(id, screen));
68
+ });
69
+ const viewportPan = Gesture.Pan()
70
+ .minPointers(2)
71
+ .maxPointers(2)
72
+ .runOnJS(true)
73
+ .onChange((e) => {
74
+ state.pan({ x: e.changeX, y: e.changeY });
75
+ });
76
+ const pinch = Gesture.Pinch()
77
+ .runOnJS(true)
78
+ .onBegin(() => {
79
+ pinchStartZoomRef.current = state.ctx.viewport.state.zoom;
80
+ })
81
+ .onUpdate((e) => {
82
+ state.zoom({ x: e.focalX, y: e.focalY }, pinchStartZoomRef.current * e.scale);
83
+ });
84
+ return Gesture.Race(tap, Gesture.Simultaneous(viewportPan, pinch), toolPan);
85
+ }, [state]);
86
+ const activeTool = props.tools.find((t) => t.id === props.activeToolId) ?? null;
87
+ const customPreview = activeTool?.renderPreview?.(state.customPreviewState, state.ctx);
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
+ }) }))] }));
138
+ };
@@ -0,0 +1,27 @@
1
+ import { type SkFont } from '@shopify/react-native-skia';
2
+ import type { ReactNode } from 'react';
3
+ import type { AnnotationCanvasState, AnnotationStroke } from '../types/annotation.js';
4
+ import { DecimalTolerance, FractionalTolerance, Units, type Measurement } from '../types/firestore.js';
5
+ export interface AnnotationCanvasSkiaProps {
6
+ width: number;
7
+ height: number;
8
+ effectiveCanvas: AnnotationCanvasState;
9
+ worldTransform: Array<{
10
+ scale: number;
11
+ } | {
12
+ translateX: number;
13
+ } | {
14
+ translateY: number;
15
+ }>;
16
+ measurementsById: Map<string, Measurement>;
17
+ fallbackUnit?: Units;
18
+ fractionalTolerance?: FractionalTolerance;
19
+ decimalTolerance?: DecimalTolerance;
20
+ resolveImageUrl?: (storagePath: string) => Promise<string>;
21
+ valueFont: SkFont | null;
22
+ labelFont: SkFont | null;
23
+ hideMeasurementStamps?: boolean;
24
+ penDrawingStroke: AnnotationStroke | null;
25
+ customPreview?: ReactNode;
26
+ }
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;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Canvas, Group } from '@shopify/react-native-skia';
3
+ import { Units, } from '../types/firestore.js';
4
+ import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
5
+ import { MeasurementStampElement } from './elements/MeasurementStampElement.js';
6
+ import { ShapeElement } from './elements/ShapeElement.js';
7
+ import { StrokeElement } from './elements/StrokeElement.js';
8
+ // Platform-agnostic Skia subtree shared by web and native Inners.
9
+ //
10
+ // Call this as a FUNCTION (`AnnotationCanvasSkia({ ... })`), not as a JSX
11
+ // component (`<AnnotationCanvasSkia ... />`). Used as a component, it adds
12
+ // a React component boundary between the parent and Skia's `<Canvas>` that
13
+ // breaks Skia's reconciler when the page first calls `useFont` — the JS
14
+ // thread hangs in `MakeFreeTypeFaceFromData`. Symptoms only appeared on
15
+ // Vite's dev server with a symlinked boldr-utils + React Refresh, but
16
+ // since the function-call pattern works identically on native we use it
17
+ // in both Inners for consistency. Don't add hooks here; this is a plain
18
+ // JSX-returning helper, not a component.
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,38 @@
1
+ import type { ReactNode, ComponentType } from 'react';
2
+ import type { AnnotationCanvasState, AnnotationDocumentPatch, AnnotationElement, Selection, Vec2 } from '../types/annotation.js';
3
+ import type { MeasurementRef } from './measurementPicker.js';
4
+ import type { ViewportApi } from './viewport.js';
5
+ export interface CanvasPointerEvent {
6
+ pointerId: number;
7
+ world: Vec2;
8
+ screen: Vec2;
9
+ pressure?: number;
10
+ shiftKey?: boolean;
11
+ altKey?: boolean;
12
+ metaKey?: boolean;
13
+ ctrlKey?: boolean;
14
+ }
15
+ export interface ToolContext {
16
+ document: AnnotationCanvasState;
17
+ selection: Selection | null;
18
+ viewport: ViewportApi;
19
+ preview(patch: AnnotationDocumentPatch): void;
20
+ commit(patch: AnnotationDocumentPatch): void;
21
+ setSelection(selection: Selection | null): void;
22
+ requestPickMeasurement(): Promise<MeasurementRef | null>;
23
+ applyPan(deltaScreen: Vec2): void;
24
+ applyZoom(focalScreen: Vec2, nextZoom: number): void;
25
+ }
26
+ export type ToolState = unknown;
27
+ export interface Tool {
28
+ id: string;
29
+ label: string;
30
+ icon?: ComponentType;
31
+ cursor?: string;
32
+ onPointerDown?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
33
+ onPointerMove?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
34
+ onPointerUp?(event: CanvasPointerEvent, ctx: ToolContext, state: ToolState): ToolState | void;
35
+ onCancel?(state: ToolState, ctx: ToolContext): void;
36
+ renderPreview?(state: ToolState, ctx: ToolContext): ReactNode;
37
+ hitTest?(element: AnnotationElement, worldPoint: Vec2): boolean;
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { AnnotationBackgroundImage, BackgroundFit } from '../../types/annotation.js';
2
+ export interface BackgroundImageElementProps {
3
+ image: AnnotationBackgroundImage;
4
+ docWidth: number;
5
+ docHeight: number;
6
+ fit?: BackgroundFit;
7
+ resolveUrl?: (path: string) => Promise<string>;
8
+ }
9
+ export declare const BackgroundImageElement: ({ image, docWidth, docHeight, fit, resolveUrl, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null;