react-svg-canvas 0.1.3 → 0.1.4

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.
@@ -36,6 +36,15 @@ export interface SvgCanvasHandle {
36
36
  centerOn: (x: number, y: number, zoom?: number) => void;
37
37
  /** Center the viewport on a rectangle (fitting it in view) */
38
38
  centerOnRect: (x: number, y: number, width: number, height: number, padding?: number) => void;
39
+ /** Center the viewport on a rectangle with smooth animation */
40
+ centerOnRectAnimated: (x: number, y: number, width: number, height: number, options?: {
41
+ duration?: number;
42
+ padding?: number;
43
+ /** Zoom out factor during transition (0-1, e.g. 0.15 = zoom out 15% at midpoint) */
44
+ zoomOutFactor?: number;
45
+ }) => void;
46
+ /** Check if a rectangle's center is currently visible in the viewport */
47
+ isRectInView: (x: number, y: number, width: number, height: number) => boolean;
39
48
  /** Get current matrix */
40
49
  getMatrix: () => [number, number, number, number, number, number];
41
50
  /** Set matrix directly */
package/lib/svgcanvas.js CHANGED
@@ -16,7 +16,8 @@ export function useSvgCanvas() {
16
16
  export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style, children, fixed, onToolStart, onToolMove, onToolEnd, onMove, onContextReady }, ref) {
17
17
  const svgRef = React.useRef(null);
18
18
  const [active, setActive] = React.useState();
19
- const [pan, setPan] = React.useState();
19
+ // Use ref for pan state to avoid React batching delays and enable window-level event handling
20
+ const panRef = React.useRef(null);
20
21
  const [drag, setDrag] = React.useState();
21
22
  const [toolStart, setToolStart] = React.useState();
22
23
  const [dragHandler, setDragHandler] = React.useState();
@@ -25,6 +26,53 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
25
26
  const activePointersRef = React.useRef(new Map());
26
27
  const lastPinchDistanceRef = React.useRef(undefined);
27
28
  const lastPinchCenterRef = React.useRef(undefined);
29
+ // Track animation frame for cancellation
30
+ const animationFrameRef = React.useRef(undefined);
31
+ // Store onContextReady callback in ref to avoid infinite loops
32
+ const onContextReadyRef = React.useRef(onContextReady);
33
+ // Cancel any ongoing animation when user interacts
34
+ const cancelAnimation = React.useCallback(() => {
35
+ if (animationFrameRef.current !== undefined) {
36
+ cancelAnimationFrame(animationFrameRef.current);
37
+ animationFrameRef.current = undefined;
38
+ }
39
+ }, []);
40
+ // Window-level pan handler - uses refs for immediate updates without React batching
41
+ const handleWindowPan = React.useCallback((evt) => {
42
+ const pan = panRef.current;
43
+ if (!pan || evt.pointerId !== pan.pointerId)
44
+ return;
45
+ const dx = evt.clientX - pan.lastX;
46
+ const dy = evt.clientY - pan.lastY;
47
+ setMatrix(m => [m[0], m[1], m[2], m[3], m[4] + dx, m[5] + dy]);
48
+ panRef.current = { lastX: evt.clientX, lastY: evt.clientY, pointerId: pan.pointerId };
49
+ }, []);
50
+ // Window-level pan end handler - cleans up window listeners
51
+ const handleWindowPanEnd = React.useCallback((evt) => {
52
+ const pan = panRef.current;
53
+ if (!pan || evt.pointerId !== pan.pointerId)
54
+ return;
55
+ panRef.current = null;
56
+ window.removeEventListener('pointermove', handleWindowPan);
57
+ window.removeEventListener('pointerup', handleWindowPanEnd);
58
+ window.removeEventListener('pointercancel', handleWindowPanEnd);
59
+ }, [handleWindowPan]);
60
+ // Start panning with window-level event listeners
61
+ const startPan = React.useCallback((evt) => {
62
+ panRef.current = { lastX: evt.clientX, lastY: evt.clientY, pointerId: evt.pointerId };
63
+ window.addEventListener('pointermove', handleWindowPan);
64
+ window.addEventListener('pointerup', handleWindowPanEnd);
65
+ window.addEventListener('pointercancel', handleWindowPanEnd);
66
+ }, [handleWindowPan, handleWindowPanEnd]);
67
+ // Stop panning and clean up window listeners (used when entering pinch-zoom or other cancellation)
68
+ const stopPan = React.useCallback(() => {
69
+ if (panRef.current) {
70
+ panRef.current = null;
71
+ window.removeEventListener('pointermove', handleWindowPan);
72
+ window.removeEventListener('pointerup', handleWindowPanEnd);
73
+ window.removeEventListener('pointercancel', handleWindowPanEnd);
74
+ }
75
+ }, [handleWindowPan, handleWindowPanEnd]);
28
76
  const translateTo = React.useCallback(function traslateTo(x, y) {
29
77
  return [(x - matrix[4]) / matrix[0], (y - matrix[5]) / matrix[3]];
30
78
  }, [matrix]);
@@ -46,10 +94,15 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
46
94
  setDragHandler,
47
95
  startDrag
48
96
  }), [svgRef.current, matrix]);
49
- // Call onContextReady callback when context changes
97
+ // Keep onContextReady ref in sync
50
98
  React.useEffect(() => {
51
- if (onContextReady) {
52
- onContextReady({
99
+ onContextReadyRef.current = onContextReady;
100
+ }, [onContextReady]);
101
+ // Call onContextReady callback when matrix changes
102
+ // Only depend on matrix - translateTo/translateFrom are derived from it
103
+ React.useEffect(() => {
104
+ if (onContextReadyRef.current) {
105
+ onContextReadyRef.current({
53
106
  svg: svgRef.current || undefined,
54
107
  matrix,
55
108
  scale: matrix[0],
@@ -57,7 +110,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
57
110
  translateFrom
58
111
  });
59
112
  }
60
- }, [onContextReady, matrix, translateTo, translateFrom]);
113
+ }, [matrix]);
61
114
  // Expose imperative handle
62
115
  React.useImperativeHandle(ref, () => ({
63
116
  centerOn: (x, y, zoom) => {
@@ -89,9 +142,90 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
89
142
  const viewCenterY = rect.height / 2;
90
143
  setMatrix([scale, 0, 0, scale, viewCenterX - targetCenterX * scale, viewCenterY - targetCenterY * scale]);
91
144
  },
145
+ centerOnRectAnimated: (x, y, width, height, options) => {
146
+ const svg = svgRef.current;
147
+ if (!svg)
148
+ return;
149
+ const duration = options?.duration ?? 350;
150
+ const padding = options?.padding ?? 50;
151
+ const zoomOutFactor = options?.zoomOutFactor ?? 0;
152
+ const rect = svg.getBoundingClientRect();
153
+ const availableWidth = rect.width - padding * 2;
154
+ const availableHeight = rect.height - padding * 2;
155
+ // Calculate target scale to fit the rectangle
156
+ const scaleX = availableWidth / width;
157
+ const scaleY = availableHeight / height;
158
+ const targetScale = Math.min(scaleX, scaleY, 2); // Cap at 2x zoom
159
+ // Center of the target rectangle
160
+ const targetCenterX = x + width / 2;
161
+ const targetCenterY = y + height / 2;
162
+ // Center of the viewport
163
+ const viewCenterX = rect.width / 2;
164
+ const viewCenterY = rect.height / 2;
165
+ // Get current matrix for interpolation
166
+ const startMatrix = [...matrix];
167
+ const startScale = startMatrix[0];
168
+ // Calculate start center in canvas coords (reverse the matrix transform)
169
+ const startCenterX = (viewCenterX - startMatrix[4]) / startScale;
170
+ const startCenterY = (viewCenterY - startMatrix[5]) / startScale;
171
+ const startTime = performance.now();
172
+ // easeInOutCubic for smooth acceleration/deceleration (better for zoom-out transitions)
173
+ const easeInOutCubic = (t) => {
174
+ return t < 0.5
175
+ ? 4 * t * t * t
176
+ : 1 - Math.pow(-2 * t + 2, 3) / 2;
177
+ };
178
+ // Cancel any existing animation
179
+ cancelAnimation();
180
+ const animate = (currentTime) => {
181
+ const elapsed = currentTime - startTime;
182
+ const progress = Math.min(elapsed / duration, 1);
183
+ const eased = easeInOutCubic(progress);
184
+ // Interpolate center position
185
+ const currentCenterX = startCenterX + (targetCenterX - startCenterX) * eased;
186
+ const currentCenterY = startCenterY + (targetCenterY - startCenterY) * eased;
187
+ // Interpolate scale with optional zoom-out dip
188
+ // zoomOutDip creates a parabola that dips at t=0.5
189
+ // At t=0: dip=0, at t=0.5: dip=1, at t=1: dip=0
190
+ const zoomOutDip = 4 * progress * (1 - progress) * zoomOutFactor;
191
+ const baseScale = startScale + (targetScale - startScale) * eased;
192
+ const currentScale = baseScale * (1 - zoomOutDip);
193
+ // Calculate new matrix with interpolated values
194
+ const newMatrix = [
195
+ currentScale,
196
+ 0,
197
+ 0,
198
+ currentScale,
199
+ viewCenterX - currentCenterX * currentScale,
200
+ viewCenterY - currentCenterY * currentScale
201
+ ];
202
+ setMatrix(newMatrix);
203
+ if (progress < 1) {
204
+ animationFrameRef.current = requestAnimationFrame(animate);
205
+ }
206
+ else {
207
+ animationFrameRef.current = undefined;
208
+ }
209
+ };
210
+ animationFrameRef.current = requestAnimationFrame(animate);
211
+ },
212
+ isRectInView: (x, y, width, height) => {
213
+ const svg = svgRef.current;
214
+ if (!svg)
215
+ return false;
216
+ const rect = svg.getBoundingClientRect();
217
+ // Calculate rect center in canvas coordinates
218
+ const rectCenterX = x + width / 2;
219
+ const rectCenterY = y + height / 2;
220
+ // Transform to screen coordinates using current matrix
221
+ const screenX = rectCenterX * matrix[0] + matrix[4];
222
+ const screenY = rectCenterY * matrix[3] + matrix[5];
223
+ // Check if center is within viewport bounds
224
+ return screenX >= 0 && screenX <= rect.width && screenY >= 0 && screenY <= rect.height;
225
+ },
92
226
  getMatrix: () => matrix,
93
227
  setMatrix: (newMatrix) => setMatrix(newMatrix)
94
- }), [matrix]);
228
+ }), [matrix, cancelAnimation]);
95
229
  function transformPointerEvent(evt) {
96
230
  if (!svgRef.current)
97
231
  return { x: 0, y: 0, shiftKey: false, ctrlKey: false, metaKey: false, altKey: false };
@@ -105,6 +239,8 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
105
239
  return { buttons: evt.buttons, x, y };
106
240
  }
107
241
  function onPointerDown(evt) {
242
+ // Cancel any ongoing animation when user interacts
243
+ cancelAnimation();
108
244
  // Track this pointer
109
245
  activePointersRef.current.set(evt.pointerId, {
110
246
  id: evt.pointerId,
@@ -123,7 +259,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
123
259
  lastPinchCenterRef.current = { x: centerX, y: centerY };
124
260
  // Cancel any active tool operation
125
261
  setToolStart(undefined);
126
- setPan(undefined);
262
+ stopPan();
127
263
  evt.preventDefault();
128
264
  evt.stopPropagation();
129
265
  return;
@@ -145,7 +281,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
145
281
  setActive(undefined);
146
282
  break;
147
283
  case 4: /* middle button */
148
- setPan({ lastX: evt.clientX, lastY: evt.clientY });
284
+ startPan(evt);
149
285
  break;
150
286
  }
151
287
  }
@@ -160,7 +296,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
160
296
  }
161
297
  else {
162
298
  // No tool active - pan mode
163
- setPan({ lastX: evt.clientX, lastY: evt.clientY });
299
+ startPan(evt);
164
300
  }
165
301
  }
166
302
  }
@@ -202,20 +338,11 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
202
338
  evt.preventDefault();
203
339
  return;
204
340
  }
205
- // Single pointer handling
341
+ // Single pointer handling (pan is handled by window-level events, not here)
206
342
  if (dragHandler && toolStart) {
207
343
  const e = transformPointerEvent(evt);
208
344
  dragHandler.onDragMove({ x: e.x, y: e.y, startX: toolStart.startX, startY: toolStart.startY });
209
345
  }
210
- else if (pan) {
211
- const x = evt.clientX;
212
- const y = evt.clientY;
213
- const dx = x - pan.lastX;
214
- const dy = y - pan.lastY;
215
- setMatrix(matrix => [matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + dx, matrix[5] + dy]);
216
- setPan({ lastX: x, lastY: y });
217
- evt.stopPropagation();
218
- }
219
346
  else if (onToolMove && toolStart) {
220
347
  const e = transformPointerEvent(evt);
221
348
  if (e) {
@@ -224,7 +351,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
224
351
  }
225
352
  // Always call onMove for cursor tracking (when not panning/pinching)
226
353
  // pointerCount <= 1 covers both hovering (0) and single pointer drag (1)
227
- if (onMove && pointerCount <= 1 && !pan) {
354
+ if (onMove && pointerCount <= 1 && !panRef.current) {
228
355
  const e = transformPointerEvent(evt);
229
356
  if (e) {
230
357
  onMove({ x: e.x, y: e.y, startX: e.x, startY: e.y });
@@ -277,10 +404,12 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
277
404
  if (onToolEnd)
278
405
  onToolEnd();
279
406
  setDrag(undefined);
280
- setPan(undefined);
407
+ stopPan();
281
408
  setToolStart(undefined);
282
409
  }
283
410
  function onWheel(evt) {
411
+ // Cancel any ongoing animation when user zooms
412
+ cancelAnimation();
284
413
  const page = svgRef.current?.getBoundingClientRect() || { left: 0, top: 0 };
285
414
  let scale = matrix[0];
286
415
  if (scale < 10 && evt.deltaY < 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-svg-canvas",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "React library for building interactive SVG canvas applications with pan, zoom, selection, drag-and-drop, resize, and Figma-style snapping",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",