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.
- package/lib/geometry/resize.d.ts +8 -1
- package/lib/geometry/resize.js +51 -5
- package/lib/hooks/useResizable.d.ts +11 -0
- package/lib/hooks/useResizable.js +11 -6
- package/lib/snapping/SnapGuides.d.ts +4 -2
- package/lib/snapping/SnapGuides.js +87 -3
- package/lib/snapping/distribution-detection.d.ts +54 -0
- package/lib/snapping/distribution-detection.js +715 -0
- package/lib/snapping/index.d.ts +1 -0
- package/lib/snapping/index.js +2 -0
- package/lib/snapping/snap-engine.d.ts +9 -0
- package/lib/snapping/snap-engine.js +155 -5
- package/lib/snapping/snap-targets.d.ts +9 -5
- package/lib/snapping/snap-targets.js +204 -16
- package/lib/snapping/types.d.ts +45 -1
- package/lib/snapping/types.js +3 -1
- package/lib/snapping/useSnapping.d.ts +3 -1
- package/lib/snapping/useSnapping.js +7 -2
- package/package.json +1 -1
package/lib/geometry/resize.d.ts
CHANGED
|
@@ -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;
|
package/lib/geometry/resize.js
CHANGED
|
@@ -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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|