react-svg-canvas 0.1.0 → 0.1.2

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.
Files changed (38) hide show
  1. package/README.md +208 -0
  2. package/lib/geometry/index.d.ts +2 -0
  3. package/lib/geometry/index.js +2 -0
  4. package/lib/geometry/math.d.ts +31 -0
  5. package/lib/geometry/math.js +53 -0
  6. package/lib/geometry/resize.d.ts +79 -0
  7. package/lib/geometry/resize.js +204 -0
  8. package/lib/geometry/view.d.ts +37 -0
  9. package/lib/geometry/view.js +61 -0
  10. package/lib/hooks/index.d.ts +4 -2
  11. package/lib/hooks/index.js +2 -2
  12. package/lib/hooks/useDraggable.d.ts +40 -5
  13. package/lib/hooks/useDraggable.js +115 -80
  14. package/lib/hooks/useResizable.d.ts +68 -3
  15. package/lib/hooks/useResizable.js +106 -34
  16. package/lib/index.d.ts +1 -0
  17. package/lib/index.js +2 -0
  18. package/lib/rotation/index.d.ts +11 -0
  19. package/lib/rotation/index.js +22 -0
  20. package/lib/rotation/rotation-utils.d.ts +132 -0
  21. package/lib/rotation/rotation-utils.js +281 -0
  22. package/lib/rotation/types.d.ts +239 -0
  23. package/lib/rotation/types.js +47 -0
  24. package/lib/rotation/useGroupPivot.d.ts +26 -0
  25. package/lib/rotation/useGroupPivot.js +163 -0
  26. package/lib/rotation/usePivotDrag.d.ts +29 -0
  27. package/lib/rotation/usePivotDrag.js +196 -0
  28. package/lib/rotation/useRotatable.d.ts +27 -0
  29. package/lib/rotation/useRotatable.js +255 -0
  30. package/lib/selection/ResizeHandle.d.ts +3 -2
  31. package/lib/selection/ResizeHandle.js +5 -4
  32. package/lib/selection/SelectionBox.d.ts +1 -1
  33. package/lib/selection/SelectionBox.js +1 -1
  34. package/lib/svgcanvas.d.ts +6 -0
  35. package/lib/svgcanvas.js +13 -5
  36. package/lib/types.d.ts +12 -0
  37. package/lib/types.js +27 -0
  38. 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
  ---
@@ -4,3 +4,5 @@
4
4
  export * from './math';
5
5
  export * from './bounds';
6
6
  export * from './transforms';
7
+ export * from './view';
8
+ export * from './resize';
@@ -4,4 +4,6 @@
4
4
  export * from './math';
5
5
  export * from './bounds';
6
6
  export * from './transforms';
7
+ export * from './view';
8
+ export * from './resize';
7
9
  // vim: ts=4
@@ -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];
@@ -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,79 @@
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
+ * Determine which dimension should drive the resize for aspect ratio constraint.
22
+ * For edge handles, the edge determines the driver.
23
+ * For corner handles, use proportional change to determine which dimension drives.
24
+ */
25
+ export declare function getDriverDimension(handle: ResizeHandle, localDx: number, localDy: number, originalWidth: number, originalHeight: number): 'width' | 'height';
26
+ /**
27
+ * Calculate new size based on handle and mouse delta in object-local space.
28
+ */
29
+ export declare function calculateResizedDimensions(handle: ResizeHandle, originalWidth: number, originalHeight: number, localDx: number, localDy: number): {
30
+ width: number;
31
+ height: number;
32
+ };
33
+ /**
34
+ * Calculate new position to keep anchor point fixed during rotated resize.
35
+ *
36
+ * The math works as follows:
37
+ * 1. Calculate the offset from pivot to anchor in the new (resized) object
38
+ * 2. Rotate this offset to screen space
39
+ * 3. The new pivot screen position = anchor screen position - rotated offset
40
+ * 4. From pivot screen position, derive the new object position
41
+ */
42
+ export declare function calculateResizedPosition(newWidth: number, newHeight: number, anchor: Point, pivot: Point, anchorScreenPos: Point, rotationMatrix: RotationMatrix): Point;
43
+ /**
44
+ * State for tracking a resize operation.
45
+ * This captures all the initial values needed to calculate new bounds
46
+ * as the user drags the resize handle.
47
+ */
48
+ export interface ResizeState {
49
+ startX: number;
50
+ startY: number;
51
+ handle: ResizeHandle;
52
+ originalBounds: Bounds;
53
+ pivot: Point;
54
+ anchor: Point;
55
+ anchorScreenPos: Point;
56
+ rotationMatrix: RotationMatrix;
57
+ }
58
+ /**
59
+ * Initialize resize state for a rotated object.
60
+ * Call this when starting a resize operation.
61
+ *
62
+ * @param startPoint - Initial mouse/pointer position in canvas coords
63
+ * @param handle - Which resize handle is being dragged
64
+ * @param bounds - Current object bounds
65
+ * @param pivot - Pivot point in normalized coords (0-1)
66
+ * @param rotation - Object rotation in degrees
67
+ */
68
+ export declare function initResizeState(startPoint: Point, handle: ResizeHandle, bounds: Bounds, pivot: Point, rotation: number): ResizeState;
69
+ /**
70
+ * Calculate new bounds during resize, keeping anchor fixed.
71
+ *
72
+ * @param state - Resize state from initResizeState
73
+ * @param currentPoint - Current mouse/pointer position in canvas coords
74
+ * @param minWidth - Minimum allowed width (default 10)
75
+ * @param minHeight - Minimum allowed height (default 10)
76
+ * @param aspectRatio - Optional aspect ratio constraint (width/height). When provided, resize maintains this ratio.
77
+ * @returns New bounds with position adjusted to keep anchor fixed
78
+ */
79
+ export declare function calculateResizeBounds(state: ResizeState, currentPoint: Point, minWidth?: number, minHeight?: number, aspectRatio?: number): Bounds;
@@ -0,0 +1,204 @@
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
+ * Determine which dimension should drive the resize for aspect ratio constraint.
42
+ * For edge handles, the edge determines the driver.
43
+ * For corner handles, use proportional change to determine which dimension drives.
44
+ */
45
+ export function getDriverDimension(handle, localDx, localDy, originalWidth, originalHeight) {
46
+ // Edge handles: the edge determines the driver
47
+ if (handle === 'e' || handle === 'w')
48
+ return 'width';
49
+ if (handle === 'n' || handle === 's')
50
+ return 'height';
51
+ // Corner handles: use proportional change to determine driver
52
+ const propX = Math.abs(localDx) / originalWidth;
53
+ const propY = Math.abs(localDy) / originalHeight;
54
+ return propX >= propY ? 'width' : 'height';
55
+ }
56
+ /**
57
+ * Calculate new size based on handle and mouse delta in object-local space.
58
+ */
59
+ export function calculateResizedDimensions(handle, originalWidth, originalHeight, localDx, localDy) {
60
+ let width = originalWidth;
61
+ let height = originalHeight;
62
+ switch (handle) {
63
+ case 'nw':
64
+ width = originalWidth - localDx;
65
+ height = originalHeight - localDy;
66
+ break;
67
+ case 'n':
68
+ height = originalHeight - localDy;
69
+ break;
70
+ case 'ne':
71
+ width = originalWidth + localDx;
72
+ height = originalHeight - localDy;
73
+ break;
74
+ case 'e':
75
+ width = originalWidth + localDx;
76
+ break;
77
+ case 'se':
78
+ width = originalWidth + localDx;
79
+ height = originalHeight + localDy;
80
+ break;
81
+ case 's':
82
+ height = originalHeight + localDy;
83
+ break;
84
+ case 'sw':
85
+ width = originalWidth - localDx;
86
+ height = originalHeight + localDy;
87
+ break;
88
+ case 'w':
89
+ width = originalWidth - localDx;
90
+ break;
91
+ }
92
+ return { width, height };
93
+ }
94
+ /**
95
+ * Calculate new position to keep anchor point fixed during rotated resize.
96
+ *
97
+ * The math works as follows:
98
+ * 1. Calculate the offset from pivot to anchor in the new (resized) object
99
+ * 2. Rotate this offset to screen space
100
+ * 3. The new pivot screen position = anchor screen position - rotated offset
101
+ * 4. From pivot screen position, derive the new object position
102
+ */
103
+ export function calculateResizedPosition(newWidth, newHeight, anchor, pivot, anchorScreenPos, rotationMatrix) {
104
+ // The anchor offset from pivot (in local coords) after resize
105
+ const newAnchorOffsetX = newWidth * (anchor.x - pivot.x);
106
+ const newAnchorOffsetY = newHeight * (anchor.y - pivot.y);
107
+ // Rotate this offset to get screen-space offset from pivot to anchor
108
+ const rotatedOffsetX = newAnchorOffsetX * rotationMatrix.cos - newAnchorOffsetY * rotationMatrix.sin;
109
+ const rotatedOffsetY = newAnchorOffsetX * rotationMatrix.sin + newAnchorOffsetY * rotationMatrix.cos;
110
+ // The pivot screen position should be such that pivot + rotatedOffset = anchorScreen
111
+ const newPivotScreenX = anchorScreenPos.x - rotatedOffsetX;
112
+ const newPivotScreenY = anchorScreenPos.y - rotatedOffsetY;
113
+ // Now pivot = (newX + newWidth * pivotX, newY + newHeight * pivotY)
114
+ // So newX = pivotScreenX - newWidth * pivotX, etc.
115
+ return {
116
+ x: newPivotScreenX - newWidth * pivot.x,
117
+ y: newPivotScreenY - newHeight * pivot.y
118
+ };
119
+ }
120
+ /**
121
+ * Initialize resize state for a rotated object.
122
+ * Call this when starting a resize operation.
123
+ *
124
+ * @param startPoint - Initial mouse/pointer position in canvas coords
125
+ * @param handle - Which resize handle is being dragged
126
+ * @param bounds - Current object bounds
127
+ * @param pivot - Pivot point in normalized coords (0-1)
128
+ * @param rotation - Object rotation in degrees
129
+ */
130
+ export function initResizeState(startPoint, handle, bounds, pivot, rotation) {
131
+ const rotationMatrix = createRotationMatrix(rotation);
132
+ const anchor = getAnchorForHandle(handle);
133
+ const anchorScreenPos = getRotatedAnchorPosition(bounds, anchor, pivot, rotationMatrix);
134
+ return {
135
+ startX: startPoint.x,
136
+ startY: startPoint.y,
137
+ handle,
138
+ originalBounds: { ...bounds },
139
+ pivot,
140
+ anchor,
141
+ anchorScreenPos,
142
+ rotationMatrix
143
+ };
144
+ }
145
+ /**
146
+ * Calculate new bounds during resize, keeping anchor fixed.
147
+ *
148
+ * @param state - Resize state from initResizeState
149
+ * @param currentPoint - Current mouse/pointer position in canvas coords
150
+ * @param minWidth - Minimum allowed width (default 10)
151
+ * @param minHeight - Minimum allowed height (default 10)
152
+ * @param aspectRatio - Optional aspect ratio constraint (width/height). When provided, resize maintains this ratio.
153
+ * @returns New bounds with position adjusted to keep anchor fixed
154
+ */
155
+ export function calculateResizeBounds(state, currentPoint, minWidth = 10, minHeight = 10, aspectRatio) {
156
+ // Calculate screen delta
157
+ const screenDx = currentPoint.x - state.startX;
158
+ const screenDy = currentPoint.y - state.startY;
159
+ // Un-rotate to get local (object space) delta
160
+ const [localDx, localDy] = unrotateDeltaWithMatrix(screenDx, screenDy, state.rotationMatrix);
161
+ // Calculate new dimensions (unconstrained first)
162
+ let { width, height } = calculateResizedDimensions(state.handle, state.originalBounds.width, state.originalBounds.height, localDx, localDy);
163
+ // Apply aspect ratio constraint if provided
164
+ if (aspectRatio !== undefined) {
165
+ const driver = getDriverDimension(state.handle, localDx, localDy, state.originalBounds.width, state.originalBounds.height);
166
+ if (driver === 'width') {
167
+ // Width drives, calculate height from aspect ratio
168
+ height = width / aspectRatio;
169
+ }
170
+ else {
171
+ // Height drives, calculate width from aspect ratio
172
+ width = height * aspectRatio;
173
+ }
174
+ }
175
+ // Enforce minimum size (maintaining aspect ratio if constrained)
176
+ if (aspectRatio !== undefined) {
177
+ // Calculate effective minimum considering aspect ratio
178
+ const minByWidth = minWidth;
179
+ const minByHeight = minHeight * aspectRatio;
180
+ if (width < minByWidth || height < minHeight) {
181
+ if (minByWidth >= minByHeight) {
182
+ width = minByWidth;
183
+ height = width / aspectRatio;
184
+ }
185
+ else {
186
+ height = minHeight;
187
+ width = height * aspectRatio;
188
+ }
189
+ }
190
+ }
191
+ else {
192
+ width = Math.max(width, minWidth);
193
+ height = Math.max(height, minHeight);
194
+ }
195
+ // Calculate position to keep anchor fixed
196
+ const position = calculateResizedPosition(width, height, state.anchor, state.pivot, state.anchorScreenPos, state.rotationMatrix);
197
+ return {
198
+ x: position.x,
199
+ y: position.y,
200
+ width,
201
+ height
202
+ };
203
+ }
204
+ // 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;