@hyperframes/studio 0.6.73 → 0.6.74
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/dist/assets/index-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- package/dist/assets/index-Dc2HfqON.js +0 -140
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
2
|
+
// Snap computation engine — pure functions, zero React/DOM dependencies.
|
|
3
|
+
// All position values are in overlay-space (screen) pixels.
|
|
4
|
+
|
|
5
|
+
export const SNAP_THRESHOLD_PX = 6;
|
|
6
|
+
const EQUIDISTANCE_TOLERANCE_PX = 1;
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export interface SnapEdge {
|
|
13
|
+
position: number;
|
|
14
|
+
source: "grid";
|
|
15
|
+
id: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SnapTarget {
|
|
19
|
+
left: number;
|
|
20
|
+
top: number;
|
|
21
|
+
right: number;
|
|
22
|
+
bottom: number;
|
|
23
|
+
centerX: number;
|
|
24
|
+
centerY: number;
|
|
25
|
+
id: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SnapGuide {
|
|
29
|
+
axis: "x" | "y";
|
|
30
|
+
position: number;
|
|
31
|
+
/** Extent of the guide line (min of involved elements). */
|
|
32
|
+
from: number;
|
|
33
|
+
/** Extent of the guide line (max of involved elements). */
|
|
34
|
+
to: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SpacingGuide {
|
|
38
|
+
axis: "x" | "y";
|
|
39
|
+
/** Position of the gap (start of gap). */
|
|
40
|
+
position: number;
|
|
41
|
+
/** Size of the gap in pixels. */
|
|
42
|
+
size: number;
|
|
43
|
+
/** Extent for rendering the indicator. */
|
|
44
|
+
from: number;
|
|
45
|
+
/** Extent for rendering the indicator. */
|
|
46
|
+
to: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SnapResult {
|
|
50
|
+
dx: number;
|
|
51
|
+
dy: number;
|
|
52
|
+
guides: SnapGuide[];
|
|
53
|
+
spacingGuides: SpacingGuide[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Rect shorthand used across the public API
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export interface Rect {
|
|
61
|
+
left: number;
|
|
62
|
+
top: number;
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
function rectRight(r: Rect): number {
|
|
72
|
+
return r.left + r.width;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function rectBottom(r: Rect): number {
|
|
76
|
+
return r.top + r.height;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rectCenterX(r: Rect): number {
|
|
80
|
+
return r.left + r.width / 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rectCenterY(r: Rect): number {
|
|
84
|
+
return r.top + r.height / 2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Public API
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert overlay rects to snap targets with precomputed edges & centers.
|
|
93
|
+
*/
|
|
94
|
+
export function extractSnapTargets(entries: Array<{ rect: Rect; id: string }>): SnapTarget[] {
|
|
95
|
+
return entries.map(({ rect: r, id }) => ({
|
|
96
|
+
left: r.left,
|
|
97
|
+
top: r.top,
|
|
98
|
+
right: rectRight(r),
|
|
99
|
+
bottom: rectBottom(r),
|
|
100
|
+
centerX: rectCenterX(r),
|
|
101
|
+
centerY: rectCenterY(r),
|
|
102
|
+
id,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a snap target from the composition/overlay boundary.
|
|
108
|
+
*/
|
|
109
|
+
export function buildCompositionSnapTarget(rect: Rect): SnapTarget {
|
|
110
|
+
return {
|
|
111
|
+
left: rect.left,
|
|
112
|
+
top: rect.top,
|
|
113
|
+
right: rectRight(rect),
|
|
114
|
+
bottom: rectBottom(rect),
|
|
115
|
+
centerX: rectCenterX(rect),
|
|
116
|
+
centerY: rectCenterY(rect),
|
|
117
|
+
id: "composition",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Generate grid-line snap edges.
|
|
123
|
+
* `gridSpacing` is in composition pixels; `scale` converts to overlay pixels.
|
|
124
|
+
* X edges are vertical grid lines; Y edges are horizontal grid lines.
|
|
125
|
+
*/
|
|
126
|
+
export function buildGridSnapEdges(
|
|
127
|
+
compositionRect: Rect,
|
|
128
|
+
gridSpacing: number,
|
|
129
|
+
scale: number,
|
|
130
|
+
): { x: SnapEdge[]; y: SnapEdge[] } {
|
|
131
|
+
const xEdges: SnapEdge[] = [];
|
|
132
|
+
const yEdges: SnapEdge[] = [];
|
|
133
|
+
|
|
134
|
+
if (gridSpacing <= 0 || scale <= 0) return { x: xEdges, y: yEdges };
|
|
135
|
+
|
|
136
|
+
const step = gridSpacing * scale;
|
|
137
|
+
|
|
138
|
+
// Vertical grid lines (x-axis edges)
|
|
139
|
+
let x = compositionRect.left + step;
|
|
140
|
+
const xMax = compositionRect.left + compositionRect.width;
|
|
141
|
+
let idx = 0;
|
|
142
|
+
while (x < xMax) {
|
|
143
|
+
xEdges.push({ position: x, source: "grid", id: `grid-x-${idx}` });
|
|
144
|
+
x += step;
|
|
145
|
+
idx++;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Horizontal grid lines (y-axis edges)
|
|
149
|
+
let y = compositionRect.top + step;
|
|
150
|
+
const yMax = compositionRect.top + compositionRect.height;
|
|
151
|
+
idx = 0;
|
|
152
|
+
while (y < yMax) {
|
|
153
|
+
yEdges.push({ position: y, source: "grid", id: `grid-y-${idx}` });
|
|
154
|
+
y += step;
|
|
155
|
+
idx++;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { x: xEdges, y: yEdges };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Internal snap resolution helpers
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
interface EdgeCandidate {
|
|
166
|
+
/** Distance the moving rect must adjust to align with this edge. */
|
|
167
|
+
adjustment: number;
|
|
168
|
+
/** Absolute distance (for comparison). */
|
|
169
|
+
distance: number;
|
|
170
|
+
/** Position of the guide line. */
|
|
171
|
+
guidePosition: number;
|
|
172
|
+
/** Source of the match. */
|
|
173
|
+
source: "element" | "composition" | "grid";
|
|
174
|
+
/** Id of the target or grid line. */
|
|
175
|
+
targetId: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Collect edge candidates on a single axis for a moving rect.
|
|
180
|
+
* `movingEdges` are the edges of the moving rect (e.g. left, centerX, right).
|
|
181
|
+
* `targetEdges` are the corresponding edges on each target.
|
|
182
|
+
*/
|
|
183
|
+
// fallow-ignore-next-line complexity
|
|
184
|
+
function collectCandidates(
|
|
185
|
+
movingEdges: number[],
|
|
186
|
+
targets: SnapTarget[],
|
|
187
|
+
targetEdgeExtractor: (t: SnapTarget) => number[],
|
|
188
|
+
gridEdges: SnapEdge[] | undefined,
|
|
189
|
+
threshold: number,
|
|
190
|
+
): EdgeCandidate[] {
|
|
191
|
+
const candidates: EdgeCandidate[] = [];
|
|
192
|
+
|
|
193
|
+
for (const target of targets) {
|
|
194
|
+
const tEdges = targetEdgeExtractor(target);
|
|
195
|
+
for (const mEdge of movingEdges) {
|
|
196
|
+
for (const tEdge of tEdges) {
|
|
197
|
+
const adjustment = tEdge - mEdge;
|
|
198
|
+
const distance = Math.abs(adjustment);
|
|
199
|
+
if (distance <= threshold) {
|
|
200
|
+
candidates.push({
|
|
201
|
+
adjustment,
|
|
202
|
+
distance,
|
|
203
|
+
guidePosition: tEdge,
|
|
204
|
+
source: target.id === "composition" ? "composition" : "element",
|
|
205
|
+
targetId: target.id,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (gridEdges) {
|
|
213
|
+
for (const edge of gridEdges) {
|
|
214
|
+
for (const mEdge of movingEdges) {
|
|
215
|
+
const adjustment = edge.position - mEdge;
|
|
216
|
+
const distance = Math.abs(adjustment);
|
|
217
|
+
if (distance <= threshold) {
|
|
218
|
+
candidates.push({
|
|
219
|
+
adjustment,
|
|
220
|
+
distance,
|
|
221
|
+
guidePosition: edge.position,
|
|
222
|
+
source: "grid",
|
|
223
|
+
targetId: edge.id,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return candidates;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* From a list of candidates, pick the best adjustment:
|
|
235
|
+
* - Element/composition matches take priority over grid matches.
|
|
236
|
+
* - Among equal-priority matches, pick the smallest distance.
|
|
237
|
+
* - Return all guides that share the winning adjustment.
|
|
238
|
+
*/
|
|
239
|
+
function pickBest(candidates: EdgeCandidate[]): {
|
|
240
|
+
adjustment: number;
|
|
241
|
+
matches: EdgeCandidate[];
|
|
242
|
+
} | null {
|
|
243
|
+
if (candidates.length === 0) return null;
|
|
244
|
+
|
|
245
|
+
// Partition into element/composition vs grid
|
|
246
|
+
const elementCandidates = candidates.filter(
|
|
247
|
+
(c) => c.source === "element" || c.source === "composition",
|
|
248
|
+
);
|
|
249
|
+
const gridCandidates = candidates.filter((c) => c.source === "grid");
|
|
250
|
+
|
|
251
|
+
// Pick the pool with the best (smallest distance) match, preferring element
|
|
252
|
+
let pool: EdgeCandidate[];
|
|
253
|
+
const bestElem = elementCandidates.length
|
|
254
|
+
? Math.min(...elementCandidates.map((c) => c.distance))
|
|
255
|
+
: Infinity;
|
|
256
|
+
const bestGrid = gridCandidates.length
|
|
257
|
+
? Math.min(...gridCandidates.map((c) => c.distance))
|
|
258
|
+
: Infinity;
|
|
259
|
+
|
|
260
|
+
if (bestElem <= bestGrid) {
|
|
261
|
+
pool = elementCandidates;
|
|
262
|
+
} else {
|
|
263
|
+
pool = gridCandidates;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const minDist = Math.min(...pool.map((c) => c.distance));
|
|
267
|
+
const winners = pool.filter((c) => c.distance === minDist);
|
|
268
|
+
|
|
269
|
+
// When candidates at the same distance pull in opposite directions (e.g.
|
|
270
|
+
// element centered between two equidistant targets), suppress the snap
|
|
271
|
+
// entirely — the element holds where the user dragged it.
|
|
272
|
+
const hasPositive = winners.some((c) => c.adjustment > 0);
|
|
273
|
+
const hasNegative = winners.some((c) => c.adjustment < 0);
|
|
274
|
+
if (hasPositive && hasNegative) return null;
|
|
275
|
+
|
|
276
|
+
const adjustment = winners[0].adjustment;
|
|
277
|
+
const matches = pool.filter((c) => c.adjustment === adjustment);
|
|
278
|
+
return { adjustment, matches };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Guide extent computation
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function computeGuideExtent(
|
|
286
|
+
axis: "x" | "y",
|
|
287
|
+
movingRect: Rect,
|
|
288
|
+
matchedTargetIds: string[],
|
|
289
|
+
targetMap: Map<string, SnapTarget>,
|
|
290
|
+
): { from: number; to: number } {
|
|
291
|
+
const extents: number[] = [];
|
|
292
|
+
|
|
293
|
+
if (axis === "x") {
|
|
294
|
+
extents.push(movingRect.top, rectBottom(movingRect));
|
|
295
|
+
for (const tid of matchedTargetIds) {
|
|
296
|
+
const t = targetMap.get(tid);
|
|
297
|
+
if (t) extents.push(t.top, t.bottom);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
extents.push(movingRect.left, rectRight(movingRect));
|
|
301
|
+
for (const tid of matchedTargetIds) {
|
|
302
|
+
const t = targetMap.get(tid);
|
|
303
|
+
if (t) extents.push(t.left, t.right);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { from: Math.min(...extents), to: Math.max(...extents) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Shared guide-building logic
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
function buildGuidesFromMatches(
|
|
315
|
+
bestX: { adjustment: number; matches: EdgeCandidate[] } | null,
|
|
316
|
+
bestY: { adjustment: number; matches: EdgeCandidate[] } | null,
|
|
317
|
+
adjustedRect: Rect,
|
|
318
|
+
targetMap: Map<string, SnapTarget>,
|
|
319
|
+
): SnapGuide[] {
|
|
320
|
+
const guides: SnapGuide[] = [];
|
|
321
|
+
|
|
322
|
+
for (const [axis, best] of [
|
|
323
|
+
["x", bestX],
|
|
324
|
+
["y", bestY],
|
|
325
|
+
] as const) {
|
|
326
|
+
if (!best) continue;
|
|
327
|
+
const seenPositions = new Set<number>();
|
|
328
|
+
for (const m of best.matches) {
|
|
329
|
+
if (seenPositions.has(m.guidePosition)) continue;
|
|
330
|
+
seenPositions.add(m.guidePosition);
|
|
331
|
+
const targetIds = best.matches
|
|
332
|
+
.filter((mm) => mm.guidePosition === m.guidePosition)
|
|
333
|
+
.map((mm) => mm.targetId);
|
|
334
|
+
const extent = computeGuideExtent(axis, adjustedRect, targetIds, targetMap);
|
|
335
|
+
guides.push({ axis, position: m.guidePosition, from: extent.from, to: extent.to });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return guides;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const DISABLED_RESULT = (dx: number, dy: number): SnapResult => ({
|
|
343
|
+
dx,
|
|
344
|
+
dy,
|
|
345
|
+
guides: [],
|
|
346
|
+
spacingGuides: [],
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// resolveSnapAdjustment — main drag snap entry point
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
// fallow-ignore-next-line complexity
|
|
354
|
+
export function resolveSnapAdjustment(input: {
|
|
355
|
+
movingRect: Rect;
|
|
356
|
+
proposedDx: number;
|
|
357
|
+
proposedDy: number;
|
|
358
|
+
targets: SnapTarget[];
|
|
359
|
+
gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
|
|
360
|
+
threshold: number;
|
|
361
|
+
disabled: boolean;
|
|
362
|
+
}): SnapResult {
|
|
363
|
+
if (input.disabled || input.threshold <= 0) {
|
|
364
|
+
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const mr = input.movingRect;
|
|
368
|
+
const proposed: Rect = {
|
|
369
|
+
left: mr.left + input.proposedDx,
|
|
370
|
+
top: mr.top + input.proposedDy,
|
|
371
|
+
width: mr.width,
|
|
372
|
+
height: mr.height,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const xCandidates = collectCandidates(
|
|
376
|
+
[proposed.left, rectCenterX(proposed), rectRight(proposed)],
|
|
377
|
+
input.targets,
|
|
378
|
+
(t) => [t.left, t.centerX, t.right],
|
|
379
|
+
input.gridEdges?.x,
|
|
380
|
+
input.threshold,
|
|
381
|
+
);
|
|
382
|
+
const yCandidates = collectCandidates(
|
|
383
|
+
[proposed.top, rectCenterY(proposed), rectBottom(proposed)],
|
|
384
|
+
input.targets,
|
|
385
|
+
(t) => [t.top, t.centerY, t.bottom],
|
|
386
|
+
input.gridEdges?.y,
|
|
387
|
+
input.threshold,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const bestX = pickBest(xCandidates);
|
|
391
|
+
const bestY = pickBest(yCandidates);
|
|
392
|
+
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
|
|
393
|
+
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
|
|
394
|
+
|
|
395
|
+
const adjustedRect: Rect = {
|
|
396
|
+
left: mr.left + adjustedDx,
|
|
397
|
+
top: mr.top + adjustedDy,
|
|
398
|
+
width: mr.width,
|
|
399
|
+
height: mr.height,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const targetMap = new Map(input.targets.map((t) => [t.id, t]));
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
dx: adjustedDx,
|
|
406
|
+
dy: adjustedDy,
|
|
407
|
+
guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
|
|
408
|
+
spacingGuides: [], // computed separately via resolveEquidistanceGuides
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// resolveResizeSnapAdjustment — resize variant (only right/bottom snap)
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
// fallow-ignore-next-line complexity
|
|
417
|
+
export function resolveResizeSnapAdjustment(input: {
|
|
418
|
+
movingRect: Rect;
|
|
419
|
+
proposedDx: number;
|
|
420
|
+
proposedDy: number;
|
|
421
|
+
targets: SnapTarget[];
|
|
422
|
+
gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
|
|
423
|
+
threshold: number;
|
|
424
|
+
disabled: boolean;
|
|
425
|
+
}): SnapResult {
|
|
426
|
+
if (input.disabled || input.threshold <= 0) {
|
|
427
|
+
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const mr = input.movingRect;
|
|
431
|
+
const proposedRight = rectRight(mr) + input.proposedDx;
|
|
432
|
+
const proposedBottom = rectBottom(mr) + input.proposedDy;
|
|
433
|
+
|
|
434
|
+
const xCandidates = collectCandidates(
|
|
435
|
+
[proposedRight],
|
|
436
|
+
input.targets,
|
|
437
|
+
(t) => [t.left, t.centerX, t.right],
|
|
438
|
+
input.gridEdges?.x,
|
|
439
|
+
input.threshold,
|
|
440
|
+
);
|
|
441
|
+
const yCandidates = collectCandidates(
|
|
442
|
+
[proposedBottom],
|
|
443
|
+
input.targets,
|
|
444
|
+
(t) => [t.top, t.centerY, t.bottom],
|
|
445
|
+
input.gridEdges?.y,
|
|
446
|
+
input.threshold,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const bestX = pickBest(xCandidates);
|
|
450
|
+
const bestY = pickBest(yCandidates);
|
|
451
|
+
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
|
|
452
|
+
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
|
|
453
|
+
|
|
454
|
+
const adjustedRect: Rect = {
|
|
455
|
+
left: mr.left,
|
|
456
|
+
top: mr.top,
|
|
457
|
+
width: mr.width + adjustedDx,
|
|
458
|
+
height: mr.height + adjustedDy,
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const targetMap = new Map(input.targets.map((t) => [t.id, t]));
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
dx: adjustedDx,
|
|
465
|
+
dy: adjustedDy,
|
|
466
|
+
guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
|
|
467
|
+
spacingGuides: [], // computed separately via resolveEquidistanceGuides
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
// resolveEquidistanceGuides
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
// fallow-ignore-next-line complexity
|
|
476
|
+
export function resolveEquidistanceGuides(input: {
|
|
477
|
+
movingRect: Rect;
|
|
478
|
+
targets: SnapTarget[];
|
|
479
|
+
threshold: number;
|
|
480
|
+
}): SpacingGuide[] {
|
|
481
|
+
const guides: SpacingGuide[] = [];
|
|
482
|
+
const mr = input.movingRect;
|
|
483
|
+
|
|
484
|
+
const movingTarget: SnapTarget = {
|
|
485
|
+
left: mr.left,
|
|
486
|
+
top: mr.top,
|
|
487
|
+
right: rectRight(mr),
|
|
488
|
+
bottom: rectBottom(mr),
|
|
489
|
+
centerX: rectCenterX(mr),
|
|
490
|
+
centerY: rectCenterY(mr),
|
|
491
|
+
id: "\0__snap_moving__",
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const allTargets = [...input.targets, movingTarget];
|
|
495
|
+
|
|
496
|
+
// X axis: sort by centerX, scan for equal gaps between adjacent triplets
|
|
497
|
+
const sortedX = [...allTargets].sort((a, b) => a.centerX - b.centerX);
|
|
498
|
+
for (let i = 0; i < sortedX.length - 2; i++) {
|
|
499
|
+
const a = sortedX[i];
|
|
500
|
+
const b = sortedX[i + 1];
|
|
501
|
+
const c = sortedX[i + 2];
|
|
502
|
+
|
|
503
|
+
// Gap between A and B = B.left - A.right
|
|
504
|
+
const gapAB = b.left - a.right;
|
|
505
|
+
const gapBC = c.left - b.right;
|
|
506
|
+
|
|
507
|
+
if (gapAB < 0 || gapBC < 0) continue; // overlapping elements
|
|
508
|
+
|
|
509
|
+
// Check if the moving rect is one of A, B, or C
|
|
510
|
+
const involvesMoving =
|
|
511
|
+
a.id === "\0__snap_moving__" || b.id === "\0__snap_moving__" || c.id === "\0__snap_moving__";
|
|
512
|
+
if (!involvesMoving) continue;
|
|
513
|
+
|
|
514
|
+
if (Math.abs(gapAB - gapBC) <= EQUIDISTANCE_TOLERANCE_PX) {
|
|
515
|
+
const crossMin = Math.min(a.top, b.top, c.top);
|
|
516
|
+
const crossMax = Math.max(a.bottom, b.bottom, c.bottom);
|
|
517
|
+
|
|
518
|
+
// Gap A-B
|
|
519
|
+
guides.push({
|
|
520
|
+
axis: "x",
|
|
521
|
+
position: a.right,
|
|
522
|
+
size: gapAB,
|
|
523
|
+
from: crossMin,
|
|
524
|
+
to: crossMax,
|
|
525
|
+
});
|
|
526
|
+
// Gap B-C
|
|
527
|
+
guides.push({
|
|
528
|
+
axis: "x",
|
|
529
|
+
position: b.right,
|
|
530
|
+
size: gapBC,
|
|
531
|
+
from: crossMin,
|
|
532
|
+
to: crossMax,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Y axis: sort by centerY, scan for equal gaps between adjacent triplets
|
|
538
|
+
const sortedY = [...allTargets].sort((a, b) => a.centerY - b.centerY);
|
|
539
|
+
for (let i = 0; i < sortedY.length - 2; i++) {
|
|
540
|
+
const a = sortedY[i];
|
|
541
|
+
const b = sortedY[i + 1];
|
|
542
|
+
const c = sortedY[i + 2];
|
|
543
|
+
|
|
544
|
+
const gapAB = b.top - a.bottom;
|
|
545
|
+
const gapBC = c.top - b.bottom;
|
|
546
|
+
|
|
547
|
+
if (gapAB < 0 || gapBC < 0) continue;
|
|
548
|
+
|
|
549
|
+
const involvesMoving =
|
|
550
|
+
a.id === "\0__snap_moving__" || b.id === "\0__snap_moving__" || c.id === "\0__snap_moving__";
|
|
551
|
+
if (!involvesMoving) continue;
|
|
552
|
+
|
|
553
|
+
if (Math.abs(gapAB - gapBC) <= EQUIDISTANCE_TOLERANCE_PX) {
|
|
554
|
+
const crossMin = Math.min(a.left, b.left, c.left);
|
|
555
|
+
const crossMax = Math.max(a.right, b.right, c.right);
|
|
556
|
+
|
|
557
|
+
guides.push({
|
|
558
|
+
axis: "y",
|
|
559
|
+
position: a.bottom,
|
|
560
|
+
size: gapAB,
|
|
561
|
+
from: crossMin,
|
|
562
|
+
to: crossMax,
|
|
563
|
+
});
|
|
564
|
+
guides.push({
|
|
565
|
+
axis: "y",
|
|
566
|
+
position: b.bottom,
|
|
567
|
+
size: gapBC,
|
|
568
|
+
from: crossMin,
|
|
569
|
+
to: crossMax,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return guides;
|
|
575
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// fallow-ignore-file unused-file
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import {
|
|
5
|
+
isElementVisibleForOverlay,
|
|
6
|
+
toOverlayRect,
|
|
7
|
+
type OverlayRect,
|
|
8
|
+
} from "./domEditOverlayGeometry";
|
|
9
|
+
import {
|
|
10
|
+
extractSnapTargets,
|
|
11
|
+
buildCompositionSnapTarget,
|
|
12
|
+
buildGridSnapEdges,
|
|
13
|
+
type SnapTarget,
|
|
14
|
+
type SnapEdge,
|
|
15
|
+
} from "./snapEngine";
|
|
16
|
+
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
17
|
+
|
|
18
|
+
export interface SnapContext {
|
|
19
|
+
targets: SnapTarget[];
|
|
20
|
+
compositionTarget: SnapTarget | null;
|
|
21
|
+
gridEdges: { x: SnapEdge[]; y: SnapEdge[] } | null;
|
|
22
|
+
snapEnabled: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readPositiveDimension(value: string | null): number | null {
|
|
26
|
+
if (!value) return null;
|
|
27
|
+
const parsed = Number.parseFloat(value);
|
|
28
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const IGNORED_TAGS = new Set(["script", "style", "link", "meta", "base", "template", "br", "wbr"]);
|
|
32
|
+
|
|
33
|
+
function isHtmlElement(node: Node): node is HTMLElement {
|
|
34
|
+
return node.nodeType === 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectVisibleElements(
|
|
38
|
+
root: HTMLElement,
|
|
39
|
+
excludeElements: Set<HTMLElement>,
|
|
40
|
+
maxItems: number,
|
|
41
|
+
): HTMLElement[] {
|
|
42
|
+
const result: HTMLElement[] = [];
|
|
43
|
+
// fallow-ignore-next-line complexity
|
|
44
|
+
const visit = (el: HTMLElement) => {
|
|
45
|
+
if (result.length >= maxItems) return;
|
|
46
|
+
for (const child of Array.from(el.children)) {
|
|
47
|
+
if (!isHtmlElement(child)) continue;
|
|
48
|
+
if (IGNORED_TAGS.has(child.tagName.toLowerCase())) continue;
|
|
49
|
+
if (child.hasAttribute("data-composition-id")) continue;
|
|
50
|
+
if (excludeElements.has(child)) continue;
|
|
51
|
+
if (!isElementVisibleForOverlay(child)) continue;
|
|
52
|
+
result.push(child);
|
|
53
|
+
visit(child);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
visit(root);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// fallow-ignore-next-line complexity
|
|
61
|
+
export function collectSnapContext(input: {
|
|
62
|
+
overlayEl: HTMLDivElement;
|
|
63
|
+
iframe: HTMLIFrameElement;
|
|
64
|
+
excludeElements: Set<HTMLElement>;
|
|
65
|
+
}): SnapContext {
|
|
66
|
+
const prefs = readStudioUiPreferences();
|
|
67
|
+
const snapEnabled = prefs.snapEnabled ?? true;
|
|
68
|
+
|
|
69
|
+
const doc = input.iframe.contentDocument;
|
|
70
|
+
if (!doc) {
|
|
71
|
+
return { targets: [], compositionTarget: null, gridEdges: null, snapEnabled };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const root =
|
|
75
|
+
doc.querySelector<HTMLElement>("[data-composition-id]") ?? (doc.documentElement as HTMLElement);
|
|
76
|
+
const rootRect = root?.getBoundingClientRect();
|
|
77
|
+
const declaredWidth = readPositiveDimension(root?.getAttribute("data-width") ?? null);
|
|
78
|
+
const declaredHeight = readPositiveDimension(root?.getAttribute("data-height") ?? null);
|
|
79
|
+
const rootWidth = declaredWidth ?? rootRect?.width;
|
|
80
|
+
const rootHeight = declaredHeight ?? rootRect?.height;
|
|
81
|
+
|
|
82
|
+
if (!rootWidth || !rootHeight || !rootRect) {
|
|
83
|
+
return { targets: [], compositionTarget: null, gridEdges: null, snapEnabled };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const iframeRect = input.iframe.getBoundingClientRect();
|
|
87
|
+
const overlayRect = input.overlayEl.getBoundingClientRect();
|
|
88
|
+
const rootScaleX = iframeRect.width / rootWidth;
|
|
89
|
+
const rootScaleY = iframeRect.height / rootHeight;
|
|
90
|
+
|
|
91
|
+
const compositionOverlayRect: OverlayRect = {
|
|
92
|
+
left: iframeRect.left - overlayRect.left,
|
|
93
|
+
top: iframeRect.top - overlayRect.top,
|
|
94
|
+
width: iframeRect.width,
|
|
95
|
+
height: iframeRect.height,
|
|
96
|
+
editScaleX: rootScaleX,
|
|
97
|
+
editScaleY: rootScaleY,
|
|
98
|
+
};
|
|
99
|
+
const compositionTarget = buildCompositionSnapTarget(compositionOverlayRect);
|
|
100
|
+
|
|
101
|
+
const MAX_SNAP_TARGETS = 80;
|
|
102
|
+
const elements = collectVisibleElements(root, input.excludeElements, MAX_SNAP_TARGETS);
|
|
103
|
+
if (elements.length >= MAX_SNAP_TARGETS) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[snap] Target cap reached (${MAX_SNAP_TARGETS}). Elements beyond this limit are excluded from snap alignment.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const entries: Array<{
|
|
110
|
+
rect: { left: number; top: number; width: number; height: number };
|
|
111
|
+
id: string;
|
|
112
|
+
}> = [];
|
|
113
|
+
for (let i = 0; i < elements.length; i++) {
|
|
114
|
+
const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]);
|
|
115
|
+
if (rect) entries.push({ rect, id: `snap-target-${i}` });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const targets = extractSnapTargets(entries);
|
|
119
|
+
|
|
120
|
+
let gridEdges: { x: SnapEdge[]; y: SnapEdge[] } | null = null;
|
|
121
|
+
const gridSpacing = prefs.gridSpacing ?? 50;
|
|
122
|
+
const snapToGrid = prefs.snapToGrid ?? false;
|
|
123
|
+
if (snapToGrid && gridSpacing > 0) {
|
|
124
|
+
gridEdges = buildGridSnapEdges(compositionOverlayRect, gridSpacing, rootScaleX);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { targets, compositionTarget, gridEdges, snapEnabled };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// fallow-ignore-next-line complexity
|
|
131
|
+
export function buildExcludeElements(input: {
|
|
132
|
+
iframe: HTMLIFrameElement;
|
|
133
|
+
selection?: DomEditSelection | null;
|
|
134
|
+
groupSelections?: DomEditSelection[];
|
|
135
|
+
}): Set<HTMLElement> {
|
|
136
|
+
const elements = new Set<HTMLElement>();
|
|
137
|
+
const sel = input.selection;
|
|
138
|
+
if (sel?.element) {
|
|
139
|
+
elements.add(sel.element);
|
|
140
|
+
}
|
|
141
|
+
if (input.groupSelections) {
|
|
142
|
+
for (const gs of input.groupSelections) {
|
|
143
|
+
if (gs.element) elements.add(gs.element);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return elements;
|
|
147
|
+
}
|