@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.
- package/dist/annotation/canvas/AnnotationCanvasInner.js +1 -0
- package/dist/annotation/canvas/AnnotationCanvasSkia.d.ts +3 -1
- package/dist/annotation/canvas/AnnotationCanvasSkia.js +2 -2
- package/dist/annotation/canvas/elements/BackgroundImageElement.d.ts +5 -1
- package/dist/annotation/canvas/elements/BackgroundImageElement.js +13 -99
- package/dist/annotation/canvas/elements/BackgroundSvg.d.ts +13 -0
- package/dist/annotation/canvas/elements/BackgroundSvg.js +337 -0
- package/dist/annotation/canvas/elements/backgroundUrl.d.ts +2 -0
- package/dist/annotation/canvas/elements/backgroundUrl.js +38 -0
- package/dist/annotation/canvas/svgRasterPlan.d.ts +56 -0
- package/dist/annotation/canvas/svgRasterPlan.js +225 -0
- package/package.json +1 -1
- package/dist/canvas/AnnotationCanvas.d.ts +0 -11
- package/dist/canvas/AnnotationCanvas.js +0 -10
- package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
- package/dist/canvas/AnnotationCanvas.native.js +0 -6
- package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
- package/dist/canvas/AnnotationCanvasInner.js +0 -219
- package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
- package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
- package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
- package/dist/canvas/AnnotationCanvasSkia.js +0 -20
- package/dist/canvas/Tool.d.ts +0 -38
- package/dist/canvas/Tool.js +0 -1
- package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
- package/dist/canvas/elements/BackgroundImageElement.js +0 -37
- package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
- package/dist/canvas/elements/MeasurementStampElement.js +0 -30
- package/dist/canvas/elements/ShapeElement.d.ts +0 -7
- package/dist/canvas/elements/ShapeElement.js +0 -62
- package/dist/canvas/elements/StrokeElement.d.ts +0 -7
- package/dist/canvas/elements/StrokeElement.js +0 -18
- package/dist/canvas/measurementPicker.d.ts +0 -10
- package/dist/canvas/measurementPicker.js +0 -1
- package/dist/canvas/measurementStampOverlay.d.ts +0 -11
- package/dist/canvas/measurementStampOverlay.js +0 -1
- package/dist/canvas/pointerAdapter.d.ts +0 -3
- package/dist/canvas/pointerAdapter.js +0 -19
- package/dist/canvas/stampLayout.d.ts +0 -5
- package/dist/canvas/stampLayout.js +0 -14
- package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
- package/dist/canvas/tools/measurementStampTool.js +0 -37
- package/dist/canvas/tools/panTool.d.ts +0 -5
- package/dist/canvas/tools/panTool.js +0 -25
- package/dist/canvas/tools/penTool.d.ts +0 -13
- package/dist/canvas/tools/penTool.js +0 -68
- package/dist/canvas/tools/selectTool.d.ts +0 -2
- package/dist/canvas/tools/selectTool.js +0 -182
- package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
- package/dist/canvas/useAnnotationCanvasState.js +0 -210
- package/dist/canvas/viewport.d.ts +0 -16
- package/dist/canvas/viewport.js +0 -54
- package/dist/data/AnnotationDataContext.d.ts +0 -8
- package/dist/data/AnnotationDataContext.js +0 -11
- package/dist/data/AnnotationDataProvider.d.ts +0 -65
- package/dist/data/AnnotationDataProvider.js +0 -4
- package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
- package/dist/data/InMemoryAnnotationProvider.js +0 -197
- package/dist/data/canvasPersistence.d.ts +0 -3
- package/dist/data/canvasPersistence.js +0 -26
- package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
- package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
- package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
- package/dist/data/hooks/useAnnotationDoc.js +0 -33
- package/dist/data/hooks/useAnnotationList.d.ts +0 -7
- package/dist/data/hooks/useAnnotationList.js +0 -26
- package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
- package/dist/data/hooks/useAnnotationMutations.js +0 -11
- package/dist/hooks/useParseMeasurement.d.ts +0 -4
- package/dist/hooks/useParseMeasurement.js +0 -14
- package/dist/utils/evaluateFormula.d.ts +0 -20
- package/dist/utils/evaluateFormula.js +0 -31
|
@@ -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 {
|
|
3
|
-
import { memo
|
|
2
|
+
import { Image, useImage } from '@shopify/react-native-skia';
|
|
3
|
+
import { memo } from 'react';
|
|
4
4
|
import { imageDocRect } from '../viewport.js';
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
|
97
|
-
// unconditionally (with a null source, so
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
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,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;
|