@reekon-tools/boldr-utils 1.10.3 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.native.js +48 -7
  2. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +2 -2
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.js +16 -3
  4. package/dist/annotation/canvas/Tool.d.ts +8 -1
  5. package/dist/annotation/canvas/backgroundLayers.d.ts +20 -0
  6. package/dist/annotation/canvas/backgroundLayers.js +98 -0
  7. package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +10 -7
  8. package/dist/annotation/canvas/elements/BackgroundImageElement.js +45 -11
  9. package/dist/annotation/canvas/elements/BackgroundSvg.d.ts +3 -4
  10. package/dist/annotation/canvas/elements/BackgroundSvg.js +23 -29
  11. package/dist/annotation/canvas/elements/backgroundUrl.js +2 -2
  12. package/dist/annotation/canvas/tools/selectTool.js +183 -19
  13. package/dist/annotation/canvas/useAnnotationCanvasState.js +29 -20
  14. package/dist/annotation/canvas/viewport.js +7 -6
  15. package/dist/annotation/data/AnnotationDataProvider.d.ts +1 -1
  16. package/dist/annotation/data/InMemoryAnnotationProvider.d.ts +1 -1
  17. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.d.ts +7 -1
  18. package/dist/annotation/data/hooks/useAnnotationCanvasDoc.js +124 -22
  19. package/dist/annotation/data/hooks/useAnnotationMutations.d.ts +1 -1
  20. package/dist/calculator/chips.d.ts +32 -0
  21. package/dist/calculator/chips.js +79 -0
  22. package/dist/calculator/display.d.ts +78 -0
  23. package/dist/calculator/display.js +223 -0
  24. package/dist/calculator/index.d.ts +3 -0
  25. package/dist/calculator/index.js +8 -3
  26. package/dist/calculator/runtime.d.ts +61 -0
  27. package/dist/calculator/runtime.js +430 -0
  28. package/dist/exports.d.ts +1 -0
  29. package/dist/exports.js +1 -0
  30. package/dist/theme/index.d.ts +3 -0
  31. package/dist/theme/index.js +3 -0
  32. package/dist/theme/preset.d.ts +120 -0
  33. package/dist/theme/preset.js +25 -0
  34. package/dist/theme/semantic.d.ts +136 -0
  35. package/dist/theme/semantic.js +116 -0
  36. package/dist/types/annotation.d.ts +27 -0
  37. package/dist/types/annotation.js +68 -0
  38. package/package.json +1 -1
  39. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  40. package/dist/canvas/AnnotationCanvas.js +0 -10
  41. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  42. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  43. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  44. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  45. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  46. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  47. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  48. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  49. package/dist/canvas/Tool.d.ts +0 -38
  50. package/dist/canvas/Tool.js +0 -1
  51. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  52. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  53. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  54. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  55. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  56. package/dist/canvas/elements/ShapeElement.js +0 -62
  57. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  58. package/dist/canvas/elements/StrokeElement.js +0 -18
  59. package/dist/canvas/measurementPicker.d.ts +0 -10
  60. package/dist/canvas/measurementPicker.js +0 -1
  61. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  62. package/dist/canvas/measurementStampOverlay.js +0 -1
  63. package/dist/canvas/pointerAdapter.d.ts +0 -3
  64. package/dist/canvas/pointerAdapter.js +0 -19
  65. package/dist/canvas/stampLayout.d.ts +0 -5
  66. package/dist/canvas/stampLayout.js +0 -14
  67. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  68. package/dist/canvas/tools/measurementStampTool.js +0 -37
  69. package/dist/canvas/tools/panTool.d.ts +0 -5
  70. package/dist/canvas/tools/panTool.js +0 -25
  71. package/dist/canvas/tools/penTool.d.ts +0 -13
  72. package/dist/canvas/tools/penTool.js +0 -68
  73. package/dist/canvas/tools/selectTool.d.ts +0 -2
  74. package/dist/canvas/tools/selectTool.js +0 -182
  75. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  76. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  77. package/dist/canvas/viewport.d.ts +0 -16
  78. package/dist/canvas/viewport.js +0 -54
  79. package/dist/data/AnnotationDataContext.d.ts +0 -8
  80. package/dist/data/AnnotationDataContext.js +0 -11
  81. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  82. package/dist/data/AnnotationDataProvider.js +0 -4
  83. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  84. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  85. package/dist/data/canvasPersistence.d.ts +0 -3
  86. package/dist/data/canvasPersistence.js +0 -26
  87. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  88. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  89. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  90. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  91. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  92. package/dist/data/hooks/useAnnotationList.js +0 -26
  93. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  94. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  95. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  96. package/dist/hooks/useParseMeasurement.js +0 -14
  97. package/dist/utils/evaluateFormula.d.ts +0 -20
  98. package/dist/utils/evaluateFormula.js +0 -31
@@ -7,7 +7,8 @@ import Animated, { runOnJS, runOnUI, useAnimatedStyle, useDerivedValue, useShare
7
7
  import { stampTileDims } from './stampLayout.js';
8
8
  import { DEFAULT_LAYER_ID, } from '../../types/annotation.js';
9
9
  import { AnnotationCanvasSkia } from './AnnotationCanvasSkia.js';
10
- import { useBackgroundSkImage } from './elements/BackgroundImageElement.js';
10
+ import { backgroundLayersOf } from './backgroundLayers.js';
11
+ import { BackgroundSkImageLoader } from './elements/BackgroundImageElement.js';
11
12
  import { buildRemoveMeasurementOps, } from './measurementGeometry.js';
12
13
  import { buildShapeFromDrag } from './tools/shapeTool.js';
13
14
  import { SELECTION_PAD } from './textGeometry.js';
@@ -73,10 +74,39 @@ export const AnnotationCanvasInner = (props) => {
73
74
  tileScalePlatform: 'mobile',
74
75
  animateViewport,
75
76
  });
76
- // Decode the background image ONCE here and share it with both the main
77
- // canvas and the magnifier loupe. If each canvas loaded its own, the loupe
78
- // would flash its blank base every gesture while it re-resolved + re-decoded.
79
- const backgroundSkImage = useBackgroundSkImage(state.effectiveCanvas.viewport.backgroundImage, resolveImageUrl);
77
+ // Decode each background layer ONCE here and share the images with both the
78
+ // main canvas and the magnifier loupe. If each canvas loaded its own, the
79
+ // loupe would flash its blank base every gesture while it re-resolved +
80
+ // re-decoded. The stack is variable-length, so the decodes can't be a fixed
81
+ // hook call — a null-rendering BackgroundSkImageLoader per layer owns each
82
+ // one and reports it into `loadedBackgroundImages` (see the loaders in the
83
+ // render tree below).
84
+ const backgroundLayers = useMemo(() => backgroundLayersOf(state.effectiveCanvas.viewport), [state.effectiveCanvas.viewport]);
85
+ const [loadedBackgroundImages, setLoadedBackgroundImages] = useState({});
86
+ const onBackgroundSkImage = useCallback((id, image) => {
87
+ setLoadedBackgroundImages((prev) => prev[id] === image ? prev : { ...prev, [id]: image });
88
+ }, []);
89
+ // Removed layers must not pin their decoded bitmaps in the record forever.
90
+ useEffect(() => {
91
+ setLoadedBackgroundImages((prev) => {
92
+ const stale = Object.keys(prev).filter((id) => !backgroundLayers.some((l) => l.id === id));
93
+ if (stale.length === 0)
94
+ return prev;
95
+ const next = { ...prev };
96
+ for (const id of stale)
97
+ delete next[id];
98
+ return next;
99
+ });
100
+ }, [backgroundLayers]);
101
+ // Every current layer id is present (null until its decode lands), so the
102
+ // canvas elements never self-load — the loupe reuses the same decode.
103
+ const backgroundSkImages = useMemo(() => {
104
+ const out = {};
105
+ for (const layer of backgroundLayers) {
106
+ out[layer.id] = loadedBackgroundImages[layer.id] ?? null;
107
+ }
108
+ return out;
109
+ }, [backgroundLayers, loadedBackgroundImages]);
80
110
  // Latest state behind a ref so the gesture object can be built once and
81
111
  // never rebuilt mid-gesture — its JS callbacks read `stateRef.current`.
82
112
  const stateRef = useRef(state);
@@ -1200,6 +1230,17 @@ export const AnnotationCanvasInner = (props) => {
1200
1230
  }
1201
1231
  const hit = cfg.hitTest(st.ctx.document, world, zoomNow, st.ctx.tileViewportScale, st.ctx.tileScaleFactor);
1202
1232
  if (!hit) {
1233
+ // Backgrounds can cover the whole document, so they only get the
1234
+ // drag once every annotation element misses, and only when ALREADY
1235
+ // selected (tap-to-select flows through the tool's pointer
1236
+ // handlers). The hit rides the exact same dragX/dragY transform and
1237
+ // buildTranslatePatch commit as any other element.
1238
+ const bg = cfg.hitTestSelectedBackground?.(st.ctx.document, world, st.ctx.selection?.ids ?? []);
1239
+ if (bg) {
1240
+ dragTargetRef.current = bg;
1241
+ setDraggingId(bg.id);
1242
+ return;
1243
+ }
1203
1244
  st.ctx.setSelection(null);
1204
1245
  dragTargetRef.current = null;
1205
1246
  return;
@@ -1435,7 +1476,7 @@ export const AnnotationCanvasInner = (props) => {
1435
1476
  effectiveCanvas: state.effectiveCanvas,
1436
1477
  worldTransform,
1437
1478
  resolveImageUrl,
1438
- backgroundSkImage,
1479
+ backgroundSkImages,
1439
1480
  valueFont,
1440
1481
  // Freehand drawing runs on the UI thread via `livePreview`, so the
1441
1482
  // JS-state pen preview is unused on native.
@@ -1512,7 +1553,7 @@ export const AnnotationCanvasInner = (props) => {
1512
1553
  // drawing to the canvas box by construction, but the stamp overlay is
1513
1554
  // plain RN views — without clipping, tiles panned past the edge of a
1514
1555
  // non-fullscreen canvas (e.g. a diagram strip) escape into surrounding UI.
1515
- _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, 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
1556
+ _jsxs(GestureHandlerRootView, { style: [{ width, height, overflow: 'hidden' }, style], children: [backgroundLayers.map((layer) => (_jsx(BackgroundSkImageLoader, { layer: layer, resolveUrl: resolveImageUrl, onImage: onBackgroundSkImage }, layer.id))), _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
1516
1557
  ? (state.measurementsById.get(placed.measurementId) ?? null)
1517
1558
  : (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
1518
1559
  ? () => {
@@ -23,7 +23,7 @@ export interface AnnotationCanvasSkiaProps {
23
23
  };
24
24
  viewport?: ViewportState;
25
25
  resolveImageUrl?: (storagePath: string) => Promise<string>;
26
- backgroundSkImage?: SkImage | null;
26
+ backgroundSkImages?: Record<string, SkImage | null>;
27
27
  valueFont: SkFont | null;
28
28
  textFontMgr?: SkTypefaceFontProvider | null;
29
29
  penDrawingStroke: AnnotationStroke | null;
@@ -92,5 +92,5 @@ export interface AnnotationCanvasSkiaProps {
92
92
  wrapContent?: (content: ReactNode) => ReactNode;
93
93
  canvasRef?: RefObject<CanvasRef | null>;
94
94
  }
95
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, 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;
95
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, 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;
96
96
  export {};
@@ -4,7 +4,8 @@ import { normalizeRect, placementOf } from './measurementGeometry.js';
4
4
  import { arrowheadTriangle, dashIntervals, toSkiaStrokeCap, } from './strokeGeometry.js';
5
5
  import { SELECTION_PAD, textResizeGeometry, textShapeBounds, } from './textGeometry.js';
6
6
  import { visualShapeBounds } from './shapeGeometry.js';
7
- import { BackgroundImageElement } from './elements/BackgroundImageElement.js';
7
+ import { backgroundLayerDocRect, backgroundLayersOf, } from './backgroundLayers.js';
8
+ import { BackgroundLayerElement } from './elements/BackgroundImageElement.js';
8
9
  import { ShapeElement } from './elements/ShapeElement.js';
9
10
  import { StrokeElement } from './elements/StrokeElement.js';
10
11
  // Default visual constants for the measurement-annotation line (the tile itself
@@ -78,8 +79,8 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
78
79
  // since the function-call pattern works identically on native we use it
79
80
  // in both Inners for consistency. Don't add hooks here; this is a plain
80
81
  // JSX-returning helper, not a component.
81
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, 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
- 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, viewport: viewport, screenWidth: width, screenHeight: height })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
82
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, customPreview, wrapContent, canvasRef, }) => {
83
+ const world = (_jsxs(Group, { transform: worldTransform, children: [backgroundLayersOf(effectiveCanvas.viewport).map((layer) => (_jsx(DraggableElement, { isDragging: layer.id === draggingId || layer.id === resizingId, transform: layer.id === resizingId ? resizeTransform : dragTransform, children: _jsx(BackgroundLayerElement, { layer: layer, resolveUrl: resolveImageUrl, skImage: backgroundSkImages?.[layer.id], viewport: viewport, screenWidth: width, screenHeight: height }) }, layer.id))), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
83
84
  // Line/arrow shapes support endpoint editing. When selected they show
84
85
  // grab handles at both ends; during an endpoint drag the line renders
85
86
  // from the live endpoints (one following the finger) — the shape twin
@@ -218,6 +219,18 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
218
219
  shape.kind === 'polygon';
219
220
  return (_jsxs(_Fragment, { children: [_jsx(SelectionBox, { bounds: b, isDragging: isLive, transform: liveTransform }), resizeGeom && handleRadius != null && (_jsx(DraggableElement, { isDragging: isLive, transform: liveTransform, children: _jsx(Handle, { c: resizeGeom.handle, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }) })), cornerResizable && handleRadius != null && (_jsxs(DraggableElement, { isDragging: isDragging, transform: dragTransform, children: [_jsx(Handle, { c: { x: b.minX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.minY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.minX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }), _jsx(Handle, { c: { x: b.maxX, y: b.maxY }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR })] }))] }));
220
221
  }
222
+ // A selected background layer: outline its doc rect and offer ONE
223
+ // bottom-right handle — backgrounds scale uniformly about their
224
+ // top-left corner (the transform origin), so a single opposite-corner
225
+ // grab is the whole gizmo. Chrome tracks the live drag/resize
226
+ // transform the same way the layer itself does above.
227
+ const layer = backgroundLayersOf(effectiveCanvas.viewport).find((l) => l.id === selectedId);
228
+ if (layer) {
229
+ const r = backgroundLayerDocRect(layer);
230
+ const isResizing = layer.id === resizingId;
231
+ const liveTransform = isResizing ? resizeTransform : dragTransform;
232
+ return (_jsxs(DraggableElement, { isDragging: isDragging || isResizing, transform: liveTransform, children: [_jsx(Rect, { x: r.x, y: r.y, width: r.width, height: r.height, color: SELECTION_COLOR, style: "stroke", strokeWidth: SELECTION_STROKE }), handleRadius != null && (_jsx(Handle, { c: { x: r.x + r.width, y: r.y + r.height }, r: handleRadius, ringWidth: handleRingWidth, color: SELECTION_COLOR }))] }));
233
+ }
221
234
  return null;
222
235
  })(), 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
236
  return (_jsx(Canvas, { ref: canvasRef, style: { width, height }, children: wrapContent ? wrapContent(world) : world }));
@@ -52,7 +52,7 @@ export interface ShapeDrawConfig {
52
52
  startCap?: StrokeCap;
53
53
  dash?: boolean;
54
54
  }
55
- export type DragElementKind = 'stroke' | 'shape' | 'measurement';
55
+ export type DragElementKind = 'stroke' | 'shape' | 'measurement' | 'background';
56
56
  export interface DragSelectionConfig {
57
57
  hitTest(doc: AnnotationCanvasState, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): {
58
58
  id: AnnotationElementId;
@@ -78,6 +78,13 @@ export interface DragSelectionConfig {
78
78
  } | null;
79
79
  buildShapeCornerPatch?(doc: AnnotationCanvasState, id: AnnotationElementId, corner: RectCorner, delta: Vec2): AnnotationDocumentPatch | null;
80
80
  isSelectedTileGrab?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number, viewportTileScale?: number, tileScaleFactor?: number): boolean;
81
+ hitTestBackground?(doc: AnnotationCanvasState, world: Vec2): {
82
+ id: AnnotationElementId;
83
+ } | null;
84
+ hitTestSelectedBackground?(doc: AnnotationCanvasState, world: Vec2, selectedIds: AnnotationElementId[]): {
85
+ id: AnnotationElementId;
86
+ kind: 'background';
87
+ } | null;
81
88
  hitTestResizeHandle?(doc: AnnotationCanvasState, id: AnnotationElementId, world: Vec2, zoom: number): ResizeGeometry | null;
82
89
  buildResizePatch?(doc: AnnotationCanvasState, id: AnnotationElementId, delta: Vec2): AnnotationDocumentPatch | null;
83
90
  }
@@ -0,0 +1,20 @@
1
+ import type { AnnotationBackgroundLayer, AnnotationViewport, BackgroundLayerTransform } from '../../types/annotation.js';
2
+ export declare const LEGACY_BACKGROUND_LAYER_ID = "background-legacy";
3
+ export declare const BACKGROUND_PLACEMENT_GAP_RATIO = 0.02;
4
+ export declare const backgroundLayersOf: (viewport: AnnotationViewport) => AnnotationBackgroundLayer[];
5
+ export declare const backgroundLayerDocRect: (layer: AnnotationBackgroundLayer) => {
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ };
11
+ export declare const backgroundLayersUnionRect: (layers: AnnotationBackgroundLayer[]) => {
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ } | null;
17
+ export declare const placeNewBackgroundLayer: (existing: AnnotationBackgroundLayer[], dims: {
18
+ width: number;
19
+ height: number;
20
+ }, viewport: AnnotationViewport) => BackgroundLayerTransform;
@@ -0,0 +1,98 @@
1
+ import { imageDocRect } from './viewport.js';
2
+ // Id of the layer synthesized from the legacy single
3
+ // `viewport.backgroundImage` slot. Deterministic (never random): two clients
4
+ // that materialize the array independently must converge on the SAME layer,
5
+ // since id equality is what lets their update/remove ops target it.
6
+ export const LEGACY_BACKGROUND_LAYER_ID = 'background-legacy';
7
+ // Horizontal gap between the existing background union and a newly placed
8
+ // image, as a fraction of the union's width (see placeNewBackgroundLayer).
9
+ export const BACKGROUND_PLACEMENT_GAP_RATIO = 0.02;
10
+ // Normalized view of a viewport's background stack. When only the legacy
11
+ // single field exists, it is presented as one layer whose transform
12
+ // reproduces the legacy fit placement exactly (via imageDocRect) under the
13
+ // deterministic legacy id. The axes scale independently so a legacy
14
+ // 'stretch' fit converts losslessly.
15
+ export const backgroundLayersOf = (viewport) => {
16
+ if (viewport.backgroundImages)
17
+ return viewport.backgroundImages;
18
+ const legacy = viewport.backgroundImage;
19
+ if (!legacy)
20
+ return [];
21
+ const dims = imageDocRect(legacy.widthPx, legacy.heightPx, viewport.width, viewport.height, viewport.backgroundFit ?? 'contain');
22
+ // Zero/unknown intrinsic dimensions: imageDocRect's guard makes such an
23
+ // image fill the whole doc rect, and the synthesized layer must occupy the
24
+ // same rect — both for rendering and for the fit/tile math that reads
25
+ // backgroundLayerDocRect. Adopting the doc rect as the intrinsic size (at
26
+ // scale 1) reproduces that placement with a finite, JSON-safe transform;
27
+ // a 0-intrinsic layer at any scale would collapse to a 0x0 rect instead.
28
+ const degenerate = !(legacy.widthPx > 0) || !(legacy.heightPx > 0);
29
+ return [
30
+ {
31
+ id: LEGACY_BACKGROUND_LAYER_ID,
32
+ storagePath: legacy.storagePath,
33
+ downloadUrl: legacy.downloadUrl,
34
+ widthPx: degenerate ? dims.width : legacy.widthPx,
35
+ heightPx: degenerate ? dims.height : legacy.heightPx,
36
+ // Only include `format` when defined — the layer must survive a
37
+ // JSON round-trip without growing/losing keys (Firestore writes strip
38
+ // undefined values), so absence stays absence.
39
+ ...(legacy.format ? { format: legacy.format } : {}),
40
+ transform: {
41
+ x: dims.x,
42
+ y: dims.y,
43
+ scaleX: degenerate ? 1 : dims.width / legacy.widthPx,
44
+ scaleY: degenerate ? 1 : dims.height / legacy.heightPx,
45
+ },
46
+ },
47
+ ];
48
+ };
49
+ // The doc-space rectangle a layer occupies: the intrinsic pixel grid under
50
+ // the layer's translate+scale.
51
+ export const backgroundLayerDocRect = (layer) => ({
52
+ x: layer.transform.x,
53
+ y: layer.transform.y,
54
+ width: layer.widthPx * layer.transform.scaleX,
55
+ height: layer.heightPx * layer.transform.scaleY,
56
+ });
57
+ // Bounding box of every layer's doc rect. Null when there are no layers —
58
+ // callers fall back to the document rect.
59
+ export const backgroundLayersUnionRect = (layers) => {
60
+ if (layers.length === 0)
61
+ return null;
62
+ let minX = Infinity;
63
+ let minY = Infinity;
64
+ let maxX = -Infinity;
65
+ let maxY = -Infinity;
66
+ for (const layer of layers) {
67
+ const rect = backgroundLayerDocRect(layer);
68
+ minX = Math.min(minX, rect.x);
69
+ minY = Math.min(minY, rect.y);
70
+ maxX = Math.max(maxX, rect.x + rect.width);
71
+ maxY = Math.max(maxY, rect.y + rect.height);
72
+ }
73
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
74
+ };
75
+ // Placement for a newly added image. The first image contain-fits into the
76
+ // doc rect — the same place the legacy single image lands, so a one-image doc
77
+ // is indistinguishable from a legacy one. Later images sit beside the
78
+ // existing content: scaled uniformly so their doc height matches the union
79
+ // rect's height, offset right of the union by a width-relative gap.
80
+ export const placeNewBackgroundLayer = (existing, dims, viewport) => {
81
+ const union = backgroundLayersUnionRect(existing);
82
+ if (!union) {
83
+ const rect = imageDocRect(dims.width, dims.height, viewport.width, viewport.height, 'contain');
84
+ return {
85
+ x: rect.x,
86
+ y: rect.y,
87
+ scaleX: dims.width > 0 ? rect.width / dims.width : 1,
88
+ scaleY: dims.height > 0 ? rect.height / dims.height : 1,
89
+ };
90
+ }
91
+ const scale = dims.height > 0 ? union.height / dims.height : 1;
92
+ return {
93
+ x: union.x + union.width + BACKGROUND_PLACEMENT_GAP_RATIO * union.width,
94
+ y: union.y,
95
+ scaleX: scale,
96
+ scaleY: scale,
97
+ };
98
+ };
@@ -1,16 +1,19 @@
1
1
  import { type SkImage } from '@shopify/react-native-skia';
2
- import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
2
+ import type { AnnotationBackgroundImage, AnnotationBackgroundLayer } from '../../../types/annotation.js';
3
3
  import type { ViewportState } from '../viewport.js';
4
4
  export declare const useBackgroundSkImage: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>) => SkImage | null;
5
- export interface BackgroundImageElementProps {
6
- image: AnnotationBackgroundImage;
7
- docWidth: number;
8
- docHeight: number;
9
- fit?: BackgroundFit;
5
+ export interface BackgroundSkImageLoaderProps {
6
+ layer: AnnotationBackgroundLayer;
7
+ resolveUrl?: (path: string) => Promise<string>;
8
+ onImage: (id: string, image: SkImage | null) => void;
9
+ }
10
+ export declare const BackgroundSkImageLoader: ({ layer, resolveUrl, onImage, }: BackgroundSkImageLoaderProps) => null;
11
+ export interface BackgroundLayerElementProps {
12
+ layer: AnnotationBackgroundLayer;
10
13
  resolveUrl?: (path: string) => Promise<string>;
11
14
  skImage?: SkImage | null;
12
15
  viewport?: ViewportState;
13
16
  screenWidth?: number;
14
17
  screenHeight?: number;
15
18
  }
16
- export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, skImage: skImageProp, viewport, screenWidth, screenHeight, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
19
+ export declare const BackgroundLayerElement: import("react").MemoExoticComponent<({ layer, resolveUrl, skImage: skImageProp, viewport, screenWidth, screenHeight, }: BackgroundLayerElementProps) => import("react/jsx-runtime").JSX.Element | null>;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Image, useImage } from '@shopify/react-native-skia';
3
- import { memo } from 'react';
4
- import { imageDocRect } from '../viewport.js';
2
+ import { Group, Image, useImage, } from '@shopify/react-native-skia';
3
+ import { memo, useEffect, useRef } from 'react';
4
+ import { backgroundLayerDocRect } from '../backgroundLayers.js';
5
5
  import { useBackgroundUrl } from './backgroundUrl.js';
6
6
  import { BackgroundSvgElement } from './BackgroundSvg.js';
7
7
  // Resolve a background image's URL and decode it to an SkImage. Exported so a
@@ -15,21 +15,55 @@ export const useBackgroundSkImage = (image, resolveUrl) => {
15
15
  const url = useBackgroundUrl(image, resolveUrl);
16
16
  return useImage(url || null);
17
17
  };
18
- export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, viewport, screenWidth, screenHeight, }) => {
19
- const isSvg = image.format === 'svg';
18
+ // Null-rendering decode host: one mounted instance per background layer, so a
19
+ // variable-length layer stack gets per-image hooks without any component
20
+ // mapping over hooks. The consumer (the native Inner) mounts one per layer,
21
+ // accumulates the reported images into a Record keyed by layer id, and feeds
22
+ // that to every canvas sharing the decode (main + magnifier loupe).
23
+ export const BackgroundSkImageLoader = ({ layer, resolveUrl, onImage, }) => {
24
+ // SVG layers never draw through an SkImage (BackgroundSvgElement owns its
25
+ // own vector pipeline), so skip the decode and report null.
26
+ const image = useBackgroundSkImage(layer.format === 'svg' ? null : layer, resolveUrl);
27
+ // The callback rides a ref so an inline consumer closure doesn't re-report
28
+ // (and re-render the collector) every render.
29
+ const onImageRef = useRef(onImage);
30
+ onImageRef.current = onImage;
31
+ useEffect(() => {
32
+ onImageRef.current(layer.id, image);
33
+ }, [layer.id, image]);
34
+ return null;
35
+ };
36
+ // One background layer: the image's intrinsic pixel grid drawn under the
37
+ // layer's translate+scale, so its doc rect is backgroundLayerDocRect(layer).
38
+ // A component (not inlined into the scene helper) because each layer owns
39
+ // per-image hooks — URL resolution and raster decode — and AnnotationCanvasSkia
40
+ // is a hook-free plain function.
41
+ export const BackgroundLayerElement = memo(({ layer, resolveUrl, skImage: skImageProp, viewport, screenWidth, screenHeight, }) => {
42
+ const isSvg = layer.format === 'svg';
20
43
  // Self-load only when no pre-decoded image is provided; the hook still runs
21
44
  // unconditionally (with a null source, so it no-ops) to satisfy hook rules.
22
45
  const provided = skImageProp !== undefined;
23
- const loaded = useBackgroundSkImage(provided || isSvg ? null : image, resolveUrl);
46
+ const loaded = useBackgroundSkImage(provided || isSvg ? null : layer, resolveUrl);
24
47
  const skImage = provided ? skImageProp : loaded;
48
+ const t = layer.transform;
49
+ let content = null;
25
50
  if (isSvg) {
26
51
  // Environment-forked renderer (one shared module — see BackgroundSvg):
27
52
  // native draws true vectors through Skia's SVG module; web rasterizes
28
- // through the browser at viewport resolution.
29
- return (_jsx(BackgroundSvgElement, { image: image, docWidth: docWidth, docHeight: docHeight, fit: fit, resolveUrl: resolveUrl, viewport: viewport, screenWidth: screenWidth, screenHeight: screenHeight }));
53
+ // through the browser at viewport resolution. Both draw at intrinsic
54
+ // size the Group below owns the placement but the web rasterizer
55
+ // still needs the doc rect to crop against the visible region.
56
+ content = (_jsx(BackgroundSvgElement, { image: layer, dims: backgroundLayerDocRect(layer), resolveUrl: resolveUrl, viewport: viewport, screenWidth: screenWidth, screenHeight: screenHeight }));
57
+ }
58
+ else if (skImage) {
59
+ content = (_jsx(Image, { image: skImage, x: 0, y: 0, width: layer.widthPx, height: layer.heightPx, fit: "fill" }));
30
60
  }
31
- if (!skImage)
61
+ if (!content)
32
62
  return null;
33
- const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
34
- return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
63
+ return (_jsx(Group, { transform: [
64
+ { translateX: t.x },
65
+ { translateY: t.y },
66
+ { scaleX: t.scaleX },
67
+ { scaleY: t.scaleY },
68
+ ], children: content }));
35
69
  });
@@ -1,10 +1,9 @@
1
- import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
1
+ import type { AnnotationBackgroundImage } from '../../../types/annotation.js';
2
2
  import { type ViewportState } from '../viewport.js';
3
+ import { type RectPx } from '../svgRasterPlan.js';
3
4
  export interface BackgroundSvgProps {
4
5
  image: AnnotationBackgroundImage;
5
- docWidth: number;
6
- docHeight: number;
7
- fit?: BackgroundFit;
6
+ dims: RectPx;
8
7
  resolveUrl?: (path: string) => Promise<string>;
9
8
  viewport?: ViewportState;
10
9
  screenWidth?: number;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Group, Image as SkiaImage, ImageSVG, Skia, } from '@shopify/react-native-skia';
2
+ import { Image as SkiaImage, ImageSVG, Skia, } from '@shopify/react-native-skia';
3
3
  import { memo, useCallback, useEffect, useRef, useState } from 'react';
4
- import { DEFAULT_VIEWPORT, imageDocRect, } from '../viewport.js';
4
+ import { DEFAULT_VIEWPORT } from '../viewport.js';
5
5
  import { ensureRootFontSize, planSvgRaster, shouldRegenerateRaster, svgIntrinsicSize, wrapSvgForRaster, } from '../svgRasterPlan.js';
6
6
  import { useBackgroundUrl } from './backgroundUrl.js';
7
7
  // SVG background renderer. ONE shared module for both platforms — this file
@@ -91,22 +91,17 @@ const useBackgroundSkSvg = (url) => {
91
91
  }, [url]);
92
92
  return svg;
93
93
  };
94
- const SkSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
94
+ const SkSvgBackground = ({ image, resolveUrl }) => {
95
95
  const url = useBackgroundUrl(image, resolveUrl);
96
96
  const svg = useBackgroundSkSvg(url);
97
- const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
98
97
  if (!svg)
99
98
  return null;
100
- // Draw the SVG at its stamped intrinsic size inside a scale transform
101
- // (rather than relying on ImageSVG's own fitting, which only applies to
102
- // sources with a viewBox). The vector source renders through the canvas
103
- // transform, so it stays crisp at any canvas zoom.
104
- return (_jsx(Group, { transform: [
105
- { translateX: dims.x },
106
- { translateY: dims.y },
107
- { scaleX: dims.width / image.widthPx },
108
- { scaleY: dims.height / image.heightPx },
109
- ], children: _jsx(ImageSVG, { svg: svg, x: 0, y: 0, width: image.widthPx, height: image.heightPx }) }));
99
+ // Draw the SVG at its stamped intrinsic size (rather than relying on
100
+ // ImageSVG's own fitting, which only applies to sources with a viewBox) —
101
+ // the caller's Group maps intrinsic px to the layer's doc rect. The vector
102
+ // source renders through the canvas transform, so it stays crisp at any
103
+ // canvas zoom.
104
+ return (_jsx(ImageSVG, { svg: svg, x: 0, y: 0, width: image.widthPx, height: image.heightPx }));
110
105
  };
111
106
  // --- Browser path (web) ------------------------------------------------------
112
107
  const REGEN_DEBOUNCE_MS = 150;
@@ -149,7 +144,7 @@ const loadSvgImage = (xml) => new Promise((resolve, reject) => {
149
144
  };
150
145
  img.src = objectUrl;
151
146
  });
152
- const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, viewport, screenWidth, screenHeight, }) => {
147
+ const BrowserSvgBackground = ({ image, dims, resolveUrl, viewport, screenWidth, screenHeight, }) => {
153
148
  const url = useBackgroundUrl(image, resolveUrl);
154
149
  const [svgText, setSvgText] = useState(null);
155
150
  useEffect(() => {
@@ -183,7 +178,6 @@ const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', res
183
178
  // changed, unmount).
184
179
  const genRef = useRef(0);
185
180
  const timerRef = useRef(null);
186
- const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
187
181
  // Latest plan inputs live in a ref so the async raster loop always plans
188
182
  // against current values without effect churn on every pan frame.
189
183
  inputsRef.current = {
@@ -191,8 +185,8 @@ const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', res
191
185
  heightPx: image.heightPx,
192
186
  dims,
193
187
  viewport: viewport ?? DEFAULT_VIEWPORT,
194
- screenWidth: screenWidth ?? docWidth,
195
- screenHeight: screenHeight ?? docHeight,
188
+ screenWidth: screenWidth ?? dims.width,
189
+ screenHeight: screenHeight ?? dims.height,
196
190
  dpr: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
197
191
  };
198
192
  const run = useCallback(async () => {
@@ -287,8 +281,9 @@ const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', res
287
281
  preparedRef.current = null;
288
282
  };
289
283
  }, [svgText, image.widthPx, image.heightPx, schedule]);
290
- // Follow the viewport: debounce-regenerate when zoom/pan/layout outgrow
291
- // the applied bitmap (run() no-ops when they haven't).
284
+ // Follow the viewport: debounce-regenerate when zoom/pan/layout — or the
285
+ // layer's own doc rect (a background drag/scale changes how much screen it
286
+ // covers) — outgrow the applied bitmap (run() no-ops when they haven't).
292
287
  const vp = viewport ?? DEFAULT_VIEWPORT;
293
288
  useEffect(() => {
294
289
  if (!preparedRef.current)
@@ -300,9 +295,10 @@ const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', res
300
295
  vp.pan.y,
301
296
  screenWidth,
302
297
  screenHeight,
303
- docWidth,
304
- docHeight,
305
- fit,
298
+ dims.x,
299
+ dims.y,
300
+ dims.width,
301
+ dims.height,
306
302
  image.widthPx,
307
303
  image.heightPx,
308
304
  schedule,
@@ -325,12 +321,10 @@ const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', res
325
321
  }, []);
326
322
  if (!applied || !(image.widthPx > 0) || !(image.heightPx > 0))
327
323
  return null;
328
- return (_jsx(Group, { transform: [
329
- { translateX: dims.x },
330
- { translateY: dims.y },
331
- { scaleX: dims.width / image.widthPx },
332
- { scaleY: dims.height / image.heightPx },
333
- ], children: _jsx(SkiaImage, { image: applied.image, x: applied.rect.x, y: applied.rect.y, width: applied.rect.width, height: applied.rect.height, fit: "fill" }) }));
324
+ // `applied.rect` is in svg-local px — the same space the caller's Group
325
+ // maps to the layer's doc rect — so the crop lands exactly where the
326
+ // vector content it rasterized would.
327
+ return (_jsx(SkiaImage, { image: applied.image, x: applied.rect.x, y: applied.rect.y, width: applied.rect.width, height: applied.rect.height, fit: "fill" }));
334
328
  };
335
329
  // HAS_DOM is fixed for the life of the process, so each mount renders one
336
330
  // branch consistently (no hook-order hazard).
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  // Resolve a background image's (possibly expired) storage URL. Shared by the
3
- // raster loader (BackgroundImageElement) and both SVG render paths
4
- // (BackgroundSvg).
3
+ // raster loader (BackgroundLayerElement / BackgroundSkImageLoader) and both
4
+ // SVG render paths (BackgroundSvg).
5
5
  export const useBackgroundUrl = (image, resolveUrl) => {
6
6
  const [url, setUrl] = useState(image?.downloadUrl ?? '');
7
7
  // Hold resolveUrl in a ref so re-resolution is keyed only on the image