@hyperframes/studio 0.8.132 → 0.8.134
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/{hyperframes-player-Cp8e_Zst.js → hyperframes-player-Bro1gj0Z.js} +1 -1
- package/dist/assets/{index-CASEBNy2.js → index-BwNDTmAD.js} +1 -1
- package/dist/assets/{index-DMTt9nTf.js → index-DnVGIbEC.js} +1 -1
- package/dist/assets/{index-oE1pOdAy.js → index-wnXyQjW-.js} +143 -143
- package/dist/index.html +1 -1
- package/dist/index.js +717 -702
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -0
- package/src/hooks/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/player/components/timelineClipDragCommit.test.ts +22 -0
- package/src/player/components/timelineClipDragCommit.ts +7 -10
- package/src/player/components/timelineStackingSync.test.ts +101 -142
- package/src/player/components/timelineStackingSync.ts +51 -235
- package/src/player/components/useTimelineStackingSync.ts +1 -1
- package/src/timelineStackingSyncExport.test.tsx +3 -2
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapUndoRestore.test.ts +22 -0
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/studioPendingEdits.ts +12 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* timelineStackingSync — lane ↔ stacking unification (pure).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A row move restacks the moved clip only: moved up, it rises above every clip
|
|
5
|
+
* it overlaps in time on the rows now below it; moved down, it sinks below every
|
|
6
|
+
* such clip on the rows now above it. Neighbours never change, so a clip nobody
|
|
7
|
+
* moved never goes behind anything (a full-frame scene on the top row stays put).
|
|
8
8
|
*
|
|
9
9
|
* Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows):
|
|
10
10
|
* tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track`
|
|
@@ -38,7 +38,7 @@ export interface StackingElement {
|
|
|
38
38
|
* (e.g. an unmounted / nested sub-comp element, or one outside the active file).
|
|
39
39
|
* A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking
|
|
40
40
|
* neighbour nor resolvable as an edit — so an unresolved node never fabricates a
|
|
41
|
-
* z=0 neighbour
|
|
41
|
+
* z=0 neighbour. The reader signals a
|
|
42
42
|
* miss with NaN rather than null so the value stays assignable to the existing
|
|
43
43
|
* `(el) => number` reader contract the drag hook / commit deps declare.
|
|
44
44
|
*/
|
|
@@ -57,10 +57,9 @@ export interface StackingElement {
|
|
|
57
57
|
/**
|
|
58
58
|
* Discovery / DOM document position (optional). Two clips with EQUAL z paint by
|
|
59
59
|
* DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
|
|
60
|
-
* B" uses (zIndex, domIndex); without it equal
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* element in the discovery order array.
|
|
60
|
+
* B" uses (zIndex, domIndex); without it equal z counts as "not above", so the
|
|
61
|
+
* move writes a z. Callers pass the index of the element in the discovery order
|
|
62
|
+
* array.
|
|
64
63
|
*/
|
|
65
64
|
domIndex?: number;
|
|
66
65
|
}
|
|
@@ -74,7 +73,7 @@ export interface StackingPatch {
|
|
|
74
73
|
/**
|
|
75
74
|
* Canonical paint-scope key: leaf z-indexes are comparable only within the same
|
|
76
75
|
* source document and CSS stacking context. The ONLY place this normalization
|
|
77
|
-
* lives
|
|
76
|
+
* lives; samePaintScope compares with it.
|
|
78
77
|
*/
|
|
79
78
|
const paintScopeKey = (el: { sourceFile?: string; stackingContextId?: string | null }): string =>
|
|
80
79
|
JSON.stringify([el.sourceFile ?? null, el.stackingContextId ?? null]);
|
|
@@ -114,21 +113,13 @@ export function laneIsAbove(
|
|
|
114
113
|
return a.track < b.track;
|
|
115
114
|
}
|
|
116
115
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
* the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips
|
|
120
|
-
* whose z could not be resolved (null) are dropped before this stage.
|
|
121
|
-
*/
|
|
122
|
-
interface MutZ extends StackingElement {
|
|
123
|
-
zIndex: number;
|
|
124
|
-
}
|
|
116
|
+
/** Which way the edited clips moved between rows. */
|
|
117
|
+
export type StackingDirection = "up" | "down";
|
|
125
118
|
|
|
126
119
|
/**
|
|
127
120
|
* Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM
|
|
128
|
-
* order (later in DOM paints on top).
|
|
129
|
-
*
|
|
130
|
-
* disambiguate (see StackingElement.domIndex). Exported (like laneIsAbove) as the
|
|
131
|
-
* ONE paint-order predicate so every consumer agrees on what "paints above" means.
|
|
121
|
+
* order (later in DOM paints on top). Without both domIndex values equal z is
|
|
122
|
+
* ambiguous and counts as "not above", so the move still writes a z.
|
|
132
123
|
*/
|
|
133
124
|
function paintsAbove(
|
|
134
125
|
a: Pick<StackingElement, "zIndex" | "domIndex">,
|
|
@@ -139,233 +130,58 @@ function paintsAbove(
|
|
|
139
130
|
return false;
|
|
140
131
|
}
|
|
141
132
|
|
|
142
|
-
/**
|
|
143
|
-
function
|
|
144
|
-
|
|
133
|
+
/** Moved up: one above the highest clip it now sits above, or null when it already paints above them all. */
|
|
134
|
+
function raisedZ(clip: StackingElement, overlapping: StackingElement[]): number | null {
|
|
135
|
+
const below = overlapping.filter((o) => laneIsAbove(clip, o));
|
|
136
|
+
if (below.every((o) => paintsAbove(clip, o))) return null;
|
|
137
|
+
return Math.max(...below.map((o) => o.zIndex)) + 1;
|
|
145
138
|
}
|
|
146
139
|
|
|
147
|
-
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* "Authored z sacred" stays the default — neighbours are touched only when the
|
|
163
|
-
* user's explicit lane move is otherwise inexpressible (same precedent as the
|
|
164
|
-
* canvas context-menu tie-aware fix).
|
|
165
|
-
*
|
|
166
|
-
* Returns true when any z changed (recorded in `patchZ`), false for a no-op.
|
|
167
|
-
*/
|
|
168
|
-
function resolveEditedZ(
|
|
169
|
-
edited: MutZ,
|
|
170
|
-
overlapping: MutZ[],
|
|
171
|
-
overlappersOf: (clip: MutZ) => MutZ[],
|
|
172
|
-
patchZ: (clip: MutZ, z: number) => void,
|
|
173
|
-
): boolean {
|
|
174
|
-
const visualOverlap = overlapping.filter((o) => !o.isAudio);
|
|
175
|
-
if (visualOverlap.length === 0) return false;
|
|
176
|
-
|
|
177
|
-
// Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane).
|
|
178
|
-
const below = visualOverlap.filter((o) => laneIsAbove(edited, o));
|
|
179
|
-
const above = visualOverlap.filter((o) => laneIsAbove(o, edited));
|
|
180
|
-
|
|
181
|
-
// Already correct against every overlapping neighbour → no-op (authored z kept).
|
|
182
|
-
const correct =
|
|
183
|
-
below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited));
|
|
184
|
-
if (correct) return false;
|
|
185
|
-
|
|
186
|
-
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
|
|
187
|
-
|
|
188
|
-
// ── Fast path: try to realise the order by moving only `edited`. ──────────────
|
|
189
|
-
const single = trySingleZ(edited, below, above);
|
|
190
|
-
if (single != null) {
|
|
191
|
-
if (single !== edited.zIndex) patchZ(edited, single);
|
|
192
|
-
// Even at an unchanged z the DOM-order ties may already be satisfied; if not,
|
|
193
|
-
// `trySingleZ` returned null and we fall through to the cascade.
|
|
194
|
-
return single !== edited.zIndex;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ───
|
|
198
|
-
// Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the
|
|
199
|
-
// above-neighbours that are now not strictly above, minimally, one step past it.
|
|
200
|
-
const target = maxBelow != null ? maxBelow + 1 : 0;
|
|
201
|
-
const clamped = Math.max(0, target);
|
|
202
|
-
if (clamped !== edited.zIndex) patchZ(edited, clamped);
|
|
203
|
-
liftAbove(edited, overlappersOf, patchZ);
|
|
204
|
-
return true;
|
|
140
|
+
/** Moved down: one below the lowest clip it now sits under, but never under z 0 (a negative z can paint behind the
|
|
141
|
+
* composition's own background) and never under a clip on a lower row it paints over now, so the move cannot hide it.
|
|
142
|
+
* Null when it already paints under them all or cannot go lower. */
|
|
143
|
+
function loweredZ(clip: StackingElement, overlapping: StackingElement[]): number | null {
|
|
144
|
+
const above = overlapping.filter((o) => laneIsAbove(o, clip));
|
|
145
|
+
if (above.every((o) => paintsAbove(o, clip))) return null;
|
|
146
|
+
const keepOver = overlapping.filter((o) => laneIsAbove(clip, o) && paintsAbove(clip, o));
|
|
147
|
+
const floor = Math.max(
|
|
148
|
+
0,
|
|
149
|
+
...keepOver.map((o) =>
|
|
150
|
+
paintsAbove({ ...clip, zIndex: o.zIndex }, o) ? o.zIndex : o.zIndex + 1,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
const z = Math.max(floor, Math.min(...above.map((o) => o.zIndex)) - 1);
|
|
154
|
+
return z < clip.zIndex ? z : null;
|
|
205
155
|
}
|
|
206
156
|
|
|
207
157
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* The candidate is verified with the SAME `paintsAbove` predicate the resolver uses
|
|
213
|
-
* (z + DOM tie-break), so an authored z that already paints correctly by DOM order
|
|
214
|
-
* is honoured instead of over-patched: with below=z3 and an above-neighbour at z4
|
|
215
|
-
* that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still
|
|
216
|
-
* paints on top by DOM order — a valid single patch, no neighbour bump (item 12).
|
|
217
|
-
* When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate
|
|
218
|
-
* fails verification and the caller cascades.
|
|
219
|
-
*/
|
|
220
|
-
// edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap;
|
|
221
|
-
// with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12),
|
|
222
|
-
// else the caller cascades.
|
|
223
|
-
function zBetweenNeighbours(
|
|
224
|
-
maxBelow: number,
|
|
225
|
-
minAbove: number,
|
|
226
|
-
correctAt: (z: number) => boolean,
|
|
227
|
-
): number | null {
|
|
228
|
-
if (minAbove - maxBelow >= 2) {
|
|
229
|
-
const mid = Math.floor((maxBelow + minAbove) / 2);
|
|
230
|
-
return mid > maxBelow && mid < minAbove ? mid : null;
|
|
231
|
-
}
|
|
232
|
-
return correctAt(minAbove) ? minAbove : null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// edited has only above-neighbours: sit one step below minAbove, or at the z=0
|
|
236
|
-
// floor tie minAbove when a DOM tie-break keeps that neighbour on top.
|
|
237
|
-
function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null {
|
|
238
|
-
const candidate = minAbove - 1;
|
|
239
|
-
if (candidate >= 0) return candidate;
|
|
240
|
-
return correctAt(minAbove) ? minAbove : null;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null {
|
|
244
|
-
const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
|
|
245
|
-
const minAbove = boundaryZ(above, (zs) => Math.min(...zs));
|
|
246
|
-
|
|
247
|
-
const correctAt = (z: number): boolean => {
|
|
248
|
-
const probe: MutZ = { ...edited, zIndex: z };
|
|
249
|
-
return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe));
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
if (maxBelow != null && minAbove != null)
|
|
253
|
-
return zBetweenNeighbours(maxBelow, minAbove, correctAt);
|
|
254
|
-
if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward
|
|
255
|
-
if (minAbove != null) return zBelowOnly(minAbove, correctAt);
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper
|
|
261
|
-
* lane paints on top — starting from `edited` and cascading TRANSITIVELY.
|
|
262
|
-
*
|
|
263
|
-
* Seeded with `edited`: each of its upper-lane overlappers must paint strictly
|
|
264
|
-
* above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER
|
|
265
|
-
* clip it overlaps that sits on an even higher lane — that clip must be lifted too,
|
|
266
|
-
* and so on. Without the cascade a lifted neighbour could tie an untouched clip on
|
|
267
|
-
* a higher lane and, being later in the DOM, paint above it — an untouched pair
|
|
268
|
-
* visibly inverting (#2198). The condition is LANE order (not "was originally
|
|
269
|
-
* above"), so a clip that was already violating lane order — e.g. a bottom-lane
|
|
270
|
-
* clip painting on top — is fixed, never preserved. Only clips whose z actually
|
|
271
|
-
* changes are patched; z climbs by +1 each step so the walk terminates.
|
|
272
|
-
*/
|
|
273
|
-
function liftAbove(
|
|
274
|
-
edited: MutZ,
|
|
275
|
-
overlappersOf: (clip: MutZ) => MutZ[],
|
|
276
|
-
patchZ: (clip: MutZ, z: number) => void,
|
|
277
|
-
): void {
|
|
278
|
-
const queue: MutZ[] = [edited];
|
|
279
|
-
const raiseAbove = (clip: MutZ, floor: MutZ): void => {
|
|
280
|
-
if (paintsAbove(clip, floor)) return; // already strictly on top
|
|
281
|
-
patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place
|
|
282
|
-
queue.push(clip);
|
|
283
|
-
};
|
|
284
|
-
while (queue.length > 0) {
|
|
285
|
-
const floor = queue.shift()!;
|
|
286
|
-
for (const other of overlappersOf(floor)) {
|
|
287
|
-
if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Compute z-index patches so each edited clip's stacking matches its lane order.
|
|
294
|
-
*
|
|
295
|
-
* @param elements The FULL post-edit element set (edited clips already carry
|
|
296
|
-
* their new lane/time). Untouched clips keep their current z.
|
|
297
|
-
* @param editedKeys Keys of the clip(s) the user just edited.
|
|
298
|
-
* @returns Minimal z patches. When a single-clip patch realises the order it is
|
|
299
|
-
* the only patch (authored z of neighbours untouched); when ties or a
|
|
300
|
-
* z=0 floor make that impossible, the minimum set of overlapping
|
|
301
|
-
* neighbours is bumped too so the lane move is always realisable with
|
|
302
|
-
* all z ≥ 0. Non-overlapping / already-correct edits yield nothing.
|
|
303
|
-
*
|
|
304
|
-
* Multi-clip edits: each edited clip is resolved against the CURRENT (already-
|
|
305
|
-
* patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy
|
|
306
|
-
* region stacks consistently.
|
|
158
|
+
* The z-index patches for a row move: each edited clip, and only it, is raised (`up`) or lowered (`down`) against the
|
|
159
|
+
* clips it overlaps in time in its own paint scope. Clips whose live z is unresolved (non-finite) and audio clips take
|
|
160
|
+
* no part. Several edited clips resolve against each other's new z: the bottom one first when moving up, the top one
|
|
161
|
+
* first when moving down.
|
|
307
162
|
*/
|
|
308
163
|
export function computeStackingPatches(
|
|
309
164
|
elements: StackingElement[],
|
|
310
165
|
editedKeys: Iterable<string>,
|
|
166
|
+
direction: StackingDirection,
|
|
311
167
|
): StackingPatch[] {
|
|
312
168
|
const editedSet = new Set(editedKeys);
|
|
313
|
-
|
|
169
|
+
const live = elements
|
|
170
|
+
.filter((e) => Number.isFinite(e.zIndex) && !e.isAudio)
|
|
171
|
+
.map((e) => ({ ...e }));
|
|
172
|
+
const edited = live
|
|
173
|
+
.filter((e) => editedSet.has(e.key))
|
|
174
|
+
.sort((a, b) => (direction === "up" ? b.track - a.track : a.track - b.track));
|
|
314
175
|
|
|
315
|
-
// Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated
|
|
316
|
-
// z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
|
|
317
|
-
// unresolved clip is neither a neighbour nor resolvable as an edit, so it is
|
|
318
|
-
// excluded outright (item 13).
|
|
319
|
-
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
|
|
320
|
-
|
|
321
|
-
// Leaf z is only meaningful within ONE source document and stacking context:
|
|
322
|
-
// across either boundary the ancestor composition/context decides paint order.
|
|
323
|
-
// Restrict the computation to the edited clips' own paint scope(s).
|
|
324
|
-
const editedScopes = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(paintScopeKey));
|
|
325
|
-
const resolved = allResolved.filter((e) => editedScopes.has(paintScopeKey(e)));
|
|
326
|
-
|
|
327
|
-
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
|
|
328
|
-
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
|
|
329
|
-
const edited = resolved
|
|
330
|
-
.filter((e) => editedSet.has(e.key) && !e.isAudio)
|
|
331
|
-
.map((e) => byKey.get(e.key)!)
|
|
332
|
-
// Resolve lower-lane (renders below) clips first so their new z is visible
|
|
333
|
-
// to higher-lane siblings resolved after them.
|
|
334
|
-
.sort((a, b) => b.track - a.track);
|
|
335
|
-
|
|
336
|
-
const changed = new Map<string, number>();
|
|
337
|
-
const patchZ = (clip: MutZ, z: number): void => {
|
|
338
|
-
clip.zIndex = z;
|
|
339
|
-
changed.set(clip.key, z);
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
// The full live set, so the transitive cascade can reach clips that overlap a
|
|
343
|
-
// LIFTED neighbour without overlapping the edited clip itself (#2198).
|
|
344
|
-
const all = [...byKey.values()];
|
|
345
|
-
const overlappersOf = (clip: MutZ): MutZ[] =>
|
|
346
|
-
all.filter(
|
|
347
|
-
(o) => o.key !== clip.key && !o.isAudio && samePaintScope(clip, o) && overlapsInTime(clip, o),
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
for (const clip of edited) {
|
|
351
|
-
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Emit in a stable order (edited clips first in their resolve order, then any
|
|
355
|
-
// cascaded neighbours) — deterministic for tests and undo grouping.
|
|
356
|
-
const emitted = new Set<string>();
|
|
357
176
|
const patches: StackingPatch[] = [];
|
|
358
177
|
for (const clip of edited) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
patches.push({ key, zIndex });
|
|
367
|
-
emitted.add(key);
|
|
368
|
-
}
|
|
178
|
+
const overlapping = live.filter(
|
|
179
|
+
(o) => o.key !== clip.key && overlapsInTime(clip, o) && samePaintScope(clip, o),
|
|
180
|
+
);
|
|
181
|
+
const zIndex = direction === "up" ? raisedZ(clip, overlapping) : loweredZ(clip, overlapping);
|
|
182
|
+
if (zIndex === null) continue;
|
|
183
|
+
clip.zIndex = zIndex;
|
|
184
|
+
patches.push({ key: clip.key, zIndex });
|
|
369
185
|
}
|
|
370
186
|
return patches;
|
|
371
187
|
}
|
|
@@ -55,7 +55,7 @@ export function useTimelineStackingSync({
|
|
|
55
55
|
// NaN (NOT 0) when the element can't be resolved in the preview iframe — a
|
|
56
56
|
// nested / unmounted sub-comp node, or one outside the active file. Fabricating
|
|
57
57
|
// z=0 would enter computeStackingPatches as a real overlapping neighbour at the
|
|
58
|
-
// z-floor
|
|
58
|
+
// z-floor; a non-finite value tells it to EXCLUDE this
|
|
59
59
|
// clip instead. NaN (rather than null) keeps the return assignable to the
|
|
60
60
|
// `(el) => number` reader contract the drag hook / commit deps declare.
|
|
61
61
|
const readClipZIndex = useCallback(
|
|
@@ -24,8 +24,9 @@ it("restacks the host's preview when a lane move puts a clip below another", asy
|
|
|
24
24
|
const iframe = document.createElement("iframe");
|
|
25
25
|
document.body.appendChild(iframe);
|
|
26
26
|
const doc = iframe.contentDocument!;
|
|
27
|
-
//
|
|
28
|
-
doc.body.innerHTML =
|
|
27
|
+
// Tag (top row) paints over title, matching the rows until the move.
|
|
28
|
+
doc.body.innerHTML =
|
|
29
|
+
'<div id="title" style="z-index: 2"></div><div id="tag" style="z-index: 3"></div><div id="note"></div>';
|
|
29
30
|
const clip = (id: string, start: number, duration: number, track: number) =>
|
|
30
31
|
({ id, key: id, domId: id, tag: "div", start, duration, track }) as const;
|
|
31
32
|
usePlayerStore.setState({
|
package/src/utils/elementGsap.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
type ElementGsapWindow = Window & {
|
|
9
9
|
gsap?: {
|
|
10
10
|
set?: (target: Element, vars: Record<string, number>) => void;
|
|
11
|
-
getProperty?: (target: Element, prop: string) => unknown;
|
|
12
11
|
};
|
|
13
12
|
};
|
|
14
13
|
|
|
@@ -38,10 +37,12 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
|
|
|
38
37
|
return true;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
/**
|
|
42
|
-
export function
|
|
43
|
-
const
|
|
44
|
-
|
|
40
|
+
/** Set the element's GSAP width/height. Returns false when no runtime is reachable. */
|
|
41
|
+
export function setElementGsapSize(element: HTMLElement, width: number, height: number): boolean {
|
|
42
|
+
const gsap = gsapOf(element);
|
|
43
|
+
if (!gsap?.set) return false;
|
|
44
|
+
gsap.set(element, { width, height });
|
|
45
|
+
return true;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "./gsapUndoRestore";
|
|
22
22
|
import { applyPatch } from "./sourcePatcher";
|
|
23
23
|
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
|
|
24
|
+
import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
|
|
24
25
|
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
|
|
25
26
|
|
|
26
27
|
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
@@ -281,6 +282,16 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
281
282
|
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
|
|
282
283
|
});
|
|
283
284
|
|
|
285
|
+
it("counts a script-only restore applied in place, so a reload requested before it loads again", () => {
|
|
286
|
+
const script = (x: number) => `window.__timelines["root"]=gsap.timeline().to("#a",{x:${x}});`;
|
|
287
|
+
const page = (x: number) => `<div id="a">t</div><script>${script(x)}</script>`;
|
|
288
|
+
const { iframe, doc } = buildLiveIframe(page(2));
|
|
289
|
+
const before = studioManualEditSavesIn(doc);
|
|
290
|
+
const files = { [ROOT]: { previous: wrap(page(2)), restored: wrap(page(1)) } };
|
|
291
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
292
|
+
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
|
|
293
|
+
});
|
|
294
|
+
|
|
284
295
|
it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
|
|
285
296
|
// The live doc holds a script element; a re-run would mutate/remove it
|
|
286
297
|
// (applySoftReload removes stale script elements before re-running).
|
|
@@ -563,6 +574,17 @@ describe("an undo that lands while the layer is being dragged", () => {
|
|
|
563
574
|
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
564
575
|
});
|
|
565
576
|
|
|
577
|
+
it("counts an undo shown or applied in place, so a reload requested before it loads again", () => {
|
|
578
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
579
|
+
const before = studioManualEditSavesIn(doc);
|
|
580
|
+
|
|
581
|
+
showRestoreInPlace(iframe, ROOT, files, 3);
|
|
582
|
+
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
|
|
583
|
+
const shown = studioManualEditSavesIn(doc);
|
|
584
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
585
|
+
expect(studioManualEditSavesIn(doc)).toBeGreaterThan(shown);
|
|
586
|
+
});
|
|
587
|
+
|
|
566
588
|
it("keeps a drag that started after the undo was shown when the undo is put back", () => {
|
|
567
589
|
const { iframe, doc } = buildLiveIframe(undone);
|
|
568
590
|
const el = doc.getElementById("a")!;
|
|
@@ -14,7 +14,7 @@ import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
|
14
14
|
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
|
|
15
15
|
import { markScenesStale } from "../player/sceneSwap";
|
|
16
16
|
import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
|
|
17
|
-
import { studioGestureDraws } from "../components/editor/manualEditsDom";
|
|
17
|
+
import { countStudioPreviewChange, studioGestureDraws } from "../components/editor/manualEditsDom";
|
|
18
18
|
|
|
19
19
|
type PreviewWindow = Window & {
|
|
20
20
|
__player?: { seek?: (t: number) => void };
|
|
@@ -312,7 +312,8 @@ export function showRestoreInPlace(
|
|
|
312
312
|
const doc = iframe?.contentDocument;
|
|
313
313
|
const win = iframe?.contentWindow as PreviewWindow | null;
|
|
314
314
|
const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
|
|
315
|
-
if (!iframe || !win || !plan || plan.scripted) return null;
|
|
315
|
+
if (!iframe || !doc || !win || !plan || plan.scripted) return null;
|
|
316
|
+
countStudioPreviewChange(doc);
|
|
316
317
|
const before = plan.targets.map(
|
|
317
318
|
({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
|
|
318
319
|
);
|
|
@@ -393,6 +394,7 @@ export function applyUndoRestoreToPreview(
|
|
|
393
394
|
reloadPreview();
|
|
394
395
|
return "full";
|
|
395
396
|
}
|
|
397
|
+
countStudioPreviewChange(doc);
|
|
396
398
|
// Sync each changed element's attributes onto the live DOM from the restored
|
|
397
399
|
// markup, so the runtime's seek-reapply reads the reverted values.
|
|
398
400
|
for (const target of plan.targets)
|
|
@@ -66,8 +66,18 @@ export function hasStudioPendingEdits(): boolean {
|
|
|
66
66
|
return pendingEdits.size > 0 || focusedField() !== null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
const shownRestores = new Set<Promise<void>>();
|
|
70
|
+
|
|
71
|
+
export function beginStudioShownRestore(): () => void {
|
|
72
|
+
let end = () => {};
|
|
73
|
+
const landed = new Promise<void>((resolve) => (end = resolve));
|
|
74
|
+
shownRestores.add(landed);
|
|
75
|
+
void landed.then(() => shownRestores.delete(landed));
|
|
76
|
+
return end;
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
export function isStudioEditSaving(): boolean {
|
|
70
|
-
return pendingEdits.size > 0;
|
|
80
|
+
return pendingEdits.size > 0 || shownRestores.size > 0;
|
|
71
81
|
}
|
|
72
82
|
|
|
73
83
|
export function afterStudioPendingEdits(run: () => void): () => void {
|
|
@@ -75,7 +85,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
|
|
|
75
85
|
const check = () => {
|
|
76
86
|
if (!waiting) return;
|
|
77
87
|
if (isStudioEditSaving()) {
|
|
78
|
-
void Promise.allSettled([...pendingEdits.keys()]).then(check);
|
|
88
|
+
void Promise.allSettled([...pendingEdits.keys(), ...shownRestores]).then(check);
|
|
79
89
|
return;
|
|
80
90
|
}
|
|
81
91
|
waiting = false;
|