@vkcha/svg-core 0.1.0 → 0.1.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/LICENSE +21 -0
- package/README.md +283 -19
- package/package.json +9 -7
- package/src/SvgCore.ts +623 -0
- package/src/canvas/PanZoomCanvas.ts +302 -0
- package/src/index.ts +11 -0
- package/src/scene/Node.ts +66 -0
- package/src/scene/fragment.ts +146 -0
- package/src/utils/dom.ts +17 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { svgEl } from "../utils/dom";
|
|
2
|
+
|
|
3
|
+
export type PanZoomState = {
|
|
4
|
+
zoom: number;
|
|
5
|
+
panX: number; // screen px
|
|
6
|
+
panY: number; // screen px
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type PanZoomListener = (state: Readonly<PanZoomState>) => void;
|
|
10
|
+
|
|
11
|
+
export type PanZoomWheelMode = "zoom" | "pan";
|
|
12
|
+
|
|
13
|
+
export type PanZoomOptions = {
|
|
14
|
+
/** Default: "pan" (current behavior). Figma/Mac trackpad feel often uses "pan". */
|
|
15
|
+
wheelMode: PanZoomWheelMode;
|
|
16
|
+
/** If true, zooming via wheel requires Ctrl/Cmd (pinch on macOS typically sets ctrlKey=true). */
|
|
17
|
+
zoomRequiresCtrlKey: boolean;
|
|
18
|
+
/** If true, panning via pointer drag requires holding Space (Figma-like). */
|
|
19
|
+
panRequiresSpaceKey: boolean;
|
|
20
|
+
/** Zoom limits. */
|
|
21
|
+
minZoom: number;
|
|
22
|
+
maxZoom: number;
|
|
23
|
+
/** Zoom speed multiplier for wheel (higher = faster). */
|
|
24
|
+
zoomSpeed: number;
|
|
25
|
+
/** If true, invert wheel direction for zoom. */
|
|
26
|
+
invertZoom: boolean;
|
|
27
|
+
/** If true, invert wheel direction for pan. */
|
|
28
|
+
invertPan: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_PANZOOM_OPTIONS: PanZoomOptions = {
|
|
32
|
+
wheelMode: "pan",
|
|
33
|
+
zoomRequiresCtrlKey: false,
|
|
34
|
+
panRequiresSpaceKey: false,
|
|
35
|
+
minZoom: 0.2,
|
|
36
|
+
maxZoom: 8,
|
|
37
|
+
zoomSpeed: 1,
|
|
38
|
+
invertZoom: false,
|
|
39
|
+
invertPan: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Minimal SVG "canvas" with pan/zoom.
|
|
44
|
+
*
|
|
45
|
+
* - Wheel: zoom around cursor
|
|
46
|
+
* - Pointer drag: pan
|
|
47
|
+
*
|
|
48
|
+
* This is intentionally tiny and standalone so the project can restart from a clean base.
|
|
49
|
+
*/
|
|
50
|
+
export class PanZoomCanvas {
|
|
51
|
+
readonly svg: SVGSVGElement;
|
|
52
|
+
readonly world: SVGGElement;
|
|
53
|
+
|
|
54
|
+
state: PanZoomState = { zoom: 1, panX: 0, panY: 0 };
|
|
55
|
+
options: PanZoomOptions = { ...DEFAULT_PANZOOM_OPTIONS };
|
|
56
|
+
|
|
57
|
+
private listeners = new Set<PanZoomListener>();
|
|
58
|
+
private notifyScheduled = false;
|
|
59
|
+
|
|
60
|
+
private dragPointerId: number | null = null;
|
|
61
|
+
private panStart: { panX: number; panY: number; x: number; y: number } | null = null;
|
|
62
|
+
private isSpaceDown = false;
|
|
63
|
+
|
|
64
|
+
private windowKeyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
65
|
+
private windowKeyUpHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
66
|
+
private svgWheelHandler: ((e: WheelEvent) => void) | null = null;
|
|
67
|
+
private svgPointerDownHandler: ((e: PointerEvent) => void) | null = null;
|
|
68
|
+
private svgPointerMoveHandler: ((e: PointerEvent) => void) | null = null;
|
|
69
|
+
private svgPointerUpHandler: ((e: PointerEvent) => void) | null = null;
|
|
70
|
+
private svgPointerCancelHandler: ((e: PointerEvent) => void) | null = null;
|
|
71
|
+
private svgPointerLeaveHandler: (() => void) | null = null;
|
|
72
|
+
|
|
73
|
+
constructor(svg: SVGSVGElement, opts: Partial<PanZoomOptions> = {}) {
|
|
74
|
+
this.svg = svg;
|
|
75
|
+
this.world = svgEl("g");
|
|
76
|
+
this.world.dataset.layer = "world";
|
|
77
|
+
this.svg.replaceChildren(this.world);
|
|
78
|
+
|
|
79
|
+
this.setOptions(opts);
|
|
80
|
+
|
|
81
|
+
// Disable browser gestures on the SVG surface.
|
|
82
|
+
this.svg.style.touchAction = "none";
|
|
83
|
+
this.svg.style.userSelect = "none";
|
|
84
|
+
|
|
85
|
+
this.attach();
|
|
86
|
+
this.render();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setOptions(next: Partial<PanZoomOptions>) {
|
|
90
|
+
this.options = { ...DEFAULT_PANZOOM_OPTIONS, ...this.options, ...next };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Subscribe to pan/zoom state changes (event-driven, no polling).
|
|
95
|
+
*
|
|
96
|
+
* Optimized:
|
|
97
|
+
* - multiple updates within a frame are coalesced into a single notification via rAF
|
|
98
|
+
*/
|
|
99
|
+
subscribe(fn: PanZoomListener): () => void {
|
|
100
|
+
this.listeners.add(fn);
|
|
101
|
+
return () => this.listeners.delete(fn);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setState(next: Partial<PanZoomState>) {
|
|
105
|
+
const merged = { ...this.state, ...next };
|
|
106
|
+
if (
|
|
107
|
+
merged.zoom === this.state.zoom &&
|
|
108
|
+
merged.panX === this.state.panX &&
|
|
109
|
+
merged.panY === this.state.panY
|
|
110
|
+
) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.state = merged;
|
|
115
|
+
this.render();
|
|
116
|
+
this.scheduleNotify();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
reset() {
|
|
120
|
+
this.setState({ zoom: 1, panX: 0, panY: 0 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clean up all event listeners and resources.
|
|
125
|
+
* Call this when the canvas is no longer needed to prevent memory leaks.
|
|
126
|
+
*/
|
|
127
|
+
destroy() {
|
|
128
|
+
// Remove window listeners
|
|
129
|
+
if (this.windowKeyDownHandler) {
|
|
130
|
+
window.removeEventListener("keydown", this.windowKeyDownHandler);
|
|
131
|
+
this.windowKeyDownHandler = null;
|
|
132
|
+
}
|
|
133
|
+
if (this.windowKeyUpHandler) {
|
|
134
|
+
window.removeEventListener("keyup", this.windowKeyUpHandler);
|
|
135
|
+
this.windowKeyUpHandler = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Remove SVG listeners
|
|
139
|
+
if (this.svgWheelHandler) {
|
|
140
|
+
this.svg.removeEventListener("wheel", this.svgWheelHandler);
|
|
141
|
+
this.svgWheelHandler = null;
|
|
142
|
+
}
|
|
143
|
+
if (this.svgPointerDownHandler) {
|
|
144
|
+
this.svg.removeEventListener("pointerdown", this.svgPointerDownHandler);
|
|
145
|
+
this.svgPointerDownHandler = null;
|
|
146
|
+
}
|
|
147
|
+
if (this.svgPointerMoveHandler) {
|
|
148
|
+
this.svg.removeEventListener("pointermove", this.svgPointerMoveHandler);
|
|
149
|
+
this.svgPointerMoveHandler = null;
|
|
150
|
+
}
|
|
151
|
+
if (this.svgPointerUpHandler) {
|
|
152
|
+
this.svg.removeEventListener("pointerup", this.svgPointerUpHandler);
|
|
153
|
+
this.svgPointerUpHandler = null;
|
|
154
|
+
}
|
|
155
|
+
if (this.svgPointerCancelHandler) {
|
|
156
|
+
this.svg.removeEventListener("pointercancel", this.svgPointerCancelHandler);
|
|
157
|
+
this.svgPointerCancelHandler = null;
|
|
158
|
+
}
|
|
159
|
+
if (this.svgPointerLeaveHandler) {
|
|
160
|
+
this.svg.removeEventListener("pointerleave", this.svgPointerLeaveHandler);
|
|
161
|
+
this.svgPointerLeaveHandler = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Release pointer capture if active
|
|
165
|
+
if (this.dragPointerId !== null) {
|
|
166
|
+
try {
|
|
167
|
+
this.svg.releasePointerCapture(this.dragPointerId);
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore errors if pointer is already released
|
|
170
|
+
}
|
|
171
|
+
this.dragPointerId = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Clear state
|
|
175
|
+
this.panStart = null;
|
|
176
|
+
this.isSpaceDown = false;
|
|
177
|
+
this.listeners.clear();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private scheduleNotify() {
|
|
181
|
+
if (this.notifyScheduled) return;
|
|
182
|
+
this.notifyScheduled = true;
|
|
183
|
+
requestAnimationFrame(() => {
|
|
184
|
+
this.notifyScheduled = false;
|
|
185
|
+
for (const fn of this.listeners) fn(this.state);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private attach() {
|
|
190
|
+
// Track Space key for "Figma-like" panning.
|
|
191
|
+
this.windowKeyDownHandler = (e: KeyboardEvent) => {
|
|
192
|
+
if (e.code === "Space") this.isSpaceDown = true;
|
|
193
|
+
};
|
|
194
|
+
this.windowKeyUpHandler = (e: KeyboardEvent) => {
|
|
195
|
+
if (e.code === "Space") this.isSpaceDown = false;
|
|
196
|
+
};
|
|
197
|
+
window.addEventListener("keydown", this.windowKeyDownHandler);
|
|
198
|
+
window.addEventListener("keyup", this.windowKeyUpHandler);
|
|
199
|
+
|
|
200
|
+
this.svgWheelHandler = (e: WheelEvent) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
|
|
203
|
+
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
204
|
+
const { wheelMode, zoomRequiresCtrlKey, invertZoom, invertPan } = this.options;
|
|
205
|
+
|
|
206
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
207
|
+
|
|
208
|
+
// Desired behavior:
|
|
209
|
+
// - wheelMode="zoom" (default): wheel zooms. If zoomRequiresCtrlKey=true, only zoom when ctrl/cmd is pressed.
|
|
210
|
+
// - wheelMode="pan": wheel pans by default. ctrl/cmd (pinch gesture on macOS) zooms instead.
|
|
211
|
+
if (wheelMode === "pan" && !ctrl) {
|
|
212
|
+
const k = invertPan ? -1 : 1;
|
|
213
|
+
this.setState({
|
|
214
|
+
panX: this.state.panX - e.deltaX * k,
|
|
215
|
+
panY: this.state.panY - e.deltaY * k,
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (wheelMode === "zoom" && zoomRequiresCtrlKey && !ctrl) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const worldBefore = this.screenToWorld(pt.x, pt.y);
|
|
225
|
+
const dy = invertZoom ? -e.deltaY : e.deltaY;
|
|
226
|
+
const zoomFactor = Math.exp(-dy * 0.001 * this.options.zoomSpeed);
|
|
227
|
+
const nextZoom = clamp(
|
|
228
|
+
this.state.zoom * zoomFactor,
|
|
229
|
+
this.options.minZoom,
|
|
230
|
+
this.options.maxZoom,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Keep world point under cursor stable:
|
|
234
|
+
// screen = world * zoom + pan => pan = screen - world * zoom
|
|
235
|
+
const nextPanX = pt.x - worldBefore.x * nextZoom;
|
|
236
|
+
const nextPanY = pt.y - worldBefore.y * nextZoom;
|
|
237
|
+
|
|
238
|
+
this.setState({ zoom: nextZoom, panX: nextPanX, panY: nextPanY });
|
|
239
|
+
};
|
|
240
|
+
this.svg.addEventListener("wheel", this.svgWheelHandler, { passive: false });
|
|
241
|
+
|
|
242
|
+
this.svgPointerDownHandler = (e: PointerEvent) => {
|
|
243
|
+
if (this.dragPointerId !== null) return;
|
|
244
|
+
if (this.options.panRequiresSpaceKey && !this.isSpaceDown) return;
|
|
245
|
+
this.dragPointerId = e.pointerId;
|
|
246
|
+
this.svg.setPointerCapture(e.pointerId);
|
|
247
|
+
|
|
248
|
+
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
249
|
+
this.panStart = { panX: this.state.panX, panY: this.state.panY, x: pt.x, y: pt.y };
|
|
250
|
+
};
|
|
251
|
+
this.svg.addEventListener("pointerdown", this.svgPointerDownHandler);
|
|
252
|
+
|
|
253
|
+
this.svgPointerMoveHandler = (e: PointerEvent) => {
|
|
254
|
+
if (this.dragPointerId === null) return;
|
|
255
|
+
if (e.pointerId !== this.dragPointerId) return;
|
|
256
|
+
if (!this.panStart) return;
|
|
257
|
+
|
|
258
|
+
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
259
|
+
const dx = pt.x - this.panStart.x;
|
|
260
|
+
const dy = pt.y - this.panStart.y;
|
|
261
|
+
this.setState({ panX: this.panStart.panX + dx, panY: this.panStart.panY + dy });
|
|
262
|
+
};
|
|
263
|
+
this.svg.addEventListener("pointermove", this.svgPointerMoveHandler);
|
|
264
|
+
|
|
265
|
+
const end = (e: PointerEvent) => {
|
|
266
|
+
if (this.dragPointerId === null) return;
|
|
267
|
+
if (e.pointerId !== this.dragPointerId) return;
|
|
268
|
+
this.dragPointerId = null;
|
|
269
|
+
this.panStart = null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
this.svgPointerUpHandler = end;
|
|
273
|
+
this.svgPointerCancelHandler = end;
|
|
274
|
+
this.svgPointerLeaveHandler = () => {
|
|
275
|
+
this.dragPointerId = null;
|
|
276
|
+
this.panStart = null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
this.svg.addEventListener("pointerup", this.svgPointerUpHandler);
|
|
280
|
+
this.svg.addEventListener("pointercancel", this.svgPointerCancelHandler);
|
|
281
|
+
this.svg.addEventListener("pointerleave", this.svgPointerLeaveHandler);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private render() {
|
|
285
|
+
const { zoom, panX, panY } = this.state;
|
|
286
|
+
this.world.setAttribute("transform", `matrix(${zoom} 0 0 ${zoom} ${panX} ${panY})`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private svgPoint(clientX: number, clientY: number) {
|
|
290
|
+
const r = this.svg.getBoundingClientRect();
|
|
291
|
+
return { x: clientX - r.left, y: clientY - r.top };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private screenToWorld(x: number, y: number) {
|
|
295
|
+
const { zoom, panX, panY } = this.state;
|
|
296
|
+
return { x: (x - panX) / zoom, y: (y - panY) / zoom };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function clamp(v: number, min: number, max: number) {
|
|
301
|
+
return Math.max(min, Math.min(max, v));
|
|
302
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { SvgCore } from "./SvgCore";
|
|
2
|
+
export type { CullingOptions, CullingStats, InitOptions } from "./SvgCore";
|
|
3
|
+
|
|
4
|
+
export { PanZoomCanvas } from "./canvas/PanZoomCanvas";
|
|
5
|
+
export type { PanZoomOptions, PanZoomState, PanZoomWheelMode } from "./canvas/PanZoomCanvas";
|
|
6
|
+
|
|
7
|
+
export { Node } from "./scene/Node";
|
|
8
|
+
export type { NodeId, NodeOptions } from "./scene/Node";
|
|
9
|
+
|
|
10
|
+
export { measureFragmentMetrics } from "./scene/fragment";
|
|
11
|
+
export type { FragmentMetrics } from "./scene/fragment";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type NodeId = string;
|
|
2
|
+
|
|
3
|
+
export type NodeOptions = {
|
|
4
|
+
/** Required unique id for the node. Used for selective redraw and removal. */
|
|
5
|
+
id: NodeId;
|
|
6
|
+
/** SVG fragment markup (no outer <svg> wrapper). */
|
|
7
|
+
fragment: string;
|
|
8
|
+
/** World-space position (top-left of the node bounds). Default: (0, 0). */
|
|
9
|
+
x?: number;
|
|
10
|
+
y?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Optional explicit node bounds (in world units). If omitted, the core may derive
|
|
13
|
+
* bounds from the fragment's measured bbox.
|
|
14
|
+
*/
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
/** Optional UI callbacks (hit-tested by the core on the root SVG). */
|
|
18
|
+
onClick?: (node: Node) => void;
|
|
19
|
+
onDoubleClick?: (node: Node) => void;
|
|
20
|
+
onRightClick?: (node: Node) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A scene-graph "node" for the SVG core.
|
|
25
|
+
*/
|
|
26
|
+
export class Node {
|
|
27
|
+
readonly id: NodeId;
|
|
28
|
+
fragment: string;
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
width: number | null;
|
|
32
|
+
height: number | null;
|
|
33
|
+
onClick?: NodeOptions["onClick"];
|
|
34
|
+
onDoubleClick?: NodeOptions["onDoubleClick"];
|
|
35
|
+
onRightClick?: NodeOptions["onRightClick"];
|
|
36
|
+
|
|
37
|
+
/** Backing element (created lazily by the core). */
|
|
38
|
+
private _el: SVGGElement | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(opts: NodeOptions) {
|
|
41
|
+
if (!opts || typeof opts.id !== "string" || opts.id === "") {
|
|
42
|
+
throw new Error("Node requires a non-empty 'id' property");
|
|
43
|
+
}
|
|
44
|
+
this.id = opts.id;
|
|
45
|
+
this.fragment = opts.fragment ?? "";
|
|
46
|
+
this.x = Number.isFinite(opts?.x) ? (opts?.x as number) : 0;
|
|
47
|
+
this.y = Number.isFinite(opts?.y) ? (opts?.y as number) : 0;
|
|
48
|
+
const w = opts?.width;
|
|
49
|
+
const h = opts?.height;
|
|
50
|
+
this.width = typeof w === "number" && Number.isFinite(w) && w > 0 ? w : null;
|
|
51
|
+
this.height = typeof h === "number" && Number.isFinite(h) && h > 0 ? h : null;
|
|
52
|
+
|
|
53
|
+
this.onClick = opts?.onClick;
|
|
54
|
+
this.onDoubleClick = opts?.onDoubleClick;
|
|
55
|
+
this.onRightClick = opts?.onRightClick;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get el(): SVGGElement {
|
|
59
|
+
if (!this._el) {
|
|
60
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
61
|
+
el.dataset.nodeId = this.id;
|
|
62
|
+
this._el = el;
|
|
63
|
+
}
|
|
64
|
+
return this._el;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
type BBox = { x: number; y: number; width: number; height: number };
|
|
2
|
+
export type FragmentMetrics = { bbox: BBox; pad: number };
|
|
3
|
+
|
|
4
|
+
// Cache fragment -> metrics so we only measure when the fragment changes.
|
|
5
|
+
const metricsCache = new Map<string, FragmentMetrics>();
|
|
6
|
+
|
|
7
|
+
let measureSvg: SVGSVGElement | null = null;
|
|
8
|
+
let measureG: SVGGElement | null = null;
|
|
9
|
+
|
|
10
|
+
function ensureMeasureDom(): { svg: SVGSVGElement; g: SVGGElement } {
|
|
11
|
+
if (measureSvg && measureG) return { svg: measureSvg, g: measureG };
|
|
12
|
+
|
|
13
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
14
|
+
svg.setAttribute("width", "0");
|
|
15
|
+
svg.setAttribute("height", "0");
|
|
16
|
+
svg.style.position = "absolute";
|
|
17
|
+
svg.style.left = "-10000px";
|
|
18
|
+
svg.style.top = "-10000px";
|
|
19
|
+
svg.style.visibility = "hidden";
|
|
20
|
+
svg.style.pointerEvents = "none";
|
|
21
|
+
|
|
22
|
+
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
23
|
+
svg.appendChild(g);
|
|
24
|
+
|
|
25
|
+
measureSvg = svg;
|
|
26
|
+
measureG = g;
|
|
27
|
+
return { svg, g };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function attachMeasureDom(svg: SVGSVGElement) {
|
|
31
|
+
// getBBox() requires the element to be in the document.
|
|
32
|
+
if (!svg.isConnected) document.body.appendChild(svg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function detachMeasureDom(svg: SVGSVGElement) {
|
|
36
|
+
// Avoid leaving hidden measurement DOM nodes around permanently.
|
|
37
|
+
if (svg.isConnected) svg.remove();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripXmlnsDeep(el: Element) {
|
|
41
|
+
// Remove redundant XML namespace declarations from fragments inserted into an existing <svg>.
|
|
42
|
+
if (el.hasAttribute("xmlns")) el.removeAttribute("xmlns");
|
|
43
|
+
for (const attr of Array.from(el.attributes)) {
|
|
44
|
+
if (attr.name.startsWith("xmlns:")) el.removeAttribute(attr.name);
|
|
45
|
+
}
|
|
46
|
+
for (const child of Array.from(el.children)) stripXmlnsDeep(child);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function sanitizeFragment(markup: string): string {
|
|
50
|
+
const s = markup.trim();
|
|
51
|
+
if (!s) return "";
|
|
52
|
+
const wrapped = `<svg xmlns="http://www.w3.org/2000/svg">${s}</svg>`;
|
|
53
|
+
try {
|
|
54
|
+
const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
|
|
55
|
+
const svg = doc.documentElement as unknown as SVGSVGElement;
|
|
56
|
+
if (!svg || svg.nodeName.toLowerCase() !== "svg") return "";
|
|
57
|
+
|
|
58
|
+
doc.querySelectorAll("script, foreignObject").forEach((n) => n.remove());
|
|
59
|
+
doc.querySelectorAll("*").forEach((el) => {
|
|
60
|
+
for (const attr of Array.from(el.attributes)) {
|
|
61
|
+
if (attr.name.toLowerCase().startsWith("on")) el.removeAttribute(attr.name);
|
|
62
|
+
// We insert fragments into an existing <svg>, so explicit xmlns declarations are redundant.
|
|
63
|
+
if (attr.name === "xmlns" || attr.name.startsWith("xmlns:")) el.removeAttribute(attr.name);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Prefer innerHTML to avoid browsers sprinkling xmlns="..." on every serialized element.
|
|
68
|
+
const inner = (svg as any).innerHTML as string | undefined;
|
|
69
|
+
if (typeof inner === "string") return inner.trim();
|
|
70
|
+
return new XMLSerializer()
|
|
71
|
+
.serializeToString(svg)
|
|
72
|
+
.replace(/^<svg[^>]*>|<\/svg>$/g, "")
|
|
73
|
+
.trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseFragmentElements(markup: string): Element[] {
|
|
80
|
+
const s = markup.trim();
|
|
81
|
+
if (!s) return [];
|
|
82
|
+
const wrapped = `<svg xmlns="http://www.w3.org/2000/svg">${s}</svg>`;
|
|
83
|
+
try {
|
|
84
|
+
const doc = new DOMParser().parseFromString(wrapped, "image/svg+xml");
|
|
85
|
+
const svg = doc.documentElement as unknown as SVGSVGElement;
|
|
86
|
+
if (!svg || svg.nodeName.toLowerCase() !== "svg") return [];
|
|
87
|
+
|
|
88
|
+
// Sanitize in DOM (no string re-serialization for canvas nodes).
|
|
89
|
+
doc.querySelectorAll("script, foreignObject").forEach((n) => n.remove());
|
|
90
|
+
doc.querySelectorAll("*").forEach((el) => {
|
|
91
|
+
for (const attr of Array.from(el.attributes)) {
|
|
92
|
+
if (attr.name.toLowerCase().startsWith("on")) el.removeAttribute(attr.name);
|
|
93
|
+
if (attr.name === "xmlns" || attr.name.startsWith("xmlns:")) el.removeAttribute(attr.name);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Import into the live document so we don't carry XML serializer artifacts (like xmlns on every node).
|
|
98
|
+
const imported = Array.from(svg.children).map((el) => document.importNode(el, true) as Element);
|
|
99
|
+
for (const el of imported) stripXmlnsDeep(el);
|
|
100
|
+
return imported;
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function measureFragmentMetrics(markup: string): FragmentMetrics | null {
|
|
107
|
+
const key = sanitizeFragment(markup);
|
|
108
|
+
if (!key) return null;
|
|
109
|
+
const cached = metricsCache.get(key);
|
|
110
|
+
if (cached) return cached;
|
|
111
|
+
|
|
112
|
+
const { svg, g } = ensureMeasureDom();
|
|
113
|
+
attachMeasureDom(svg);
|
|
114
|
+
g.replaceChildren();
|
|
115
|
+
|
|
116
|
+
const els = parseFragmentElements(key);
|
|
117
|
+
for (const el of els) g.appendChild(el.cloneNode(true));
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const b = g.getBBox();
|
|
121
|
+
// getBBox() does NOT include stroke, so compute a padding based on computed stroke-width.
|
|
122
|
+
let maxStrokeWidth = 0;
|
|
123
|
+
g.querySelectorAll("*").forEach((node) => {
|
|
124
|
+
try {
|
|
125
|
+
const cs = getComputedStyle(node as any);
|
|
126
|
+
const stroke = (cs as any).stroke as string | undefined;
|
|
127
|
+
if (!stroke || stroke === "none" || stroke === "transparent") return;
|
|
128
|
+
const sw = Number.parseFloat(((cs as any).strokeWidth as string | undefined) ?? "0");
|
|
129
|
+
if (Number.isFinite(sw) && sw > maxStrokeWidth) maxStrokeWidth = sw;
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
const pad = Math.max(0, maxStrokeWidth / 2);
|
|
135
|
+
|
|
136
|
+
const bbox = { x: b.x, y: b.y, width: b.width, height: b.height };
|
|
137
|
+
const metrics = { bbox, pad };
|
|
138
|
+
metricsCache.set(key, metrics);
|
|
139
|
+
return metrics;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
// Detach measurement DOM even when getBBox throws.
|
|
144
|
+
detachMeasureDom(svg);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/utils/dom.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function svgEl<K extends keyof SVGElementTagNameMap>(
|
|
2
|
+
tag: K,
|
|
3
|
+
attrs: Record<string, string> = {}
|
|
4
|
+
): SVGElementTagNameMap[K] {
|
|
5
|
+
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
6
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
|
7
|
+
return el;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function setSvgAttrs(el: Element, attrs: Record<string, string | number | null | undefined>) {
|
|
11
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
12
|
+
if (v === null || v === undefined) el.removeAttribute(k);
|
|
13
|
+
else el.setAttribute(k, String(v));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|