react-svg-canvas 0.0.2 → 0.1.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 CHANGED
@@ -24,7 +24,7 @@ pnpm add react-svg-canvas
24
24
  yarn add react-svg-canvas
25
25
  ```
26
26
 
27
- **Peer Dependencies:** React 19.x
27
+ **Peer Dependencies:** React 18+
28
28
 
29
29
  ## Quick Start
30
30
 
@@ -154,14 +154,17 @@ function Canvas({ objects }: { objects: MyObject[] }) {
154
154
  const {
155
155
  selectedIds,
156
156
  selectedObjects,
157
+ selectionCount,
157
158
  selectionBounds,
158
159
  hasSelection,
159
160
  select,
160
161
  selectMultiple,
162
+ deselect,
161
163
  toggle,
162
164
  clear,
163
165
  selectAll,
164
166
  selectInRect,
167
+ setSelection,
165
168
  isSelected
166
169
  } = useSelection({ objects, onChange: (ids) => console.log('Selection:', ids) })
167
170
 
@@ -306,6 +309,27 @@ function Canvas({ objects }) {
306
309
  }
307
310
  ```
308
311
 
312
+ #### useGrabPoint
313
+
314
+ Helper hook for calculating the normalized grab point when dragging objects.
315
+
316
+ ```tsx
317
+ import { useGrabPoint } from 'react-svg-canvas'
318
+
319
+ function MyDraggable({ bounds }) {
320
+ const { setGrabPoint, getGrabPoint } = useGrabPoint()
321
+
322
+ function handleDragStart(mousePos) {
323
+ setGrabPoint(mousePos, bounds)
324
+ }
325
+
326
+ function handleDrag(delta) {
327
+ const grabPoint = getGrabPoint() // Returns { x: 0-1, y: 0-1 }
328
+ // Use with snapDrag...
329
+ }
330
+ }
331
+ ```
332
+
309
333
  #### Snap Configuration
310
334
 
311
335
  ```tsx
@@ -488,6 +512,13 @@ interface SpatialObject {
488
512
  bounds: Bounds
489
513
  }
490
514
 
515
+ interface ToolEvent {
516
+ startX: number
517
+ startY: number
518
+ x: number
519
+ y: number
520
+ }
521
+
491
522
  type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
492
523
  ```
493
524
 
@@ -569,7 +600,7 @@ function Editor() {
569
600
 
570
601
  ## Browser Support
571
602
 
572
- - Modern browsers with ES2020 support
603
+ - Modern browsers with ES2021 support
573
604
  - Touch devices (iOS Safari, Android Chrome)
574
605
 
575
606
  ## License
@@ -12,7 +12,7 @@ export declare function useSvgCanvas(): {
12
12
  translateTo: (x: number, y: number) => [number, number];
13
13
  translateFrom: (x: number, y: number) => [number, number];
14
14
  setDragHandler: React.Dispatch<React.SetStateAction<DragHandler | undefined>>;
15
- startDrag: (evt: React.MouseEvent | React.TouchEvent) => void;
15
+ startDrag: (evt: React.PointerEvent) => void;
16
16
  };
17
17
  /**
18
18
  * Context exposed to consumers via onContextReady callback
package/lib/svgcanvas.js CHANGED
@@ -17,11 +17,14 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
17
17
  const svgRef = React.useRef(null);
18
18
  const [active, setActive] = React.useState();
19
19
  const [pan, setPan] = React.useState();
20
- const [pinch, setPinch] = React.useState();
21
20
  const [drag, setDrag] = React.useState();
22
21
  const [toolStart, setToolStart] = React.useState();
23
22
  const [dragHandler, setDragHandler] = React.useState();
24
23
  const [matrix, setMatrix] = React.useState([1, 0, 0, 1, 0, 0]);
24
+ // Track active pointers for multi-touch gestures
25
+ const activePointersRef = React.useRef(new Map());
26
+ const lastPinchDistanceRef = React.useRef(undefined);
27
+ const lastPinchCenterRef = React.useRef(undefined);
25
28
  const translateTo = React.useCallback(function traslateTo(x, y) {
26
29
  return [(x - matrix[4]) / matrix[0], (y - matrix[5]) / matrix[3]];
27
30
  }, [matrix]);
@@ -29,11 +32,10 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
29
32
  return [x * matrix[0] + matrix[4], y * matrix[3] + matrix[5]];
30
33
  }, [matrix]);
31
34
  function startDrag(evt) {
32
- const e = 'touches' in evt ? transformTouchEvent(evt) : transformMouseEvent(evt);
35
+ const e = transformPointerEvent(evt);
33
36
  if (!e)
34
37
  return;
35
38
  setToolStart({ startX: e.x, startY: e.y });
36
- //setDrag({ target, startX: e.x, startY: e.y, lastX: e.x, lastY: e.y})
37
39
  }
38
40
  const svgContext = React.useMemo(() => ({
39
41
  svg: svgRef.current || undefined,
@@ -90,11 +92,11 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
90
92
  getMatrix: () => matrix,
91
93
  setMatrix: (newMatrix) => setMatrix(newMatrix)
92
94
  }), [matrix]);
93
- function transformTouchEvent(evt) {
95
+ function transformPointerEvent(evt) {
94
96
  if (!svgRef.current)
95
97
  return { x: 0, y: 0 };
96
- const br = svgRef.current.getBoundingClientRect(), [x, y] = translateTo(evt.touches[0].clientX - br.left, evt.touches[0].clientY - br.top);
97
- return { buttons: undefined, x, y };
98
+ const br = svgRef.current.getBoundingClientRect(), [x, y] = translateTo(evt.clientX - br.left, evt.clientY - br.top);
99
+ return { buttons: evt.buttons, x, y };
98
100
  }
99
101
  function transformMouseEvent(evt) {
100
102
  if (!svgRef.current)
@@ -102,126 +104,161 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
102
104
  const br = svgRef.current.getBoundingClientRect(), [x, y] = translateTo(evt.clientX - br.left, evt.clientY - br.top);
103
105
  return { buttons: evt.buttons, x, y };
104
106
  }
105
- function onMouseDown(evt) {
107
+ function onPointerDown(evt) {
108
+ // Track this pointer
109
+ activePointersRef.current.set(evt.pointerId, {
110
+ id: evt.pointerId,
111
+ x: evt.clientX,
112
+ y: evt.clientY
113
+ });
114
+ const pointerCount = activePointersRef.current.size;
115
+ // If we have 2+ pointers, enter pinch-zoom mode
116
+ if (pointerCount >= 2) {
117
+ const pointers = Array.from(activePointersRef.current.values());
118
+ const distance = Math.sqrt((pointers[0].x - pointers[1].x) ** 2 +
119
+ (pointers[0].y - pointers[1].y) ** 2);
120
+ const centerX = (pointers[0].x + pointers[1].x) / 2;
121
+ const centerY = (pointers[0].y + pointers[1].y) / 2;
122
+ lastPinchDistanceRef.current = distance;
123
+ lastPinchCenterRef.current = { x: centerX, y: centerY };
124
+ // Cancel any active tool operation
125
+ setToolStart(undefined);
126
+ setPan(undefined);
127
+ evt.preventDefault();
128
+ evt.stopPropagation();
129
+ return;
130
+ }
131
+ // Single pointer handling
106
132
  evt.preventDefault();
107
133
  evt.stopPropagation();
108
- switch (evt.buttons) {
109
- case 1: /* left button */
110
- if (onToolStart) {
111
- const e = transformMouseEvent(evt);
112
- if (e) {
113
- setToolStart({ startX: e.x, startY: e.y });
114
- onToolStart({ startX: e.x, startY: e.y, x: e.x, y: e.y });
134
+ // Check if this is a touch event (pointerType === 'touch') or mouse
135
+ if (evt.pointerType === 'mouse') {
136
+ switch (evt.buttons) {
137
+ case 1: /* left button */
138
+ if (onToolStart) {
139
+ const e = transformPointerEvent(evt);
140
+ if (e) {
141
+ setToolStart({ startX: e.x, startY: e.y });
142
+ onToolStart({ startX: e.x, startY: e.y, x: e.x, y: e.y });
143
+ }
115
144
  }
116
- }
117
- setActive(undefined);
118
- break;
119
- case 4: /* middle button */
120
- // Pan
121
- const lastX = evt.clientX;
122
- const lastY = evt.clientY;
123
- setPan({ lastX, lastY });
124
- break;
125
- }
126
- }
127
- function onTouchStart(evt) {
128
- const lastX = evt.touches[0].clientX;
129
- const lastY = evt.touches[0].clientY;
130
- evt.stopPropagation();
131
- if (onToolStart) {
132
- const e = transformTouchEvent(evt);
133
- if (e) {
134
- setToolStart({ startX: e.x, startY: e.y });
135
- onToolStart({ startX: e.x, startY: e.y, x: e.x, y: e.y });
145
+ setActive(undefined);
146
+ break;
147
+ case 4: /* middle button */
148
+ setPan({ lastX: evt.clientX, lastY: evt.clientY });
149
+ break;
136
150
  }
137
151
  }
138
152
  else {
139
- setPan({ lastX, lastY });
153
+ // Touch or pen - single finger
154
+ if (onToolStart) {
155
+ const e = transformPointerEvent(evt);
156
+ if (e) {
157
+ setToolStart({ startX: e.x, startY: e.y });
158
+ onToolStart({ startX: e.x, startY: e.y, x: e.x, y: e.y });
159
+ }
160
+ }
161
+ else {
162
+ // No tool active - pan mode
163
+ setPan({ lastX: evt.clientX, lastY: evt.clientY });
164
+ }
140
165
  }
141
166
  }
142
- function onMouseMove(evt) {
143
- if (!evt.buttons)
167
+ function onPointerMove(evt) {
168
+ // Update tracked pointer position
169
+ if (activePointersRef.current.has(evt.pointerId)) {
170
+ activePointersRef.current.set(evt.pointerId, {
171
+ id: evt.pointerId,
172
+ x: evt.clientX,
173
+ y: evt.clientY
174
+ });
175
+ }
176
+ const pointerCount = activePointersRef.current.size;
177
+ // Handle 2-finger pinch+pan
178
+ if (pointerCount >= 2 && lastPinchDistanceRef.current !== undefined && lastPinchCenterRef.current !== undefined) {
179
+ const pointers = Array.from(activePointersRef.current.values());
180
+ const newDistance = Math.sqrt((pointers[0].x - pointers[1].x) ** 2 +
181
+ (pointers[0].y - pointers[1].y) ** 2);
182
+ const newCenterX = (pointers[0].x + pointers[1].x) / 2;
183
+ const newCenterY = (pointers[0].y + pointers[1].y) / 2;
184
+ // Calculate zoom scale
185
+ const scale = newDistance / lastPinchDistanceRef.current;
186
+ // Calculate pan delta (center point movement)
187
+ const dx = newCenterX - lastPinchCenterRef.current.x;
188
+ const dy = newCenterY - lastPinchCenterRef.current.y;
189
+ // Apply combined pan+zoom transformation
190
+ setMatrix(m => [
191
+ m[0] * scale,
192
+ m[1] * scale,
193
+ m[2] * scale,
194
+ m[3] * scale,
195
+ (m[4] + dx) - (newCenterX - (m[4] + dx)) * (scale - 1),
196
+ (m[5] + dy) - (newCenterY - (m[5] + dy)) * (scale - 1)
197
+ ]);
198
+ // Update tracking state
199
+ lastPinchDistanceRef.current = newDistance;
200
+ lastPinchCenterRef.current = { x: newCenterX, y: newCenterY };
201
+ evt.stopPropagation();
202
+ evt.preventDefault();
144
203
  return;
145
- evt.preventDefault();
146
- // Don't stopPropagation - allow window event listeners to work
204
+ }
205
+ // Single pointer handling
147
206
  if (dragHandler && toolStart) {
148
- const e = transformMouseEvent(evt);
207
+ const e = transformPointerEvent(evt);
149
208
  dragHandler.onDragMove({ x: e.x, y: e.y, startX: toolStart.startX, startY: toolStart.startY });
150
209
  }
151
210
  else if (pan) {
152
- if (evt.buttons == 4) {
153
- // Pan
154
- let x = evt.clientX, y = evt.clientY;
155
- const dx = x - pan.lastX;
156
- const dy = y - pan.lastY;
157
- setMatrix(matrix => [matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + dx, matrix[5] + dy]);
158
- setPan({ lastX: x, lastY: y });
159
- evt.stopPropagation();
160
- }
161
- else {
162
- onDragEnd();
163
- }
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();
164
218
  }
165
219
  else if (onToolMove && toolStart) {
166
- const e = transformMouseEvent(evt);
220
+ const e = transformPointerEvent(evt);
167
221
  if (e) {
168
222
  onToolMove({ x: e.x, y: e.y, startX: toolStart.startX, startY: toolStart.startY });
169
223
  }
170
224
  }
171
- // Don't call onDragEnd when nothing was started - allows external handlers (like resize) to work
172
225
  }
173
- function onTouchMove(evt) {
174
- evt.stopPropagation();
175
- if (evt.touches.length == 1) {
176
- if (dragHandler && toolStart) {
177
- const e = transformTouchEvent(evt);
178
- dragHandler.onDragMove({ x: e.x, y: e.y, startX: toolStart?.startX, startY: toolStart?.startY });
179
- /*
180
- if (e && drag.target) {
181
- let scale = matrix[0]
182
- } else return onDragEnd()
183
- setDrag(drag => drag ? { ...drag, lastX: e.x, lastY: e.y } : undefined)
184
- */
185
- }
186
- else if (pan) {
187
- // Pan
188
- let x = evt.touches[0].clientX, y = evt.touches[0].clientY;
189
- const dx = x - pan.lastX;
190
- const dy = y - pan.lastY;
191
- setMatrix(matrix => [matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + dx, matrix[5] + dy]);
192
- setPan({ lastX: x, lastY: y });
193
- evt.stopPropagation();
194
- }
195
- else if (onToolMove && toolStart) {
196
- const e = transformTouchEvent(evt);
197
- if (e) {
198
- onToolMove({ x: e.x, y: e.y, startX: toolStart.startX, startY: toolStart.startY });
199
- }
200
- }
201
- else
202
- return onDragEnd();
226
+ function onPointerUp(evt) {
227
+ // Remove this pointer from tracking
228
+ activePointersRef.current.delete(evt.pointerId);
229
+ const pointerCount = activePointersRef.current.size;
230
+ // If we still have pointers, reinitialize pinch state with remaining pointers
231
+ if (pointerCount >= 2) {
232
+ const pointers = Array.from(activePointersRef.current.values());
233
+ lastPinchDistanceRef.current = Math.sqrt((pointers[0].x - pointers[1].x) ** 2 +
234
+ (pointers[0].y - pointers[1].y) ** 2);
235
+ lastPinchCenterRef.current = {
236
+ x: (pointers[0].x + pointers[1].x) / 2,
237
+ y: (pointers[0].y + pointers[1].y) / 2
238
+ };
239
+ return;
203
240
  }
204
- else if (evt.touches.length == 2) {
205
- if (!pinch) {
206
- setPinch(Math.sqrt((evt.touches[0].clientX - evt.touches[1].clientX) ** 2
207
- + (evt.touches[0].clientY - evt.touches[1].clientY) ** 2));
208
- }
209
- else {
210
- const newPinch = Math.sqrt((evt.touches[0].clientX - evt.touches[1].clientX) ** 2
211
- + (evt.touches[0].clientY - evt.touches[1].clientY) ** 2);
212
- const cx = (evt.touches[0].clientX + evt.touches[1].clientX) / 2;
213
- const xy = (evt.touches[0].clientY + evt.touches[1].clientY) / 2;
214
- const scale = newPinch / pinch;
215
- zoom(scale, cx, xy);
216
- setPinch(newPinch);
217
- evt.stopPropagation();
218
- evt.preventDefault();
219
- }
241
+ // If we're down to 1 pointer and were pinching, reset pinch state
242
+ if (pointerCount === 1) {
243
+ lastPinchDistanceRef.current = undefined;
244
+ lastPinchCenterRef.current = undefined;
245
+ // Don't start panning - let the remaining pointer continue without action
246
+ return;
247
+ }
248
+ // All pointers released - cleanup
249
+ onDragEnd();
250
+ }
251
+ function onPointerCancel(evt) {
252
+ activePointersRef.current.delete(evt.pointerId);
253
+ if (activePointersRef.current.size === 0) {
254
+ lastPinchDistanceRef.current = undefined;
255
+ lastPinchCenterRef.current = undefined;
256
+ onDragEnd();
220
257
  }
221
258
  }
222
259
  function onDragEnd() {
223
- if (pinch)
224
- setPinch(undefined);
260
+ lastPinchDistanceRef.current = undefined;
261
+ lastPinchCenterRef.current = undefined;
225
262
  if (dragHandler) {
226
263
  dragHandler.onDragEnd?.();
227
264
  setDragHandler(undefined);
@@ -233,6 +270,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
233
270
  onToolEnd();
234
271
  setDrag(undefined);
235
272
  setPan(undefined);
273
+ setToolStart(undefined);
236
274
  }
237
275
  function onWheel(evt) {
238
276
  const page = svgRef.current?.getBoundingClientRect() || { left: 0, top: 0 };
@@ -256,7 +294,7 @@ export const SvgCanvas = React.forwardRef(function SvgCanvas({ className, style,
256
294
  ]);
257
295
  }
258
296
  return React.createElement(SvgCanvasContext.Provider, { value: svgContext },
259
- React.createElement("svg", { ref: svgRef, className: className, style: { ...style, touchAction: 'none' }, onMouseDown: onMouseDown, onTouchStart: onTouchStart, onMouseMove: onMouseMove, onTouchMove: onTouchMove, onMouseUp: onDragEnd, onTouchEnd: onDragEnd, onWheel: onWheel },
297
+ React.createElement("svg", { ref: svgRef, className: className, style: { ...style, touchAction: 'none' }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerCancel, onWheel: onWheel },
260
298
  React.createElement("g", { transform: `matrix(${matrix.map(x => Math.round(x * 1000) / 1000).join(' ')})` }, children),
261
299
  React.createElement("g", null, fixed)));
262
300
  });
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "react-svg-canvas",
3
- "version": "0.0.2",
4
- "description": "React SVG Canvas",
3
+ "version": "0.1.0",
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",
7
+ "module": "lib/index.js",
8
+ "types": "lib/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/index.d.ts",
12
+ "import": "./lib/index.js",
13
+ "default": "./lib/index.js"
14
+ }
15
+ },
16
+ "sideEffects": false,
7
17
  "files": [
8
18
  "lib"
9
19
  ],
@@ -12,15 +22,38 @@
12
22
  "build": "tsc",
13
23
  "watch": "tsc -w",
14
24
  "clean": "rimraf .cache lib",
25
+ "prepublishOnly": "npm run build",
15
26
  "pub": "npm publish --access public"
16
27
  },
17
28
  "keywords": [
18
29
  "react",
19
30
  "svg",
20
- "canvas"
31
+ "canvas",
32
+ "pan",
33
+ "zoom",
34
+ "drag-and-drop",
35
+ "selection",
36
+ "resize",
37
+ "snapping",
38
+ "interactive",
39
+ "editor",
40
+ "diagram",
41
+ "whiteboard",
42
+ "figma"
21
43
  ],
22
- "author": "Szilard Hajba <szilu@symbion.hu>",
44
+ "author": "Szilard Hajba <szilard@cloudillo.org>",
23
45
  "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/cloudillo/react-svg-canvas.git"
49
+ },
50
+ "homepage": "https://github.com/cloudillo/react-svg-canvas#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/cloudillo/react-svg-canvas/issues"
53
+ },
54
+ "engines": {
55
+ "node": ">=18.0.0"
56
+ },
24
57
  "devDependencies": {
25
58
  "@types/react": "^19.2.7",
26
59
  "@types/react-dom": "^19.2.3",
@@ -29,6 +62,6 @@
29
62
  "typescript": "^5.9.3"
30
63
  },
31
64
  "peerDependencies": {
32
- "react": "^19.2.0"
65
+ "react": ">=18.0.0"
33
66
  }
34
67
  }