react-svg-canvas 0.1.0 → 0.1.1
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 +208 -0
- package/lib/geometry/index.d.ts +2 -0
- package/lib/geometry/index.js +2 -0
- package/lib/geometry/math.d.ts +31 -0
- package/lib/geometry/math.js +53 -0
- package/lib/geometry/resize.d.ts +72 -0
- package/lib/geometry/resize.js +158 -0
- package/lib/geometry/view.d.ts +37 -0
- package/lib/geometry/view.js +61 -0
- package/lib/hooks/index.d.ts +4 -2
- package/lib/hooks/index.js +2 -2
- package/lib/hooks/useDraggable.d.ts +40 -5
- package/lib/hooks/useDraggable.js +115 -80
- package/lib/hooks/useResizable.d.ts +57 -3
- package/lib/hooks/useResizable.js +101 -34
- package/lib/index.d.ts +1 -0
- package/lib/index.js +2 -0
- package/lib/rotation/index.d.ts +11 -0
- package/lib/rotation/index.js +22 -0
- package/lib/rotation/rotation-utils.d.ts +132 -0
- package/lib/rotation/rotation-utils.js +281 -0
- package/lib/rotation/types.d.ts +239 -0
- package/lib/rotation/types.js +47 -0
- package/lib/rotation/useGroupPivot.d.ts +26 -0
- package/lib/rotation/useGroupPivot.js +163 -0
- package/lib/rotation/usePivotDrag.d.ts +29 -0
- package/lib/rotation/usePivotDrag.js +196 -0
- package/lib/rotation/useRotatable.d.ts +27 -0
- package/lib/rotation/useRotatable.js +255 -0
- package/lib/selection/ResizeHandle.d.ts +3 -2
- package/lib/selection/ResizeHandle.js +5 -4
- package/lib/selection/SelectionBox.d.ts +1 -1
- package/lib/selection/SelectionBox.js +1 -1
- package/lib/svgcanvas.d.ts +6 -0
- package/lib/svgcanvas.js +13 -5
- package/lib/types.d.ts +12 -0
- package/lib/types.js +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A React library for building interactive SVG canvas applications with pan, zoom,
|
|
|
9
9
|
- **Selection System** - Multi-select, rectangle selection, selection bounds
|
|
10
10
|
- **Drag & Drop** - Smooth dragging with window-level event handling
|
|
11
11
|
- **Resize Handles** - 8-point resize with min/max constraints
|
|
12
|
+
- **Rotation** - Object and group rotation with snap angles and pivot point manipulation
|
|
12
13
|
- **Snapping** - Figma-style snapping to edges, centers, grid, and matching sizes
|
|
13
14
|
- **Geometry Utilities** - Bounds operations, transforms, coordinate conversion
|
|
14
15
|
- **Spatial Queries** - Hit testing, rectangle selection, culling
|
|
@@ -367,6 +368,189 @@ const config: SnapConfiguration = {
|
|
|
367
368
|
|
|
368
369
|
---
|
|
369
370
|
|
|
371
|
+
### Rotation System
|
|
372
|
+
|
|
373
|
+
Object and group rotation with visual snap zones and pivot point manipulation.
|
|
374
|
+
|
|
375
|
+
#### useRotatable
|
|
376
|
+
|
|
377
|
+
Provides rotation interaction with visual snap zones. When the pointer is within the inner portion of the rotation arc (default 75%), angles snap to predefined values.
|
|
378
|
+
|
|
379
|
+
```tsx
|
|
380
|
+
import { useRotatable, DEFAULT_SNAP_ANGLES } from 'react-svg-canvas'
|
|
381
|
+
|
|
382
|
+
function RotatableObject({ bounds, rotation, onRotate }) {
|
|
383
|
+
const { translateTo, translateFrom } = useSvgCanvas()
|
|
384
|
+
|
|
385
|
+
const {
|
|
386
|
+
rotationState,
|
|
387
|
+
handleRotateStart,
|
|
388
|
+
rotateProps,
|
|
389
|
+
checkSnapZone,
|
|
390
|
+
arcRadius,
|
|
391
|
+
pivotPosition
|
|
392
|
+
} = useRotatable({
|
|
393
|
+
bounds,
|
|
394
|
+
rotation,
|
|
395
|
+
pivotX: 0.5,
|
|
396
|
+
pivotY: 0.5,
|
|
397
|
+
snapAngles: DEFAULT_SNAP_ANGLES, // 15° intervals: [0, 15, 30, ...]
|
|
398
|
+
snapZoneRatio: 0.75, // Inner 75% of arc triggers snapping
|
|
399
|
+
translateTo,
|
|
400
|
+
translateFrom,
|
|
401
|
+
screenSpaceSnapZone: true, // Consistent UX at all zoom levels
|
|
402
|
+
onRotate: (angle, isSnapped) => onRotate(angle),
|
|
403
|
+
onRotateEnd: (angle) => console.log('Final:', angle)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<g>
|
|
408
|
+
<rect {...bounds} />
|
|
409
|
+
<RotationHandle
|
|
410
|
+
position={pivotPosition}
|
|
411
|
+
arcRadius={arcRadius}
|
|
412
|
+
isInSnapZone={rotationState.isInSnapZone}
|
|
413
|
+
onPointerDown={handleRotateStart}
|
|
414
|
+
{...rotateProps}
|
|
415
|
+
/>
|
|
416
|
+
</g>
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
##### CRDT-Friendly State
|
|
422
|
+
|
|
423
|
+
For collaborative editing with external state (e.g., Yjs), use getter functions to avoid stale closures:
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
const { handleRotateStart } = useRotatable({
|
|
427
|
+
bounds,
|
|
428
|
+
rotation,
|
|
429
|
+
// Getters are called at drag start for fresh values
|
|
430
|
+
getBounds: () => yObject.get('bounds'),
|
|
431
|
+
getRotation: () => yObject.get('rotation'),
|
|
432
|
+
getPivot: () => ({ x: yObject.get('pivotX'), y: yObject.get('pivotY') }),
|
|
433
|
+
onRotate: (angle) => yObject.set('rotation', angle)
|
|
434
|
+
})
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### usePivotDrag
|
|
438
|
+
|
|
439
|
+
Drag interaction for manipulating an object's rotation pivot point.
|
|
440
|
+
|
|
441
|
+
```tsx
|
|
442
|
+
import { usePivotDrag, DEFAULT_PIVOT_SNAP_POINTS } from 'react-svg-canvas'
|
|
443
|
+
|
|
444
|
+
function PivotHandle({ bounds, rotation, pivotX, pivotY, onPivotChange }) {
|
|
445
|
+
const { translateTo } = useSvgCanvas()
|
|
446
|
+
|
|
447
|
+
const {
|
|
448
|
+
pivotState,
|
|
449
|
+
handlePivotDragStart,
|
|
450
|
+
pivotDragProps,
|
|
451
|
+
getPositionCompensation
|
|
452
|
+
} = usePivotDrag({
|
|
453
|
+
bounds,
|
|
454
|
+
rotation,
|
|
455
|
+
pivotX,
|
|
456
|
+
pivotY,
|
|
457
|
+
snapPoints: DEFAULT_PIVOT_SNAP_POINTS, // 9 points: corners, edges, center
|
|
458
|
+
snapThreshold: 0.08,
|
|
459
|
+
translateTo,
|
|
460
|
+
onDrag: (pivot, snappedPoint, positionCompensation) => {
|
|
461
|
+
// positionCompensation adjusts object position to keep it visually in place
|
|
462
|
+
onPivotChange(pivot, positionCompensation)
|
|
463
|
+
},
|
|
464
|
+
onDragEnd: (pivot, positionCompensation) => {
|
|
465
|
+
console.log('Final pivot:', pivot)
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<circle
|
|
471
|
+
cx={bounds.x + bounds.width * pivotX}
|
|
472
|
+
cy={bounds.y + bounds.height * pivotY}
|
|
473
|
+
r={6}
|
|
474
|
+
fill={pivotState.snappedPoint ? 'blue' : 'gray'}
|
|
475
|
+
onPointerDown={handlePivotDragStart}
|
|
476
|
+
{...pivotDragProps}
|
|
477
|
+
/>
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
#### useGroupPivot
|
|
483
|
+
|
|
484
|
+
Manages a shared pivot point for rotating multiple selected objects together.
|
|
485
|
+
|
|
486
|
+
```tsx
|
|
487
|
+
import { useGroupPivot } from 'react-svg-canvas'
|
|
488
|
+
|
|
489
|
+
function GroupRotationUI({ selectedObjects, selectionBounds }) {
|
|
490
|
+
const {
|
|
491
|
+
groupPivotState,
|
|
492
|
+
groupPivot,
|
|
493
|
+
handleGroupPivotDragStart,
|
|
494
|
+
groupPivotDragProps,
|
|
495
|
+
rotateObjectsAroundPivot,
|
|
496
|
+
resetPivotToCenter,
|
|
497
|
+
setGroupPivot
|
|
498
|
+
} = useGroupPivot({
|
|
499
|
+
objects: selectedObjects, // Array of { id, bounds, rotation, pivotX?, pivotY? }
|
|
500
|
+
selectionBounds,
|
|
501
|
+
onRotate: (angle, transformedObjects) => {
|
|
502
|
+
// transformedObjects: { id, x, y, rotation }[]
|
|
503
|
+
updateObjects(transformedObjects)
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<>
|
|
509
|
+
{/* Pivot handle */}
|
|
510
|
+
<circle
|
|
511
|
+
cx={groupPivot.x}
|
|
512
|
+
cy={groupPivot.y}
|
|
513
|
+
r={8}
|
|
514
|
+
fill={groupPivotState.isPivotCustom ? 'orange' : 'white'}
|
|
515
|
+
onPointerDown={handleGroupPivotDragStart}
|
|
516
|
+
{...groupPivotDragProps}
|
|
517
|
+
/>
|
|
518
|
+
|
|
519
|
+
{/* Reset button */}
|
|
520
|
+
<button onClick={resetPivotToCenter}>Reset Pivot</button>
|
|
521
|
+
</>
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
#### Rotation Utilities
|
|
527
|
+
|
|
528
|
+
```tsx
|
|
529
|
+
import {
|
|
530
|
+
// Constants
|
|
531
|
+
DEFAULT_SNAP_ANGLES, // [0, 15, 30, 45, ..., 345]
|
|
532
|
+
DEFAULT_SNAP_ZONE_RATIO, // 0.75
|
|
533
|
+
DEFAULT_PIVOT_SNAP_THRESHOLD, // 0.08
|
|
534
|
+
|
|
535
|
+
// Angle utilities
|
|
536
|
+
getAngleFromCenter, // (center, point) => degrees
|
|
537
|
+
snapAngle, // (angle, snapAngles, isInSnapZone) => angle
|
|
538
|
+
findClosestSnapAngle, // (angle, snapAngles) => snapAngle
|
|
539
|
+
|
|
540
|
+
// Pivot utilities
|
|
541
|
+
getPivotPosition, // (bounds, pivotX, pivotY) => Point
|
|
542
|
+
calculatePivotCompensation, // Position adjustment when pivot moves
|
|
543
|
+
canvasToPivot, // Convert canvas coords to normalized pivot
|
|
544
|
+
snapPivot, // Snap to nearest snap point
|
|
545
|
+
|
|
546
|
+
// Rotation transforms
|
|
547
|
+
rotatePointAroundCenter, // (point, center, angleDeg) => Point
|
|
548
|
+
rotateObjectAroundPivot // Transform object position during rotation
|
|
549
|
+
} from 'react-svg-canvas'
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
370
554
|
### Geometry Utilities
|
|
371
555
|
|
|
372
556
|
#### Bounds Operations
|
|
@@ -520,6 +704,30 @@ interface ToolEvent {
|
|
|
520
704
|
}
|
|
521
705
|
|
|
522
706
|
type ResizeHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
|
|
707
|
+
|
|
708
|
+
// Rotation types
|
|
709
|
+
interface RotationState {
|
|
710
|
+
isRotating: boolean
|
|
711
|
+
startAngle: number
|
|
712
|
+
currentAngle: number
|
|
713
|
+
centerX: number
|
|
714
|
+
centerY: number
|
|
715
|
+
isInSnapZone: boolean
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
interface PivotState {
|
|
719
|
+
isDragging: boolean
|
|
720
|
+
pivotX: number // 0-1 normalized
|
|
721
|
+
pivotY: number // 0-1 normalized
|
|
722
|
+
snappedPoint: Point | null
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
interface GroupPivotState {
|
|
726
|
+
isDragging: boolean
|
|
727
|
+
pivotX: number // Canvas coordinates
|
|
728
|
+
pivotY: number // Canvas coordinates
|
|
729
|
+
isPivotCustom: boolean // User moved pivot from default center
|
|
730
|
+
}
|
|
523
731
|
```
|
|
524
732
|
|
|
525
733
|
---
|
package/lib/geometry/index.d.ts
CHANGED
package/lib/geometry/index.js
CHANGED
package/lib/geometry/math.d.ts
CHANGED
|
@@ -42,3 +42,34 @@ export declare function degToRad(degrees: number): number;
|
|
|
42
42
|
* Convert radians to degrees
|
|
43
43
|
*/
|
|
44
44
|
export declare function radToDeg(radians: number): number;
|
|
45
|
+
/**
|
|
46
|
+
* Pre-calculated rotation matrix for performance.
|
|
47
|
+
* When performing multiple operations with the same rotation angle,
|
|
48
|
+
* this avoids redundant Math.cos/sin calls.
|
|
49
|
+
*/
|
|
50
|
+
export interface RotationMatrix {
|
|
51
|
+
degrees: number;
|
|
52
|
+
radians: number;
|
|
53
|
+
cos: number;
|
|
54
|
+
sin: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Create a rotation matrix from angle in degrees
|
|
58
|
+
*/
|
|
59
|
+
export declare function createRotationMatrix(degrees: number): RotationMatrix;
|
|
60
|
+
/**
|
|
61
|
+
* Rotate a point around center using pre-calculated matrix
|
|
62
|
+
*/
|
|
63
|
+
export declare function rotatePointWithMatrix(point: Point, center: Point, matrix: RotationMatrix): Point;
|
|
64
|
+
/**
|
|
65
|
+
* Un-rotate a point (inverse rotation) using pre-calculated matrix
|
|
66
|
+
*/
|
|
67
|
+
export declare function unrotatePointWithMatrix(point: Point, center: Point, matrix: RotationMatrix): Point;
|
|
68
|
+
/**
|
|
69
|
+
* Rotate a delta (vector) using pre-calculated matrix
|
|
70
|
+
*/
|
|
71
|
+
export declare function rotateDeltaWithMatrix(dx: number, dy: number, matrix: RotationMatrix): [number, number];
|
|
72
|
+
/**
|
|
73
|
+
* Un-rotate a delta (inverse rotation) using pre-calculated matrix
|
|
74
|
+
*/
|
|
75
|
+
export declare function unrotateDeltaWithMatrix(dx: number, dy: number, matrix: RotationMatrix): [number, number];
|
package/lib/geometry/math.js
CHANGED
|
@@ -77,4 +77,57 @@ export function degToRad(degrees) {
|
|
|
77
77
|
export function radToDeg(radians) {
|
|
78
78
|
return radians * 180 / Math.PI;
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Create a rotation matrix from angle in degrees
|
|
82
|
+
*/
|
|
83
|
+
export function createRotationMatrix(degrees) {
|
|
84
|
+
const radians = degrees * Math.PI / 180;
|
|
85
|
+
return {
|
|
86
|
+
degrees,
|
|
87
|
+
radians,
|
|
88
|
+
cos: Math.cos(radians),
|
|
89
|
+
sin: Math.sin(radians)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Rotate a point around center using pre-calculated matrix
|
|
94
|
+
*/
|
|
95
|
+
export function rotatePointWithMatrix(point, center, matrix) {
|
|
96
|
+
const dx = point.x - center.x;
|
|
97
|
+
const dy = point.y - center.y;
|
|
98
|
+
return {
|
|
99
|
+
x: center.x + dx * matrix.cos - dy * matrix.sin,
|
|
100
|
+
y: center.y + dx * matrix.sin + dy * matrix.cos
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Un-rotate a point (inverse rotation) using pre-calculated matrix
|
|
105
|
+
*/
|
|
106
|
+
export function unrotatePointWithMatrix(point, center, matrix) {
|
|
107
|
+
const dx = point.x - center.x;
|
|
108
|
+
const dy = point.y - center.y;
|
|
109
|
+
// Inverse rotation: use -sin instead of sin
|
|
110
|
+
return {
|
|
111
|
+
x: center.x + dx * matrix.cos + dy * matrix.sin,
|
|
112
|
+
y: center.y - dx * matrix.sin + dy * matrix.cos
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Rotate a delta (vector) using pre-calculated matrix
|
|
117
|
+
*/
|
|
118
|
+
export function rotateDeltaWithMatrix(dx, dy, matrix) {
|
|
119
|
+
return [
|
|
120
|
+
dx * matrix.cos - dy * matrix.sin,
|
|
121
|
+
dx * matrix.sin + dy * matrix.cos
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Un-rotate a delta (inverse rotation) using pre-calculated matrix
|
|
126
|
+
*/
|
|
127
|
+
export function unrotateDeltaWithMatrix(dx, dy, matrix) {
|
|
128
|
+
return [
|
|
129
|
+
dx * matrix.cos + dy * matrix.sin,
|
|
130
|
+
-dx * matrix.sin + dy * matrix.cos
|
|
131
|
+
];
|
|
132
|
+
}
|
|
80
133
|
// vim: ts=4
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation-aware resize utilities
|
|
3
|
+
*
|
|
4
|
+
* These utilities handle resizing objects that are rotated around a pivot point.
|
|
5
|
+
* The key challenge is keeping the anchor point (opposite corner from the resize handle)
|
|
6
|
+
* fixed on screen while the object changes size.
|
|
7
|
+
*/
|
|
8
|
+
import type { Point, Bounds, ResizeHandle } from '../types';
|
|
9
|
+
import type { RotationMatrix } from './math';
|
|
10
|
+
/**
|
|
11
|
+
* Get the anchor point (opposite corner/edge) for a resize handle.
|
|
12
|
+
* Returns normalized coordinates (0, 0.5, or 1).
|
|
13
|
+
*/
|
|
14
|
+
export declare function getAnchorForHandle(handle: ResizeHandle): Point;
|
|
15
|
+
/**
|
|
16
|
+
* Calculate the initial anchor screen position for a rotated object.
|
|
17
|
+
* The anchor is rotated around the pivot point.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getRotatedAnchorPosition(bounds: Bounds, anchor: Point, pivot: Point, rotationMatrix: RotationMatrix): Point;
|
|
20
|
+
/**
|
|
21
|
+
* Calculate new size based on handle and mouse delta in object-local space.
|
|
22
|
+
*/
|
|
23
|
+
export declare function calculateResizedDimensions(handle: ResizeHandle, originalWidth: number, originalHeight: number, localDx: number, localDy: number): {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Calculate new position to keep anchor point fixed during rotated resize.
|
|
29
|
+
*
|
|
30
|
+
* The math works as follows:
|
|
31
|
+
* 1. Calculate the offset from pivot to anchor in the new (resized) object
|
|
32
|
+
* 2. Rotate this offset to screen space
|
|
33
|
+
* 3. The new pivot screen position = anchor screen position - rotated offset
|
|
34
|
+
* 4. From pivot screen position, derive the new object position
|
|
35
|
+
*/
|
|
36
|
+
export declare function calculateResizedPosition(newWidth: number, newHeight: number, anchor: Point, pivot: Point, anchorScreenPos: Point, rotationMatrix: RotationMatrix): Point;
|
|
37
|
+
/**
|
|
38
|
+
* State for tracking a resize operation.
|
|
39
|
+
* This captures all the initial values needed to calculate new bounds
|
|
40
|
+
* as the user drags the resize handle.
|
|
41
|
+
*/
|
|
42
|
+
export interface ResizeState {
|
|
43
|
+
startX: number;
|
|
44
|
+
startY: number;
|
|
45
|
+
handle: ResizeHandle;
|
|
46
|
+
originalBounds: Bounds;
|
|
47
|
+
pivot: Point;
|
|
48
|
+
anchor: Point;
|
|
49
|
+
anchorScreenPos: Point;
|
|
50
|
+
rotationMatrix: RotationMatrix;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Initialize resize state for a rotated object.
|
|
54
|
+
* Call this when starting a resize operation.
|
|
55
|
+
*
|
|
56
|
+
* @param startPoint - Initial mouse/pointer position in canvas coords
|
|
57
|
+
* @param handle - Which resize handle is being dragged
|
|
58
|
+
* @param bounds - Current object bounds
|
|
59
|
+
* @param pivot - Pivot point in normalized coords (0-1)
|
|
60
|
+
* @param rotation - Object rotation in degrees
|
|
61
|
+
*/
|
|
62
|
+
export declare function initResizeState(startPoint: Point, handle: ResizeHandle, bounds: Bounds, pivot: Point, rotation: number): ResizeState;
|
|
63
|
+
/**
|
|
64
|
+
* Calculate new bounds during resize, keeping anchor fixed.
|
|
65
|
+
*
|
|
66
|
+
* @param state - Resize state from initResizeState
|
|
67
|
+
* @param currentPoint - Current mouse/pointer position in canvas coords
|
|
68
|
+
* @param minWidth - Minimum allowed width (default 10)
|
|
69
|
+
* @param minHeight - Minimum allowed height (default 10)
|
|
70
|
+
* @returns New bounds with position adjusted to keep anchor fixed
|
|
71
|
+
*/
|
|
72
|
+
export declare function calculateResizeBounds(state: ResizeState, currentPoint: Point, minWidth?: number, minHeight?: number): Bounds;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotation-aware resize utilities
|
|
3
|
+
*
|
|
4
|
+
* These utilities handle resizing objects that are rotated around a pivot point.
|
|
5
|
+
* The key challenge is keeping the anchor point (opposite corner from the resize handle)
|
|
6
|
+
* fixed on screen while the object changes size.
|
|
7
|
+
*/
|
|
8
|
+
import { createRotationMatrix, unrotateDeltaWithMatrix } from './math';
|
|
9
|
+
/**
|
|
10
|
+
* Get the anchor point (opposite corner/edge) for a resize handle.
|
|
11
|
+
* Returns normalized coordinates (0, 0.5, or 1).
|
|
12
|
+
*/
|
|
13
|
+
export function getAnchorForHandle(handle) {
|
|
14
|
+
switch (handle) {
|
|
15
|
+
case 'nw': return { x: 1, y: 1 }; // anchor SE
|
|
16
|
+
case 'n': return { x: 0.5, y: 1 }; // anchor S center
|
|
17
|
+
case 'ne': return { x: 0, y: 1 }; // anchor SW
|
|
18
|
+
case 'e': return { x: 0, y: 0.5 }; // anchor W center
|
|
19
|
+
case 'se': return { x: 0, y: 0 }; // anchor NW
|
|
20
|
+
case 's': return { x: 0.5, y: 0 }; // anchor N center
|
|
21
|
+
case 'sw': return { x: 1, y: 0 }; // anchor NE
|
|
22
|
+
case 'w': return { x: 1, y: 0.5 }; // anchor E center
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Calculate the initial anchor screen position for a rotated object.
|
|
27
|
+
* The anchor is rotated around the pivot point.
|
|
28
|
+
*/
|
|
29
|
+
export function getRotatedAnchorPosition(bounds, anchor, pivot, rotationMatrix) {
|
|
30
|
+
const anchorLocalX = bounds.x + bounds.width * anchor.x;
|
|
31
|
+
const anchorLocalY = bounds.y + bounds.height * anchor.y;
|
|
32
|
+
const pivotAbsX = bounds.x + bounds.width * pivot.x;
|
|
33
|
+
const pivotAbsY = bounds.y + bounds.height * pivot.y;
|
|
34
|
+
// Rotate anchor around pivot
|
|
35
|
+
return {
|
|
36
|
+
x: pivotAbsX + (anchorLocalX - pivotAbsX) * rotationMatrix.cos - (anchorLocalY - pivotAbsY) * rotationMatrix.sin,
|
|
37
|
+
y: pivotAbsY + (anchorLocalX - pivotAbsX) * rotationMatrix.sin + (anchorLocalY - pivotAbsY) * rotationMatrix.cos
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Calculate new size based on handle and mouse delta in object-local space.
|
|
42
|
+
*/
|
|
43
|
+
export function calculateResizedDimensions(handle, originalWidth, originalHeight, localDx, localDy) {
|
|
44
|
+
let width = originalWidth;
|
|
45
|
+
let height = originalHeight;
|
|
46
|
+
switch (handle) {
|
|
47
|
+
case 'nw':
|
|
48
|
+
width = originalWidth - localDx;
|
|
49
|
+
height = originalHeight - localDy;
|
|
50
|
+
break;
|
|
51
|
+
case 'n':
|
|
52
|
+
height = originalHeight - localDy;
|
|
53
|
+
break;
|
|
54
|
+
case 'ne':
|
|
55
|
+
width = originalWidth + localDx;
|
|
56
|
+
height = originalHeight - localDy;
|
|
57
|
+
break;
|
|
58
|
+
case 'e':
|
|
59
|
+
width = originalWidth + localDx;
|
|
60
|
+
break;
|
|
61
|
+
case 'se':
|
|
62
|
+
width = originalWidth + localDx;
|
|
63
|
+
height = originalHeight + localDy;
|
|
64
|
+
break;
|
|
65
|
+
case 's':
|
|
66
|
+
height = originalHeight + localDy;
|
|
67
|
+
break;
|
|
68
|
+
case 'sw':
|
|
69
|
+
width = originalWidth - localDx;
|
|
70
|
+
height = originalHeight + localDy;
|
|
71
|
+
break;
|
|
72
|
+
case 'w':
|
|
73
|
+
width = originalWidth - localDx;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return { width, height };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Calculate new position to keep anchor point fixed during rotated resize.
|
|
80
|
+
*
|
|
81
|
+
* The math works as follows:
|
|
82
|
+
* 1. Calculate the offset from pivot to anchor in the new (resized) object
|
|
83
|
+
* 2. Rotate this offset to screen space
|
|
84
|
+
* 3. The new pivot screen position = anchor screen position - rotated offset
|
|
85
|
+
* 4. From pivot screen position, derive the new object position
|
|
86
|
+
*/
|
|
87
|
+
export function calculateResizedPosition(newWidth, newHeight, anchor, pivot, anchorScreenPos, rotationMatrix) {
|
|
88
|
+
// The anchor offset from pivot (in local coords) after resize
|
|
89
|
+
const newAnchorOffsetX = newWidth * (anchor.x - pivot.x);
|
|
90
|
+
const newAnchorOffsetY = newHeight * (anchor.y - pivot.y);
|
|
91
|
+
// Rotate this offset to get screen-space offset from pivot to anchor
|
|
92
|
+
const rotatedOffsetX = newAnchorOffsetX * rotationMatrix.cos - newAnchorOffsetY * rotationMatrix.sin;
|
|
93
|
+
const rotatedOffsetY = newAnchorOffsetX * rotationMatrix.sin + newAnchorOffsetY * rotationMatrix.cos;
|
|
94
|
+
// The pivot screen position should be such that pivot + rotatedOffset = anchorScreen
|
|
95
|
+
const newPivotScreenX = anchorScreenPos.x - rotatedOffsetX;
|
|
96
|
+
const newPivotScreenY = anchorScreenPos.y - rotatedOffsetY;
|
|
97
|
+
// Now pivot = (newX + newWidth * pivotX, newY + newHeight * pivotY)
|
|
98
|
+
// So newX = pivotScreenX - newWidth * pivotX, etc.
|
|
99
|
+
return {
|
|
100
|
+
x: newPivotScreenX - newWidth * pivot.x,
|
|
101
|
+
y: newPivotScreenY - newHeight * pivot.y
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Initialize resize state for a rotated object.
|
|
106
|
+
* Call this when starting a resize operation.
|
|
107
|
+
*
|
|
108
|
+
* @param startPoint - Initial mouse/pointer position in canvas coords
|
|
109
|
+
* @param handle - Which resize handle is being dragged
|
|
110
|
+
* @param bounds - Current object bounds
|
|
111
|
+
* @param pivot - Pivot point in normalized coords (0-1)
|
|
112
|
+
* @param rotation - Object rotation in degrees
|
|
113
|
+
*/
|
|
114
|
+
export function initResizeState(startPoint, handle, bounds, pivot, rotation) {
|
|
115
|
+
const rotationMatrix = createRotationMatrix(rotation);
|
|
116
|
+
const anchor = getAnchorForHandle(handle);
|
|
117
|
+
const anchorScreenPos = getRotatedAnchorPosition(bounds, anchor, pivot, rotationMatrix);
|
|
118
|
+
return {
|
|
119
|
+
startX: startPoint.x,
|
|
120
|
+
startY: startPoint.y,
|
|
121
|
+
handle,
|
|
122
|
+
originalBounds: { ...bounds },
|
|
123
|
+
pivot,
|
|
124
|
+
anchor,
|
|
125
|
+
anchorScreenPos,
|
|
126
|
+
rotationMatrix
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Calculate new bounds during resize, keeping anchor fixed.
|
|
131
|
+
*
|
|
132
|
+
* @param state - Resize state from initResizeState
|
|
133
|
+
* @param currentPoint - Current mouse/pointer position in canvas coords
|
|
134
|
+
* @param minWidth - Minimum allowed width (default 10)
|
|
135
|
+
* @param minHeight - Minimum allowed height (default 10)
|
|
136
|
+
* @returns New bounds with position adjusted to keep anchor fixed
|
|
137
|
+
*/
|
|
138
|
+
export function calculateResizeBounds(state, currentPoint, minWidth = 10, minHeight = 10) {
|
|
139
|
+
// Calculate screen delta
|
|
140
|
+
const screenDx = currentPoint.x - state.startX;
|
|
141
|
+
const screenDy = currentPoint.y - state.startY;
|
|
142
|
+
// Un-rotate to get local (object space) delta
|
|
143
|
+
const [localDx, localDy] = unrotateDeltaWithMatrix(screenDx, screenDy, state.rotationMatrix);
|
|
144
|
+
// Calculate new dimensions
|
|
145
|
+
let { width, height } = calculateResizedDimensions(state.handle, state.originalBounds.width, state.originalBounds.height, localDx, localDy);
|
|
146
|
+
// Enforce minimum size
|
|
147
|
+
width = Math.max(width, minWidth);
|
|
148
|
+
height = Math.max(height, minHeight);
|
|
149
|
+
// Calculate position to keep anchor fixed
|
|
150
|
+
const position = calculateResizedPosition(width, height, state.anchor, state.pivot, state.anchorScreenPos, state.rotationMatrix);
|
|
151
|
+
return {
|
|
152
|
+
x: position.x,
|
|
153
|
+
y: position.y,
|
|
154
|
+
width,
|
|
155
|
+
height
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// vim: ts=4
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View/viewport coordinate utilities
|
|
3
|
+
*
|
|
4
|
+
* These utilities convert between canvas coordinates and view-local coordinates.
|
|
5
|
+
* A "view" is a rectangular viewport (using Bounds type) that defines
|
|
6
|
+
* a portion of the canvas.
|
|
7
|
+
*/
|
|
8
|
+
import type { Point, Bounds } from '../types';
|
|
9
|
+
/**
|
|
10
|
+
* Convert canvas coordinates to view-local coordinates
|
|
11
|
+
* @param point - Point in canvas coordinate space
|
|
12
|
+
* @param view - The view/viewport (x, y defines top-left corner)
|
|
13
|
+
* @returns Point relative to view's top-left corner
|
|
14
|
+
*/
|
|
15
|
+
export declare function canvasToView(point: Point, view: Bounds): Point;
|
|
16
|
+
/**
|
|
17
|
+
* Convert view-local coordinates to canvas coordinates
|
|
18
|
+
* @param point - Point relative to view's top-left corner
|
|
19
|
+
* @param view - The view/viewport
|
|
20
|
+
* @returns Point in canvas coordinate space
|
|
21
|
+
*/
|
|
22
|
+
export declare function viewToCanvas(point: Point, view: Bounds): Point;
|
|
23
|
+
/**
|
|
24
|
+
* Check if a point (in canvas coords) is inside a view
|
|
25
|
+
* @param point - Point in canvas coordinates
|
|
26
|
+
* @param view - The view/viewport bounds
|
|
27
|
+
* @returns true if point is inside the view
|
|
28
|
+
*/
|
|
29
|
+
export declare function isPointInView(point: Point, view: Bounds): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Check if bounds intersects with a view
|
|
32
|
+
* Uses AABB (axis-aligned bounding box) intersection test.
|
|
33
|
+
* @param bounds - Rectangle to test
|
|
34
|
+
* @param view - The view/viewport bounds
|
|
35
|
+
* @returns true if bounds intersects with view
|
|
36
|
+
*/
|
|
37
|
+
export declare function boundsIntersectsView(bounds: Bounds, view: Bounds): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View/viewport coordinate utilities
|
|
3
|
+
*
|
|
4
|
+
* These utilities convert between canvas coordinates and view-local coordinates.
|
|
5
|
+
* A "view" is a rectangular viewport (using Bounds type) that defines
|
|
6
|
+
* a portion of the canvas.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Convert canvas coordinates to view-local coordinates
|
|
10
|
+
* @param point - Point in canvas coordinate space
|
|
11
|
+
* @param view - The view/viewport (x, y defines top-left corner)
|
|
12
|
+
* @returns Point relative to view's top-left corner
|
|
13
|
+
*/
|
|
14
|
+
export function canvasToView(point, view) {
|
|
15
|
+
return {
|
|
16
|
+
x: point.x - view.x,
|
|
17
|
+
y: point.y - view.y
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Convert view-local coordinates to canvas coordinates
|
|
22
|
+
* @param point - Point relative to view's top-left corner
|
|
23
|
+
* @param view - The view/viewport
|
|
24
|
+
* @returns Point in canvas coordinate space
|
|
25
|
+
*/
|
|
26
|
+
export function viewToCanvas(point, view) {
|
|
27
|
+
return {
|
|
28
|
+
x: point.x + view.x,
|
|
29
|
+
y: point.y + view.y
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check if a point (in canvas coords) is inside a view
|
|
34
|
+
* @param point - Point in canvas coordinates
|
|
35
|
+
* @param view - The view/viewport bounds
|
|
36
|
+
* @returns true if point is inside the view
|
|
37
|
+
*/
|
|
38
|
+
export function isPointInView(point, view) {
|
|
39
|
+
return (point.x >= view.x &&
|
|
40
|
+
point.x <= view.x + view.width &&
|
|
41
|
+
point.y >= view.y &&
|
|
42
|
+
point.y <= view.y + view.height);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Check if bounds intersects with a view
|
|
46
|
+
* Uses AABB (axis-aligned bounding box) intersection test.
|
|
47
|
+
* @param bounds - Rectangle to test
|
|
48
|
+
* @param view - The view/viewport bounds
|
|
49
|
+
* @returns true if bounds intersects with view
|
|
50
|
+
*/
|
|
51
|
+
export function boundsIntersectsView(bounds, view) {
|
|
52
|
+
const boundsRight = bounds.x + bounds.width;
|
|
53
|
+
const boundsBottom = bounds.y + bounds.height;
|
|
54
|
+
const viewRight = view.x + view.width;
|
|
55
|
+
const viewBottom = view.y + view.height;
|
|
56
|
+
return !(bounds.x > viewRight ||
|
|
57
|
+
boundsRight < view.x ||
|
|
58
|
+
bounds.y > viewBottom ||
|
|
59
|
+
boundsBottom < view.y);
|
|
60
|
+
}
|
|
61
|
+
// vim: ts=4
|
package/lib/hooks/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hooks - re-exports
|
|
3
3
|
*/
|
|
4
|
-
export
|
|
5
|
-
export
|
|
4
|
+
export { useDraggable, svgTransformCoordinates } from './useDraggable';
|
|
5
|
+
export type { UseDraggableOptions, UseDraggableReturn, SnapDragFn, SnapDragResult } from './useDraggable';
|
|
6
|
+
export { useResizable } from './useResizable';
|
|
7
|
+
export type { UseResizableOptions, UseResizableReturn, SnapResizeFn, SnapResizeResult } from './useResizable';
|
package/lib/hooks/index.js
CHANGED