@reekon-tools/boldr-utils 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/annotation/canvas/AnnotationCanvasInner.js +1 -0
  2. package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +3 -1
  3. package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
  4. package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +5 -1
  5. package/dist/annotation/canvas/elements/BackgroundImageElement.js +13 -99
  6. package/dist/annotation/canvas/elements/BackgroundSvg.d.ts +13 -0
  7. package/dist/annotation/canvas/elements/BackgroundSvg.js +337 -0
  8. package/dist/annotation/canvas/elements/backgroundUrl.d.ts +2 -0
  9. package/dist/annotation/canvas/elements/backgroundUrl.js +38 -0
  10. package/dist/annotation/canvas/svgRasterPlan.d.ts +56 -0
  11. package/dist/annotation/canvas/svgRasterPlan.js +225 -0
  12. package/package.json +1 -1
  13. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  14. package/dist/canvas/AnnotationCanvas.js +0 -10
  15. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  16. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  17. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  18. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  19. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  20. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  21. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  22. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  23. package/dist/canvas/Tool.d.ts +0 -38
  24. package/dist/canvas/Tool.js +0 -1
  25. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  26. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  27. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  28. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  29. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  30. package/dist/canvas/elements/ShapeElement.js +0 -62
  31. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  32. package/dist/canvas/elements/StrokeElement.js +0 -18
  33. package/dist/canvas/measurementPicker.d.ts +0 -10
  34. package/dist/canvas/measurementPicker.js +0 -1
  35. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  36. package/dist/canvas/measurementStampOverlay.js +0 -1
  37. package/dist/canvas/pointerAdapter.d.ts +0 -3
  38. package/dist/canvas/pointerAdapter.js +0 -19
  39. package/dist/canvas/stampLayout.d.ts +0 -5
  40. package/dist/canvas/stampLayout.js +0 -14
  41. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  42. package/dist/canvas/tools/measurementStampTool.js +0 -37
  43. package/dist/canvas/tools/panTool.d.ts +0 -5
  44. package/dist/canvas/tools/panTool.js +0 -25
  45. package/dist/canvas/tools/penTool.d.ts +0 -13
  46. package/dist/canvas/tools/penTool.js +0 -68
  47. package/dist/canvas/tools/selectTool.d.ts +0 -2
  48. package/dist/canvas/tools/selectTool.js +0 -182
  49. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  50. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  51. package/dist/canvas/viewport.d.ts +0 -16
  52. package/dist/canvas/viewport.js +0 -54
  53. package/dist/data/AnnotationDataContext.d.ts +0 -8
  54. package/dist/data/AnnotationDataContext.js +0 -11
  55. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  56. package/dist/data/AnnotationDataProvider.js +0 -4
  57. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  58. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  59. package/dist/data/canvasPersistence.d.ts +0 -3
  60. package/dist/data/canvasPersistence.js +0 -26
  61. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  62. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  63. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  64. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  65. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  66. package/dist/data/hooks/useAnnotationList.js +0 -26
  67. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  68. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  69. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  70. package/dist/hooks/useParseMeasurement.js +0 -14
  71. package/dist/utils/evaluateFormula.d.ts +0 -20
  72. package/dist/utils/evaluateFormula.js +0 -31
@@ -293,6 +293,7 @@ export const AnnotationCanvasInner = (props) => {
293
293
  height,
294
294
  effectiveCanvas: state.effectiveCanvas,
295
295
  worldTransform: state.worldTransform,
296
+ viewport: state.viewport,
296
297
  resolveImageUrl,
297
298
  canvasRef,
298
299
  valueFont,
@@ -1,6 +1,7 @@
1
1
  import { type CanvasRef, type SkFont, type SkImage, type SkPath, type SkTypefaceFontProvider, type Transforms3d } from '@shopify/react-native-skia';
2
2
  import type { ReactNode, RefObject } from 'react';
3
3
  import type { AnnotationCanvasState, AnnotationStroke, StrokeCap } from '../../types/annotation.js';
4
+ import type { ViewportState } from './viewport.js';
4
5
  type AnimatedPoint = {
5
6
  x: number;
6
7
  y: number;
@@ -20,6 +21,7 @@ export interface AnnotationCanvasSkiaProps {
20
21
  worldTransform: Transforms3d | {
21
22
  value: Transforms3d;
22
23
  };
24
+ viewport?: ViewportState;
23
25
  resolveImageUrl?: (storagePath: string) => Promise<string>;
24
26
  backgroundSkImage?: SkImage | null;
25
27
  valueFont: SkFont | null;
@@ -90,5 +92,5 @@ export interface AnnotationCanvasSkiaProps {
90
92
  wrapContent?: (content: ReactNode) => ReactNode;
91
93
  canvasRef?: RefObject<CanvasRef | null>;
92
94
  }
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;
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;
94
96
  export {};
@@ -78,8 +78,8 @@ 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, 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 })), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
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) => {
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
85
85
  // from the live endpoints (one following the finger) — the shape twin
@@ -1,5 +1,6 @@
1
1
  import { type SkImage } from '@shopify/react-native-skia';
2
2
  import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
3
+ import type { ViewportState } from '../viewport.js';
3
4
  export declare const useBackgroundSkImage: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>) => SkImage | null;
4
5
  export interface BackgroundImageElementProps {
5
6
  image: AnnotationBackgroundImage;
@@ -8,5 +9,8 @@ export interface BackgroundImageElementProps {
8
9
  fit?: BackgroundFit;
9
10
  resolveUrl?: (path: string) => Promise<string>;
10
11
  skImage?: SkImage | null;
12
+ viewport?: ViewportState;
13
+ screenWidth?: number;
14
+ screenHeight?: number;
11
15
  }
12
- export declare const BackgroundImageElement: import("react").MemoExoticComponent<({ image, docWidth, docHeight, fit, resolveUrl, skImage: skImageProp, }: BackgroundImageElementProps) => import("react/jsx-runtime").JSX.Element | null>;
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>;
@@ -1,121 +1,35 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Group, Image, ImageSVG, Skia, useImage, } from '@shopify/react-native-skia';
3
- import { memo, useEffect, useRef, useState } from 'react';
2
+ import { Image, useImage } from '@shopify/react-native-skia';
3
+ import { memo } from 'react';
4
4
  import { imageDocRect } from '../viewport.js';
5
- // Resolve a background image's (possibly expired) storage URL. Shared by the
6
- // raster and SVG loaders below.
7
- const useBackgroundUrl = (image, resolveUrl) => {
8
- const [url, setUrl] = useState(image?.downloadUrl ?? '');
9
- // Hold resolveUrl in a ref so re-resolution is keyed only on the image
10
- // identity, not the function's. Consumers commonly pass an inline closure
11
- // (a new reference each render); depending on it directly would re-run a
12
- // network URL resolve on every render.
13
- const resolveUrlRef = useRef(resolveUrl);
14
- resolveUrlRef.current = resolveUrl;
15
- useEffect(() => {
16
- if (!image)
17
- return;
18
- let cancelled = false;
19
- const resolve = resolveUrlRef.current;
20
- if (resolve) {
21
- // A rejecting resolver (e.g. offline URL resolution) must never become
22
- // an unhandled rejection here — keep the current url; the consumer's
23
- // own retries/invalidations drive the re-resolve.
24
- resolve(image.storagePath)
25
- .then((next) => {
26
- if (!cancelled)
27
- setUrl(next);
28
- })
29
- .catch(() => { });
30
- }
31
- else {
32
- setUrl(image.downloadUrl);
33
- }
34
- return () => {
35
- cancelled = true;
36
- };
37
- // eslint-disable-next-line react-hooks/exhaustive-deps
38
- }, [image?.downloadUrl, image?.storagePath]);
39
- return url;
40
- };
41
- // Null-safe SVG loader. NOT RN Skia's useSVG: that hook throws synchronously
42
- // on a null/undefined source (despite its DataSourceParam type), so it can't
43
- // be called unconditionally from a component that usually renders raster
44
- // backgrounds. An empty url resolves to null (nothing to draw yet).
45
- // Loaded through Skia's own scheme-agnostic data loader rather than fetch():
46
- // RN's Android networking rejects non-http(s) schemes, and offline-first
47
- // consumers resolve the background to a local file:// URI (fromURI handles
48
- // file://, http(s) and data: on native and web alike). fetch() remains as a
49
- // fallback for any environment where fromURI can't service the URL.
50
- const useBackgroundSvg = (url) => {
51
- const [svg, setSvg] = useState(null);
52
- useEffect(() => {
53
- if (!url) {
54
- setSvg(null);
55
- return;
56
- }
57
- let cancelled = false;
58
- (async () => {
59
- try {
60
- let next = null;
61
- try {
62
- const data = await Skia.Data.fromURI(url);
63
- next = Skia.SVG.MakeFromData(data);
64
- data.dispose();
65
- }
66
- catch {
67
- const res = await fetch(url);
68
- next = Skia.SVG.MakeFromString(await res.text());
69
- }
70
- if (!cancelled)
71
- setSvg(next);
72
- }
73
- catch (e) {
74
- console.warn('[BackgroundImageElement] failed to load SVG background', e);
75
- }
76
- })();
77
- return () => {
78
- cancelled = true;
79
- };
80
- }, [url]);
81
- return svg;
82
- };
5
+ import { useBackgroundUrl } from './backgroundUrl.js';
6
+ import { BackgroundSvgElement } from './BackgroundSvg.js';
83
7
  // Resolve a background image's URL and decode it to an SkImage. Exported so a
84
8
  // consumer can hoist the decode ABOVE the canvas and share one SkImage across
85
9
  // multiple canvases (e.g. the main canvas + a magnifier overlay) — otherwise
86
10
  // each canvas mounts its own loader and flashes a blank while it re-resolves +
87
11
  // re-decodes. Returns null until loaded, or when `image` is null/undefined.
88
12
  // Hooks run unconditionally regardless of `image`. Raster only — SVG
89
- // backgrounds self-load in the element below (parsing an SVG is cheap).
13
+ // backgrounds self-load in BackgroundSvgElement (parsing an SVG is cheap).
90
14
  export const useBackgroundSkImage = (image, resolveUrl) => {
91
15
  const url = useBackgroundUrl(image, resolveUrl);
92
16
  return useImage(url || null);
93
17
  };
94
- export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, }) => {
18
+ export const BackgroundImageElement = memo(({ image, docWidth, docHeight, fit = 'contain', resolveUrl, skImage: skImageProp, viewport, screenWidth, screenHeight, }) => {
95
19
  const isSvg = image.format === 'svg';
96
- // Self-load only when no pre-decoded image is provided; the hooks still run
97
- // unconditionally (with a null source, so they no-op) to satisfy hook rules.
20
+ // Self-load only when no pre-decoded image is provided; the hook still runs
21
+ // unconditionally (with a null source, so it no-ops) to satisfy hook rules.
98
22
  const provided = skImageProp !== undefined;
99
23
  const loaded = useBackgroundSkImage(provided || isSvg ? null : image, resolveUrl);
100
- const svgUrl = useBackgroundUrl(isSvg ? image : null, resolveUrl);
101
- const svg = useBackgroundSvg(isSvg ? svgUrl : '');
102
24
  const skImage = provided ? skImageProp : loaded;
103
- const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
104
25
  if (isSvg) {
105
- if (!svg)
106
- return null;
107
- // Draw the SVG at its stamped intrinsic size inside a scale transform
108
- // (rather than relying on ImageSVG's own fitting, which only applies to
109
- // sources with a viewBox). The vector source re-rasterizes per frame, so
110
- // it stays crisp at any canvas zoom.
111
- return (_jsx(Group, { transform: [
112
- { translateX: dims.x },
113
- { translateY: dims.y },
114
- { scaleX: dims.width / image.widthPx },
115
- { scaleY: dims.height / image.heightPx },
116
- ], children: _jsx(ImageSVG, { svg: svg, x: 0, y: 0, width: image.widthPx, height: image.heightPx }) }));
26
+ // Environment-forked renderer (one shared module — see BackgroundSvg):
27
+ // 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 }));
117
30
  }
118
31
  if (!skImage)
119
32
  return null;
33
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
120
34
  return (_jsx(Image, { image: skImage, x: dims.x, y: dims.y, width: dims.width, height: dims.height, fit: "fill" }));
121
35
  });
@@ -0,0 +1,13 @@
1
+ import type { AnnotationBackgroundImage, BackgroundFit } from '../../../types/annotation.js';
2
+ import { type ViewportState } from '../viewport.js';
3
+ export interface BackgroundSvgProps {
4
+ image: AnnotationBackgroundImage;
5
+ docWidth: number;
6
+ docHeight: number;
7
+ fit?: BackgroundFit;
8
+ resolveUrl?: (path: string) => Promise<string>;
9
+ viewport?: ViewportState;
10
+ screenWidth?: number;
11
+ screenHeight?: number;
12
+ }
13
+ export declare const BackgroundSvgElement: import("react").MemoExoticComponent<(props: BackgroundSvgProps) => import("react/jsx-runtime").JSX.Element>;
@@ -0,0 +1,337 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Group, Image as SkiaImage, ImageSVG, Skia, } from '@shopify/react-native-skia';
3
+ import { memo, useCallback, useEffect, useRef, useState } from 'react';
4
+ import { DEFAULT_VIEWPORT, imageDocRect, } from '../viewport.js';
5
+ import { ensureRootFontSize, planSvgRaster, shouldRegenerateRaster, svgIntrinsicSize, wrapSvgForRaster, } from '../svgRasterPlan.js';
6
+ import { useBackgroundUrl } from './backgroundUrl.js';
7
+ // SVG background renderer. ONE shared module for both platforms — this file
8
+ // is reached through shared modules (AnnotationCanvasSkia →
9
+ // BackgroundImageElement), and imports from shared modules can't fork by
10
+ // filename: this package's platform split works only via explicit
11
+ // `.native.js` imports from files that are themselves native-only (see
12
+ // index.native.ts) — Metro does NOT platform-expand the compiled `./X.js`
13
+ // specifiers. So the fork happens at runtime instead, on the question that
14
+ // actually matters: can the browser rasterize the SVG for us?
15
+ //
16
+ // - Browser path (web): RN Skia's web build has no SVG module — its
17
+ // <ImageSVG> snapshots a hidden <img> at the SVG's intrinsic pixel size
18
+ // every frame, so canvas zoom magnifies pixels. Instead let the browser — a
19
+ // fully compliant SVG renderer, text, CSS, filters and all — rasterize the
20
+ // SVG at the resolution the current viewport needs (see svgRasterPlan.ts),
21
+ // import that bitmap once as an SkImage, and draw it like any raster
22
+ // background. Zoom/pan changes re-rasterize (debounced) only when they
23
+ // outgrow the applied bitmap.
24
+ //
25
+ // - Skia path (native): Skia's SVG module draws true vectors through the
26
+ // canvas transform — crisp at any zoom. It only renders <text> when the DOM
27
+ // is built with a font manager (without one they are silently dropped — the
28
+ // C++ factory skips setFontManager entirely), so pass the system one.
29
+ const HAS_DOM = typeof document !== 'undefined' &&
30
+ typeof DOMParser !== 'undefined' &&
31
+ typeof XMLSerializer !== 'undefined';
32
+ // --- Skia path (native) ------------------------------------------------------
33
+ // Resolve the platform's system font manager once; an SVG referencing a
34
+ // family the device lacks still falls back to the default typeface instead of
35
+ // disappearing.
36
+ let systemFontMgr;
37
+ const getSystemFontMgr = () => {
38
+ if (systemFontMgr === undefined) {
39
+ try {
40
+ systemFontMgr = Skia.FontMgr.System();
41
+ }
42
+ catch (e) {
43
+ console.warn('[BackgroundSvg] system font manager unavailable', e);
44
+ systemFontMgr = null;
45
+ }
46
+ }
47
+ return systemFontMgr;
48
+ };
49
+ // Null-safe SVG loader. NOT RN Skia's useSVG: that hook throws synchronously
50
+ // on a null/undefined source (despite its DataSourceParam type), so it can't
51
+ // be called unconditionally while the url resolves. An empty url resolves to
52
+ // null (nothing to draw yet).
53
+ // Loaded as TEXT first so the source can be normalized (ensureRootFontSize —
54
+ // Skia's default font-size for unsized text is larger than the browsers'
55
+ // 16px). SkData exposes no byte access, so when fetch() can't service the
56
+ // scheme (RN's Android networking rejects file://, which offline-first
57
+ // consumers resolve cached backgrounds to), fall back to Skia's own loader
58
+ // with the raw bytes — such documents skip the font-size normalization but
59
+ // still render, explicit sizes intact.
60
+ const useBackgroundSkSvg = (url) => {
61
+ const [svg, setSvg] = useState(null);
62
+ useEffect(() => {
63
+ if (!url) {
64
+ setSvg(null);
65
+ return;
66
+ }
67
+ let cancelled = false;
68
+ (async () => {
69
+ try {
70
+ const fontMgr = getSystemFontMgr();
71
+ let next = null;
72
+ try {
73
+ const res = await fetch(url);
74
+ next = Skia.SVG.MakeFromString(ensureRootFontSize(await res.text()), fontMgr);
75
+ }
76
+ catch {
77
+ const data = await Skia.Data.fromURI(url);
78
+ next = Skia.SVG.MakeFromData(data, fontMgr);
79
+ data.dispose();
80
+ }
81
+ if (!cancelled)
82
+ setSvg(next);
83
+ }
84
+ catch (e) {
85
+ console.warn('[BackgroundSvg] failed to load SVG background', e);
86
+ }
87
+ })();
88
+ return () => {
89
+ cancelled = true;
90
+ };
91
+ }, [url]);
92
+ return svg;
93
+ };
94
+ const SkSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, }) => {
95
+ const url = useBackgroundUrl(image, resolveUrl);
96
+ const svg = useBackgroundSkSvg(url);
97
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
98
+ if (!svg)
99
+ 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 }) }));
110
+ };
111
+ // --- Browser path (web) ------------------------------------------------------
112
+ const REGEN_DEBOUNCE_MS = 150;
113
+ // Parse the SVG and normalize its root so it can be crop-wrapped: enforce a
114
+ // viewBox (a root without one CLIPS when its size changes instead of
115
+ // scaling) and pin width/height to the user-unit size so the nested
116
+ // viewport maps 1:1 inside the wrapper.
117
+ const prepareSvg = (text, localWidthPx, localHeightPx) => {
118
+ if (!(localWidthPx > 0) || !(localHeightPx > 0))
119
+ return null;
120
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
121
+ if (doc.getElementsByTagName('parsererror').length > 0)
122
+ return null;
123
+ const root = doc.documentElement;
124
+ const size = svgIntrinsicSize(root.getAttribute('viewBox'), root.getAttribute('width'), root.getAttribute('height'), localWidthPx, localHeightPx);
125
+ root.setAttribute('viewBox', size.viewBox);
126
+ root.setAttribute('width', String(size.width));
127
+ root.setAttribute('height', String(size.height));
128
+ return {
129
+ innerXml: new XMLSerializer().serializeToString(root),
130
+ ux: size.width / localWidthPx,
131
+ uy: size.height / localHeightPx,
132
+ minX: size.minX,
133
+ minY: size.minY,
134
+ failed: false,
135
+ };
136
+ };
137
+ // Decode an SVG string to a drawable <img>. The element retains its SVG
138
+ // document once loaded, so the blob URL is revoked immediately.
139
+ const loadSvgImage = (xml) => new Promise((resolve, reject) => {
140
+ const objectUrl = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml' }));
141
+ const img = document.createElement('img');
142
+ img.onload = () => {
143
+ URL.revokeObjectURL(objectUrl);
144
+ resolve(img);
145
+ };
146
+ img.onerror = () => {
147
+ URL.revokeObjectURL(objectUrl);
148
+ reject(new Error('SVG image failed to decode'));
149
+ };
150
+ img.src = objectUrl;
151
+ });
152
+ const BrowserSvgBackground = ({ image, docWidth, docHeight, fit = 'contain', resolveUrl, viewport, screenWidth, screenHeight, }) => {
153
+ const url = useBackgroundUrl(image, resolveUrl);
154
+ const [svgText, setSvgText] = useState(null);
155
+ useEffect(() => {
156
+ if (!url) {
157
+ setSvgText(null);
158
+ return;
159
+ }
160
+ let cancelled = false;
161
+ (async () => {
162
+ try {
163
+ const res = await fetch(url);
164
+ const text = await res.text();
165
+ if (!cancelled)
166
+ setSvgText(text);
167
+ }
168
+ catch (e) {
169
+ console.warn('[BackgroundSvg] failed to load SVG background', e);
170
+ }
171
+ })();
172
+ return () => {
173
+ cancelled = true;
174
+ };
175
+ }, [url]);
176
+ const [applied, setApplied] = useState(null);
177
+ const appliedRef = useRef(null);
178
+ const preparedRef = useRef(null);
179
+ const inputsRef = useRef(null);
180
+ const busyRef = useRef(false);
181
+ const pendingRef = useRef(false);
182
+ // Bumped whenever in-flight raster results must be discarded (source
183
+ // changed, unmount).
184
+ const genRef = useRef(0);
185
+ const timerRef = useRef(null);
186
+ const dims = imageDocRect(image.widthPx, image.heightPx, docWidth, docHeight, fit);
187
+ // Latest plan inputs live in a ref so the async raster loop always plans
188
+ // against current values without effect churn on every pan frame.
189
+ inputsRef.current = {
190
+ widthPx: image.widthPx,
191
+ heightPx: image.heightPx,
192
+ dims,
193
+ viewport: viewport ?? DEFAULT_VIEWPORT,
194
+ screenWidth: screenWidth ?? docWidth,
195
+ screenHeight: screenHeight ?? docHeight,
196
+ dpr: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
197
+ };
198
+ const run = useCallback(async () => {
199
+ const prepared = preparedRef.current;
200
+ const inputs = inputsRef.current;
201
+ if (!prepared || prepared.failed || !inputs)
202
+ return;
203
+ const plan = planSvgRaster(inputs);
204
+ if (!plan || !shouldRegenerateRaster(appliedRef.current, plan))
205
+ return;
206
+ if (busyRef.current) {
207
+ // A raster is in flight; re-evaluate once it lands.
208
+ pendingRef.current = true;
209
+ return;
210
+ }
211
+ busyRef.current = true;
212
+ const gen = genRef.current;
213
+ try {
214
+ // Wrap the document so the crop window IS the wrapper's viewBox and the
215
+ // output size IS its intrinsic size, then draw 1:1 — see
216
+ // wrapSvgForRaster for why scaling inside drawImage is not an option.
217
+ const wrapped = wrapSvgForRaster(prepared.innerXml, {
218
+ x: prepared.minX + plan.rect.x * prepared.ux,
219
+ y: prepared.minY + plan.rect.y * prepared.uy,
220
+ width: plan.rect.width * prepared.ux,
221
+ height: plan.rect.height * prepared.uy,
222
+ }, plan.outWidth, plan.outHeight);
223
+ const img = await loadSvgImage(wrapped);
224
+ const canvas = document.createElement('canvas');
225
+ canvas.width = plan.outWidth;
226
+ canvas.height = plan.outHeight;
227
+ const ctx = canvas.getContext('2d');
228
+ if (!ctx)
229
+ throw new Error('2d canvas context unavailable');
230
+ ctx.drawImage(img, 0, 0);
231
+ const skImage = Skia.Image.MakeImageFromNativeBuffer(canvas);
232
+ // A failed import wraps a null CanvasKit image; poking it throws into
233
+ // the catch below rather than crashing at draw time.
234
+ skImage.width();
235
+ if (gen === genRef.current) {
236
+ const next = {
237
+ image: skImage,
238
+ rect: plan.rect,
239
+ full: plan.full,
240
+ zx: plan.zx,
241
+ zy: plan.zy,
242
+ };
243
+ appliedRef.current = next;
244
+ setApplied(next);
245
+ }
246
+ else {
247
+ skImage.dispose();
248
+ }
249
+ }
250
+ catch (e) {
251
+ prepared.failed = true;
252
+ console.warn('[BackgroundSvg] failed to rasterize SVG background', e);
253
+ }
254
+ finally {
255
+ busyRef.current = false;
256
+ if (pendingRef.current) {
257
+ pendingRef.current = false;
258
+ void run();
259
+ }
260
+ }
261
+ }, []);
262
+ const schedule = useCallback((delayMs) => {
263
+ if (timerRef.current)
264
+ clearTimeout(timerRef.current);
265
+ timerRef.current = setTimeout(() => {
266
+ timerRef.current = null;
267
+ void run();
268
+ }, delayMs);
269
+ }, [run]);
270
+ // (Re)prepare the <img> when the SVG source changes. URL re-resolution
271
+ // that yields identical text short-circuits at setSvgText.
272
+ useEffect(() => {
273
+ preparedRef.current = null;
274
+ appliedRef.current = null;
275
+ setApplied(null);
276
+ if (!svgText)
277
+ return;
278
+ const prepared = prepareSvg(svgText, image.widthPx, image.heightPx);
279
+ if (!prepared) {
280
+ console.warn('[BackgroundSvg] unparseable SVG background');
281
+ return;
282
+ }
283
+ preparedRef.current = prepared;
284
+ schedule(0);
285
+ return () => {
286
+ genRef.current++;
287
+ preparedRef.current = null;
288
+ };
289
+ }, [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).
292
+ const vp = viewport ?? DEFAULT_VIEWPORT;
293
+ useEffect(() => {
294
+ if (!preparedRef.current)
295
+ return;
296
+ schedule(appliedRef.current ? REGEN_DEBOUNCE_MS : 0);
297
+ }, [
298
+ vp.zoom,
299
+ vp.pan.x,
300
+ vp.pan.y,
301
+ screenWidth,
302
+ screenHeight,
303
+ docWidth,
304
+ docHeight,
305
+ fit,
306
+ image.widthPx,
307
+ image.heightPx,
308
+ schedule,
309
+ ]);
310
+ // Dispose replaced bitmaps after the commit that stopped drawing them,
311
+ // and everything on unmount.
312
+ const prevAppliedRef = useRef(null);
313
+ useEffect(() => {
314
+ const prev = prevAppliedRef.current;
315
+ if (prev && prev !== applied)
316
+ prev.image.dispose();
317
+ prevAppliedRef.current = applied;
318
+ }, [applied]);
319
+ useEffect(() => () => {
320
+ genRef.current++;
321
+ if (timerRef.current)
322
+ clearTimeout(timerRef.current);
323
+ prevAppliedRef.current?.image.dispose();
324
+ prevAppliedRef.current = null;
325
+ }, []);
326
+ if (!applied || !(image.widthPx > 0) || !(image.heightPx > 0))
327
+ 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" }) }));
334
+ };
335
+ // HAS_DOM is fixed for the life of the process, so each mount renders one
336
+ // branch consistently (no hook-order hazard).
337
+ export const BackgroundSvgElement = memo((props) => HAS_DOM ? (_jsx(BrowserSvgBackground, { ...props })) : (_jsx(SkSvgBackground, { ...props })));
@@ -0,0 +1,2 @@
1
+ import type { AnnotationBackgroundImage } from '../../../types/annotation.js';
2
+ export declare const useBackgroundUrl: (image: AnnotationBackgroundImage | null | undefined, resolveUrl?: (path: string) => Promise<string>) => string;
@@ -0,0 +1,38 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ // Resolve a background image's (possibly expired) storage URL. Shared by the
3
+ // raster loader (BackgroundImageElement) and both SVG render paths
4
+ // (BackgroundSvg).
5
+ export const useBackgroundUrl = (image, resolveUrl) => {
6
+ const [url, setUrl] = useState(image?.downloadUrl ?? '');
7
+ // Hold resolveUrl in a ref so re-resolution is keyed only on the image
8
+ // identity, not the function's. Consumers commonly pass an inline closure
9
+ // (a new reference each render); depending on it directly would re-run a
10
+ // network URL resolve on every render.
11
+ const resolveUrlRef = useRef(resolveUrl);
12
+ resolveUrlRef.current = resolveUrl;
13
+ useEffect(() => {
14
+ if (!image)
15
+ return;
16
+ let cancelled = false;
17
+ const resolve = resolveUrlRef.current;
18
+ if (resolve) {
19
+ // A rejecting resolver (e.g. offline URL resolution) must never become
20
+ // an unhandled rejection here — keep the current url; the consumer's
21
+ // own retries/invalidations drive the re-resolve.
22
+ resolve(image.storagePath)
23
+ .then((next) => {
24
+ if (!cancelled)
25
+ setUrl(next);
26
+ })
27
+ .catch(() => { });
28
+ }
29
+ else {
30
+ setUrl(image.downloadUrl);
31
+ }
32
+ return () => {
33
+ cancelled = true;
34
+ };
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps
36
+ }, [image?.downloadUrl, image?.storagePath]);
37
+ return url;
38
+ };
@@ -0,0 +1,56 @@
1
+ import type { ViewportState } from './viewport.js';
2
+ export interface RectPx {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }
8
+ export interface SvgRasterPlan {
9
+ full: boolean;
10
+ rect: RectPx;
11
+ visible: RectPx;
12
+ outWidth: number;
13
+ outHeight: number;
14
+ zx: number;
15
+ zy: number;
16
+ }
17
+ export interface AppliedRasterKey {
18
+ full: boolean;
19
+ rect: RectPx;
20
+ zx: number;
21
+ zy: number;
22
+ }
23
+ export interface SvgRasterInput {
24
+ widthPx: number;
25
+ heightPx: number;
26
+ dims: RectPx;
27
+ viewport: ViewportState;
28
+ screenWidth: number;
29
+ screenHeight: number;
30
+ dpr: number;
31
+ }
32
+ export declare const FULL_RASTER_CAP = 4096;
33
+ export declare const CROP_RASTER_CAP = 8192;
34
+ export declare const MAX_RASTER_PIXELS = 24000000;
35
+ export declare const CROP_MARGIN = 0.35;
36
+ export declare const REGEN_SCALE_UP = 1.3;
37
+ export declare const REGEN_SCALE_DOWN = 0.5;
38
+ export declare const rectContains: (outer: RectPx, inner: RectPx, eps?: number) => boolean;
39
+ export declare const planSvgRaster: (input: SvgRasterInput) => SvgRasterPlan | null;
40
+ export declare const shouldRegenerateRaster: (applied: AppliedRasterKey | null, plan: SvgRasterPlan | null) => boolean;
41
+ export declare const parseSvgLength: (value: string | null) => number | null;
42
+ export declare const parseViewBox: (value: string | null) => {
43
+ x: number;
44
+ y: number;
45
+ width: number;
46
+ height: number;
47
+ } | null;
48
+ export declare const svgIntrinsicSize: (viewBoxAttr: string | null, widthAttr: string | null, heightAttr: string | null, fallbackWidth: number, fallbackHeight: number) => {
49
+ viewBox: string;
50
+ minX: number;
51
+ minY: number;
52
+ width: number;
53
+ height: number;
54
+ };
55
+ export declare const wrapSvgForRaster: (innerXml: string, cropUserUnits: RectPx, outWidth: number, outHeight: number) => string;
56
+ export declare const ensureRootFontSize: (svgText: string, px?: number) => string;