@reekon-tools/boldr-utils 1.8.0 → 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/dist/export/buildJobCsv.js +6 -4
- package/package.json +1 -1
- package/dist/annotation/canvas/tools/measurementLineTool.d.ts +0 -12
- package/dist/annotation/canvas/tools/measurementLineTool.js +0 -95
|
@@ -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;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// A whole-SVG raster may not exceed this per side. Kept modest because a full
|
|
2
|
+
// raster is retained for the lifetime of the zoom level.
|
|
3
|
+
export const FULL_RASTER_CAP = 4096;
|
|
4
|
+
// A visible-region crop may not exceed this per side. Crops are transient and
|
|
5
|
+
// sized by the screen, and every WebGL implementation this app targets
|
|
6
|
+
// supports ≥8192px textures.
|
|
7
|
+
export const CROP_RASTER_CAP = 8192;
|
|
8
|
+
// Absolute pixel budget for one raster (≈96MB RGBA) — the caps above bound
|
|
9
|
+
// each side, this bounds the area.
|
|
10
|
+
export const MAX_RASTER_PIXELS = 24000000;
|
|
11
|
+
// Fraction of the visible region added on each side of a crop, so small pans
|
|
12
|
+
// stay inside the bitmap instead of re-rasterizing.
|
|
13
|
+
export const CROP_MARGIN = 0.35;
|
|
14
|
+
// Re-rasterize when the needed density outgrows the bitmap by this factor
|
|
15
|
+
// (sharpen promptly on zoom-in)…
|
|
16
|
+
export const REGEN_SCALE_UP = 1.3;
|
|
17
|
+
// …or when the bitmap is this much denser than needed (reclaim memory lazily
|
|
18
|
+
// on zoom-out; minified drawing looks fine meanwhile).
|
|
19
|
+
export const REGEN_SCALE_DOWN = 0.5;
|
|
20
|
+
const EMPTY_RECT = { x: 0, y: 0, width: 0, height: 0 };
|
|
21
|
+
const intersectRect = (a, b) => {
|
|
22
|
+
const x = Math.max(a.x, b.x);
|
|
23
|
+
const y = Math.max(a.y, b.y);
|
|
24
|
+
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
25
|
+
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
26
|
+
if (right <= x || bottom <= y)
|
|
27
|
+
return null;
|
|
28
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
29
|
+
};
|
|
30
|
+
// Does `outer` contain `inner`, within eps svg-local px? An empty inner
|
|
31
|
+
// (off-screen SVG) is trivially contained.
|
|
32
|
+
export const rectContains = (outer, inner, eps = 0.5) => inner.width <= 0 ||
|
|
33
|
+
inner.height <= 0 ||
|
|
34
|
+
(inner.x >= outer.x - eps &&
|
|
35
|
+
inner.y >= outer.y - eps &&
|
|
36
|
+
inner.x + inner.width <= outer.x + outer.width + eps &&
|
|
37
|
+
inner.y + inner.height <= outer.y + outer.height + eps);
|
|
38
|
+
// Clamp per-axis densities so `rect` rasterized at (zx, zy) fits the side cap
|
|
39
|
+
// and the pixel budget, and derive the integer bitmap size.
|
|
40
|
+
const capScales = (rect, zx, zy, cap) => {
|
|
41
|
+
let cx = Math.min(zx, cap / rect.width);
|
|
42
|
+
let cy = Math.min(zy, cap / rect.height);
|
|
43
|
+
const pixels = rect.width * cx * rect.height * cy;
|
|
44
|
+
if (pixels > MAX_RASTER_PIXELS) {
|
|
45
|
+
const r = Math.sqrt(MAX_RASTER_PIXELS / pixels);
|
|
46
|
+
cx *= r;
|
|
47
|
+
cy *= r;
|
|
48
|
+
}
|
|
49
|
+
const outWidth = Math.max(1, Math.ceil(rect.width * cx));
|
|
50
|
+
const outHeight = Math.max(1, Math.ceil(rect.height * cy));
|
|
51
|
+
return {
|
|
52
|
+
outWidth,
|
|
53
|
+
outHeight,
|
|
54
|
+
zx: outWidth / rect.width,
|
|
55
|
+
zy: outHeight / rect.height,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
// Plan the raster the current viewport needs, or null when the inputs can't
|
|
59
|
+
// produce one (degenerate sizes).
|
|
60
|
+
export const planSvgRaster = (input) => {
|
|
61
|
+
const { widthPx, heightPx, dims, viewport, screenWidth, screenHeight } = input;
|
|
62
|
+
if (!(widthPx > 0) || !(heightPx > 0))
|
|
63
|
+
return null;
|
|
64
|
+
if (!(dims.width > 0) || !(dims.height > 0))
|
|
65
|
+
return null;
|
|
66
|
+
const zoom = viewport.zoom > 0 ? viewport.zoom : 1;
|
|
67
|
+
const dpr = input.dpr > 0 ? input.dpr : 1;
|
|
68
|
+
// Doc units per svg-local px (per-axis: 'stretch' fits are anisotropic).
|
|
69
|
+
const sx = dims.width / widthPx;
|
|
70
|
+
const sy = dims.height / heightPx;
|
|
71
|
+
// Device px per svg-local px the screen currently shows.
|
|
72
|
+
const zx = zoom * sx * dpr;
|
|
73
|
+
const zy = zoom * sy * dpr;
|
|
74
|
+
if (!Number.isFinite(zx) || !Number.isFinite(zy) || zx <= 0 || zy <= 0) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const whole = { x: 0, y: 0, width: widthPx, height: heightPx };
|
|
78
|
+
const visibleDoc = {
|
|
79
|
+
x: viewport.pan.x,
|
|
80
|
+
y: viewport.pan.y,
|
|
81
|
+
width: Math.max(0, screenWidth) / zoom,
|
|
82
|
+
height: Math.max(0, screenHeight) / zoom,
|
|
83
|
+
};
|
|
84
|
+
const visible = intersectRect({
|
|
85
|
+
x: (visibleDoc.x - dims.x) / sx,
|
|
86
|
+
y: (visibleDoc.y - dims.y) / sy,
|
|
87
|
+
width: visibleDoc.width / sx,
|
|
88
|
+
height: visibleDoc.height / sy,
|
|
89
|
+
}, whole);
|
|
90
|
+
// Whole-SVG raster whenever the needed density fits the budget — pan never
|
|
91
|
+
// invalidates it. Also the fallback when the SVG is entirely off-screen
|
|
92
|
+
// (capped density; only used until something is visible again).
|
|
93
|
+
const fullFits = widthPx * zx <= FULL_RASTER_CAP &&
|
|
94
|
+
heightPx * zy <= FULL_RASTER_CAP &&
|
|
95
|
+
widthPx * zx * heightPx * zy <= MAX_RASTER_PIXELS;
|
|
96
|
+
if (fullFits || !visible) {
|
|
97
|
+
const capped = capScales(whole, zx, zy, FULL_RASTER_CAP);
|
|
98
|
+
return {
|
|
99
|
+
full: true,
|
|
100
|
+
rect: whole,
|
|
101
|
+
visible: visible ?? EMPTY_RECT,
|
|
102
|
+
...capped,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const marginX = visible.width * CROP_MARGIN;
|
|
106
|
+
const marginY = visible.height * CROP_MARGIN;
|
|
107
|
+
const rect = intersectRect({
|
|
108
|
+
x: visible.x - marginX,
|
|
109
|
+
y: visible.y - marginY,
|
|
110
|
+
width: visible.width + marginX * 2,
|
|
111
|
+
height: visible.height + marginY * 2,
|
|
112
|
+
}, whole);
|
|
113
|
+
const capped = capScales(rect, zx, zy, CROP_RASTER_CAP);
|
|
114
|
+
return { full: false, rect, visible, ...capped };
|
|
115
|
+
};
|
|
116
|
+
// Should the applied raster be replaced by what `plan` asks for? Scale drift
|
|
117
|
+
// beyond the regen thresholds always regenerates; otherwise a full raster
|
|
118
|
+
// stands, and a crop stands while the visible region stays inside it.
|
|
119
|
+
export const shouldRegenerateRaster = (applied, plan) => {
|
|
120
|
+
if (!plan)
|
|
121
|
+
return false;
|
|
122
|
+
if (!applied)
|
|
123
|
+
return true;
|
|
124
|
+
const rx = plan.zx / applied.zx;
|
|
125
|
+
const ry = plan.zy / applied.zy;
|
|
126
|
+
if (rx > REGEN_SCALE_UP ||
|
|
127
|
+
ry > REGEN_SCALE_UP ||
|
|
128
|
+
rx < REGEN_SCALE_DOWN ||
|
|
129
|
+
ry < REGEN_SCALE_DOWN) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (applied.full)
|
|
133
|
+
return false;
|
|
134
|
+
return !rectContains(applied.rect, plan.visible);
|
|
135
|
+
};
|
|
136
|
+
// --- SVG root-size normalization (DOM-free half) -----------------------------
|
|
137
|
+
//
|
|
138
|
+
// The browser rasterizes an <img> SVG from its root width/height; a root
|
|
139
|
+
// without a viewBox doesn't scale at all (it clips). Given the root's
|
|
140
|
+
// attributes, decide the viewBox to enforce and the user-unit size to pin the
|
|
141
|
+
// root to — the coordinate space raster crops are expressed in.
|
|
142
|
+
// Parse an absolute SVG length attribute. Percentages and other relative
|
|
143
|
+
// units have no meaning outside a CSS layout context — treated as absent.
|
|
144
|
+
export const parseSvgLength = (value) => {
|
|
145
|
+
if (!value)
|
|
146
|
+
return null;
|
|
147
|
+
const trimmed = value.trim();
|
|
148
|
+
if (!/^\d*\.?\d+(px)?$/.test(trimmed))
|
|
149
|
+
return null;
|
|
150
|
+
const parsed = parseFloat(trimmed);
|
|
151
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
152
|
+
};
|
|
153
|
+
export const parseViewBox = (value) => {
|
|
154
|
+
if (!value)
|
|
155
|
+
return null;
|
|
156
|
+
const parts = value
|
|
157
|
+
.trim()
|
|
158
|
+
.split(/[\s,]+/)
|
|
159
|
+
.map(Number);
|
|
160
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
const [x, y, width, height] = parts;
|
|
164
|
+
if (!(width > 0) || !(height > 0))
|
|
165
|
+
return null;
|
|
166
|
+
return { x, y, width, height };
|
|
167
|
+
};
|
|
168
|
+
export const svgIntrinsicSize = (viewBoxAttr, widthAttr, heightAttr, fallbackWidth, fallbackHeight) => {
|
|
169
|
+
const vb = parseViewBox(viewBoxAttr);
|
|
170
|
+
if (vb) {
|
|
171
|
+
return {
|
|
172
|
+
viewBox: viewBoxAttr.trim(),
|
|
173
|
+
minX: vb.x,
|
|
174
|
+
minY: vb.y,
|
|
175
|
+
width: vb.width,
|
|
176
|
+
height: vb.height,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const w = parseSvgLength(widthAttr);
|
|
180
|
+
const h = parseSvgLength(heightAttr);
|
|
181
|
+
if (w && h) {
|
|
182
|
+
return { viewBox: `0 0 ${w} ${h}`, minX: 0, minY: 0, width: w, height: h };
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
viewBox: `0 0 ${fallbackWidth} ${fallbackHeight}`,
|
|
186
|
+
minX: 0,
|
|
187
|
+
minY: 0,
|
|
188
|
+
width: fallbackWidth,
|
|
189
|
+
height: fallbackHeight,
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
// Wrap a normalized SVG document in an outer <svg> whose viewBox is the crop
|
|
193
|
+
// window (user units) and whose width/height are the output size in device
|
|
194
|
+
// px. The wrapper's intrinsic size then EQUALS the raster destination, so
|
|
195
|
+
// drawImage(img, 0, 0) never scales — critical on Chromium, which rasterizes
|
|
196
|
+
// SVG drawImage sources at their intrinsic size and would otherwise magnify
|
|
197
|
+
// that bitmap (the "still blurry under zoom" failure). Nesting — rather than
|
|
198
|
+
// editing the inner root's viewBox — keeps percentage lengths and viewport-
|
|
199
|
+
// relative units inside the document resolving against the ORIGINAL viewport.
|
|
200
|
+
// preserveAspectRatio="none" absorbs the sub-pixel aspect drift from integer
|
|
201
|
+
// output sizes (and any anisotropic density caps).
|
|
202
|
+
export const wrapSvgForRaster = (innerXml, cropUserUnits, outWidth, outHeight) => `<svg xmlns="http://www.w3.org/2000/svg" width="${outWidth}" ` +
|
|
203
|
+
`height="${outHeight}" viewBox="${cropUserUnits.x} ${cropUserUnits.y} ` +
|
|
204
|
+
`${cropUserUnits.width} ${cropUserUnits.height}" ` +
|
|
205
|
+
`preserveAspectRatio="none">${innerXml}</svg>`;
|
|
206
|
+
// Skia's SVG module and browsers disagree on the default font-size when a
|
|
207
|
+
// document never specifies one: browsers use the CSS initial value 'medium'
|
|
208
|
+
// (16px), Skia's is larger — unsized <text> renders visibly bigger on native
|
|
209
|
+
// than the same SVG in a browser. Injecting an explicit, inheritable 16 on
|
|
210
|
+
// the root pins the two to the same spec-aligned default; any font-size the
|
|
211
|
+
// document does set (on the root or below) still wins through inheritance.
|
|
212
|
+
// String surgery, not DOM — the native path has no DOMParser.
|
|
213
|
+
export const ensureRootFontSize = (svgText, px = 16) => {
|
|
214
|
+
const match = /<svg\b[^>]*>/i.exec(svgText);
|
|
215
|
+
if (!match)
|
|
216
|
+
return svgText;
|
|
217
|
+
const tag = match[0];
|
|
218
|
+
if (/\bfont-size\s*=/i.test(tag))
|
|
219
|
+
return svgText;
|
|
220
|
+
if (/\bstyle\s*=\s*["'][^"']*font-size/i.test(tag))
|
|
221
|
+
return svgText;
|
|
222
|
+
const selfClosing = tag.endsWith('/>');
|
|
223
|
+
const insertAt = match.index + tag.length - (selfClosing ? 2 : 1);
|
|
224
|
+
return (svgText.slice(0, insertAt) + ` font-size="${px}"` + svgText.slice(insertAt));
|
|
225
|
+
};
|
|
@@ -97,8 +97,9 @@ export function buildJobCsv(input) {
|
|
|
97
97
|
const columnHeaders = [];
|
|
98
98
|
section.tableConfig.forEach((column) => {
|
|
99
99
|
columnHeaders.push(column.name);
|
|
100
|
-
if (
|
|
101
|
-
column.type === ColumnType.
|
|
100
|
+
if (includeField('notes') &&
|
|
101
|
+
(column.type === ColumnType.Measurement ||
|
|
102
|
+
column.type === ColumnType.Angle)) {
|
|
102
103
|
columnHeaders.push(`${column.name} Notes`);
|
|
103
104
|
}
|
|
104
105
|
});
|
|
@@ -164,8 +165,9 @@ export function buildJobCsv(input) {
|
|
|
164
165
|
else {
|
|
165
166
|
row.push('');
|
|
166
167
|
}
|
|
167
|
-
if (
|
|
168
|
-
column.type === ColumnType.
|
|
168
|
+
if (includeField('notes') &&
|
|
169
|
+
(column.type === ColumnType.Measurement ||
|
|
170
|
+
column.type === ColumnType.Angle)) {
|
|
169
171
|
if (group.descriptions && group.descriptions[column.id]) {
|
|
170
172
|
row.push(escapeCSVField(group.descriptions[column.id]));
|
|
171
173
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { PlacedMeasurementRef } from '../../../types/annotation.js';
|
|
2
|
-
import type { Tool } from '../Tool.js';
|
|
3
|
-
export interface MeasurementLineToolOptions {
|
|
4
|
-
id?: string;
|
|
5
|
-
label?: string;
|
|
6
|
-
minDragPx?: number;
|
|
7
|
-
autoSwitchToSelect?: boolean;
|
|
8
|
-
selectToolId?: string;
|
|
9
|
-
onAutoSwitch?: (toToolId: string) => void;
|
|
10
|
-
onPlaced?: (measurement: PlacedMeasurementRef) => void;
|
|
11
|
-
}
|
|
12
|
-
export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
|
|
2
|
-
import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
|
|
3
|
-
let counter = 0;
|
|
4
|
-
const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
5
|
-
const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
|
|
6
|
-
// Build the blank line-measurement annotation for a drag from `a` to `b`: a
|
|
7
|
-
// 2-point line with a value tile magnetized to its center (linePos 0.5). The
|
|
8
|
-
// payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
|
|
9
|
-
// line and a (legacy) center-placed one are byte-identical once committed.
|
|
10
|
-
const buildLineMeasurement = (opts) => {
|
|
11
|
-
const line = { a: opts.a, b: opts.b };
|
|
12
|
-
return {
|
|
13
|
-
id: opts.id,
|
|
14
|
-
layerId: opts.layerId,
|
|
15
|
-
placement: 'line',
|
|
16
|
-
line,
|
|
17
|
-
linePos: DEFAULT_LINE_POS,
|
|
18
|
-
// Center of the line; recomputeAnchor keeps this in sync on edits.
|
|
19
|
-
anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
|
|
20
|
-
x: (opts.a.x + opts.b.x) / 2,
|
|
21
|
-
y: (opts.a.y + opts.b.y) / 2,
|
|
22
|
-
}),
|
|
23
|
-
showLabel: true,
|
|
24
|
-
showValue: true,
|
|
25
|
-
createdAt: Date.now(),
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
// Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
|
|
29
|
-
// annotation (a line with a blank value tile at its center); release commits
|
|
30
|
-
// it. Mirrors createShapeTool's interaction so "input lines" are placed the
|
|
31
|
-
// same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
|
|
32
|
-
// old center-place affordance. The annotation stays blank (no measurement
|
|
33
|
-
// associated) until the user fills it via the tile / picker. Skia-free, like
|
|
34
|
-
// every tool factory; it renders its live preview through ctx.preview, so it
|
|
35
|
-
// works on web and (via the generic tool-pan dispatch) on native.
|
|
36
|
-
export const createMeasurementLineTool = (options = {}) => {
|
|
37
|
-
const minDragPx = options.minDragPx ?? 4;
|
|
38
|
-
const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
|
|
39
|
-
const selectToolId = options.selectToolId ?? 'select';
|
|
40
|
-
return {
|
|
41
|
-
id: options.id ?? 'measure-line',
|
|
42
|
-
label: options.label ?? 'Measurement line',
|
|
43
|
-
cursor: 'crosshair',
|
|
44
|
-
onPointerDown(event) {
|
|
45
|
-
return {
|
|
46
|
-
kind: 'measurement-line-drawing',
|
|
47
|
-
id: makeId(),
|
|
48
|
-
startWorld: event.world,
|
|
49
|
-
startScreen: event.screen,
|
|
50
|
-
moved: false,
|
|
51
|
-
};
|
|
52
|
-
},
|
|
53
|
-
onPointerMove(event, ctx, state) {
|
|
54
|
-
const s = state;
|
|
55
|
-
if (s?.kind !== 'measurement-line-drawing')
|
|
56
|
-
return s;
|
|
57
|
-
const measurement = buildLineMeasurement({
|
|
58
|
-
id: s.id,
|
|
59
|
-
layerId: firstLayerId(ctx.document),
|
|
60
|
-
a: s.startWorld,
|
|
61
|
-
b: event.world,
|
|
62
|
-
});
|
|
63
|
-
ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
64
|
-
return { ...s, moved: true };
|
|
65
|
-
},
|
|
66
|
-
onPointerUp(event, ctx, state) {
|
|
67
|
-
const s = state;
|
|
68
|
-
if (s?.kind !== 'measurement-line-drawing')
|
|
69
|
-
return;
|
|
70
|
-
const dx = event.screen.x - s.startScreen.x;
|
|
71
|
-
const dy = event.screen.y - s.startScreen.y;
|
|
72
|
-
if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
|
|
73
|
-
// Accidental tap — discard the rubber-band, commit nothing.
|
|
74
|
-
ctx.preview({ ops: [] });
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
const measurement = buildLineMeasurement({
|
|
78
|
-
id: s.id,
|
|
79
|
-
layerId: firstLayerId(ctx.document),
|
|
80
|
-
a: s.startWorld,
|
|
81
|
-
b: event.world,
|
|
82
|
-
});
|
|
83
|
-
ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
|
|
84
|
-
// Leave it selected (and hand back to select) so the blank line can be
|
|
85
|
-
// filled / moved straight away — the same flow as the text tool.
|
|
86
|
-
ctx.setSelection({ ids: [measurement.id] });
|
|
87
|
-
options.onPlaced?.(measurement);
|
|
88
|
-
if (autoSwitchToSelect)
|
|
89
|
-
options.onAutoSwitch?.(selectToolId);
|
|
90
|
-
},
|
|
91
|
-
onCancel(_state, ctx) {
|
|
92
|
-
ctx.preview({ ops: [] });
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
};
|