react-svg-canvas 0.1.1 → 0.1.3

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);
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import * as React from 'react';
5
5
  import type { Bounds } from '../types';
6
- import type { ActiveSnap, ScoredCandidate, SnapGuidesConfig, SnapDebugConfig, RotatedBounds } from './types';
6
+ import type { ActiveSnap, ActiveSnapEdge, ScoredCandidate, SnapGuidesConfig, SnapDebugConfig, RotatedBounds } from './types';
7
7
  export interface SnapGuidesProps {
8
8
  activeSnaps: ActiveSnap[];
9
9
  allCandidates?: ScoredCandidate[];
@@ -11,10 +11,12 @@ export interface SnapGuidesProps {
11
11
  debugConfig?: SnapDebugConfig;
12
12
  viewBounds: Bounds;
13
13
  draggedBounds?: RotatedBounds;
14
+ /** Which snap edges are active based on grab point (for visual feedback) */
15
+ activeSnapEdges?: ActiveSnapEdge[];
14
16
  /** Transform function to convert canvas coords to screen coords (for fixed layer rendering) */
15
17
  transformPoint?: (x: number, y: number) => [number, number];
16
18
  }
17
19
  /**
18
20
  * Main snap guides component - renders Figma-style red guide lines
19
21
  */
20
- export declare function SnapGuides({ activeSnaps, allCandidates, config, debugConfig, viewBounds, draggedBounds, transformPoint }: SnapGuidesProps): React.JSX.Element | null;
22
+ export declare function SnapGuides({ activeSnaps, allCandidates, config, debugConfig, viewBounds, draggedBounds, activeSnapEdges, transformPoint }: SnapGuidesProps): React.JSX.Element | null;
@@ -49,11 +49,55 @@ function SourceBoundingBoxHighlight({ bounds, color, strokeWidth, transformPoint
49
49
  const height = Math.abs(y2 - y1);
50
50
  return (React.createElement("rect", { x: minX, y: minY, width: width, height: height, fill: "none", stroke: color, strokeWidth: strokeWidth, strokeDasharray: "4,3", strokeOpacity: 0.7 }));
51
51
  }
52
+ /**
53
+ * Render active snap edge indicators on the dragged object
54
+ * Shows lines along the edges that are being used for snapping
55
+ */
56
+ function ActiveSnapEdgeIndicators({ activeSnapEdges, draggedBounds, config, transformPoint }) {
57
+ if (activeSnapEdges.length === 0)
58
+ return null;
59
+ const color = config.color;
60
+ const extensionLength = 10; // How far to extend beyond the object bounds
61
+ return (React.createElement("g", { className: "active-snap-edges" }, activeSnapEdges.map((edge, index) => {
62
+ // Calculate line along the full edge
63
+ let x1, y1, x2, y2;
64
+ if (edge.axis === 'x') {
65
+ // Vertical edge (left, right, centerX)
66
+ // Draw a vertical line along the edge, extending beyond bounds
67
+ const edgeX = edge.position;
68
+ x1 = edgeX;
69
+ y1 = draggedBounds.y - extensionLength;
70
+ x2 = edgeX;
71
+ y2 = draggedBounds.y + draggedBounds.height + extensionLength;
72
+ }
73
+ else {
74
+ // Horizontal edge (top, bottom, centerY)
75
+ // Draw a horizontal line along the edge, extending beyond bounds
76
+ const edgeY = edge.position;
77
+ x1 = draggedBounds.x - extensionLength;
78
+ y1 = edgeY;
79
+ x2 = draggedBounds.x + draggedBounds.width + extensionLength;
80
+ y2 = edgeY;
81
+ }
82
+ // Transform to screen coordinates
83
+ const [sx1, sy1] = transformPoint(x1, y1);
84
+ const [sx2, sy2] = transformPoint(x2, y2);
85
+ // Calculate midpoint for the diamond marker
86
+ const midX = (sx1 + sx2) / 2;
87
+ const midY = (sy1 + sy2) / 2;
88
+ return (React.createElement("g", { key: `snap-edge-${edge.edge}-${index}` },
89
+ React.createElement("line", { x1: sx1, y1: sy1, x2: sx2, y2: sy2, stroke: "white", strokeWidth: 5, strokeLinecap: "round" }),
90
+ React.createElement("line", { x1: sx1, y1: sy1, x2: sx2, y2: sy2, stroke: color, strokeWidth: 3, strokeLinecap: "round" }),
91
+ React.createElement(DiamondMarker, { x: midX, y: midY, size: 10, color: "white" }),
92
+ React.createElement(DiamondMarker, { x: midX, y: midY, size: 7, color: color })));
93
+ })));
94
+ }
52
95
  /**
53
96
  * Main snap guides component - renders Figma-style red guide lines
54
97
  */
55
- export function SnapGuides({ activeSnaps, allCandidates, config, debugConfig, viewBounds, draggedBounds, transformPoint }) {
56
- if (activeSnaps.length === 0 && (!debugConfig?.enabled || !allCandidates?.length)) {
98
+ export function SnapGuides({ activeSnaps, allCandidates, config, debugConfig, viewBounds, draggedBounds, activeSnapEdges, transformPoint }) {
99
+ const hasActiveContent = activeSnaps.length > 0 || (activeSnapEdges && activeSnapEdges.length > 0);
100
+ if (!hasActiveContent && (!debugConfig?.enabled || !allCandidates?.length)) {
57
101
  return null;
58
102
  }
59
103
  // Identity transform if none provided
@@ -71,7 +115,13 @@ export function SnapGuides({ activeSnaps, allCandidates, config, debugConfig, vi
71
115
  }, [activeSnaps]);
72
116
  return (React.createElement("g", { className: "snap-guides", pointerEvents: "none" },
73
117
  sourceObjectsToHighlight.map(([sourceId, bounds]) => (React.createElement(SourceBoundingBoxHighlight, { key: `source-highlight-${sourceId}`, bounds: bounds, color: config.color, strokeWidth: config.strokeWidth, transformPoint: transform }))),
74
- activeSnaps.map((snap, index) => (React.createElement(SnapGuideLine, { key: `active-${index}-${snap.target.axis}-${snap.target.value}`, snap: snap, config: config, draggedBounds: draggedBounds, isActive: true, transformPoint: transform }))),
118
+ activeSnapEdges && draggedBounds && (React.createElement(ActiveSnapEdgeIndicators, { activeSnapEdges: activeSnapEdges, draggedBounds: draggedBounds, config: config, transformPoint: transform })),
119
+ activeSnaps
120
+ .filter(snap => !snap.distribution)
121
+ .map((snap, index) => (React.createElement(SnapGuideLine, { key: `active-${index}-${snap.target.axis}-${snap.target.value}`, snap: snap, config: config, draggedBounds: draggedBounds, isActive: true, transformPoint: transform }))),
122
+ activeSnaps
123
+ .filter(snap => snap.distribution)
124
+ .map((snap, index) => (React.createElement(DistributionGapsRenderer, { key: `dist-${index}`, snap: snap, config: config, transformPoint: transform }))),
75
125
  debugConfig?.enabled && allCandidates && (React.createElement(SnapDebugCandidates, { candidates: allCandidates, config: debugConfig, activeSnaps: activeSnaps, transformPoint: transform }))));
76
126
  }
77
127
  /**
@@ -81,6 +131,40 @@ function DiamondMarker({ x, y, size = 4, color }) {
81
131
  const half = size / 2;
82
132
  return (React.createElement("polygon", { points: `${x},${y - half} ${x + half},${y} ${x},${y + half} ${x - half},${y}`, fill: color }));
83
133
  }
134
+ /**
135
+ * Distribution gap indicator with arrows and spacing label
136
+ */
137
+ function DistributionGapIndicator({ gap, color, strokeWidth, transformPoint }) {
138
+ const [x1, y1] = transformPoint(gap.start.x, gap.start.y);
139
+ const [x2, y2] = transformPoint(gap.end.x, gap.end.y);
140
+ const midX = (x1 + x2) / 2;
141
+ const midY = (y1 + y2) / 2;
142
+ const spacing = Math.round(gap.distance);
143
+ // Arrow size
144
+ const arrowSize = 5;
145
+ const isHorizontal = gap.axis === 'x';
146
+ return (React.createElement("g", null,
147
+ React.createElement("line", { x1: x1, y1: y1, x2: x2, y2: y2, stroke: color, strokeWidth: strokeWidth }),
148
+ isHorizontal ? (React.createElement(React.Fragment, null,
149
+ React.createElement("polygon", { points: `${x1},${y1} ${x1 + arrowSize},${y1 - arrowSize / 2} ${x1 + arrowSize},${y1 + arrowSize / 2}`, fill: color }),
150
+ React.createElement("polygon", { points: `${x2},${y2} ${x2 - arrowSize},${y2 - arrowSize / 2} ${x2 - arrowSize},${y2 + arrowSize / 2}`, fill: color }))) : (React.createElement(React.Fragment, null,
151
+ React.createElement("polygon", { points: `${x1},${y1} ${x1 - arrowSize / 2},${y1 + arrowSize} ${x1 + arrowSize / 2},${y1 + arrowSize}`, fill: color }),
152
+ React.createElement("polygon", { points: `${x2},${y2} ${x2 - arrowSize / 2},${y2 - arrowSize} ${x2 + arrowSize / 2},${y2 - arrowSize}`, fill: color }))),
153
+ spacing > 0 && (React.createElement("g", null, isHorizontal ? (React.createElement(React.Fragment, null,
154
+ React.createElement("rect", { x: midX - 15, y: midY - 18, width: 30, height: 14, fill: "white", fillOpacity: 0.95, rx: 2 }),
155
+ React.createElement("text", { x: midX, y: midY - 7, fontSize: 10, fill: color, fontFamily: "system-ui, sans-serif", textAnchor: "middle" }, spacing))) : (React.createElement(React.Fragment, null,
156
+ React.createElement("rect", { x: midX + 5, y: midY - 7, width: 30, height: 14, fill: "white", fillOpacity: 0.95, rx: 2 }),
157
+ React.createElement("text", { x: midX + 20, y: midY + 4, fontSize: 10, fill: color, fontFamily: "system-ui, sans-serif", textAnchor: "middle" }, spacing)))))));
158
+ }
159
+ /**
160
+ * Render all distribution gaps for an active snap
161
+ */
162
+ function DistributionGapsRenderer({ snap, config, transformPoint }) {
163
+ if (!snap.distribution || snap.distribution.gaps.length === 0) {
164
+ return null;
165
+ }
166
+ return (React.createElement("g", { className: "distribution-gaps" }, snap.distribution.gaps.map((gap, index) => (React.createElement(DistributionGapIndicator, { key: `dist-gap-${index}`, gap: gap, color: config.color, strokeWidth: config.strokeWidth, transformPoint: transformPoint })))));
167
+ }
84
168
  /**
85
169
  * Individual snap guide line
86
170
  */
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Distribution snap detection for equal-spacing patterns
3
+ * Detects row, column, and staircase arrangements
4
+ */
5
+ import type { Point, Bounds } from '../types';
6
+ import type { SnapSpatialObject, RotatedBounds, DistributionSnapInfo, ActiveSnap, SnapConfiguration } from './types';
7
+ /**
8
+ * Candidate for distribution snapping
9
+ */
10
+ export interface DistributionCandidate {
11
+ /** Snapped position for the dragged object */
12
+ position: Point;
13
+ /** Distribution pattern info */
14
+ info: DistributionSnapInfo;
15
+ /** How far the object moved to snap */
16
+ distance: number;
17
+ /** Score for prioritization */
18
+ score: number;
19
+ }
20
+ /**
21
+ * Object with computed bounds for distribution detection
22
+ */
23
+ interface ObjectWithBounds {
24
+ id: string;
25
+ bounds: Bounds;
26
+ centerX: number;
27
+ centerY: number;
28
+ left: number;
29
+ right: number;
30
+ top: number;
31
+ bottom: number;
32
+ }
33
+ /**
34
+ * Detect row distribution pattern (horizontal equal spacing)
35
+ */
36
+ export declare function detectRowDistribution(dragged: ObjectWithBounds, objects: ObjectWithBounds[], threshold: number, minObjects?: number): DistributionCandidate | null;
37
+ /**
38
+ * Detect column distribution pattern (vertical equal spacing)
39
+ */
40
+ export declare function detectColumnDistribution(dragged: ObjectWithBounds, objects: ObjectWithBounds[], threshold: number, minObjects?: number): DistributionCandidate | null;
41
+ /**
42
+ * Detect staircase distribution pattern (diagonal equal spacing)
43
+ */
44
+ export declare function detectStaircaseDistribution(dragged: ObjectWithBounds, objects: ObjectWithBounds[], threshold: number, minObjects?: number): DistributionCandidate | null;
45
+ /**
46
+ * Main distribution detection function
47
+ * Detects all distribution patterns and returns the best candidates
48
+ */
49
+ export declare function detectDistribution(draggedBounds: RotatedBounds, draggedId: string, objects: SnapSpatialObject[], excludeIds: Set<string>, config: SnapConfiguration): DistributionCandidate[];
50
+ /**
51
+ * Convert distribution candidate to ActiveSnap for rendering
52
+ */
53
+ export declare function distributionToActiveSnap(candidate: DistributionCandidate, config: SnapConfiguration): ActiveSnap;
54
+ export {};