@vkcha/svg-core 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -1
- package/dist/compat.js +391 -0
- package/dist/index.cjs +1142 -0
- package/dist/index.d.ts +47 -1
- package/dist/index.js +1137 -0
- package/dist/input-dom.js +198 -0
- package/dist/panzoom-svg.js +27 -0
- package/dist/renderer-svg-dom.js +261 -0
- package/dist/vkcha.min.js +1 -1
- package/package.json +7 -4
- package/src/SvgCore.ts +32 -2
- package/src/canvas/PanZoomCanvas.ts +181 -3
- package/src/index.ts +7 -2
|
@@ -10,6 +10,19 @@ export type PanZoomListener = (state: Readonly<PanZoomState>) => void;
|
|
|
10
10
|
|
|
11
11
|
export type PanZoomWheelMode = "zoom" | "pan";
|
|
12
12
|
|
|
13
|
+
export type WorldGroupConfig = {
|
|
14
|
+
/** Custom ID for the world group (e.g., "canvasgroup"). */
|
|
15
|
+
id?: string;
|
|
16
|
+
/** Static attributes to set on the world group. */
|
|
17
|
+
attributes?: Record<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* Dynamic attribute that updates with zoom level.
|
|
20
|
+
* The callback receives the current zoom and returns the attribute value.
|
|
21
|
+
* Example: { "data-canvas-zoom": (zoom) => String(Math.round(zoom)) }
|
|
22
|
+
*/
|
|
23
|
+
dynamicAttributes?: Record<string, (zoom: number) => string>;
|
|
24
|
+
};
|
|
25
|
+
|
|
13
26
|
export type PanZoomOptions = {
|
|
14
27
|
/** Default: "pan" (current behavior). Figma/Mac trackpad feel often uses "pan". */
|
|
15
28
|
wheelMode: PanZoomWheelMode;
|
|
@@ -22,10 +35,14 @@ export type PanZoomOptions = {
|
|
|
22
35
|
maxZoom: number;
|
|
23
36
|
/** Zoom speed multiplier for wheel (higher = faster). */
|
|
24
37
|
zoomSpeed: number;
|
|
38
|
+
/** Extra zoom speed multiplier when ctrl/cmd is held (pinch on macOS). */
|
|
39
|
+
pinchZoomSpeed: number;
|
|
25
40
|
/** If true, invert wheel direction for zoom. */
|
|
26
41
|
invertZoom: boolean;
|
|
27
42
|
/** If true, invert wheel direction for pan. */
|
|
28
43
|
invertPan: boolean;
|
|
44
|
+
/** Configuration for the world group element. */
|
|
45
|
+
worldGroup?: WorldGroupConfig;
|
|
29
46
|
};
|
|
30
47
|
|
|
31
48
|
export const DEFAULT_PANZOOM_OPTIONS: PanZoomOptions = {
|
|
@@ -35,6 +52,7 @@ export const DEFAULT_PANZOOM_OPTIONS: PanZoomOptions = {
|
|
|
35
52
|
minZoom: 0.2,
|
|
36
53
|
maxZoom: 8,
|
|
37
54
|
zoomSpeed: 1,
|
|
55
|
+
pinchZoomSpeed: 2,
|
|
38
56
|
invertZoom: false,
|
|
39
57
|
invertPan: false,
|
|
40
58
|
};
|
|
@@ -59,8 +77,12 @@ export class PanZoomCanvas {
|
|
|
59
77
|
|
|
60
78
|
private dragPointerId: number | null = null;
|
|
61
79
|
private panStart: { panX: number; panY: number; x: number; y: number } | null = null;
|
|
80
|
+
private isPanning = false;
|
|
62
81
|
private isSpaceDown = false;
|
|
63
82
|
|
|
83
|
+
private static readonly DRAG_THRESHOLD_PX = 5;
|
|
84
|
+
private animationFrameId: number | null = null;
|
|
85
|
+
|
|
64
86
|
private windowKeyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
65
87
|
private windowKeyUpHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
66
88
|
private svgWheelHandler: ((e: WheelEvent) => void) | null = null;
|
|
@@ -77,17 +99,68 @@ export class PanZoomCanvas {
|
|
|
77
99
|
this.svg.replaceChildren(this.world);
|
|
78
100
|
|
|
79
101
|
this.setOptions(opts);
|
|
102
|
+
this.applyWorldGroupConfig();
|
|
80
103
|
|
|
81
|
-
// Disable browser gestures on the SVG surface.
|
|
104
|
+
// Disable browser gestures, focus outline, and selection highlight on the SVG surface.
|
|
82
105
|
this.svg.style.touchAction = "none";
|
|
83
106
|
this.svg.style.userSelect = "none";
|
|
107
|
+
const svgStyle = this.svg.style as CSSStyleDeclaration & {
|
|
108
|
+
webkitUserSelect?: string;
|
|
109
|
+
webkitTapHighlightColor?: string;
|
|
110
|
+
};
|
|
111
|
+
svgStyle.webkitUserSelect = "none";
|
|
112
|
+
svgStyle.webkitTapHighlightColor = "transparent";
|
|
113
|
+
this.svg.style.outline = "none";
|
|
114
|
+
this.svg.setAttribute("tabindex", "-1");
|
|
115
|
+
|
|
116
|
+
// Also disable outline on the world group.
|
|
117
|
+
this.world.style.outline = "none";
|
|
84
118
|
|
|
85
119
|
this.attach();
|
|
86
120
|
this.render();
|
|
87
121
|
}
|
|
88
122
|
|
|
123
|
+
private applyWorldGroupConfig() {
|
|
124
|
+
const cfg = this.options.worldGroup;
|
|
125
|
+
if (!cfg) return;
|
|
126
|
+
|
|
127
|
+
if (cfg.id) {
|
|
128
|
+
this.world.id = cfg.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (cfg.attributes) {
|
|
132
|
+
for (const [key, value] of Object.entries(cfg.attributes)) {
|
|
133
|
+
this.world.setAttribute(key, value);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a new <g> layer inside the world.
|
|
140
|
+
* Useful when you want pan/zoom only and manage your own SVG content.
|
|
141
|
+
*/
|
|
142
|
+
createLayer(
|
|
143
|
+
name?: string,
|
|
144
|
+
opts?: { position?: "front" | "back"; pointerEvents?: string },
|
|
145
|
+
): SVGGElement {
|
|
146
|
+
const layer = svgEl("g");
|
|
147
|
+
if (name) layer.dataset.layer = name;
|
|
148
|
+
if (opts?.pointerEvents) layer.style.pointerEvents = opts.pointerEvents;
|
|
149
|
+
if (opts?.position === "back" && this.world.firstChild) {
|
|
150
|
+
this.world.insertBefore(layer, this.world.firstChild);
|
|
151
|
+
} else {
|
|
152
|
+
this.world.appendChild(layer);
|
|
153
|
+
}
|
|
154
|
+
return layer;
|
|
155
|
+
}
|
|
156
|
+
|
|
89
157
|
setOptions(next: Partial<PanZoomOptions>) {
|
|
90
158
|
this.options = { ...DEFAULT_PANZOOM_OPTIONS, ...this.options, ...next };
|
|
159
|
+
|
|
160
|
+
if (next.worldGroup) {
|
|
161
|
+
this.applyWorldGroupConfig();
|
|
162
|
+
this.render();
|
|
163
|
+
}
|
|
91
164
|
}
|
|
92
165
|
|
|
93
166
|
/**
|
|
@@ -116,6 +189,66 @@ export class PanZoomCanvas {
|
|
|
116
189
|
this.scheduleNotify();
|
|
117
190
|
}
|
|
118
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Animate to a target state over a duration.
|
|
194
|
+
* Uses linear easing by default. Pass a custom easing function for different curves.
|
|
195
|
+
* Example: `(t) => 1 - Math.pow(1 - t, 3)` for ease-out cubic.
|
|
196
|
+
*/
|
|
197
|
+
animateTo(
|
|
198
|
+
target: Partial<PanZoomState>,
|
|
199
|
+
durationMs = 300,
|
|
200
|
+
easing: (t: number) => number = (t) => t,
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
if (this.animationFrameId !== null) {
|
|
204
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
205
|
+
this.animationFrameId = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const start = {
|
|
209
|
+
zoom: this.state.zoom,
|
|
210
|
+
panX: this.state.panX,
|
|
211
|
+
panY: this.state.panY,
|
|
212
|
+
};
|
|
213
|
+
const end = {
|
|
214
|
+
zoom: target.zoom ?? start.zoom,
|
|
215
|
+
panX: target.panX ?? start.panX,
|
|
216
|
+
panY: target.panY ?? start.panY,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const startTime = performance.now();
|
|
220
|
+
|
|
221
|
+
const step = (now: number) => {
|
|
222
|
+
const elapsed = now - startTime;
|
|
223
|
+
const t = Math.min(1, elapsed / durationMs);
|
|
224
|
+
const eased = easing(t);
|
|
225
|
+
|
|
226
|
+
this.setState({
|
|
227
|
+
zoom: start.zoom + (end.zoom - start.zoom) * eased,
|
|
228
|
+
panX: start.panX + (end.panX - start.panX) * eased,
|
|
229
|
+
panY: start.panY + (end.panY - start.panY) * eased,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (t < 1) {
|
|
233
|
+
this.animationFrameId = requestAnimationFrame(step);
|
|
234
|
+
} else {
|
|
235
|
+
this.animationFrameId = null;
|
|
236
|
+
resolve();
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
this.animationFrameId = requestAnimationFrame(step);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Stop any running animation. */
|
|
245
|
+
stopAnimation() {
|
|
246
|
+
if (this.animationFrameId !== null) {
|
|
247
|
+
cancelAnimationFrame(this.animationFrameId);
|
|
248
|
+
this.animationFrameId = null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
119
252
|
reset() {
|
|
120
253
|
this.setState({ zoom: 1, panX: 0, panY: 0 });
|
|
121
254
|
}
|
|
@@ -125,6 +258,9 @@ export class PanZoomCanvas {
|
|
|
125
258
|
* Call this when the canvas is no longer needed to prevent memory leaks.
|
|
126
259
|
*/
|
|
127
260
|
destroy() {
|
|
261
|
+
// Cancel any running animation
|
|
262
|
+
this.stopAnimation();
|
|
263
|
+
|
|
128
264
|
// Remove window listeners
|
|
129
265
|
if (this.windowKeyDownHandler) {
|
|
130
266
|
window.removeEventListener("keydown", this.windowKeyDownHandler);
|
|
@@ -173,6 +309,7 @@ export class PanZoomCanvas {
|
|
|
173
309
|
|
|
174
310
|
// Clear state
|
|
175
311
|
this.panStart = null;
|
|
312
|
+
this.isPanning = false;
|
|
176
313
|
this.isSpaceDown = false;
|
|
177
314
|
this.listeners.clear();
|
|
178
315
|
}
|
|
@@ -204,6 +341,7 @@ export class PanZoomCanvas {
|
|
|
204
341
|
const { wheelMode, zoomRequiresCtrlKey, invertZoom, invertPan } = this.options;
|
|
205
342
|
|
|
206
343
|
const ctrl = e.ctrlKey || e.metaKey;
|
|
344
|
+
const isPinchGesture = e.ctrlKey && !e.metaKey;
|
|
207
345
|
|
|
208
346
|
// Desired behavior:
|
|
209
347
|
// - wheelMode="zoom" (default): wheel zooms. If zoomRequiresCtrlKey=true, only zoom when ctrl/cmd is pressed.
|
|
@@ -223,7 +361,11 @@ export class PanZoomCanvas {
|
|
|
223
361
|
|
|
224
362
|
const worldBefore = this.screenToWorld(pt.x, pt.y);
|
|
225
363
|
const dy = invertZoom ? -e.deltaY : e.deltaY;
|
|
226
|
-
const
|
|
364
|
+
const pinchBoost =
|
|
365
|
+
isPinchGesture && e.deltaMode === WheelEvent.DOM_DELTA_PIXEL
|
|
366
|
+
? this.options.pinchZoomSpeed
|
|
367
|
+
: 1;
|
|
368
|
+
const zoomFactor = Math.exp(-dy * 0.001 * this.options.zoomSpeed * pinchBoost);
|
|
227
369
|
const nextZoom = clamp(
|
|
228
370
|
this.state.zoom * zoomFactor,
|
|
229
371
|
this.options.minZoom,
|
|
@@ -240,10 +382,12 @@ export class PanZoomCanvas {
|
|
|
240
382
|
this.svg.addEventListener("wheel", this.svgWheelHandler, { passive: false });
|
|
241
383
|
|
|
242
384
|
this.svgPointerDownHandler = (e: PointerEvent) => {
|
|
385
|
+
if (e.button !== 0) return;
|
|
243
386
|
if (this.dragPointerId !== null) return;
|
|
244
387
|
if (this.options.panRequiresSpaceKey && !this.isSpaceDown) return;
|
|
388
|
+
|
|
245
389
|
this.dragPointerId = e.pointerId;
|
|
246
|
-
this.
|
|
390
|
+
this.isPanning = false;
|
|
247
391
|
|
|
248
392
|
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
249
393
|
this.panStart = { panX: this.state.panX, panY: this.state.panY, x: pt.x, y: pt.y };
|
|
@@ -258,6 +402,15 @@ export class PanZoomCanvas {
|
|
|
258
402
|
const pt = this.svgPoint(e.clientX, e.clientY);
|
|
259
403
|
const dx = pt.x - this.panStart.x;
|
|
260
404
|
const dy = pt.y - this.panStart.y;
|
|
405
|
+
|
|
406
|
+
if (!this.isPanning) {
|
|
407
|
+
if (Math.hypot(dx, dy) < PanZoomCanvas.DRAG_THRESHOLD_PX) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
this.isPanning = true;
|
|
411
|
+
this.svg.setPointerCapture(e.pointerId);
|
|
412
|
+
}
|
|
413
|
+
|
|
261
414
|
this.setState({ panX: this.panStart.panX + dx, panY: this.panStart.panY + dy });
|
|
262
415
|
};
|
|
263
416
|
this.svg.addEventListener("pointermove", this.svgPointerMoveHandler);
|
|
@@ -265,15 +418,33 @@ export class PanZoomCanvas {
|
|
|
265
418
|
const end = (e: PointerEvent) => {
|
|
266
419
|
if (this.dragPointerId === null) return;
|
|
267
420
|
if (e.pointerId !== this.dragPointerId) return;
|
|
421
|
+
|
|
422
|
+
if (this.isPanning) {
|
|
423
|
+
try {
|
|
424
|
+
this.svg.releasePointerCapture(e.pointerId);
|
|
425
|
+
} catch {
|
|
426
|
+
// Ignore if already released
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
268
430
|
this.dragPointerId = null;
|
|
269
431
|
this.panStart = null;
|
|
432
|
+
this.isPanning = false;
|
|
270
433
|
};
|
|
271
434
|
|
|
272
435
|
this.svgPointerUpHandler = end;
|
|
273
436
|
this.svgPointerCancelHandler = end;
|
|
274
437
|
this.svgPointerLeaveHandler = () => {
|
|
438
|
+
if (this.isPanning && this.dragPointerId !== null) {
|
|
439
|
+
try {
|
|
440
|
+
this.svg.releasePointerCapture(this.dragPointerId);
|
|
441
|
+
} catch {
|
|
442
|
+
// Ignore
|
|
443
|
+
}
|
|
444
|
+
}
|
|
275
445
|
this.dragPointerId = null;
|
|
276
446
|
this.panStart = null;
|
|
447
|
+
this.isPanning = false;
|
|
277
448
|
};
|
|
278
449
|
|
|
279
450
|
this.svg.addEventListener("pointerup", this.svgPointerUpHandler);
|
|
@@ -284,6 +455,13 @@ export class PanZoomCanvas {
|
|
|
284
455
|
private render() {
|
|
285
456
|
const { zoom, panX, panY } = this.state;
|
|
286
457
|
this.world.setAttribute("transform", `matrix(${zoom} 0 0 ${zoom} ${panX} ${panY})`);
|
|
458
|
+
|
|
459
|
+
const dynamicAttrs = this.options.worldGroup?.dynamicAttributes;
|
|
460
|
+
if (dynamicAttrs) {
|
|
461
|
+
for (const [key, fn] of Object.entries(dynamicAttrs)) {
|
|
462
|
+
this.world.setAttribute(key, fn(zoom));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
287
465
|
}
|
|
288
466
|
|
|
289
467
|
private svgPoint(clientX: number, clientY: number) {
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
export { SvgCore } from "./SvgCore";
|
|
2
|
-
export type { CullingOptions, CullingStats, InitOptions } from "./SvgCore";
|
|
2
|
+
export type { CullingOptions, CullingStats, InitOptions, WorldLayerPosition } from "./SvgCore";
|
|
3
3
|
|
|
4
4
|
export { PanZoomCanvas } from "./canvas/PanZoomCanvas";
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
PanZoomOptions,
|
|
7
|
+
PanZoomState,
|
|
8
|
+
PanZoomWheelMode,
|
|
9
|
+
WorldGroupConfig,
|
|
10
|
+
} from "./canvas/PanZoomCanvas";
|
|
6
11
|
|
|
7
12
|
export { Node } from "./scene/Node";
|
|
8
13
|
export type { NodeId, NodeOptions } from "./scene/Node";
|