react-svg-canvas 0.0.1 → 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/LICENSE +8 -8
- package/README.md +610 -2
- package/lib/geometry/bounds.d.ts +44 -0
- package/lib/geometry/bounds.js +157 -0
- package/lib/geometry/index.d.ts +6 -0
- package/lib/geometry/index.js +7 -0
- package/lib/geometry/math.d.ts +44 -0
- package/lib/geometry/math.js +80 -0
- package/lib/geometry/transforms.d.ts +52 -0
- package/lib/geometry/transforms.js +122 -0
- package/lib/hooks/index.d.ts +5 -0
- package/lib/hooks/index.js +6 -0
- package/lib/hooks/useDraggable.d.ts +32 -0
- package/lib/hooks/useDraggable.js +162 -0
- package/lib/hooks/useResizable.d.ts +33 -0
- package/lib/hooks/useResizable.js +78 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +16 -0
- package/lib/queries/index.d.ts +4 -0
- package/lib/queries/index.js +5 -0
- package/lib/queries/spatial.d.ts +51 -0
- package/lib/queries/spatial.js +107 -0
- package/lib/selection/ResizeHandle.d.ts +18 -0
- package/lib/selection/ResizeHandle.js +14 -0
- package/lib/selection/SelectionBox.d.ts +21 -0
- package/lib/selection/SelectionBox.js +21 -0
- package/lib/selection/index.d.ts +6 -0
- package/lib/selection/index.js +7 -0
- package/lib/selection/useSelection.d.ts +43 -0
- package/lib/selection/useSelection.js +101 -0
- package/lib/snapping/SnapDebugOverlay.d.ts +17 -0
- package/lib/snapping/SnapDebugOverlay.js +105 -0
- package/lib/snapping/SnapGuides.d.ts +20 -0
- package/lib/snapping/SnapGuides.js +184 -0
- package/lib/snapping/index.d.ts +12 -0
- package/lib/snapping/index.js +17 -0
- package/lib/snapping/rotation-utils.d.ts +67 -0
- package/lib/snapping/rotation-utils.js +204 -0
- package/lib/snapping/snap-engine.d.ts +40 -0
- package/lib/snapping/snap-engine.js +592 -0
- package/lib/snapping/snap-targets.d.ts +40 -0
- package/lib/snapping/snap-targets.js +272 -0
- package/lib/snapping/types.d.ts +178 -0
- package/lib/snapping/types.js +37 -0
- package/lib/snapping/useSnapping.d.ts +52 -0
- package/lib/snapping/useSnapping.js +121 -0
- package/lib/svgcanvas.d.ts +27 -2
- package/lib/svgcanvas.js +189 -116
- package/lib/types.d.ts +67 -0
- package/lib/types.js +14 -0
- package/package.json +45 -11
package/LICENSE
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2024
|
|
3
|
+
Copyright (c) 2024 Szilárd Hajba
|
|
4
4
|
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
this software and associated documentation files (the
|
|
7
|
-
the Software without restriction, including without limitation the rights
|
|
8
|
-
use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
of the Software, and to permit persons to whom the Software is
|
|
10
|
-
so, subject to the following conditions:
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
11
|
|
|
12
12
|
The above copyright notice and this permission notice shall be included in all
|
|
13
13
|
copies or substantial portions of the Software.
|
|
14
14
|
|
|
15
|
-
THE SOFTWARE IS PROVIDED
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
16
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
17
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
18
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
package/README.md
CHANGED
|
@@ -1,4 +1,612 @@
|
|
|
1
|
-
|
|
2
|
-
================
|
|
1
|
+
# react-svg-canvas
|
|
3
2
|
|
|
3
|
+
A React library for building interactive SVG canvas applications with pan, zoom, selection, drag-and-drop, resize, and Figma-style snapping.
|
|
4
4
|
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Pan & Zoom** - Middle mouse/touch panning, mouse wheel/pinch-to-zoom
|
|
8
|
+
- **Touch Support** - Full touch event handling for mobile devices
|
|
9
|
+
- **Selection System** - Multi-select, rectangle selection, selection bounds
|
|
10
|
+
- **Drag & Drop** - Smooth dragging with window-level event handling
|
|
11
|
+
- **Resize Handles** - 8-point resize with min/max constraints
|
|
12
|
+
- **Snapping** - Figma-style snapping to edges, centers, grid, and matching sizes
|
|
13
|
+
- **Geometry Utilities** - Bounds operations, transforms, coordinate conversion
|
|
14
|
+
- **Spatial Queries** - Hit testing, rectangle selection, culling
|
|
15
|
+
- **TypeScript** - Full type definitions included
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install react-svg-canvas
|
|
21
|
+
# or
|
|
22
|
+
pnpm add react-svg-canvas
|
|
23
|
+
# or
|
|
24
|
+
yarn add react-svg-canvas
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Peer Dependencies:** React 18+
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { SvgCanvas, useSvgCanvas } from 'react-svg-canvas'
|
|
33
|
+
|
|
34
|
+
function MyCanvas() {
|
|
35
|
+
return (
|
|
36
|
+
<SvgCanvas
|
|
37
|
+
className="my-canvas"
|
|
38
|
+
style={{ width: '100%', height: '100%' }}
|
|
39
|
+
>
|
|
40
|
+
<rect x={100} y={100} width={200} height={150} fill="#3b82f6" />
|
|
41
|
+
<circle cx={400} cy={200} r={50} fill="#ef4444" />
|
|
42
|
+
</SvgCanvas>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API Reference
|
|
48
|
+
|
|
49
|
+
### SvgCanvas
|
|
50
|
+
|
|
51
|
+
The main canvas component that provides pan, zoom, and coordinate transformation.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { SvgCanvas, SvgCanvasHandle } from 'react-svg-canvas'
|
|
55
|
+
|
|
56
|
+
function App() {
|
|
57
|
+
const canvasRef = useRef<SvgCanvasHandle>(null)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<SvgCanvas
|
|
61
|
+
ref={canvasRef}
|
|
62
|
+
className="canvas"
|
|
63
|
+
style={{ width: '100vw', height: '100vh' }}
|
|
64
|
+
fixed={<MyToolbar />} // Renders in screen space (not transformed)
|
|
65
|
+
onToolStart={(e) => console.log('Tool start:', e.x, e.y)}
|
|
66
|
+
onToolMove={(e) => console.log('Tool move:', e.x, e.y)}
|
|
67
|
+
onToolEnd={() => console.log('Tool end')}
|
|
68
|
+
onContextReady={(ctx) => console.log('Scale:', ctx.scale)}
|
|
69
|
+
>
|
|
70
|
+
{/* Children render in canvas space (transformed) */}
|
|
71
|
+
<MyShapes />
|
|
72
|
+
</SvgCanvas>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### Props
|
|
78
|
+
|
|
79
|
+
| Prop | Type | Description |
|
|
80
|
+
|------|------|-------------|
|
|
81
|
+
| `className` | `string` | CSS class for the SVG element |
|
|
82
|
+
| `style` | `CSSProperties` | Inline styles for the SVG element |
|
|
83
|
+
| `children` | `ReactNode` | Content rendered in canvas space (pan/zoom applied) |
|
|
84
|
+
| `fixed` | `ReactNode` | Content rendered in screen space (UI overlays) |
|
|
85
|
+
| `onToolStart` | `(e: ToolEvent) => void` | Called on left mouse/touch start |
|
|
86
|
+
| `onToolMove` | `(e: ToolEvent) => void` | Called during drag |
|
|
87
|
+
| `onToolEnd` | `() => void` | Called on mouse/touch end |
|
|
88
|
+
| `onContextReady` | `(ctx: SvgCanvasContext) => void` | Called when context changes (zoom, pan) |
|
|
89
|
+
|
|
90
|
+
#### Imperative Handle
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
const canvasRef = useRef<SvgCanvasHandle>(null)
|
|
94
|
+
|
|
95
|
+
// Center viewport on a point
|
|
96
|
+
canvasRef.current?.centerOn(100, 100, 1.5) // x, y, optional zoom
|
|
97
|
+
|
|
98
|
+
// Fit a rectangle in view
|
|
99
|
+
canvasRef.current?.centerOnRect(0, 0, 500, 400, 50) // x, y, w, h, padding
|
|
100
|
+
|
|
101
|
+
// Get/set transform matrix
|
|
102
|
+
const matrix = canvasRef.current?.getMatrix()
|
|
103
|
+
canvasRef.current?.setMatrix([1, 0, 0, 1, 0, 0])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
#### Interaction Controls
|
|
107
|
+
|
|
108
|
+
| Input | Action |
|
|
109
|
+
|-------|--------|
|
|
110
|
+
| Left mouse | Tool events (onToolStart/Move/End) |
|
|
111
|
+
| Middle mouse | Pan canvas |
|
|
112
|
+
| Mouse wheel | Zoom in/out |
|
|
113
|
+
| Single touch | Tool events or pan |
|
|
114
|
+
| Two-finger pinch | Zoom |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### useSvgCanvas
|
|
119
|
+
|
|
120
|
+
Hook to access canvas context from child components.
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
function MyShape() {
|
|
124
|
+
const { svg, matrix, scale, translateTo, translateFrom } = useSvgCanvas()
|
|
125
|
+
|
|
126
|
+
// Convert screen coords to canvas coords
|
|
127
|
+
const [canvasX, canvasY] = translateTo(screenX, screenY)
|
|
128
|
+
|
|
129
|
+
// Convert canvas coords to screen coords
|
|
130
|
+
const [screenX, screenY] = translateFrom(canvasX, canvasY)
|
|
131
|
+
|
|
132
|
+
return <rect x={100} y={100} width={100 / scale} height={100 / scale} />
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
### Selection System
|
|
139
|
+
|
|
140
|
+
#### useSelection
|
|
141
|
+
|
|
142
|
+
Manages selection state for canvas objects.
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
import { useSelection, SpatialObject } from 'react-svg-canvas'
|
|
146
|
+
|
|
147
|
+
interface MyObject extends SpatialObject {
|
|
148
|
+
id: string
|
|
149
|
+
bounds: Bounds
|
|
150
|
+
color: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function Canvas({ objects }: { objects: MyObject[] }) {
|
|
154
|
+
const {
|
|
155
|
+
selectedIds,
|
|
156
|
+
selectedObjects,
|
|
157
|
+
selectionCount,
|
|
158
|
+
selectionBounds,
|
|
159
|
+
hasSelection,
|
|
160
|
+
select,
|
|
161
|
+
selectMultiple,
|
|
162
|
+
deselect,
|
|
163
|
+
toggle,
|
|
164
|
+
clear,
|
|
165
|
+
selectAll,
|
|
166
|
+
selectInRect,
|
|
167
|
+
setSelection,
|
|
168
|
+
isSelected
|
|
169
|
+
} = useSelection({ objects, onChange: (ids) => console.log('Selection:', ids) })
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<SvgCanvas>
|
|
173
|
+
{objects.map(obj => (
|
|
174
|
+
<rect
|
|
175
|
+
key={obj.id}
|
|
176
|
+
{...obj.bounds}
|
|
177
|
+
fill={isSelected(obj.id) ? 'blue' : obj.color}
|
|
178
|
+
onClick={(e) => select(obj.id, e.shiftKey)}
|
|
179
|
+
/>
|
|
180
|
+
))}
|
|
181
|
+
{selectionBounds && (
|
|
182
|
+
<SelectionBox bounds={selectionBounds} onResizeStart={handleResize} />
|
|
183
|
+
)}
|
|
184
|
+
</SvgCanvas>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### SelectionBox
|
|
190
|
+
|
|
191
|
+
Renders a selection rectangle with resize handles.
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
import { SelectionBox } from 'react-svg-canvas'
|
|
195
|
+
|
|
196
|
+
<SelectionBox
|
|
197
|
+
bounds={{ x: 100, y: 100, width: 200, height: 150 }}
|
|
198
|
+
rotation={45}
|
|
199
|
+
stroke="#0066ff"
|
|
200
|
+
strokeDasharray="4,4"
|
|
201
|
+
showHandles={true}
|
|
202
|
+
handleSize={8}
|
|
203
|
+
onResizeStart={(handle, e) => console.log('Resize:', handle)}
|
|
204
|
+
/>
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
### Interaction Hooks
|
|
210
|
+
|
|
211
|
+
#### useDraggable
|
|
212
|
+
|
|
213
|
+
Provides smooth drag interaction with window-level events.
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { useDraggable, svgTransformCoordinates } from 'react-svg-canvas'
|
|
217
|
+
|
|
218
|
+
function DraggableRect({ x, y, onMove }) {
|
|
219
|
+
const { isDragging, dragProps } = useDraggable({
|
|
220
|
+
onDragStart: (e) => console.log('Start:', e.x, e.y),
|
|
221
|
+
onDragMove: (e) => onMove(e.deltaX, e.deltaY),
|
|
222
|
+
onDragEnd: (e) => console.log('End'),
|
|
223
|
+
transformCoordinates: svgTransformCoordinates // For SVG coordinate space
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<rect
|
|
228
|
+
x={x} y={y}
|
|
229
|
+
width={100} height={80}
|
|
230
|
+
fill={isDragging ? 'orange' : 'blue'}
|
|
231
|
+
style={{ cursor: 'move' }}
|
|
232
|
+
{...dragProps}
|
|
233
|
+
/>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### useResizable
|
|
239
|
+
|
|
240
|
+
Provides resize interaction for selected objects.
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
import { useResizable } from 'react-svg-canvas'
|
|
244
|
+
|
|
245
|
+
function ResizableRect({ bounds, onResize }) {
|
|
246
|
+
const { isResizing, activeHandle, handleResizeStart } = useResizable({
|
|
247
|
+
bounds,
|
|
248
|
+
minWidth: 50,
|
|
249
|
+
minHeight: 50,
|
|
250
|
+
onResize: (e) => onResize(e.bounds),
|
|
251
|
+
onResizeEnd: (e) => console.log('Final bounds:', e.bounds)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<SelectionBox
|
|
256
|
+
bounds={bounds}
|
|
257
|
+
onResizeStart={handleResizeStart}
|
|
258
|
+
/>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### Snapping System
|
|
266
|
+
|
|
267
|
+
Figma-style snapping with visual guide lines.
|
|
268
|
+
|
|
269
|
+
#### useSnapping
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
import { useSnapping, SnapGuides, DEFAULT_SNAP_CONFIG } from 'react-svg-canvas'
|
|
273
|
+
|
|
274
|
+
function Canvas({ objects }) {
|
|
275
|
+
const { svg, translateFrom } = useSvgCanvas()
|
|
276
|
+
const viewBounds = { x: 0, y: 0, width: 1000, height: 800 }
|
|
277
|
+
|
|
278
|
+
const { snapDrag, snapResize, activeSnaps, allCandidates, clearSnaps } = useSnapping({
|
|
279
|
+
objects,
|
|
280
|
+
config: DEFAULT_SNAP_CONFIG,
|
|
281
|
+
viewBounds
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
function handleDrag(objectId, bounds, delta, grabPoint) {
|
|
285
|
+
const result = snapDrag({
|
|
286
|
+
bounds: { ...bounds, rotation: 0 },
|
|
287
|
+
objectId,
|
|
288
|
+
delta,
|
|
289
|
+
grabPoint
|
|
290
|
+
})
|
|
291
|
+
// result.position contains snapped coordinates
|
|
292
|
+
// result.activeSnaps contains active snap info
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<SvgCanvas
|
|
297
|
+
fixed={
|
|
298
|
+
<SnapGuides
|
|
299
|
+
activeSnaps={activeSnaps}
|
|
300
|
+
config={DEFAULT_SNAP_CONFIG.guides}
|
|
301
|
+
viewBounds={viewBounds}
|
|
302
|
+
transformPoint={translateFrom}
|
|
303
|
+
/>
|
|
304
|
+
}
|
|
305
|
+
>
|
|
306
|
+
{/* Your objects */}
|
|
307
|
+
</SvgCanvas>
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
```
|
|
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
|
+
|
|
333
|
+
#### Snap Configuration
|
|
334
|
+
|
|
335
|
+
```tsx
|
|
336
|
+
const config: SnapConfiguration = {
|
|
337
|
+
enabled: true,
|
|
338
|
+
snapToGrid: true,
|
|
339
|
+
snapToObjects: true,
|
|
340
|
+
snapToSizes: true, // Snap to matching widths/heights
|
|
341
|
+
gridSize: 10,
|
|
342
|
+
snapThreshold: 8, // Pixels within which snapping activates
|
|
343
|
+
weights: {
|
|
344
|
+
distance: 10, // How much distance affects snap priority
|
|
345
|
+
direction: 3, // Movement direction influence
|
|
346
|
+
velocity: 2, // Faster movement = less sticky
|
|
347
|
+
grabProximity: 5, // Snaps near grab point prioritized
|
|
348
|
+
hierarchy: 4, // Parent/sibling preference
|
|
349
|
+
edgePriority: 1.2,
|
|
350
|
+
centerPriority: 1.0,
|
|
351
|
+
gridPriority: 0.8,
|
|
352
|
+
sizePriority: 0.9
|
|
353
|
+
},
|
|
354
|
+
guides: {
|
|
355
|
+
color: '#ff3366',
|
|
356
|
+
strokeWidth: 1,
|
|
357
|
+
showDistanceIndicators: true
|
|
358
|
+
},
|
|
359
|
+
debug: {
|
|
360
|
+
enabled: false,
|
|
361
|
+
showTopN: 5,
|
|
362
|
+
showScores: true,
|
|
363
|
+
showScoreBreakdown: false
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
### Geometry Utilities
|
|
371
|
+
|
|
372
|
+
#### Bounds Operations
|
|
373
|
+
|
|
374
|
+
```tsx
|
|
375
|
+
import {
|
|
376
|
+
getBoundsCenter,
|
|
377
|
+
expandBounds,
|
|
378
|
+
unionBounds,
|
|
379
|
+
unionAllBounds,
|
|
380
|
+
boundsIntersect,
|
|
381
|
+
boundsContains,
|
|
382
|
+
pointInBounds,
|
|
383
|
+
boundsFromPoints,
|
|
384
|
+
getHandlePositions,
|
|
385
|
+
resizeBounds
|
|
386
|
+
} from 'react-svg-canvas'
|
|
387
|
+
|
|
388
|
+
// Get center point
|
|
389
|
+
const center = getBoundsCenter({ x: 0, y: 0, width: 100, height: 100 })
|
|
390
|
+
// { x: 50, y: 50 }
|
|
391
|
+
|
|
392
|
+
// Expand bounds by margin
|
|
393
|
+
const expanded = expandBounds(bounds, 10)
|
|
394
|
+
|
|
395
|
+
// Union of two bounds
|
|
396
|
+
const combined = unionBounds(boundsA, boundsB)
|
|
397
|
+
|
|
398
|
+
// Check intersection
|
|
399
|
+
if (boundsIntersect(selection, object.bounds)) {
|
|
400
|
+
// Object is selected
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Create bounds from drag rectangle
|
|
404
|
+
const selectionRect = boundsFromPoints(startPoint, endPoint)
|
|
405
|
+
|
|
406
|
+
// Resize bounds from handle drag
|
|
407
|
+
const newBounds = resizeBounds(originalBounds, 'se', deltaX, deltaY, minW, minH)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
#### Transforms
|
|
411
|
+
|
|
412
|
+
```tsx
|
|
413
|
+
import {
|
|
414
|
+
transformPoint,
|
|
415
|
+
invertTransform,
|
|
416
|
+
composeTransforms,
|
|
417
|
+
matrixToTransform,
|
|
418
|
+
transformToMatrix,
|
|
419
|
+
getAbsolutePosition
|
|
420
|
+
} from 'react-svg-canvas'
|
|
421
|
+
|
|
422
|
+
// Apply transform to point
|
|
423
|
+
const worldPoint = transformPoint({ x: 10, y: 10 }, transform)
|
|
424
|
+
|
|
425
|
+
// Convert SVG matrix to transform object
|
|
426
|
+
const transform = matrixToTransform([1, 0, 0, 1, 100, 50])
|
|
427
|
+
|
|
428
|
+
// Get absolute position walking up hierarchy
|
|
429
|
+
const absPos = getAbsolutePosition(item, (item) => itemsById[item.parentId])
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
#### Math Utilities
|
|
433
|
+
|
|
434
|
+
```tsx
|
|
435
|
+
import {
|
|
436
|
+
rotatePoint,
|
|
437
|
+
scalePoint,
|
|
438
|
+
distance,
|
|
439
|
+
snapToGrid,
|
|
440
|
+
snapPointToGrid,
|
|
441
|
+
lerp,
|
|
442
|
+
clamp,
|
|
443
|
+
normalizeAngle,
|
|
444
|
+
degToRad,
|
|
445
|
+
radToDeg
|
|
446
|
+
} from 'react-svg-canvas'
|
|
447
|
+
|
|
448
|
+
// Rotate point around center
|
|
449
|
+
const rotated = rotatePoint({ x: 100, y: 0 }, { x: 0, y: 0 }, 90)
|
|
450
|
+
|
|
451
|
+
// Snap to grid
|
|
452
|
+
const snapped = snapPointToGrid({ x: 123, y: 456 }, 10)
|
|
453
|
+
// { x: 120, y: 460 }
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
### Spatial Queries
|
|
459
|
+
|
|
460
|
+
```tsx
|
|
461
|
+
import {
|
|
462
|
+
getObjectsAtPoint,
|
|
463
|
+
getTopmostAtPoint,
|
|
464
|
+
getObjectsIntersectingRect,
|
|
465
|
+
getObjectsContainedInRect,
|
|
466
|
+
getSelectionBounds,
|
|
467
|
+
getObjectsInView,
|
|
468
|
+
findNearestObject,
|
|
469
|
+
getObjectsInRadius
|
|
470
|
+
} from 'react-svg-canvas'
|
|
471
|
+
|
|
472
|
+
// Hit testing
|
|
473
|
+
const clicked = getTopmostAtPoint(objects, { x: mouseX, y: mouseY })
|
|
474
|
+
|
|
475
|
+
// Rectangle selection
|
|
476
|
+
const selected = getObjectsIntersectingRect(objects, selectionRect)
|
|
477
|
+
|
|
478
|
+
// Viewport culling (render only visible objects)
|
|
479
|
+
const visible = getObjectsInView(objects, viewBounds)
|
|
480
|
+
|
|
481
|
+
// Find nearest object
|
|
482
|
+
const nearest = findNearestObject(objects, cursorPos, maxDistance)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Types
|
|
488
|
+
|
|
489
|
+
```tsx
|
|
490
|
+
interface Point {
|
|
491
|
+
x: number
|
|
492
|
+
y: number
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
interface Bounds {
|
|
496
|
+
x: number
|
|
497
|
+
y: number
|
|
498
|
+
width: number
|
|
499
|
+
height: number
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface Transform {
|
|
503
|
+
x: number
|
|
504
|
+
y: number
|
|
505
|
+
rotation: number
|
|
506
|
+
scaleX: number
|
|
507
|
+
scaleY: number
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface SpatialObject {
|
|
511
|
+
id: string
|
|
512
|
+
bounds: Bounds
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
interface ToolEvent {
|
|
516
|
+
startX: number
|
|
517
|
+
startY: number
|
|
518
|
+
x: number
|
|
519
|
+
y: number
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Example: Complete Editor
|
|
528
|
+
|
|
529
|
+
```tsx
|
|
530
|
+
import {
|
|
531
|
+
SvgCanvas,
|
|
532
|
+
SvgCanvasHandle,
|
|
533
|
+
useSelection,
|
|
534
|
+
useDraggable,
|
|
535
|
+
useSnapping,
|
|
536
|
+
SelectionBox,
|
|
537
|
+
SnapGuides,
|
|
538
|
+
DEFAULT_SNAP_CONFIG
|
|
539
|
+
} from 'react-svg-canvas'
|
|
540
|
+
|
|
541
|
+
function Editor() {
|
|
542
|
+
const canvasRef = useRef<SvgCanvasHandle>(null)
|
|
543
|
+
const [objects, setObjects] = useState<MyObject[]>(initialObjects)
|
|
544
|
+
|
|
545
|
+
const selection = useSelection({
|
|
546
|
+
objects,
|
|
547
|
+
onChange: (ids) => console.log('Selected:', ids)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
const snapping = useSnapping({
|
|
551
|
+
objects,
|
|
552
|
+
config: DEFAULT_SNAP_CONFIG,
|
|
553
|
+
viewBounds: { x: 0, y: 0, width: 1920, height: 1080 }
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<SvgCanvas
|
|
558
|
+
ref={canvasRef}
|
|
559
|
+
style={{ width: '100%', height: '100vh' }}
|
|
560
|
+
onToolStart={(e) => {
|
|
561
|
+
const hit = getTopmostAtPoint(objects, e)
|
|
562
|
+
if (hit) selection.select(hit.id, false)
|
|
563
|
+
else selection.clear()
|
|
564
|
+
}}
|
|
565
|
+
fixed={
|
|
566
|
+
<SnapGuides
|
|
567
|
+
activeSnaps={snapping.activeSnaps}
|
|
568
|
+
config={DEFAULT_SNAP_CONFIG.guides}
|
|
569
|
+
viewBounds={viewBounds}
|
|
570
|
+
/>
|
|
571
|
+
}
|
|
572
|
+
>
|
|
573
|
+
{objects.map(obj => (
|
|
574
|
+
<DraggableShape
|
|
575
|
+
key={obj.id}
|
|
576
|
+
object={obj}
|
|
577
|
+
isSelected={selection.isSelected(obj.id)}
|
|
578
|
+
onMove={(delta) => {
|
|
579
|
+
const result = snapping.snapDrag({
|
|
580
|
+
bounds: obj.bounds,
|
|
581
|
+
objectId: obj.id,
|
|
582
|
+
delta,
|
|
583
|
+
grabPoint: { x: 0.5, y: 0.5 }
|
|
584
|
+
})
|
|
585
|
+
updateObject(obj.id, result.position)
|
|
586
|
+
}}
|
|
587
|
+
/>
|
|
588
|
+
))}
|
|
589
|
+
|
|
590
|
+
{selection.selectionBounds && (
|
|
591
|
+
<SelectionBox
|
|
592
|
+
bounds={selection.selectionBounds}
|
|
593
|
+
onResizeStart={handleResize}
|
|
594
|
+
/>
|
|
595
|
+
)}
|
|
596
|
+
</SvgCanvas>
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
## Browser Support
|
|
602
|
+
|
|
603
|
+
- Modern browsers with ES2021 support
|
|
604
|
+
- Touch devices (iOS Safari, Android Chrome)
|
|
605
|
+
|
|
606
|
+
## License
|
|
607
|
+
|
|
608
|
+
MIT License - see [LICENSE](./LICENSE) for details.
|
|
609
|
+
|
|
610
|
+
## Author
|
|
611
|
+
|
|
612
|
+
Szilard Hajba <szilard@cloudillo.org>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds operations for SVG canvas
|
|
3
|
+
*/
|
|
4
|
+
import type { Point, Bounds, HandlePosition, ResizeHandle } from '../types';
|
|
5
|
+
/**
|
|
6
|
+
* Get center of bounds
|
|
7
|
+
*/
|
|
8
|
+
export declare function getBoundsCenter(bounds: Bounds): Point;
|
|
9
|
+
/**
|
|
10
|
+
* Expand bounds by margin
|
|
11
|
+
*/
|
|
12
|
+
export declare function expandBounds(bounds: Bounds, margin: number): Bounds;
|
|
13
|
+
/**
|
|
14
|
+
* Union of two bounds
|
|
15
|
+
*/
|
|
16
|
+
export declare function unionBounds(a: Bounds, b: Bounds): Bounds;
|
|
17
|
+
/**
|
|
18
|
+
* Union of multiple bounds
|
|
19
|
+
*/
|
|
20
|
+
export declare function unionAllBounds(boundsArray: Bounds[]): Bounds | null;
|
|
21
|
+
/**
|
|
22
|
+
* Check if two bounds intersect (AABB collision)
|
|
23
|
+
*/
|
|
24
|
+
export declare function boundsIntersect(a: Bounds, b: Bounds): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Check if point is inside bounds
|
|
27
|
+
*/
|
|
28
|
+
export declare function pointInBounds(point: Point, bounds: Bounds): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Check if bounds A is completely inside bounds B
|
|
31
|
+
*/
|
|
32
|
+
export declare function boundsContains(outer: Bounds, inner: Bounds): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Create bounds from two points (handles negative width/height)
|
|
35
|
+
*/
|
|
36
|
+
export declare function boundsFromPoints(p1: Point, p2: Point): Bounds;
|
|
37
|
+
/**
|
|
38
|
+
* Get the 8 resize handle positions for a bounds
|
|
39
|
+
*/
|
|
40
|
+
export declare function getHandlePositions(bounds: Bounds): HandlePosition[];
|
|
41
|
+
/**
|
|
42
|
+
* Calculate new bounds after resizing from a handle
|
|
43
|
+
*/
|
|
44
|
+
export declare function resizeBounds(originalBounds: Bounds, handle: ResizeHandle, deltaX: number, deltaY: number, minWidth?: number, minHeight?: number): Bounds;
|