react-svg-canvas 0.1.3 → 0.1.5

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.
@@ -42,8 +42,8 @@ export declare function computeScore(target: SnapTarget, distance: number, dragS
42
42
  /**
43
43
  * Main snap computation for drag operations
44
44
  */
45
- export declare function computeSnap(context: DragSnapContext, objects: SnapSpatialObject[], viewBounds: Bounds, config: SnapConfiguration, getParent?: (id: string) => string | undefined): SnapResult;
45
+ export declare function computeSnap(context: DragSnapContext, objects: SnapSpatialObject[], viewBounds: Bounds, config: SnapConfiguration, getParent?: (id: string) => string | undefined, customTargets?: SnapTarget[]): SnapResult;
46
46
  /**
47
47
  * Compute snapping for resize operations
48
48
  */
49
- export declare function computeResizeSnap(context: ResizeSnapContext, objects: SnapSpatialObject[], viewBounds: Bounds, config: SnapConfiguration, getParent?: (id: string) => string | undefined): ResizeSnapResult;
49
+ export declare function computeResizeSnap(context: ResizeSnapContext, objects: SnapSpatialObject[], viewBounds: Bounds, config: SnapConfiguration, getParent?: (id: string) => string | undefined, customTargets?: SnapTarget[]): ResizeSnapResult;
@@ -304,7 +304,7 @@ function getDragSnapValuesForAxis(snapPoints, axis, grabPoint) {
304
304
  /**
305
305
  * Main snap computation for drag operations
306
306
  */
307
- export function computeSnap(context, objects, viewBounds, config, getParent) {
307
+ export function computeSnap(context, objects, viewBounds, config, getParent, customTargets) {
308
308
  if (!config.enabled) {
309
309
  return {
310
310
  snappedPosition: { x: context.draggedBounds.x, y: context.draggedBounds.y },
@@ -320,7 +320,9 @@ export function computeSnap(context, objects, viewBounds, config, getParent) {
320
320
  }
321
321
  // Generate all snap targets with geometric relevance filtering
322
322
  const excludeIds = new Set([context.draggedId]);
323
- const targets = generateAllSnapTargets(objects, excludeIds, viewBounds, config, context.draggedBounds);
323
+ const generatedTargets = generateAllSnapTargets(objects, excludeIds, viewBounds, config, context.draggedBounds);
324
+ // Merge with custom targets (e.g., from table grids)
325
+ const targets = customTargets ? [...generatedTargets, ...customTargets] : generatedTargets;
324
326
  // Get snap points from dragged bounds
325
327
  const draggedSnapPoints = getSnapPoints(context.draggedBounds);
326
328
  // Score all candidates
@@ -499,7 +501,7 @@ export function computeSnap(context, objects, viewBounds, config, getParent) {
499
501
  /**
500
502
  * Compute snapping for resize operations
501
503
  */
502
- export function computeResizeSnap(context, objects, viewBounds, config, getParent) {
504
+ export function computeResizeSnap(context, objects, viewBounds, config, getParent, customTargets) {
503
505
  if (!config.enabled) {
504
506
  return {
505
507
  snappedBounds: context.currentBounds,
@@ -514,7 +516,9 @@ export function computeResizeSnap(context, objects, viewBounds, config, getParen
514
516
  objectBoundsMap.set(obj.id, getAABB(obj));
515
517
  }
516
518
  const excludeIds = new Set([context.objectId]);
517
- const targets = generateAllSnapTargets(objects, excludeIds, viewBounds, config);
519
+ const generatedTargets = generateAllSnapTargets(objects, excludeIds, viewBounds, config);
520
+ // Merge with custom targets (e.g., from table grids)
521
+ const targets = customTargets ? [...generatedTargets, ...customTargets] : generatedTargets;
518
522
  // For resize, we snap the edges being resized
519
523
  const currentSnapPoints = getSnapPoints(context.currentBounds);
520
524
  // Determine which edges are being resized based on handle
@@ -2,12 +2,14 @@
2
2
  * React hook for snapping functionality
3
3
  */
4
4
  import type { Point, Bounds, ResizeHandle } from '../types';
5
- import type { SnapConfiguration, SnapSpatialObject, RotatedBounds, ActiveSnap, ActiveSnapEdge, ScoredCandidate } from './types';
5
+ import type { SnapConfiguration, SnapSpatialObject, SnapTarget, RotatedBounds, ActiveSnap, ActiveSnapEdge, ScoredCandidate } from './types';
6
6
  export interface UseSnappingOptions {
7
7
  objects: SnapSpatialObject[];
8
8
  config: SnapConfiguration;
9
9
  viewBounds: Bounds;
10
10
  getParent?: (id: string) => string | undefined;
11
+ /** Additional snap targets (e.g., from table grids, guides) */
12
+ customTargets?: SnapTarget[];
11
13
  }
12
14
  export interface SnapDragParams {
13
15
  bounds: RotatedBounds;
@@ -38,7 +38,7 @@ function normalizeDirection(delta) {
38
38
  * Hook for snapping during drag and resize operations
39
39
  */
40
40
  export function useSnapping(options) {
41
- const { objects, config, viewBounds, getParent } = options;
41
+ const { objects, config, viewBounds, getParent, customTargets } = options;
42
42
  // State for active snaps, candidates, and active snap edges
43
43
  const [activeSnaps, setActiveSnaps] = React.useState([]);
44
44
  const [allCandidates, setAllCandidates] = React.useState([]);
@@ -66,7 +66,7 @@ export function useSnapping(options) {
66
66
  delta
67
67
  };
68
68
  const finalExcludeIds = excludeIds || new Set([objectId]);
69
- const result = computeSnap(context, objectsRef.current, viewBounds, config, getParent);
69
+ const result = computeSnap(context, objectsRef.current, viewBounds, config, getParent, customTargets);
70
70
  setActiveSnaps(result.activeSnaps);
71
71
  setAllCandidates(result.candidates);
72
72
  setActiveSnapEdges(result.activeSnapEdges);
@@ -76,7 +76,7 @@ export function useSnapping(options) {
76
76
  candidates: result.candidates,
77
77
  activeSnapEdges: result.activeSnapEdges
78
78
  };
79
- }, [config, viewBounds, getParent]);
79
+ }, [config, viewBounds, getParent, customTargets]);
80
80
  const snapResize = React.useCallback((params) => {
81
81
  const { originalBounds, currentBounds, objectId, handle, delta, excludeIds } = params;
82
82
  const context = {
@@ -86,7 +86,7 @@ export function useSnapping(options) {
86
86
  handle,
87
87
  delta
88
88
  };
89
- const result = computeResizeSnap(context, objectsRef.current, viewBounds, config, getParent);
89
+ const result = computeResizeSnap(context, objectsRef.current, viewBounds, config, getParent, customTargets);
90
90
  setActiveSnaps(result.activeSnaps);
91
91
  setAllCandidates(result.candidates);
92
92
  return {
@@ -94,7 +94,7 @@ export function useSnapping(options) {
94
94
  activeSnaps: result.activeSnaps,
95
95
  candidates: result.candidates
96
96
  };
97
- }, [config, viewBounds, getParent]);
97
+ }, [config, viewBounds, getParent, customTargets]);
98
98
  const clearSnaps = React.useCallback(() => {
99
99
  setActiveSnaps([]);
100
100
  setAllCandidates([]);
@@ -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.5",
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",