@hyperframes/studio 0.8.128 → 0.8.130
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-CKCx0CFA.js → hyperframes-player-Cm1Cj3TU.js} +1 -1
- package/dist/assets/{index-CwS08gWW.js → index--Z5JgizA.js} +1 -1
- package/dist/assets/{index-CF31Pq1E.js → index-DAp0QmWa.js} +1 -1
- package/dist/assets/{index-ClLzyuwb.js → index-DDsV6l9T.js} +191 -191
- package/dist/index.d.ts +61 -1
- package/dist/index.html +1 -1
- package/dist/index.js +227 -55
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/LayersPanel.test.ts +2 -2
- package/src/components/editor/LayersPanel.tsx +35 -5
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineTimingSync.ts +20 -21
- package/src/index.ts +2 -0
- package/src/layersPanelExport.test.tsx +115 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
- package/src/utils/gsapLiveRetime.ts +286 -0
- package/src/utils/gsapSoftReload.ts +16 -7
- package/src/utils/gsapUndoRestore.test.ts +43 -0
- package/src/utils/gsapUndoRestore.ts +6 -3
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
3
|
+
import { hasExplicitTime } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
4
|
+
import { RUNTIME_FILLER } from "@hyperframes/core/runtime/protocol";
|
|
5
|
+
import {
|
|
6
|
+
applySoftReloadFinalization,
|
|
7
|
+
findGsapScriptElements,
|
|
8
|
+
scriptsRegistering,
|
|
9
|
+
timelineKeys,
|
|
10
|
+
} from "./gsapSoftReload";
|
|
11
|
+
|
|
12
|
+
interface TweenTiming {
|
|
13
|
+
selector: string;
|
|
14
|
+
start: number;
|
|
15
|
+
duration: number;
|
|
16
|
+
wasStart: number;
|
|
17
|
+
wasDuration: number;
|
|
18
|
+
moved: boolean;
|
|
19
|
+
/** Its place in the script, which is the order a fresh run adds it to the timeline. */
|
|
20
|
+
source: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How a saved script differs from the live one: `retime` when only tween starts and lengths moved
|
|
25
|
+
* (`tweens` in the live timeline's child order), `rerun` for anything only a re-run reproduces.
|
|
26
|
+
*/
|
|
27
|
+
export type LiveRetimePlan =
|
|
28
|
+
| { kind: "retime"; key: string; after: string; tweens: TweenTiming[] }
|
|
29
|
+
| { kind: "rerun" };
|
|
30
|
+
|
|
31
|
+
const RERUN = { kind: "rerun" } as const;
|
|
32
|
+
|
|
33
|
+
/** The preview runs a reformatted copy of the script: compare code, not its layout. */
|
|
34
|
+
const withoutLayout = (code: string) =>
|
|
35
|
+
code
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.replace(/ ?([^\w$ ]) ?/g, "$1")
|
|
38
|
+
.trim();
|
|
39
|
+
|
|
40
|
+
function untimedShape(animation: GsapAnimation): string {
|
|
41
|
+
const { id: _id, position: _p, resolvedStart: _s, duration: _d, ...rest } = animation;
|
|
42
|
+
// An offset into the text, which moves when the preview reformats its copy of the script.
|
|
43
|
+
const provenance = rest.provenance && { ...rest.provenance, sourceRange: undefined };
|
|
44
|
+
return JSON.stringify({ ...rest, provenance }, (_key, value) =>
|
|
45
|
+
typeof value === "string" && value.startsWith("__raw:") ? withoutLayout(value) : value,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const onTimeline = (animation: GsapAnimation) => !animation.global;
|
|
50
|
+
|
|
51
|
+
const placedAlone = (animation: GsapAnimation) =>
|
|
52
|
+
hasExplicitTime(animation) && typeof animation.resolvedStart === "number";
|
|
53
|
+
|
|
54
|
+
/** Undefined when only GSAP knows it: an unwritten length takes `gsap.defaults`, which a script may change. */
|
|
55
|
+
const lengthOf = (animation: GsapAnimation) =>
|
|
56
|
+
animation.method === "set" ? 0 : animation.durationUnresolved ? undefined : animation.duration;
|
|
57
|
+
|
|
58
|
+
/** A timing edit rewrites numbers only; anything else may be code the parser does not read. */
|
|
59
|
+
const withoutNumbers = (script: string) =>
|
|
60
|
+
withoutLayout(script.replace(/(?<![\w$#.])\d*\.?\d+/g, "0"));
|
|
61
|
+
|
|
62
|
+
/** Decides, from the two scripts alone, whether a saved edit moved tweens and nothing else. */
|
|
63
|
+
export function planLiveRetime(before: string, after: string): LiveRetimePlan {
|
|
64
|
+
if (withoutNumbers(before) !== withoutNumbers(after)) return RERUN;
|
|
65
|
+
const keys = timelineKeys(after);
|
|
66
|
+
if (keys.length !== 1 || timelineKeys(before).join() !== keys.join()) return RERUN;
|
|
67
|
+
const was = parseGsapScriptAcorn(before).animations.filter(onTimeline);
|
|
68
|
+
const now = parseGsapScriptAcorn(after).animations.filter(onTimeline);
|
|
69
|
+
if (was.length !== now.length) return RERUN;
|
|
70
|
+
const tweens: TweenTiming[] = [];
|
|
71
|
+
for (const [index, next] of now.entries()) {
|
|
72
|
+
const prev = was[index]!;
|
|
73
|
+
if (untimedShape(prev) !== untimedShape(next)) return RERUN;
|
|
74
|
+
// A relative position follows the parser's playhead, which is not GSAP's; only numbers place a tween alone.
|
|
75
|
+
if (!placedAlone(next) || !placedAlone(prev)) return RERUN;
|
|
76
|
+
const duration = lengthOf(next);
|
|
77
|
+
const wasDuration = lengthOf(prev);
|
|
78
|
+
if (duration === undefined || wasDuration === undefined) return RERUN;
|
|
79
|
+
tweens.push({
|
|
80
|
+
selector: next.targetSelector,
|
|
81
|
+
start: next.resolvedStart!,
|
|
82
|
+
duration,
|
|
83
|
+
wasStart: prev.resolvedStart!,
|
|
84
|
+
wasDuration,
|
|
85
|
+
moved: next.resolvedStart !== prev.resolvedStart || duration !== wasDuration,
|
|
86
|
+
source: index,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// A GSAP timeline keeps its children sorted by start, an equal start after the earlier one: pair in that order.
|
|
90
|
+
tweens.sort((x, y) => x.wasStart - y.wasStart || x.source - y.source);
|
|
91
|
+
return { kind: "retime", key: keys[0]!, after, tweens };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type LiveRetime = Extract<LiveRetimePlan, { kind: "retime" }> & {
|
|
95
|
+
script: HTMLScriptElement;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Plans against the script the preview is running now; `rerun` when no single live script owns the timeline. */
|
|
99
|
+
export function planLiveRetimeFromPreview(
|
|
100
|
+
iframe: HTMLIFrameElement | null,
|
|
101
|
+
after: string,
|
|
102
|
+
): LiveRetime | typeof RERUN {
|
|
103
|
+
const doc = iframe?.contentDocument;
|
|
104
|
+
const [key] = timelineKeys(after);
|
|
105
|
+
if (!doc || !key) return RERUN;
|
|
106
|
+
const [script, ...extra] = scriptsRegistering(findGsapScriptElements(doc), [key]);
|
|
107
|
+
if (!script || extra.length > 0) return RERUN;
|
|
108
|
+
const plan = planLiveRetime(script.textContent ?? "", after);
|
|
109
|
+
return plan.kind === "retime" ? { ...plan, script } : RERUN;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface LiveTween {
|
|
113
|
+
duration: (value?: number) => number;
|
|
114
|
+
startTime: () => number;
|
|
115
|
+
delay: () => number;
|
|
116
|
+
repeatDelay?: () => number;
|
|
117
|
+
data?: unknown;
|
|
118
|
+
targets?: () => unknown[];
|
|
119
|
+
}
|
|
120
|
+
interface LiveTimeline {
|
|
121
|
+
getChildren(nested: boolean, tweens: true, timelines: false): LiveTween[];
|
|
122
|
+
getChildren(nested: false, tweens: false, timelines: true): LiveTimeline[];
|
|
123
|
+
remove: (child: LiveTween) => unknown;
|
|
124
|
+
add: (child: LiveTween, position: number) => unknown;
|
|
125
|
+
time: () => number;
|
|
126
|
+
progress: (value: number, suppressEvents: boolean) => unknown;
|
|
127
|
+
totalTime: (time: number, suppressEvents: boolean) => unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const same = (a: number, b: number) => Math.abs(a - b) < 1e-6;
|
|
131
|
+
|
|
132
|
+
const targetsMatch = (tween: LiveTween, selector: string) => {
|
|
133
|
+
const targets = tween.targets?.() ?? [];
|
|
134
|
+
try {
|
|
135
|
+
return targets.length > 0 && targets.every((t) => (t as Element).matches?.(selector) === true);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The live tweens, one per planned tween, when each sits exactly where the parser says it did. */
|
|
142
|
+
function pairedLiveTweens(timeline: LiveTimeline, plan: LiveRetime): LiveTween[] | null {
|
|
143
|
+
// The script's tweens only: the runtime also nests sub-composition timelines and adds filler tweens here.
|
|
144
|
+
const children = timeline
|
|
145
|
+
.getChildren(false, true, false)
|
|
146
|
+
.filter((tween) => tween.data !== RUNTIME_FILLER);
|
|
147
|
+
if (children.length !== plan.tweens.length) return null;
|
|
148
|
+
const paired = plan.tweens.every((t, i) => {
|
|
149
|
+
const live = children[i]!;
|
|
150
|
+
return (
|
|
151
|
+
targetsMatch(live, t.selector) &&
|
|
152
|
+
same(live.startTime() - live.delay(), t.wasStart) &&
|
|
153
|
+
same(live.duration(), t.wasDuration)
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
return paired ? children : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The writer rounds every time it writes to 3 decimals. */
|
|
160
|
+
const near = (a: number, b: number) => Math.abs(a - b) <= 2e-3;
|
|
161
|
+
|
|
162
|
+
/** One stretch for every tween (start = shift + stretch × old start, length = stretch × old length), in old order. */
|
|
163
|
+
function sharesOneStretch(tweens: TweenTiming[]): boolean {
|
|
164
|
+
const first = tweens[0]!;
|
|
165
|
+
const last = tweens.at(-1)!;
|
|
166
|
+
const longest = tweens.reduce((x, y) => (y.wasDuration > x.wasDuration ? y : x));
|
|
167
|
+
const spread = last.wasStart - first.wasStart;
|
|
168
|
+
// Taken over the widest span, so the writer's rounding cannot push it past `near`.
|
|
169
|
+
const stretch =
|
|
170
|
+
spread > longest.wasDuration
|
|
171
|
+
? (last.start - first.start) / spread
|
|
172
|
+
: longest.wasDuration > 0
|
|
173
|
+
? longest.duration / longest.wasDuration
|
|
174
|
+
: 1;
|
|
175
|
+
const fits = tweens.every(
|
|
176
|
+
(t) =>
|
|
177
|
+
near(t.start, first.start + stretch * (t.wasStart - first.wasStart)) &&
|
|
178
|
+
near(t.duration, stretch * t.wasDuration),
|
|
179
|
+
);
|
|
180
|
+
return stretch > 0 && fits;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The runtime places a sub-composition's timeline at its host's start once; moving the host leaves it there. */
|
|
184
|
+
function inOrHostsSubComposition(target: unknown, ownId: string): boolean {
|
|
185
|
+
const element = target as Element;
|
|
186
|
+
const owner = element.closest?.("[data-composition-id]");
|
|
187
|
+
return (
|
|
188
|
+
(owner != null && owner.getAttribute("data-composition-id") !== ownId) ||
|
|
189
|
+
element.querySelector?.("[data-composition-id]") != null
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Tweens on one element still start in the order they did, an equal start in script order. */
|
|
194
|
+
const keepsOrder = (tweens: TweenTiming[]) =>
|
|
195
|
+
tweens.every(
|
|
196
|
+
(t, k) => k === 0 || (tweens[k - 1]!.start - t.start || tweens[k - 1]!.source - t.source) < 0,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A tween keeps the start values it recorded when it first played, so every element a move touches
|
|
201
|
+
* must keep its tweens' spacing, and no timeline the script does not own may animate it.
|
|
202
|
+
*/
|
|
203
|
+
function movesKeepEachElementsHistory(
|
|
204
|
+
timeline: LiveTimeline,
|
|
205
|
+
plan: LiveRetime,
|
|
206
|
+
children: LiveTween[],
|
|
207
|
+
): boolean {
|
|
208
|
+
const byElement = new Map<unknown, number[]>();
|
|
209
|
+
children.forEach((child, i) => {
|
|
210
|
+
for (const target of child.targets?.() ?? []) {
|
|
211
|
+
const indexes = byElement.get(target);
|
|
212
|
+
if (indexes) indexes.push(i);
|
|
213
|
+
else byElement.set(target, [i]);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
const moved = new Set<unknown>();
|
|
217
|
+
for (const [target, indexes] of byElement) {
|
|
218
|
+
if (!indexes.some((i) => plan.tweens[i]!.moved)) continue;
|
|
219
|
+
if (inOrHostsSubComposition(target, plan.key)) return false;
|
|
220
|
+
const tweens = indexes.map((i) => plan.tweens[i]!);
|
|
221
|
+
// GSAP adds delays to the schedule unstretched; a shift keeps every length.
|
|
222
|
+
const shifted = tweens.every((t) => t.duration === t.wasDuration);
|
|
223
|
+
const undelayed = indexes.every((i) => !children[i]!.delay() && !children[i]!.repeatDelay?.());
|
|
224
|
+
if (!keepsOrder(tweens) || !sharesOneStretch(tweens) || !(shifted || undelayed)) return false;
|
|
225
|
+
moved.add(target);
|
|
226
|
+
}
|
|
227
|
+
return !timeline
|
|
228
|
+
.getChildren(false, false, true)
|
|
229
|
+
.some((sub) =>
|
|
230
|
+
sub
|
|
231
|
+
.getChildren(true, true, false)
|
|
232
|
+
.some((tween) => tween.targets?.().some((t) => moved.has(t))),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Moves the live tweens a `retime` plan names and records the saved script as the live one.
|
|
238
|
+
* False, with nothing changed, when the live timeline is not exactly what the parser read.
|
|
239
|
+
*/
|
|
240
|
+
export function applyLiveRetime(iframe: HTMLIFrameElement | null, plan: LiveRetime): boolean {
|
|
241
|
+
const win = iframe?.contentWindow as { __timelines?: Record<string, unknown> } | null;
|
|
242
|
+
const timeline = win?.__timelines?.[plan.key] as LiveTimeline | undefined;
|
|
243
|
+
if (typeof timeline?.getChildren !== "function") return false;
|
|
244
|
+
// Paired before anything moves: moving a tween re-sorts the timeline's children.
|
|
245
|
+
const children = pairedLiveTweens(timeline, plan);
|
|
246
|
+
if (!children) return false;
|
|
247
|
+
if (plan.tweens.some((t) => t.moved)) {
|
|
248
|
+
if (!movesKeepEachElementsHistory(timeline, plan, children)) return false;
|
|
249
|
+
// Re-added in script order: GSAP orders equal starts by when they were added, as a fresh run does.
|
|
250
|
+
const bySource = plan.tweens
|
|
251
|
+
.map((t, i) => ({ ...t, tween: children[i]! }))
|
|
252
|
+
.sort((a, b) => a.source - b.source);
|
|
253
|
+
for (const { tween } of bySource) timeline.remove(tween);
|
|
254
|
+
for (const { tween, start, duration } of bySource) {
|
|
255
|
+
if (tween.duration() !== duration) tween.duration(duration);
|
|
256
|
+
timeline.add(tween, start);
|
|
257
|
+
}
|
|
258
|
+
// Replayed to the playhead as the runtime binds a fresh load, callbacks included.
|
|
259
|
+
const at = timeline.time();
|
|
260
|
+
timeline.progress(0.0001, true);
|
|
261
|
+
timeline.totalTime(at, false);
|
|
262
|
+
}
|
|
263
|
+
// A script element runs once, so this only keeps the next comparison honest; nothing re-executes.
|
|
264
|
+
plan.script.textContent = plan.after;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Syncs a saved timing edit by moving the live tweens; false when only re-running the script can. */
|
|
269
|
+
export function moveLiveTweens(
|
|
270
|
+
iframe: HTMLIFrameElement,
|
|
271
|
+
scriptText: string,
|
|
272
|
+
currentTime: number,
|
|
273
|
+
reloadPreview: () => void,
|
|
274
|
+
): boolean {
|
|
275
|
+
const plan = planLiveRetimeFromPreview(iframe, scriptText);
|
|
276
|
+
if (plan.kind !== "retime") return false;
|
|
277
|
+
try {
|
|
278
|
+
if (!applyLiveRetime(iframe, plan)) return false;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error("[Studio] Moving the live tweens threw; reloading the preview", error);
|
|
281
|
+
reloadPreview();
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
if (!applySoftReloadFinalization(iframe, currentTime)) reloadPreview();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
@@ -277,12 +277,26 @@ export function applySoftReloadFinalization(
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
function timelineKeys(scriptText: string): string[] {
|
|
280
|
+
export function timelineKeys(scriptText: string): string[] {
|
|
281
281
|
return [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
|
|
282
282
|
.map((m) => m[1]!)
|
|
283
283
|
.filter((key) => key !== "__proxied");
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
/** The live script elements that register one of `keys` on `window.__timelines`. */
|
|
287
|
+
export function scriptsRegistering(
|
|
288
|
+
scripts: readonly HTMLScriptElement[],
|
|
289
|
+
keys: readonly string[],
|
|
290
|
+
): HTMLScriptElement[] {
|
|
291
|
+
return scripts.filter((script) => {
|
|
292
|
+
const text = script.textContent || "";
|
|
293
|
+
return keys.some(
|
|
294
|
+
(key) => text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`),
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// fallow-ignore-next-line complexity
|
|
286
300
|
export function applySoftReload(
|
|
287
301
|
iframe: HTMLIFrameElement | null,
|
|
288
302
|
scriptText: string,
|
|
@@ -311,12 +325,7 @@ export function applySoftReload(
|
|
|
311
325
|
if (gsapScripts.length === 0) return "cannot-soft-reload";
|
|
312
326
|
// Remove only the stale script element(s) that registered a target key; one we
|
|
313
327
|
// can't match in the doc is left alone (re-running appends a fresh element).
|
|
314
|
-
const staleScripts = gsapScripts
|
|
315
|
-
targetKeys.some((key) => {
|
|
316
|
-
const text = script.textContent || "";
|
|
317
|
-
return text.includes(`__timelines["${key}"]`) || text.includes(`__timelines['${key}']`);
|
|
318
|
-
}),
|
|
319
|
-
);
|
|
328
|
+
const staleScripts = scriptsRegistering(gsapScripts, targetKeys);
|
|
320
329
|
// Multiple GSAP scripts exist but none registers a key this script owns — we
|
|
321
330
|
// can't identify which element to replace (ambiguous, matching
|
|
322
331
|
// extractGsapScriptText's single-script requirement). Escalate to a full reload
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
5
|
+
|
|
6
|
+
const stamping = vi.hoisted(() => ({ calls: 0 }));
|
|
7
|
+
vi.mock("@hyperframes/parsers/hf-ids", async (importOriginal) => {
|
|
8
|
+
const real = await importOriginal<typeof import("@hyperframes/parsers/hf-ids")>();
|
|
9
|
+
return {
|
|
10
|
+
...real,
|
|
11
|
+
ensureHfIds: (html: string) => {
|
|
12
|
+
stamping.calls += 1;
|
|
13
|
+
return real.ensureHfIds(html);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
4
17
|
import {
|
|
5
18
|
applyUndoRestoreToPreview,
|
|
6
19
|
diffSoftReloadableRestore,
|
|
@@ -100,6 +113,36 @@ function buildLiveIframe(bodyHtml: string) {
|
|
|
100
113
|
describe("applyUndoRestoreToPreview", () => {
|
|
101
114
|
const ROOT = "index.html";
|
|
102
115
|
|
|
116
|
+
it("never stamps an undo whose sides are both already stamped", () => {
|
|
117
|
+
const film = (color: string) =>
|
|
118
|
+
`<div id="root" data-composition-id="root"><div id="target" style="background-color: ${color}">T</div><p>caption</p></div>`;
|
|
119
|
+
const blue = ensureHfIds(film("blue"));
|
|
120
|
+
const red = blue.replace("background-color: blue", "background-color: red");
|
|
121
|
+
const { iframe } = buildLiveIframe(red);
|
|
122
|
+
stamping.calls = 0;
|
|
123
|
+
|
|
124
|
+
const files = { [ROOT]: { previous: wrap(red), restored: wrap(blue) } };
|
|
125
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
|
|
126
|
+
expect(stamping.calls).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("repaints in place an undo back to the film before Studio first stamped it", () => {
|
|
130
|
+
const film = (color: string) =>
|
|
131
|
+
`<div id="root" data-composition-id="root"><div id="target" style="background-color: ${color}">T</div><p>caption</p></div>`;
|
|
132
|
+
// The preview stamped the film as it loaded; the first edit wrote those stamps with its red.
|
|
133
|
+
const edited = ensureHfIds(film("blue")).replace(
|
|
134
|
+
"background-color: blue",
|
|
135
|
+
"background-color: red",
|
|
136
|
+
);
|
|
137
|
+
const { iframe, doc } = buildLiveIframe(edited);
|
|
138
|
+
const reloadPreview = vi.fn();
|
|
139
|
+
const files = { [ROOT]: { previous: wrap(edited), restored: wrap(film("blue")) } };
|
|
140
|
+
|
|
141
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
|
|
142
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
143
|
+
expect((doc.getElementById("target") as HTMLElement).style.backgroundColor).toBe("blue");
|
|
144
|
+
});
|
|
145
|
+
|
|
103
146
|
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
|
|
104
147
|
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
105
148
|
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
findGsapScriptElements,
|
|
10
10
|
readNestedFiles,
|
|
11
11
|
} from "./gsapSoftReload";
|
|
12
|
-
import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
12
|
+
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
13
13
|
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
14
14
|
import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
|
|
15
15
|
import { markScenesStale } from "../player/sceneSwap";
|
|
@@ -252,8 +252,11 @@ function fileTargets(
|
|
|
252
252
|
isActive: boolean,
|
|
253
253
|
file: UndoRestoreFile,
|
|
254
254
|
): RestorePlan | null {
|
|
255
|
-
const
|
|
256
|
-
const
|
|
255
|
+
const previewStamped = doc.querySelector("[data-hf-id]") !== null;
|
|
256
|
+
const stampedLikeThePreview = (html: string) =>
|
|
257
|
+
previewStamped && !html.includes("data-hf-id") ? ensureHfIds(html) : html;
|
|
258
|
+
const prevDoc = parseRestoreSource(stampedLikeThePreview(file.previous));
|
|
259
|
+
const nextDoc = parseRestoreSource(stampedLikeThePreview(file.restored));
|
|
257
260
|
const scripted = restoreScripted(prevDoc, nextDoc, isActive);
|
|
258
261
|
if (scripted === null) return null;
|
|
259
262
|
const keys = diffRestoreDocs(prevDoc, nextDoc);
|