@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.
@@ -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 zoomFactor = Math.exp(-dy * 0.001 * this.options.zoomSpeed);
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.svg.setPointerCapture(e.pointerId);
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 { PanZoomOptions, PanZoomState, PanZoomWheelMode } from "./canvas/PanZoomCanvas";
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";