@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.
Files changed (34) hide show
  1. package/dist/assets/{hyperframes-player-Cp8e_Zst.js → hyperframes-player-Bro1gj0Z.js} +1 -1
  2. package/dist/assets/{index-CASEBNy2.js → index-BwNDTmAD.js} +1 -1
  3. package/dist/assets/{index-DMTt9nTf.js → index-DnVGIbEC.js} +1 -1
  4. package/dist/assets/{index-oE1pOdAy.js → index-wnXyQjW-.js} +143 -143
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +717 -702
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/editor/manualEditsDom.ts +5 -1
  10. package/src/components/editor/plainTranslate.test.ts +21 -2
  11. package/src/components/editor/plainTranslate.ts +7 -0
  12. package/src/hooks/draggedGsapPosition.test.ts +34 -0
  13. package/src/hooks/draggedGsapPosition.ts +40 -15
  14. package/src/hooks/gsapDragCommit.ts +17 -10
  15. package/src/hooks/gsapDragPositionCommit.ts +8 -9
  16. package/src/hooks/gsapResizeIntercept.test.ts +72 -1
  17. package/src/hooks/gsapResizeIntercept.ts +84 -17
  18. package/src/hooks/gsapRuntimeBridge.ts +4 -1
  19. package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
  20. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  21. package/src/hooks/useEditHistoryActions.ts +3 -0
  22. package/src/hooks/useEnableKeyframes.ts +32 -37
  23. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  24. package/src/hooks/useGsapAwareEditing.ts +10 -2
  25. package/src/player/components/timelineClipDragCommit.test.ts +22 -0
  26. package/src/player/components/timelineClipDragCommit.ts +7 -10
  27. package/src/player/components/timelineStackingSync.test.ts +101 -142
  28. package/src/player/components/timelineStackingSync.ts +51 -235
  29. package/src/player/components/useTimelineStackingSync.ts +1 -1
  30. package/src/timelineStackingSyncExport.test.tsx +3 -2
  31. package/src/utils/elementGsap.ts +6 -5
  32. package/src/utils/gsapUndoRestore.test.ts +22 -0
  33. package/src/utils/gsapUndoRestore.ts +4 -2
  34. package/src/utils/studioPendingEdits.ts +12 -2
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * timelineStackingSync — lane ↔ stacking unification (pure).
3
3
  *
4
- * The approved design: **lane order implies stacking**. A clip on a higher lane
5
- * (rendered ABOVE another in the timeline) should render ON TOP of any clip it
6
- * OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user
7
- * edit, and ONLY for the clip(s) the user actually edited.
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 that poisons the boundary math (item 13). The reader signals a
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-z is ambiguous and the sync can
61
- * under-patch (the reported bug: a clip dragged to the bottom lane over an
62
- * equal-z neighbour changed nothing on canvas). Callers pass the index of the
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 — partitioning, membership checks, and pairwise equality all use it.
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
- * Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z
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). When either domIndex is absent, equal z is
129
- * treated as "not strictly above" (ambiguous) — callers should supply domIndex to
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
- /** Reduce a neighbour set's z-indices to a single bound, or null when empty. */
143
- function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null {
144
- return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null;
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
- * Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order
149
- * matches its lane order (lower lane ⇒ paints on top). Records every z change
150
- * (edited clip AND any neighbours that must be bumped) into `patchZ`.
151
- *
152
- * Fast path (unchanged behaviour): when a single non-negative z for the edited
153
- * clip alone realises the order — strictly between the neighbours if there is
154
- * integer room, else just above the lower neighbour, else just below the upper —
155
- * emit only that. This keeps every existing single-patch test passing.
156
- *
157
- * Cascade path: when ties/clamping make the single-clip patch impossible or
158
- * ineffective (must sit below an overlapping z=0 neighbour, or between adjacent /
159
- * equal-z neighbours where DOM order alone can't express it), bump the minimum set
160
- * of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as
161
- * needed) so the edited clip's intended lane order is realised with all z ≥ 0.
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
- * Pick a single non-negative z for `edited` that lands it correctly against its
209
- * neighbours (paints above every below-neighbour, below every above-neighbour), or
210
- * null when no such z exists and the caller must cascade.
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
- if (editedSet.size === 0) return [];
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
- if (changed.has(clip.key) && !emitted.has(clip.key)) {
360
- patches.push({ key: clip.key, zIndex: changed.get(clip.key)! });
361
- emitted.add(clip.key);
362
- }
363
- }
364
- for (const [key, zIndex] of changed) {
365
- if (!emitted.has(key)) {
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 and skew the boundary math; a non-finite value tells it to EXCLUDE this
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
- // No authored z: DOM order paints tag over title, which matches the rows until the move.
28
- doc.body.innerHTML = '<div id="title"></div><div id="tag"></div><div id="note"></div>';
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({
@@ -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
- /** The element's GSAP numeric property, or null when unreadable. */
42
- export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
43
- const value = Number(gsapOf(element)?.getProperty?.(element, prop));
44
- return Number.isFinite(value) ? value : null;
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;