react-svg-canvas 0.1.1 → 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.
@@ -17,6 +17,12 @@ export declare function getAnchorForHandle(handle: ResizeHandle): Point;
17
17
  * The anchor is rotated around the pivot point.
18
18
  */
19
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';
20
26
  /**
21
27
  * Calculate new size based on handle and mouse delta in object-local space.
22
28
  */
@@ -67,6 +73,7 @@ export declare function initResizeState(startPoint: Point, handle: ResizeHandle,
67
73
  * @param currentPoint - Current mouse/pointer position in canvas coords
68
74
  * @param minWidth - Minimum allowed width (default 10)
69
75
  * @param minHeight - Minimum allowed height (default 10)
76
+ * @param aspectRatio - Optional aspect ratio constraint (width/height). When provided, resize maintains this ratio.
70
77
  * @returns New bounds with position adjusted to keep anchor fixed
71
78
  */
72
- export declare function calculateResizeBounds(state: ResizeState, currentPoint: Point, minWidth?: number, minHeight?: number): Bounds;
79
+ export declare function calculateResizeBounds(state: ResizeState, currentPoint: Point, minWidth?: number, minHeight?: number, aspectRatio?: number): Bounds;
@@ -37,6 +37,22 @@ export function getRotatedAnchorPosition(bounds, anchor, pivot, rotationMatrix)
37
37
  y: pivotAbsY + (anchorLocalX - pivotAbsX) * rotationMatrix.sin + (anchorLocalY - pivotAbsY) * rotationMatrix.cos
38
38
  };
39
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
+ }
40
56
  /**
41
57
  * Calculate new size based on handle and mouse delta in object-local space.
42
58
  */
@@ -133,19 +149,49 @@ export function initResizeState(startPoint, handle, bounds, pivot, rotation) {
133
149
  * @param currentPoint - Current mouse/pointer position in canvas coords
134
150
  * @param minWidth - Minimum allowed width (default 10)
135
151
  * @param minHeight - Minimum allowed height (default 10)
152
+ * @param aspectRatio - Optional aspect ratio constraint (width/height). When provided, resize maintains this ratio.
136
153
  * @returns New bounds with position adjusted to keep anchor fixed
137
154
  */
138
- export function calculateResizeBounds(state, currentPoint, minWidth = 10, minHeight = 10) {
155
+ export function calculateResizeBounds(state, currentPoint, minWidth = 10, minHeight = 10, aspectRatio) {
139
156
  // Calculate screen delta
140
157
  const screenDx = currentPoint.x - state.startX;
141
158
  const screenDy = currentPoint.y - state.startY;
142
159
  // Un-rotate to get local (object space) delta
143
160
  const [localDx, localDy] = unrotateDeltaWithMatrix(screenDx, screenDy, state.rotationMatrix);
144
- // Calculate new dimensions
161
+ // Calculate new dimensions (unconstrained first)
145
162
  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);
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
+ }
149
195
  // Calculate position to keep anchor fixed
150
196
  const position = calculateResizedPosition(width, height, state.anchor, state.pivot, state.anchorScreenPos, state.rotationMatrix);
151
197
  return {
@@ -75,6 +75,17 @@ export interface UseResizableOptions {
75
75
  * Returns { x: pivotX, y: pivotY } in normalized coordinates (0-1).
76
76
  */
77
77
  getPivot?: () => Point;
78
+ /**
79
+ * Aspect ratio constraint (width/height).
80
+ * When provided, resize maintains this ratio.
81
+ * For images, typically originalWidth / originalHeight.
82
+ */
83
+ aspectRatio?: number;
84
+ /**
85
+ * Optional getter for fresh aspect ratio at resize start.
86
+ * Use this when working with CRDT/external state to avoid stale closures.
87
+ */
88
+ getAspectRatio?: () => number | undefined;
78
89
  }
79
90
  export interface UseResizableReturn {
80
91
  /** Whether currently resizing */
@@ -38,7 +38,8 @@ export function useResizable(options) {
38
38
  resizeState: null,
39
39
  element: null,
40
40
  pointerId: -1,
41
- currentBounds: { x: 0, y: 0, width: 0, height: 0 }
41
+ currentBounds: { x: 0, y: 0, width: 0, height: 0 },
42
+ aspectRatio: undefined
42
43
  });
43
44
  const optionsRef = React.useRef(options);
44
45
  optionsRef.current = options;
@@ -56,11 +57,14 @@ export function useResizable(options) {
56
57
  const { bounds, rotation = 0, pivotX = 0.5, pivotY = 0.5 } = optionsRef.current;
57
58
  // Initialize rotation-aware resize state
58
59
  const resizeState = initResizeState({ x: coords.x, y: coords.y }, handle, bounds, { x: pivotX, y: pivotY }, rotation);
60
+ // Capture aspect ratio at resize start (from getter if provided, else from prop)
61
+ const aspectRatio = optionsRef.current.getAspectRatio?.() ?? optionsRef.current.aspectRatio;
59
62
  stateRef.current = {
60
63
  resizeState,
61
64
  element,
62
65
  pointerId: e.pointerId,
63
- currentBounds: { ...bounds }
66
+ currentBounds: { ...bounds },
67
+ aspectRatio
64
68
  };
65
69
  setIsResizing(true);
66
70
  setActiveHandle(handle);
@@ -69,7 +73,7 @@ export function useResizable(options) {
69
73
  bounds: { ...bounds }
70
74
  });
71
75
  const handlePointerMove = (moveEvent) => {
72
- const { resizeState, element, pointerId } = stateRef.current;
76
+ const { resizeState, element, pointerId, aspectRatio } = stateRef.current;
73
77
  if (!resizeState || !element || moveEvent.pointerId !== pointerId)
74
78
  return;
75
79
  const opts = optionsRef.current;
@@ -77,8 +81,8 @@ export function useResizable(options) {
77
81
  // Calculate delta for snapping
78
82
  const deltaX = moveCoords.x - resizeState.startX;
79
83
  const deltaY = moveCoords.y - resizeState.startY;
80
- // Calculate new bounds (rotation-aware)
81
- let newBounds = calculateResizeBounds(resizeState, { x: moveCoords.x, y: moveCoords.y }, opts.minWidth ?? 10, opts.minHeight ?? 10);
84
+ // Calculate new bounds (rotation-aware, with aspect ratio constraint if provided)
85
+ let newBounds = calculateResizeBounds(resizeState, { x: moveCoords.x, y: moveCoords.y }, opts.minWidth ?? 10, opts.minHeight ?? 10, aspectRatio);
82
86
  // Apply snapping if available
83
87
  if (opts.snapResize && opts.objectId) {
84
88
  const snapResult = opts.snapResize({
@@ -124,7 +128,8 @@ export function useResizable(options) {
124
128
  resizeState: null,
125
129
  element: null,
126
130
  pointerId: -1,
127
- currentBounds: { x: 0, y: 0, width: 0, height: 0 }
131
+ currentBounds: { x: 0, y: 0, width: 0, height: 0 },
132
+ aspectRatio: undefined
128
133
  };
129
134
  setIsResizing(false);
130
135
  setActiveHandle(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-svg-canvas",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "React library for building interactive SVG canvas applications with pan, zoom, selection, drag-and-drop, resize, and Figma-style snapping",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",