@reekon-tools/boldr-utils 1.6.29 → 1.6.31

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 (32) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.d.ts +1 -0
  2. package/dist/annotation/canvas/AnnotationCanvasInner.js +29 -5
  3. package/dist/annotation/canvas/AnnotationCanvasInner.native.d.ts +1 -0
  4. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +21 -17
  5. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +4 -3
  6. package/dist/annotation/canvas/AnnotationCanvasSkia.js +3 -3
  7. package/dist/annotation/canvas/Tool.d.ts +4 -3
  8. package/dist/annotation/canvas/elements/BackgroundImageElement.js +67 -12
  9. package/dist/annotation/canvas/stampLayout.d.ts +2 -1
  10. package/dist/annotation/canvas/stampLayout.js +23 -11
  11. package/dist/annotation/canvas/tools/panTool.js +1 -1
  12. package/dist/annotation/canvas/tools/selectTool.js +13 -13
  13. package/dist/annotation/canvas/useAnnotationCanvasState.d.ts +5 -1
  14. package/dist/annotation/canvas/useAnnotationCanvasState.js +56 -2
  15. package/dist/annotation/data/AnnotationDataProvider.d.ts +20 -9
  16. package/dist/annotation/data/AnnotationDataProvider.js +8 -0
  17. package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +10 -10
  18. package/dist/annotation/data/InMemoryAnnotationProvider.js +30 -4
  19. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +2 -2
  20. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +10 -3
  21. package/dist/annotation/data/hooks/useAnnotationDoc.d.ts +2 -2
  22. package/dist/annotation/data/hooks/useAnnotationDoc.js +3 -8
  23. package/dist/annotation/data/hooks/useAnnotationList.d.ts +2 -2
  24. package/dist/annotation/data/hooks/useAnnotationList.js +3 -1
  25. package/dist/annotation/data/hooks/useAnnotationMutations.d.ts +3 -3
  26. package/dist/annotation/data/hooks/useAnnotationMutations.js +9 -5
  27. package/dist/exports.d.ts +2 -2
  28. package/dist/exports.js +2 -2
  29. package/dist/types/annotation.d.ts +4 -0
  30. package/dist/types/annotation.js +14 -4
  31. package/dist/types/firestore.d.ts +6 -1
  32. package/package.json +1 -1
@@ -37,6 +37,7 @@ export interface AnnotationCanvasInnerProps {
37
37
  style?: CSSProperties;
38
38
  magnifierTopOffset?: number;
39
39
  imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
40
+ snapshotRef?: MutableRefObject<(() => Uint8Array | null) | null>;
40
41
  }
41
42
  export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
42
43
  export interface GestureConfig {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Skia, useFont, useTypeface } from '@shopify/react-native-skia';
2
+ import { Skia, useCanvasRef, useFont, useTypeface, } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useMemo, useRef, } from 'react';
4
4
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
5
5
  import { TEXT_FONT_FAMILY } from './elements/ShapeElement.js';
@@ -21,7 +21,25 @@ const LONG_PRESS_MS = 500;
21
21
  const LONG_PRESS_SLOP_PX = 10;
22
22
  const DEFAULT_PAN_TRIGGERS = ['middleMouse', 'space'];
23
23
  export const AnnotationCanvasInner = (props) => {
24
- const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, } = props;
24
+ const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, gestures, width, height, style, activeToolId, tools, snapshotRef, } = props;
25
+ // Snapshot plumbing for thumbnail capture: hold the <Canvas> ref and publish
26
+ // a capture fn on the consumer's snapshotRef. encodeToBytes() yields PNG.
27
+ const canvasRef = useCanvasRef();
28
+ useEffect(() => {
29
+ if (!snapshotRef)
30
+ return;
31
+ snapshotRef.current = () => {
32
+ const image = canvasRef.current?.makeImageSnapshot();
33
+ if (!image)
34
+ return null;
35
+ const bytes = image.encodeToBytes();
36
+ image.dispose?.();
37
+ return bytes ?? null;
38
+ };
39
+ return () => {
40
+ snapshotRef.current = null;
41
+ };
42
+ }, [snapshotRef, canvasRef]);
25
43
  const wheelMode = gestures?.wheel ?? 'auto';
26
44
  const panTriggers = gestures?.panTriggers ?? DEFAULT_PAN_TRIGGERS;
27
45
  const allowSpacePan = panTriggers.includes('space');
@@ -42,7 +60,12 @@ export const AnnotationCanvasInner = (props) => {
42
60
  provider.registerFont(textTypeface, TEXT_FONT_FAMILY);
43
61
  return provider;
44
62
  }, [textTypeface]);
45
- const state = useAnnotationCanvasState(props);
63
+ // This is the web renderer, so the canvas reads web's tile-scale knob (web
64
+ // and mobile persist separate values — see resolveTileScaleFactor).
65
+ const state = useAnnotationCanvasState({
66
+ ...props,
67
+ tileScalePlatform: 'web',
68
+ });
46
69
  const containerRef = useRef(null);
47
70
  const panGestureRef = useRef(null);
48
71
  const spaceDownRef = useRef(false);
@@ -261,7 +284,7 @@ export const AnnotationCanvasInner = (props) => {
261
284
  const world = state.ctx.viewport.screenToWorld(screen);
262
285
  const placed = [...state.effectiveCanvas.placedMeasurements]
263
286
  .reverse()
264
- .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale));
287
+ .find((m) => hitPlacedMeasurement(m, world, state.viewport.zoom, state.tileScaleFactor, state.tileViewportScale));
265
288
  if (placed)
266
289
  onMeasurementStampDoubleTap(placed);
267
290
  }, [onMeasurementStampDoubleTap, state]);
@@ -271,6 +294,7 @@ export const AnnotationCanvasInner = (props) => {
271
294
  effectiveCanvas: state.effectiveCanvas,
272
295
  worldTransform: state.worldTransform,
273
296
  resolveImageUrl,
297
+ canvasRef,
274
298
  valueFont,
275
299
  textFontMgr,
276
300
  penDrawingStroke: state.penDrawingStroke,
@@ -293,7 +317,7 @@ export const AnnotationCanvasInner = (props) => {
293
317
  inset: 0,
294
318
  pointerEvents: 'none',
295
319
  }, children: state.effectiveCanvas.placedMeasurements.map((placed) => {
296
- const size = stampTileSize(placed, state.effectiveCanvas.tileScaleFactor, state.tileViewportScale);
320
+ const size = stampTileSize(placed, state.tileScaleFactor, state.tileViewportScale);
297
321
  const cx = (placed.anchor.x - state.viewport.pan.x) * state.viewport.zoom;
298
322
  const cy = (placed.anchor.y - state.viewport.pan.y) * state.viewport.zoom;
299
323
  const isSelected = selection?.ids.includes(placed.id) ?? false;
@@ -38,5 +38,6 @@ export interface AnnotationCanvasInnerProps {
38
38
  style?: ViewStyle;
39
39
  magnifierTopOffset?: number;
40
40
  imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
41
+ snapshotRef?: MutableRefObject<(() => Uint8Array | null) | null>;
41
42
  }
42
43
  export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;
@@ -58,7 +58,13 @@ const LOUPE_CROSSHAIR_ARM = 9; // half-length of each crosshair arm, points
58
58
  export const AnnotationCanvasInner = (props) => {
59
59
  const { resolveImageUrl, stampFontSource, stampValueFontSize = 14, width, height, style, magnifierTopOffset = 0, } = props;
60
60
  const valueFont = useFont(stampFontSource, stampValueFontSize);
61
- const state = useAnnotationCanvasState(props);
61
+ // This is the native (mobile) renderer, so the canvas reads mobile's
62
+ // tile-scale knob (web and mobile persist separate values — see
63
+ // resolveTileScaleFactor).
64
+ const state = useAnnotationCanvasState({
65
+ ...props,
66
+ tileScalePlatform: 'mobile',
67
+ });
62
68
  // Decode the background image ONCE here and share it with both the main
63
69
  // canvas and the magnifier loupe. If each canvas loaded its own, the loupe
64
70
  // would flash its blank base every gesture while it re-resolved + re-decoded.
@@ -746,7 +752,7 @@ export const AnnotationCanvasInner = (props) => {
746
752
  return;
747
753
  const world = st.ctx.viewport.screenToWorld(screen);
748
754
  const zoomNow = st.ctx.viewport.state.zoom;
749
- const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
755
+ const hit = dragSelection.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
750
756
  if (!hit || hit.kind !== 'measurement' || hit.id !== prevSelectedId) {
751
757
  return;
752
758
  }
@@ -1012,7 +1018,7 @@ export const AnnotationCanvasInner = (props) => {
1012
1018
  // so a selected tile isn't stuck until you deselect it).
1013
1019
  const selId = st.ctx.selection?.ids[0];
1014
1020
  if (selId &&
1015
- !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale)) {
1021
+ !cfg.isSelectedTileGrab?.(st.ctx.document, selId, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)) {
1016
1022
  const handle = cfg.hitTestHandle?.(st.ctx.document, selId, world, zoomNow);
1017
1023
  if (handle) {
1018
1024
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === selId);
@@ -1112,7 +1118,7 @@ export const AnnotationCanvasInner = (props) => {
1112
1118
  return;
1113
1119
  }
1114
1120
  }
1115
- const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale);
1121
+ const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
1116
1122
  if (!hit) {
1117
1123
  st.ctx.setSelection(null);
1118
1124
  dragTargetRef.current = null;
@@ -1121,7 +1127,7 @@ export const AnnotationCanvasInner = (props) => {
1121
1127
  st.ctx.setSelection({ ids: [hit.id] });
1122
1128
  // Grabbing a line annotation's tile slides it; otherwise group-move.
1123
1129
  const grab = hit.kind === 'measurement'
1124
- ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale)
1130
+ ? cfg.classifyMeasurementGrab?.(st.ctx.document, hit.id, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor)
1125
1131
  : 'move';
1126
1132
  if (grab === 'slide') {
1127
1133
  const m = st.ctx.document.placedMeasurements.find((x) => x.id === hit.id);
@@ -1423,19 +1429,17 @@ export const AnnotationCanvasInner = (props) => {
1423
1429
  const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH }), _jsx(Line, { p1: { x: loupeCx - LOUPE_CROSSHAIR_ARM, y: loupeCy }, p2: { x: loupeCx + LOUPE_CROSSHAIR_ARM, y: loupeCy }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 }), _jsx(Line, { p1: { x: loupeCx, y: loupeCy - LOUPE_CROSSHAIR_ARM }, p2: { x: loupeCx, y: loupeCy + LOUPE_CROSSHAIR_ARM }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 })] }));
1424
1430
  return (_jsxs(GestureHandlerRootView, { style: [{ width, height }, style], children: [_jsx(GestureDetector, { gesture: gesture, children: _jsx(View, { style: { width, height }, collapsable: false, children: AnnotationCanvasSkia(skiaProps) }) }), renderMeasurementStamp && (_jsx(View, { pointerEvents: "box-none", style: StyleSheet.absoluteFill, children: state.effectiveCanvas.placedMeasurements.map((placed) => (_jsx(MeasurementStampOverlayItem, { placed: placed, measurement: placed.measurementId
1425
1431
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1426
- : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.effectiveCanvas.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: () => {
1427
- const defaultRemove = () => {
1428
- const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1429
- state.ctx.commit({ ops });
1430
- if (!keepSelection)
1431
- state.ctx.setSelection(null);
1432
- };
1433
- if (onMeasurementStampRemove) {
1432
+ : (resolveStampMeasurement?.(placed) ?? null), selected: selection?.ids.includes(placed.id) ?? false, dragging: draggingId === placed.id, sliding: slidingId === placed.id, endpointDragging: epDragId === placed.id, rectResizing: rectDragId === placed.id, zoomSnapshot: state.viewport.zoom, zoom: zoom, panX: panX, panY: panY, dragX: dragX, dragY: dragY, slideCtx: slideCtx, epCtx: epCtx, rectCtx: rectCtx, renderMeasurementStamp: renderMeasurementStamp, tileScaleFactor: state.tileScaleFactor, tileViewportScale: state.tileViewportScale, onStampPress: onMeasurementStampPress, onStampLongPress: onMeasurementStampLongPress, onRemove: onMeasurementStampRemove
1433
+ ? () => {
1434
+ const defaultRemove = () => {
1435
+ const { ops, keepSelection } = buildRemoveMeasurementOps(placed);
1436
+ state.ctx.commit({ ops });
1437
+ if (!keepSelection)
1438
+ state.ctx.setSelection(null);
1439
+ };
1434
1440
  onMeasurementStampRemove(placed, defaultRemove);
1435
- return;
1436
1441
  }
1437
- defaultRemove();
1438
- } }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
1442
+ : undefined }, placed.id))) })), magnifying && (_jsx(View, { pointerEvents: "none", style: StyleSheet.absoluteFill, children: AnnotationCanvasSkia({
1439
1443
  ...skiaProps,
1440
1444
  worldTransform: loupeTransform,
1441
1445
  wrapContent: loupeWrap,
@@ -1517,7 +1521,7 @@ const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging,
1517
1521
  selected,
1518
1522
  size,
1519
1523
  zoom: zoomSnapshot,
1520
- }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
1524
+ }) }), onStampPress && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Open measurement", onPress: () => onStampPress(placed), onLongPress: onStampLongPress ? () => onStampLongPress(placed) : undefined, style: StyleSheet.absoluteFill })), selected && measurement && onRemove && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Remove measurement", onPress: onRemove, style: {
1521
1525
  position: 'absolute',
1522
1526
  top: 0,
1523
1527
  right: 0,
@@ -1,5 +1,5 @@
1
- import { type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
- import type { ReactNode } from 'react';
1
+ import { type CanvasRef, type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
+ import type { ReactNode, RefObject } from 'react';
3
3
  import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
4
  type AnimatedPoint = {
5
5
  x: number;
@@ -88,6 +88,7 @@ export interface AnnotationCanvasSkiaProps {
88
88
  };
89
89
  customPreview?: ReactNode;
90
90
  wrapContent?: (content: ReactNode) => ReactNode;
91
+ canvasRef?: RefObject<CanvasRef | null>;
91
92
  }
92
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
93
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
93
94
  export {};
@@ -15,7 +15,7 @@ import { StrokeElement } from './elements/StrokeElement.js';
15
15
  // against the document; the rectangle border stays thinner (it outlines a
16
16
  // region rather than pointing at one).
17
17
  const MEASUREMENT_LINE_COLOR = '#0066FF';
18
- const MEASUREMENT_LINE_WIDTH = 6;
18
+ const MEASUREMENT_LINE_WIDTH = 16;
19
19
  const MEASUREMENT_RECT_WIDTH = 2;
20
20
  // Endpoint handle color — constant selection chrome, independent of the (now
21
21
  // editable) line color so the handles stay legible on any line color.
@@ -78,7 +78,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
78
78
  // since the function-call pattern works identically on native we use it
79
79
  // in both Inners for consistency. Don't add hooks here; this is a plain
80
80
  // JSX-returning helper, not a component.
81
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, }) => {
81
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, resolveImageUrl, backgroundSkImage, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }) => {
82
82
  const world = (_jsxs(Group, { transform: worldTransform, children: [effectiveCanvas.viewport.backgroundImage && (_jsx(BackgroundImageElement, { image: effectiveCanvas.viewport.backgroundImage, docWidth: effectiveCanvas.viewport.width, docHeight: effectiveCanvas.viewport.height, fit: effectiveCanvas.viewport.backgroundFit ?? 'contain', resolveUrl: resolveImageUrl, skImage: backgroundSkImage })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
83
83
  // Line/arrow shapes support endpoint editing. When selected they show
84
84
  // grab handles at both ends; during an endpoint drag the line renders
@@ -220,5 +220,5 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
220
220
  }
221
221
  return null;
222
222
  })(), penDrawingStroke && _jsx(StrokeElement, { stroke: penDrawingStroke }), shapePreview && (_jsxs(_Fragment, { children: [_jsx(Path, { path: shapePreview.path, color: shapePreview.color, style: "stroke", strokeWidth: shapePreview.width, strokeCap: toSkiaStrokeCap(shapePreview.cap), strokeJoin: "round", children: shapePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(shapePreview.width) })) }), _jsx(Path, { path: shapePreview.headPath, color: shapePreview.color, style: "fill" })] })), livePreview?.handoffPaths?.map((p, i) => (_jsx(Path, { path: p, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) }, i))), livePreview && (_jsx(Path, { path: livePreview.path, color: livePreview.color, style: "stroke", strokeWidth: livePreview.width, strokeCap: toSkiaStrokeCap(livePreview.cap), strokeJoin: "round", opacity: livePreview.opacity, children: livePreview.dash && (_jsx(DashPathEffect, { intervals: dashIntervals(livePreview.width) })) })), customPreview] }));
223
- return (_jsx(Canvas, { style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
223
+ return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
224
224
  };
@@ -23,6 +23,7 @@ export interface ToolContext {
23
23
  selection: Selection | null;
24
24
  viewport: ViewportApi;
25
25
  tileViewportScale: number;
26
+ tileScaleFactor: number;
26
27
  preview(patch: AnnotationDocumentPatch): void;
27
28
  commit(patch: AnnotationDocumentPatch): void;
28
29
  setSelection(selection: Selection | null): void;
@@ -53,12 +54,12 @@ export interface ShapeDrawConfig {
53
54
  }
54
55
  export type DragElementKind = 'stroke' | 'shape' | 'measurement';
55
56
  export interface DragSelectionConfig {
56
- hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number): {
57
+ hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): {
57
58
  id: AnnotationElementId;
58
59
  kind: DragElementKind;
59
60
  } | null;
60
61
  buildTranslatePatch(doc: AnnotationCanvasState, id: AnnotationElementId, kind: DragElementKind, delta: Vec2): AnnotationDocumentPatch | null;
61
- classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): 'slide' | 'move';
62
+ classifyMeasurementGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): 'slide' | 'move';
62
63
  buildSlidePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2, zoom: number): AnnotationDocumentPatch | null;
63
64
  hitTestHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): 'a' | 'b' | null;
64
65
  buildEndpointPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, handle: 'a' | 'b', delta: Vec2): AnnotationDocumentPatch | null;
@@ -76,7 +77,7 @@ export interface DragSelectionConfig {
76
77
  fixed: Vec2;
77
78
  } | null;
78
79
  buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
79
- isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number): boolean;
80
+ isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): boolean;
80
81
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
81
82
  buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
82
83
  }
@@ -1,14 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Image, useImage } from '@shopify/react-native-skia';
2
+ import { Group, Image, ImageSVG, Skia, useImage, } from '@shopify/react-native-skia';
3
3
  import { memo, useEffect, useRef, useState } from 'react';
4
4
  import { imageDocRect } from '../viewport.js';
5
- // Resolve a background image's (possibly expired) storage URL and decode it to
6
- // an SkImage. Exported so a consumer can hoist the decode ABOVE the canvas and
7
- // share one SkImage across multiple canvases (e.g. the main canvas + a
8
- // magnifier overlay) — otherwise each canvas mounts its own loader and flashes
9
- // a blank while it re-resolves + re-decodes. Returns null until loaded, or when
10
- // `image` is null/undefined. Hooks run unconditionally regardless of `image`.
11
- export const useBackgroundSkImage = (image, resolveUrl) => {
5
+ // Resolve a background image's (possibly expired) storage URL. Shared by the
6
+ // raster and SVG loaders below.
7
+ const useBackgroundUrl = (image, resolveUrl) => {
12
8
  const [url, setUrl] = useState(image?.downloadUrl ?? '');
13
9
  // Hold resolveUrl in a ref so re-resolution is keyed only on the image
14
10
  // identity, not the function's. Consumers commonly pass an inline closure
@@ -33,17 +29,76 @@ export const useBackgroundSkImage = (image, resolveUrl) => {
33
29
  return () => {
34
30
  cancelled = true;
35
31
  };
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
33
  }, [image?.downloadUrl, image?.storagePath]);
34
+ return url;
35
+ };
36
+ // Null-safe SVG loader. NOT RN Skia's useSVG: that hook throws synchronously
37
+ // on a null/undefined source (despite its DataSourceParam type), so it can't
38
+ // be called unconditionally from a component that usually renders raster
39
+ // backgrounds. An empty url resolves to null (nothing to draw yet).
40
+ const useBackgroundSvg = (url) => {
41
+ const [svg, setSvg] = useState(null);
42
+ useEffect(() => {
43
+ if (!url) {
44
+ setSvg(null);
45
+ return;
46
+ }
47
+ let cancelled = false;
48
+ (async () => {
49
+ try {
50
+ const res = await fetch(url);
51
+ const text = await res.text();
52
+ const next = Skia.SVG.MakeFromString(text);
53
+ if (!cancelled)
54
+ setSvg(next);
55
+ }
56
+ catch (e) {
57
+ console.warn('[BackgroundImageElement] failed to load SVG background', e);
58
+ }
59
+ })();
60
+ return () => {
61
+ cancelled = true;
62
+ };
63
+ }, [url]);
64
+ return svg;
65
+ };
66
+ // Resolve a background image's URL and decode it to an SkImage. Exported so a
67
+ // consumer can hoist the decode ABOVE the canvas and share one SkImage across
68
+ // multiple canvases (e.g. the main canvas + a magnifier overlay) — otherwise
69
+ // each canvas mounts its own loader and flashes a blank while it re-resolves +
70
+ // re-decodes. Returns null until loaded, or when `image` is null/undefined.
71
+ // Hooks run unconditionally regardless of `image`. Raster only — SVG
72
+ // backgrounds self-load in the element below (parsing an SVG is cheap).
73
+ export const useBackgroundSkImage = (image, resolveUrl) => {
74
+ const url = useBackgroundUrl(image, resolveUrl);
37
75
  return useImage(url || null);
38
76
  };
39
77
  export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, }) => {
40
- // Self-load only when no pre-decoded image is provided; the hook still runs
41
- // unconditionally (with a null image, so it no-ops) to satisfy hook rules.
78
+ const isSvg = image.format === 'svg';
79
+ // Self-load only when no pre-decoded image is provided; the hooks still run
80
+ // unconditionally (with a null source, so they no-op) to satisfy hook rules.
42
81
  const provided = skImageProp !== undefined;
43
- const loaded = useBackgroundSkImage(provided ? null : image, resolveUrl);
82
+ const loaded = useBackgroundSkImage(provided || isSvg ? null : image, resolveUrl);
83
+ const svgUrl = useBackgroundUrl(isSvg ? image : null, resolveUrl);
84
+ const svg = useBackgroundSvg(isSvg ? svgUrl : '');
44
85
  const skImage = provided ? skImageProp : loaded;
86
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
87
+ if (isSvg) {
88
+ if (!svg)
89
+ return null;
90
+ // Draw the SVG at its stamped intrinsic size inside a scale transform
91
+ // (rather than relying on ImageSVG's own fitting, which only applies to
92
+ // sources with a viewBox). The vector source re-rasterizes per frame, so
93
+ // it stays crisp at any canvas zoom.
94
+ return (_jsx(Group, { transform: [
95
+ { translateX: dims.x },
96
+ { translateY: dims.y },
97
+ { scaleX: dims.width / image.widthPx },
98
+ { scaleY: dims.height / image.heightPx },
99
+ ], children: _jsx(ImageSVG, { svg: svg, x: 0, y: 0, width: image.widthPx, height: image.heightPx }) }));
100
+ }
45
101
  if (!skImage)
46
102
  return null;
47
- const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
48
103
  return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
49
104
  });
@@ -1,8 +1,9 @@
1
- import type { PlacedMeasurementRef } from '../../types/annotation.js';
1
+ import type { AnnotationCanvasState, PlacedMeasurementRef, TileScalePlatform } from '../../types/annotation.js';
2
2
  export declare const STAMP_TILE_SIZE = 96;
3
3
  export declare const DEFAULT_TILE_SCALE = 1;
4
4
  export declare const TILE_SCALE_MIN = 0.4;
5
5
  export declare const TILE_SCALE_MAX = 2;
6
+ export declare const resolveTileScaleFactor: (canvas: Pick<AnnotationCanvasState, "tileScaleFactor" | "tileScaleFactorMobile">, platform: TileScalePlatform) => number;
6
7
  export declare const clampTileScale: (v: number) => number;
7
8
  export declare const isUnassociatedStamp: (m: Pick<PlacedMeasurementRef, "measurementId" | "measurementPath" | "columnId">) => boolean;
8
9
  export declare const stampTileSize: (m: Pick<PlacedMeasurementRef, "scale">, tileScaleFactor?: number, viewportScale?: number) => number;
@@ -12,16 +12,28 @@
12
12
  // user who set the tile-scale slider against blank tiles saw their tiles
13
13
  // balloon once populated (Asana 1216233394800417).
14
14
  export const STAMP_TILE_SIZE = 96;
15
- // Document-wide tile scale factor (AnnotationCanvasState.tileScaleFactor): one
16
- // knob that shrinks/grows EVERY measurement tile on the canvas at once, on top
17
- // of each tile's own `scale`. Lets a user pull tiles down on a dense drawing
18
- // where lines crowd together, or bump them up on a sparse one. Like `scale` it
19
- // is purely a screen-space multiplier — it does NOT change with zoom. Absent ===
20
- // DEFAULT_TILE_SCALE (visually identical to documents written before the knob
21
- // existed).
15
+ // Document-wide tile scale factor: one knob that shrinks/grows EVERY
16
+ // measurement tile on the canvas at once, on top of each tile's own `scale`.
17
+ // Lets a user pull tiles down on a dense drawing where lines crowd together,
18
+ // or bump them up on a sparse one. Like `scale` it is purely a screen-space
19
+ // multiplier — it does NOT change with zoom. Absent === DEFAULT_TILE_SCALE
20
+ // (visually identical to documents written before the knob existed).
21
+ // Web and mobile persist separate knobs (AnnotationCanvasState.tileScaleFactor
22
+ // vs .tileScaleFactorMobile) — read them through resolveTileScaleFactor below.
22
23
  export const DEFAULT_TILE_SCALE = 1;
23
24
  export const TILE_SCALE_MIN = 0.4;
24
25
  export const TILE_SCALE_MAX = 2;
26
+ // Resolve the document-wide tile-scale knob for a platform. Web and mobile
27
+ // persist separate values because a scale calibrated against a desktop window
28
+ // is wrong on a phone screen (and vice versa): web reads `tileScaleFactor`
29
+ // (also the legacy shared field from before the split); mobile reads its own
30
+ // `tileScaleFactorMobile` and falls back to the shared field, so a pre-split
31
+ // document keeps its current mobile look until a mobile user first moves the
32
+ // slider. The ONE read path for these fields — renderers, hit-testing, and
33
+ // the slider UIs must all resolve through here so they agree.
34
+ export const resolveTileScaleFactor = (canvas, platform) => (platform === 'mobile'
35
+ ? (canvas.tileScaleFactorMobile ?? canvas.tileScaleFactor)
36
+ : canvas.tileScaleFactor) ?? DEFAULT_TILE_SCALE;
25
37
  // Clamp a tile-scale-factor candidate to the supported range. The single guard
26
38
  // for the value before it lands in the document (slider input, restored docs).
27
39
  export const clampTileScale = (v) => v < TILE_SCALE_MIN ? TILE_SCALE_MIN : v > TILE_SCALE_MAX ? TILE_SCALE_MAX : v;
@@ -54,8 +66,8 @@ export const isUnassociatedStamp = (m) => !m.measurementId && !m.measurementPath
54
66
  // value. The ONE source of truth for tile footprint — render overlay,
55
67
  // hit-test, and slide-grab classification all call this so the drawn tile and
56
68
  // its touch box always agree. `tileScaleFactor` is the document-wide knob
57
- // (default 1, from `AnnotationCanvasState.tileScaleFactor`, driven by the
58
- // "Tile size" slider). `viewportScale` is retained for call compatibility and
59
- // is always 1 — the old per-canvas multiplier was retired (see the note
60
- // above). Independent of zoom.
69
+ // (default 1), driven by the "Tile size" slider and already resolved for the
70
+ // caller's platform via resolveTileScaleFactor above. `viewportScale` is
71
+ // retained for call compatibility and is always 1 — the old per-canvas
72
+ // multiplier was retired (see the note above). Independent of zoom.
61
73
  export const stampTileSize = (m, tileScaleFactor = DEFAULT_TILE_SCALE, viewportScale = 1) => STAMP_TILE_SIZE * (m.scale ?? 1) * tileScaleFactor * viewportScale;
@@ -51,7 +51,7 @@ export const createPanTool = (options = {}) => ({
51
51
  const measurements = ctx.document.placedMeasurements;
52
52
  for (let i = measurements.length - 1; i >= 0; i--) {
53
53
  const m = measurements[i];
54
- if (hitPlacedMeasurement(m, event.world, zoom, ctx.document.tileScaleFactor, ctx.tileViewportScale)) {
54
+ if (hitPlacedMeasurement(m, event.world, zoom, ctx.tileScaleFactor, ctx.tileViewportScale)) {
55
55
  ctx.setSelection({ ids: [m.id] });
56
56
  return;
57
57
  }
@@ -56,11 +56,14 @@ const segmentDistanceSq = (px, py, ax, ay, bx, by) => {
56
56
  const dy = py - cy;
57
57
  return dx * dx + dy * dy;
58
58
  };
59
- const findHit = (doc, world, zoom, viewportTileScale = 1) => {
59
+ const findHit = (doc, world, zoom, viewportTileScale = 1,
60
+ // Platform-resolved tile-scale knob (ctx.tileScaleFactor). Defaults to the
61
+ // doc's web/legacy field so un-threaded callers keep the pre-split behavior.
62
+ tileScaleFactor = doc.tileScaleFactor) => {
60
63
  // Hit-test in z-order (top first): measurements > shapes > strokes.
61
64
  for (let i = doc.placedMeasurements.length - 1; i >= 0; i--) {
62
65
  const m = doc.placedMeasurements[i];
63
- if (hitPlacedMeasurement(m, world, zoom, doc.tileScaleFactor, viewportTileScale)) {
66
+ if (hitPlacedMeasurement(m, world, zoom, tileScaleFactor, viewportTileScale)) {
64
67
  return { id: m.id, kind: 'measurement' };
65
68
  }
66
69
  }
@@ -150,12 +153,11 @@ const translatePatch = (elementKind, id, doc, delta) => {
150
153
  // is selected; without this, a handle sitting under the tile (a rectangle
151
154
  // tile at the rect center, a line tile slid onto an endpoint) hijacks the grab
152
155
  // and the tile becomes unmovable until deselected.
153
- const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
156
+ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
154
157
  const m = doc.placedMeasurements.find((x) => x.id === id);
155
158
  if (!m)
156
159
  return false;
157
- const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
158
- HIT_PADDING) /
160
+ const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
159
161
  zoom;
160
162
  return (Math.abs(world.x - m.anchor.x) <= half &&
161
163
  Math.abs(world.y - m.anchor.y) <= half);
@@ -164,15 +166,14 @@ const isOnMeasurementTile = (doc, id, world, zoom, viewportTileScale = 1) => {
164
166
  // via DragSelectionConfig AND the web pointer handlers — one source of truth) ---
165
167
  // Grabbing the tile of a line annotation slides it along the line; everything
166
168
  // else (bare stamps, grabs on the line body) is a group move.
167
- const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1) => {
169
+ const classifyGrab = (doc, id, world, zoom, viewportTileScale = 1, tileScaleFactor = doc.tileScaleFactor) => {
168
170
  const m = doc.placedMeasurements.find((x) => x.id === id);
169
171
  if (!m)
170
172
  return 'move';
171
173
  // Same footprint the tile is drawn at (smaller for unassociated inputs, #6;
172
174
  // folds in the document-wide tile scale + viewport scale so slide-grab
173
175
  // matches the draw).
174
- const half = (stampTileSize(m, doc.tileScaleFactor, viewportTileScale) / 2 +
175
- HIT_PADDING) /
176
+ const half = (stampTileSize(m, tileScaleFactor, viewportTileScale) / 2 + HIT_PADDING) /
176
177
  zoom;
177
178
  const onTile = Math.abs(world.x - m.anchor.x) <= half &&
178
179
  Math.abs(world.y - m.anchor.y) <= half;
@@ -480,7 +481,7 @@ export const createSelectTool = (options = {}) => ({
480
481
  // reusing the same hit-test and translate logic the pointer handlers below
481
482
  // use for web — one source of truth.
482
483
  dragSelection: {
483
- hitTest: (doc, world, zoom, viewportTileScale) => findHit(doc, world, zoom, viewportTileScale),
484
+ hitTest: (doc, world, zoom, viewportTileScale, tileScaleFactor) => findHit(doc, world, zoom, viewportTileScale, tileScaleFactor),
484
485
  buildTranslatePatch: (doc, id, kind, delta) => {
485
486
  const op = translatePatch(kind, id, doc, delta);
486
487
  return op ? { ops: [op] } : null;
@@ -515,7 +516,7 @@ export const createSelectTool = (options = {}) => ({
515
516
  // tile stays draggable (otherwise it can only be moved after deselecting).
516
517
  const selId = ctx.selection?.ids[0];
517
518
  if (selId &&
518
- !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale)) {
519
+ !isOnMeasurementTile(ctx.document, selId, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor)) {
519
520
  const handle = findHandleHit(ctx.document, selId, world, zoom);
520
521
  if (handle) {
521
522
  ctx.setSelection({ ids: [selId] });
@@ -579,7 +580,7 @@ export const createSelectTool = (options = {}) => ({
579
580
  };
580
581
  }
581
582
  }
582
- const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale);
583
+ const hit = findHit(ctx.document, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor);
583
584
  if (!hit) {
584
585
  ctx.setSelection(null);
585
586
  // Nothing under the pointer: with panOnEmptyDrag the gesture pans the
@@ -600,8 +601,7 @@ export const createSelectTool = (options = {}) => ({
600
601
  : null;
601
602
  ctx.setSelection({ ids: [hit.id] });
602
603
  const mode = hit.kind === 'measurement' &&
603
- classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale) ===
604
- 'slide'
604
+ classifyGrab(ctx.document, hit.id, world, zoom, ctx.tileViewportScale, ctx.tileScaleFactor) === 'slide'
605
605
  ? 'slide'
606
606
  : 'move';
607
607
  return {
@@ -1,5 +1,5 @@
1
1
  import type { Measurement } from '../../types/firestore.js';
2
- import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type Vec2 } from '../../types/annotation.js';
2
+ import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationElementId, type AnnotationStroke, type MeasurementPlacement, type Selection, type TileScalePlatform, type Vec2 } from '../../types/annotation.js';
3
3
  import type { MeasurementRef } from './measurementPicker.js';
4
4
  import type { CanvasPointerEvent, RequestTextInput, Tool, ToolContext, ToolState } from './Tool.js';
5
5
  import { type ViewportState } from './viewport.js';
@@ -15,6 +15,8 @@ export interface AnnotationCanvasHandle {
15
15
  defaultLengthDoc?: number;
16
16
  }): void;
17
17
  setAnnotationType(id: AnnotationElementId, type: MeasurementPlacement): void;
18
+ centerOnLine(id: AnnotationElementId): void;
19
+ removeMeasurementValue(id: AnnotationElementId): void;
18
20
  associateMeasurement(id: AnnotationElementId, ref: MeasurementRef): void;
19
21
  bindColumn(id: AnnotationElementId, binding: {
20
22
  groupId: string;
@@ -39,6 +41,7 @@ export interface UseAnnotationCanvasStateProps {
39
41
  width: number;
40
42
  height: number;
41
43
  initialViewport?: ViewportState;
44
+ tileScalePlatform?: TileScalePlatform;
42
45
  imperativeRef?: {
43
46
  current: AnnotationCanvasHandle | null;
44
47
  };
@@ -58,6 +61,7 @@ export interface AnnotationCanvasStateApi {
58
61
  toolState: ToolState;
59
62
  ctx: ToolContext;
60
63
  tileViewportScale: number;
64
+ tileScaleFactor: number;
61
65
  penDrawingStroke: AnnotationStroke | null;
62
66
  customPreviewState: ToolState;
63
67
  dispatchPointerDown(event: CanvasPointerEvent): void;
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { applyPatch, invertPatch, DEFAULT_LAYER_ID, } from '../../types/annotation.js';
3
+ import { resolveTileScaleFactor } from './stampLayout.js';
3
4
  import { createViewportApi, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './viewport.js';
4
- import { recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
+ import { buildRemoveMeasurementOps, recomputeAnchor, rectCenter, DEFAULT_LINE_POS, } from './measurementGeometry.js';
5
6
  // The viewport that frames the document's content on screen. When a background
6
7
  // image is present we fit its rendered rect (so the whole image shows, filling
7
8
  // the viewport regardless of its pixel resolution — never the native-resolution
@@ -22,7 +23,7 @@ const computeContentFit = (canvas, width, height) => {
22
23
  // inners share this hook; each wraps it with platform-specific event
23
24
  // capture and JSX (div + DOM events vs. GestureDetector + RN Views).
24
25
  export const useAnnotationCanvasState = (props) => {
25
- const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, imperativeRef, } = props;
26
+ const { canvas, onCommit, tools, activeToolId, selection, onSelectionChange, measurements, pickMeasurement, requestTextInput, width, height, initialViewport, tileScalePlatform, imperativeRef, } = props;
26
27
  const [viewport, setViewport] = useState(initialViewport ?? DEFAULT_VIEWPORT);
27
28
  const [toolState, setToolState] = useState(undefined);
28
29
  const [previewPatch, setPreviewPatch] = useState(null);
@@ -63,11 +64,17 @@ export const useAnnotationCanvasState = (props) => {
63
64
  // (hit box) thread it; keeping them in lockstep at 1 means both still agree.
64
65
  // (See stampLayout.ts for why the old per-canvas multiplier was retired.)
65
66
  const tileViewportScale = 1;
67
+ // The platform's tile-scale knob, resolved once from the effective canvas so
68
+ // tools (hit boxes) and overlays (drawn size) share one value. Uses the
69
+ // effective (preview-applied) canvas for parity with what is on screen; the
70
+ // slider only ever commits, so this matches ctx.document in practice.
71
+ const tileScaleFactor = resolveTileScaleFactor(effectiveCanvas, tileScalePlatform ?? 'web');
66
72
  const ctx = useMemo(() => ({
67
73
  document: canvas,
68
74
  selection,
69
75
  viewport: viewportApi,
70
76
  tileViewportScale,
77
+ tileScaleFactor,
71
78
  preview(patch) {
72
79
  setPreviewPatch(patch);
73
80
  },
@@ -100,6 +107,7 @@ export const useAnnotationCanvasState = (props) => {
100
107
  selection,
101
108
  viewportApi,
102
109
  tileViewportScale,
110
+ tileScaleFactor,
103
111
  onCommit,
104
112
  onSelectionChange,
105
113
  pickMeasurement,
@@ -331,6 +339,46 @@ export const useAnnotationCanvasState = (props) => {
331
339
  ops: [{ op: 'updateMeasurement', id, patch: { placement: 'none' } }],
332
340
  });
333
341
  },
342
+ centerOnLine(id) {
343
+ const c = ctxRef.current;
344
+ const m = c.document.placedMeasurements.find((x) => x.id === id);
345
+ if (!m)
346
+ return;
347
+ // Same default-line synthesis as setAnnotationType('line') so centering
348
+ // a floating tile also gives it a line to sit on.
349
+ let line = m.line;
350
+ if (!line) {
351
+ const len = Math.min(400, Math.max(120, canvas.viewport.width * 0.25));
352
+ line = {
353
+ a: { x: m.anchor.x - len / 2, y: m.anchor.y },
354
+ b: { x: m.anchor.x + len / 2, y: m.anchor.y },
355
+ };
356
+ }
357
+ c.commit({
358
+ ops: [
359
+ {
360
+ op: 'updateMeasurement',
361
+ id,
362
+ patch: {
363
+ placement: 'line',
364
+ line,
365
+ linePos: 0.5,
366
+ anchor: recomputeAnchor(line, 'line', 0.5, m.anchor),
367
+ },
368
+ },
369
+ ],
370
+ });
371
+ },
372
+ removeMeasurementValue(id) {
373
+ const c = ctxRef.current;
374
+ const m = c.document.placedMeasurements.find((x) => x.id === id);
375
+ if (!m)
376
+ return;
377
+ const { ops, keepSelection } = buildRemoveMeasurementOps(m);
378
+ c.commit({ ops });
379
+ if (!keepSelection)
380
+ c.setSelection(null);
381
+ },
334
382
  associateMeasurement(id, ref) {
335
383
  const c = ctxRef.current;
336
384
  c.commit({
@@ -342,6 +390,11 @@ export const useAnnotationCanvasState = (props) => {
342
390
  measurementId: ref.measurementId,
343
391
  measurementPath: ref.measurementPath,
344
392
  groupId: ref.groupId,
393
+ // A direct association replaces any FORM-column binding — the
394
+ // tile now resolves through the measurement, not
395
+ // group.columns[columnId]. Cleared keys serialize away at the
396
+ // persistence boundary; undo restores the prior binding.
397
+ columnId: undefined,
345
398
  labelOverride: ref.label,
346
399
  unitOverride: ref.unit,
347
400
  },
@@ -443,6 +496,7 @@ export const useAnnotationCanvasState = (props) => {
443
496
  toolState,
444
497
  ctx,
445
498
  tileViewportScale,
499
+ tileScaleFactor,
446
500
  penDrawingStroke,
447
501
  customPreviewState: toolState,
448
502
  dispatchPointerDown,
@@ -7,6 +7,14 @@ export interface JobScope {
7
7
  export interface JobGroupScope extends JobScope {
8
8
  groupId: string;
9
9
  }
10
+ export interface TemplateScope {
11
+ kind: 'template';
12
+ orgId?: string;
13
+ templateId: string;
14
+ }
15
+ export type AnnotationScope = JobGroupScope | TemplateScope;
16
+ export declare const isTemplateScope: (s: AnnotationScope) => s is TemplateScope;
17
+ export declare const annotationScopeKey: (s: AnnotationScope | null) => string;
10
18
  export type Unsubscribe = () => void;
11
19
  export type FieldOp = {
12
20
  kind: 'serverTimestamp';
@@ -26,6 +34,9 @@ export declare const isFieldOp: (v: unknown) => v is FieldOp;
26
34
  export type Patch<T> = {
27
35
  [K in keyof T]?: T[K] | FieldOp;
28
36
  };
37
+ export type AnnotationFilePatch = Patch<AnnotationFile> & {
38
+ [dotPath: `fileData.${string}`]: unknown;
39
+ };
29
40
  export interface ImageBlob {
30
41
  data: Blob | ArrayBuffer | string;
31
42
  contentType: string;
@@ -51,15 +62,15 @@ export interface AnnotationFileSummary {
51
62
  createdBy?: AnnotationFile['createdBy'];
52
63
  }
53
64
  export interface AnnotationDataProvider {
54
- create(scope: JobGroupScope, seed: Partial<AnnotationFile>): Promise<string>;
55
- get(scope: JobGroupScope, fileId: string): Promise<AnnotationFile | null>;
56
- update(scope: JobGroupScope, fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
57
- delete(scope: JobGroupScope, fileId: string): Promise<void>;
58
- subscribe(scope: JobGroupScope, fileId: string, onNext: (doc: AnnotationFile | null) => void, onError?: (err: Error) => void): Unsubscribe;
59
- list(scope: JobGroupScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
65
+ create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
66
+ get(scope: AnnotationScope, fileId: string): Promise<AnnotationFile | null>;
67
+ update(scope: AnnotationScope, fileId: string, patch: AnnotationFilePatch): Promise<void>;
68
+ delete(scope: AnnotationScope, fileId: string): Promise<void>;
69
+ subscribe(scope: AnnotationScope, fileId: string, onNext: (doc: AnnotationFile | null) => void, onError?: (err: Error) => void): Unsubscribe;
70
+ list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void, onError?: (err: Error) => void): Unsubscribe;
60
71
  subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
61
72
  subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void, onError?: (err: Error) => void): Unsubscribe;
62
- uploadImage(scope: JobGroupScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
63
- getImageUrl(scope: JobGroupScope, fileId: string, storagePath: string): Promise<string>;
64
- deleteImage(scope: JobGroupScope, fileId: string, storagePath: string): Promise<void>;
73
+ uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
74
+ getImageUrl(scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
75
+ deleteImage(scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
65
76
  }
@@ -1,3 +1,11 @@
1
+ export const isTemplateScope = (s) => 'kind' in s && s.kind === 'template';
2
+ // Stable string identity for a scope, for React hook dep arrays — scope
3
+ // objects are typically rebuilt every render.
4
+ export const annotationScopeKey = (s) => s == null
5
+ ? ''
6
+ : isTemplateScope(s)
7
+ ? `template/${s.orgId ?? ''}/${s.templateId}`
8
+ : `job/${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
1
9
  export const isFieldOp = (v) => !!v &&
2
10
  typeof v === 'object' &&
3
11
  'kind' in v &&
@@ -1,5 +1,5 @@
1
1
  import { type Measurement } from '../../types/firestore.js';
2
- import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
2
+ import { type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type ImageBlob, type JobGroupScope, type JobScope, type Unsubscribe, type UploadedImageRef } from './AnnotationDataProvider.js';
3
3
  export declare class InMemoryAnnotationProvider implements AnnotationDataProvider {
4
4
  private docs;
5
5
  private measurements;
@@ -10,17 +10,17 @@ export declare class InMemoryAnnotationProvider implements AnnotationDataProvide
10
10
  private jobMeasurementListeners;
11
11
  private nextId;
12
12
  setMeasurements(scope: JobGroupScope, measurements: Measurement[]): void;
13
- create(scope: JobGroupScope, seed: Partial<AnnotationFile>): Promise<string>;
14
- get(scope: JobGroupScope, fileId: string): Promise<AnnotationFile | null>;
15
- update(scope: JobGroupScope, fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
16
- delete(scope: JobGroupScope, fileId: string): Promise<void>;
17
- subscribe(scope: JobGroupScope, fileId: string, onNext: (doc: AnnotationFile | null) => void): Unsubscribe;
18
- list(scope: JobGroupScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
13
+ create(scope: AnnotationScope, seed: Partial<AnnotationFile>): Promise<string>;
14
+ get(scope: AnnotationScope, fileId: string): Promise<AnnotationFile | null>;
15
+ update(scope: AnnotationScope, fileId: string, patch: AnnotationFilePatch): Promise<void>;
16
+ delete(scope: AnnotationScope, fileId: string): Promise<void>;
17
+ subscribe(scope: AnnotationScope, fileId: string, onNext: (doc: AnnotationFile | null) => void): Unsubscribe;
18
+ list(scope: AnnotationScope, onNext: (files: AnnotationFileSummary[]) => void): Unsubscribe;
19
19
  subscribeGroupMeasurements(scope: JobGroupScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
20
20
  subscribeJobMeasurements(scope: JobScope, onNext: (measurements: Measurement[]) => void): Unsubscribe;
21
- uploadImage(scope: JobGroupScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
22
- getImageUrl(_scope: JobGroupScope, fileId: string, storagePath: string): Promise<string>;
23
- deleteImage(_scope: JobGroupScope, fileId: string, storagePath: string): Promise<void>;
21
+ uploadImage(scope: AnnotationScope, fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
22
+ getImageUrl(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<string>;
23
+ deleteImage(_scope: AnnotationScope, fileId: string, storagePath: string): Promise<void>;
24
24
  private getBucket;
25
25
  private notifyDoc;
26
26
  private notifyList;
@@ -1,7 +1,9 @@
1
1
  import { FileUploadType } from '../../types/firestore.js';
2
- import { isFieldOp, } from './AnnotationDataProvider.js';
3
- const scopeKey = (s) => `${s.orgId}/${s.projectId}/${s.jobId}/${s.groupId}`;
4
- const jobKey = (s) => `${s.orgId}/${s.projectId}/${s.jobId}`;
2
+ import { annotationScopeKey, isFieldOp, } from './AnnotationDataProvider.js';
3
+ const scopeKey = annotationScopeKey;
4
+ // Prefix of annotationScopeKey for job scopes, so collectJobMeasurements can
5
+ // match group keys by startsWith.
6
+ const jobKey = (s) => `job/${s.orgId}/${s.projectId}/${s.jobId}`;
5
7
  const summarize = (file) => ({
6
8
  id: file.id,
7
9
  name: file.name,
@@ -37,6 +39,30 @@ const resolveFieldOp = (op) => {
37
39
  return undefined;
38
40
  }
39
41
  };
42
+ // Firestore-style dotted field paths: 'fileData.canvas' merges into the
43
+ // nested map (creating intermediate maps as needed) instead of replacing the
44
+ // whole top-level field — mirrors what the real SDKs do with dotted keys.
45
+ const applyDotPaths = (base, resolved) => {
46
+ const next = { ...base };
47
+ for (const [key, value] of Object.entries(resolved)) {
48
+ if (!key.includes('.')) {
49
+ next[key] = value;
50
+ continue;
51
+ }
52
+ const parts = key.split('.');
53
+ let target = next;
54
+ for (let i = 0; i < parts.length - 1; i++) {
55
+ const existing = target[parts[i]];
56
+ target[parts[i]] =
57
+ existing && typeof existing === 'object' && !Array.isArray(existing)
58
+ ? { ...existing }
59
+ : {};
60
+ target = target[parts[i]];
61
+ }
62
+ target[parts[parts.length - 1]] = value;
63
+ }
64
+ return next;
65
+ };
40
66
  // Simple test/dev provider. Stores documents and image blobs in memory and
41
67
  // fans out subscription notifications synchronously. Not designed for
42
68
  // performance — designed for predictable behavior in tests and Storybook.
@@ -86,7 +112,7 @@ export class InMemoryAnnotationProvider {
86
112
  if (!prev)
87
113
  throw new Error(`Annotation file ${fileId} not found`);
88
114
  const resolved = applyFieldOps(patch);
89
- const next = { ...prev, ...resolved };
115
+ const next = applyDotPaths(prev, resolved);
90
116
  bucket.set(fileId, next);
91
117
  this.notifyDoc(scope, fileId);
92
118
  this.notifyList(scope);
@@ -1,8 +1,8 @@
1
1
  import { type AnnotationCanvasState, type AnnotationDocumentPatch, type AnnotationViewport, type BackgroundFit } from '../../../types/annotation.js';
2
- import type { ImageBlob, JobGroupScope } from '../AnnotationDataProvider.js';
2
+ import type { AnnotationScope, ImageBlob } from '../AnnotationDataProvider.js';
3
3
  export type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
4
4
  export interface UseAnnotationCanvasDocOptions {
5
- scope: JobGroupScope | null;
5
+ scope: AnnotationScope | null;
6
6
  fileId: string | null;
7
7
  fallbackViewport?: Partial<AnnotationViewport>;
8
8
  debounceMs?: number;
@@ -223,10 +223,17 @@ export const useAnnotationCanvasDoc = (options) => {
223
223
  }
224
224
  else {
225
225
  const doc = dataRef.current;
226
+ const fileType = doc?.fileData.fileType ?? createSeedRef.current?.fileType ?? 'sketch';
227
+ const isLabel = doc?.fileData.isLabel ?? createSeedRef.current?.isLabel;
228
+ // Dotted field paths so the write MERGES into fileData instead of
229
+ // replacing the map — the doc may carry sibling fileData keys the
230
+ // canvas doesn't own (a calculator file's columns/tableConfig/
231
+ // isCompleted, …) which a whole-map write would silently delete.
226
232
  await updateRef.current(id, {
227
- fileData: buildFileData(doc?.fileData.fileType ??
228
- createSeedRef.current?.fileType ??
229
- 'sketch', doc?.fileData.isLabel ?? createSeedRef.current?.isLabel, canvasPayload, canvasRev),
233
+ 'fileData.fileType': fileType,
234
+ ...(isLabel !== undefined ? { 'fileData.isLabel': isLabel } : {}),
235
+ 'fileData.canvas': canvasPayload,
236
+ 'fileData.canvasRev': canvasRev,
230
237
  });
231
238
  if (debug) {
232
239
  console.log('[useAnnotationCanvasDoc] updated file', id);
@@ -1,7 +1,7 @@
1
- import type { AnnotationFile, JobGroupScope } from '../AnnotationDataProvider.js';
1
+ import { type AnnotationFile, type AnnotationScope } from '../AnnotationDataProvider.js';
2
2
  export interface UseAnnotationDocResult {
3
3
  data: AnnotationFile | null;
4
4
  loading: boolean;
5
5
  error: Error | null;
6
6
  }
7
- export declare const useAnnotationDoc: (scope: JobGroupScope | null, fileId: string | null) => UseAnnotationDocResult;
7
+ export declare const useAnnotationDoc: (scope: AnnotationScope | null, fileId: string | null) => UseAnnotationDocResult;
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useAnnotationData } from '../AnnotationDataContext.js';
3
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
3
4
  export const useAnnotationDoc = (scope, fileId) => {
4
5
  const provider = useAnnotationData();
5
6
  const [data, setData] = useState(null);
@@ -21,13 +22,7 @@ export const useAnnotationDoc = (scope, fileId) => {
21
22
  setLoading(false);
22
23
  });
23
24
  return unsubscribe;
24
- }, [
25
- provider,
26
- scope?.orgId,
27
- scope?.projectId,
28
- scope?.jobId,
29
- scope?.groupId,
30
- fileId,
31
- ]);
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [provider, annotationScopeKey(scope), fileId]);
32
27
  return { data, loading, error };
33
28
  };
@@ -1,7 +1,7 @@
1
- import type { AnnotationFileSummary, JobGroupScope } from '../AnnotationDataProvider.js';
1
+ import { type AnnotationFileSummary, type AnnotationScope } from '../AnnotationDataProvider.js';
2
2
  export interface UseAnnotationListResult {
3
3
  files: AnnotationFileSummary[];
4
4
  loading: boolean;
5
5
  error: Error | null;
6
6
  }
7
- export declare const useAnnotationList: (scope: JobGroupScope | null) => UseAnnotationListResult;
7
+ export declare const useAnnotationList: (scope: AnnotationScope | null) => UseAnnotationListResult;
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { useAnnotationData } from '../AnnotationDataContext.js';
3
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
3
4
  export const useAnnotationList = (scope) => {
4
5
  const provider = useAnnotationData();
5
6
  const [files, setFiles] = useState([]);
@@ -21,6 +22,7 @@ export const useAnnotationList = (scope) => {
21
22
  setLoading(false);
22
23
  });
23
24
  return unsubscribe;
24
- }, [provider, scope?.orgId, scope?.projectId, scope?.jobId, scope?.groupId]);
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [provider, annotationScopeKey(scope)]);
25
27
  return { files, loading, error };
26
28
  };
@@ -1,9 +1,9 @@
1
- import type { AnnotationFile, ImageBlob, JobGroupScope, Patch, UploadedImageRef } from '../AnnotationDataProvider.js';
1
+ import { type AnnotationFile, type AnnotationFilePatch, type AnnotationScope, type ImageBlob, type UploadedImageRef } from '../AnnotationDataProvider.js';
2
2
  export interface AnnotationMutations {
3
3
  create(seed: Partial<AnnotationFile>): Promise<string>;
4
- update(fileId: string, patch: Patch<AnnotationFile>): Promise<void>;
4
+ update(fileId: string, patch: AnnotationFilePatch): Promise<void>;
5
5
  remove(fileId: string): Promise<void>;
6
6
  uploadImage(fileId: string, role: 'background' | 'thumbnail', blob: ImageBlob): Promise<UploadedImageRef>;
7
7
  deleteImage(fileId: string, storagePath: string): Promise<void>;
8
8
  }
9
- export declare const useAnnotationMutations: (scope: JobGroupScope) => AnnotationMutations;
9
+ export declare const useAnnotationMutations: (scope: AnnotationScope) => AnnotationMutations;
@@ -1,11 +1,15 @@
1
1
  import { useCallback, useMemo } from 'react';
2
2
  import { useAnnotationData } from '../AnnotationDataContext.js';
3
+ import { annotationScopeKey, } from '../AnnotationDataProvider.js';
3
4
  export const useAnnotationMutations = (scope) => {
4
5
  const provider = useAnnotationData();
5
- const create = useCallback((seed) => provider.create(scope, seed), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
6
- const update = useCallback((fileId, patch) => provider.update(scope, fileId, patch), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
7
- const remove = useCallback((fileId) => provider.delete(scope, fileId), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
8
- const uploadImage = useCallback((fileId, role, blob) => provider.uploadImage(scope, fileId, role, blob), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
9
- const deleteImage = useCallback((fileId, storagePath) => provider.deleteImage(scope, fileId, storagePath), [provider, scope.orgId, scope.projectId, scope.jobId, scope.groupId]);
6
+ const scopeKey = annotationScopeKey(scope);
7
+ /* eslint-disable react-hooks/exhaustive-deps */
8
+ const create = useCallback((seed) => provider.create(scope, seed), [provider, scopeKey]);
9
+ const update = useCallback((fileId, patch) => provider.update(scope, fileId, patch), [provider, scopeKey]);
10
+ const remove = useCallback((fileId) => provider.delete(scope, fileId), [provider, scopeKey]);
11
+ const uploadImage = useCallback((fileId, role, blob) => provider.uploadImage(scope, fileId, role, blob), [provider, scopeKey]);
12
+ const deleteImage = useCallback((fileId, storagePath) => provider.deleteImage(scope, fileId, storagePath), [provider, scopeKey]);
13
+ /* eslint-enable react-hooks/exhaustive-deps */
10
14
  return useMemo(() => ({ create, update, remove, uploadImage, deleteImage }), [create, update, remove, uploadImage, deleteImage]);
11
15
  };
package/dist/exports.d.ts CHANGED
@@ -9,7 +9,7 @@ export * from './types/annotation.js';
9
9
  export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, generateToleranceGradient, createDefaultToleranceThresholds, DEFAULT_TOLERANCE_COLORS, type ToleranceThreshold, type ToleranceConfig, } from './utils/tolerance.js';
10
10
  export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
11
11
  export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
12
- export { isFieldOp, type AnnotationDataProvider, type AnnotationFile, type AnnotationFileSummary, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
12
+ export { annotationScopeKey, isFieldOp, isTemplateScope, type AnnotationDataProvider, type AnnotationFile, type AnnotationFilePatch, type AnnotationFileSummary, type AnnotationScope, type FieldOp, type ImageBlob, type JobGroupScope, type JobScope, type Patch, type TemplateScope, type Unsubscribe, type UploadedImageRef, } from './annotation/data/AnnotationDataProvider.js';
13
13
  export { AnnotationDataProviderContext, useAnnotationData, type AnnotationDataProviderProps, } from './annotation/data/AnnotationDataContext.js';
14
14
  export { useAnnotationDoc, type UseAnnotationDocResult, } from './annotation/data/hooks/useAnnotationDoc.js';
15
15
  export { useAnnotationList, type UseAnnotationListResult, } from './annotation/data/hooks/useAnnotationList.js';
@@ -22,7 +22,7 @@ export type { GestureConfig, PanTrigger, AnnotationCanvasInnerProps, } from './a
22
22
  export type { CanvasPointerEvent, RequestTextInput, ShapeDrawConfig, Tool, ToolContext, ToolState, } from './annotation/canvas/Tool.js';
23
23
  export type { MeasurementRef, PickMeasurement, } from './annotation/canvas/measurementPicker.js';
24
24
  export type { MeasurementStampRenderArgs, RenderMeasurementStamp, } from './annotation/canvas/measurementStampOverlay.js';
25
- export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
25
+ export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
26
26
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, type ViewportApi, type ViewportState, } from './annotation/canvas/viewport.js';
27
27
  export { createPenTool, type PenToolOptions, } from './annotation/canvas/tools/penTool.js';
28
28
  export { createSelectTool, type SelectToolOptions, } from './annotation/canvas/tools/selectTool.js';
package/dist/exports.js CHANGED
@@ -14,7 +14,7 @@ export { getToleranceColor, calculateDeviationPercentage, isWithinTolerance, gen
14
14
  export { DEFAULT_GROUP_INDEX, isDefaultGroup, findDefaultGroup, } from './utils/groups.js';
15
15
  export { numberToLetterIndex, formGroupInputLabel, listGroupMeasurementLabel, } from './utils/indexLabels.js';
16
16
  // Annotation data layer (SDK-neutral; apps provide their own provider).
17
- export { isFieldOp, } from './annotation/data/AnnotationDataProvider.js';
17
+ export { annotationScopeKey, isFieldOp, isTemplateScope, } from './annotation/data/AnnotationDataProvider.js';
18
18
  export { AnnotationDataProviderContext, useAnnotationData, } from './annotation/data/AnnotationDataContext.js';
19
19
  export { useAnnotationDoc, } from './annotation/data/hooks/useAnnotationDoc.js';
20
20
  export { useAnnotationList, } from './annotation/data/hooks/useAnnotationList.js';
@@ -22,7 +22,7 @@ export { useAnnotationMutations, } from './annotation/data/hooks/useAnnotationMu
22
22
  export { useAnnotationCanvasDoc, } from './annotation/data/hooks/useAnnotationCanvasDoc.js';
23
23
  export { hydrateCanvasState } from './annotation/data/canvasPersistence.js';
24
24
  export { InMemoryAnnotationProvider } from './annotation/data/InMemoryAnnotationProvider.js';
25
- export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
25
+ export { STAMP_TILE_SIZE, DEFAULT_TILE_SCALE, TILE_SCALE_MIN, TILE_SCALE_MAX, clampTileScale, resolveTileScaleFactor, stampTileSize, isUnassociatedStamp, } from './annotation/canvas/stampLayout.js';
26
26
  export { createViewportApi, fitToScreen, fitRectToScreen, imageDocRect, panBy, zoomAt, DEFAULT_VIEWPORT, } from './annotation/canvas/viewport.js';
27
27
  export { createPenTool, } from './annotation/canvas/tools/penTool.js';
28
28
  export { createSelectTool, } from './annotation/canvas/tools/selectTool.js';
@@ -93,6 +93,7 @@ export interface AnnotationBackgroundImage {
93
93
  downloadUrl: string;
94
94
  widthPx: number;
95
95
  heightPx: number;
96
+ format?: 'raster' | 'svg';
96
97
  }
97
98
  export interface AnnotationViewport {
98
99
  width: number;
@@ -100,6 +101,7 @@ export interface AnnotationViewport {
100
101
  backgroundImage?: AnnotationBackgroundImage;
101
102
  backgroundFit?: BackgroundFit;
102
103
  }
104
+ export type TileScalePlatform = 'web' | 'mobile';
103
105
  export interface AnnotationCanvasState {
104
106
  schemaVersion: 1;
105
107
  layers: AnnotationLayer[];
@@ -108,6 +110,7 @@ export interface AnnotationCanvasState {
108
110
  shapes: AnnotationShape[];
109
111
  placedMeasurements: PlacedMeasurementRef[];
110
112
  tileScaleFactor?: number;
113
+ tileScaleFactorMobile?: number;
111
114
  externalPayloadPath?: string;
112
115
  }
113
116
  export type AnnotationElement = (AnnotationStroke & {
@@ -156,6 +159,7 @@ export type AnnotationPatchOp = {
156
159
  } | {
157
160
  op: 'setTileScaleFactor';
158
161
  value: number;
162
+ platform?: TileScalePlatform;
159
163
  } | {
160
164
  op: 'setLayers';
161
165
  layers: AnnotationLayer[];
@@ -74,7 +74,9 @@ const applyOp = (state, op) => {
74
74
  case 'setViewport':
75
75
  return { ...state, viewport: { ...state.viewport, ...op.patch } };
76
76
  case 'setTileScaleFactor':
77
- return { ...state, tileScaleFactor: op.value };
77
+ return op.platform === 'mobile'
78
+ ? { ...state, tileScaleFactorMobile: op.value }
79
+ : { ...state, tileScaleFactor: op.value };
78
80
  case 'setLayers':
79
81
  return { ...state, layers: op.layers };
80
82
  }
@@ -150,9 +152,17 @@ const invertOp = (before, op) => {
150
152
  return { op: 'setViewport', patch: inversePatch };
151
153
  }
152
154
  case 'setTileScaleFactor':
153
- // Restore the prior factor; absent an explicit 1 (visually identical to
154
- // absent), keeping the op's `value: number` contract.
155
- return { op: 'setTileScaleFactor', value: before.tileScaleFactor ?? 1 };
155
+ // Restore the prior EFFECTIVE factor for the same platform knob. Mobile
156
+ // falls back to the shared/web value when it has no own value yet (the
157
+ // pre-split doc case mirrors resolveTileScaleFactor); absent → an
158
+ // explicit 1 (visually identical), keeping the `value: number` contract.
159
+ return {
160
+ op: 'setTileScaleFactor',
161
+ value: (op.platform === 'mobile'
162
+ ? (before.tileScaleFactorMobile ?? before.tileScaleFactor)
163
+ : before.tileScaleFactor) ?? 1,
164
+ ...(op.platform && { platform: op.platform }),
165
+ };
156
166
  case 'setLayers':
157
167
  return { op: 'setLayers', layers: before.layers };
158
168
  }
@@ -126,6 +126,11 @@ export interface CalculatorFileData {
126
126
  diagramFileId: string | null;
127
127
  columns: Record<string, any>;
128
128
  isCompleted: boolean;
129
+ canvas?: AnnotationCanvasState;
130
+ canvasRev?: {
131
+ clientId: string;
132
+ seq: number;
133
+ };
129
134
  }
130
135
  export interface LayoutGroupFileData {
131
136
  layoutCount?: number;
@@ -184,7 +189,7 @@ export type FileUpload = FileUploadBase & ({
184
189
  fileData?: undefined;
185
190
  } | {
186
191
  type: FileUploadType.Template;
187
- fileData?: undefined;
192
+ fileData?: AnnotationFileData;
188
193
  } | {
189
194
  type: FileUploadType.Label;
190
195
  fileData: Label;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.6.29",
3
+ "version": "1.6.31",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",