@hyperframes/studio 0.8.127 → 0.8.129

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
  3. package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
  4. package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +1396 -917
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +82 -63
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
  109. package/src/utils/gsapLiveRetime.ts +286 -0
  110. package/src/utils/gsapSoftReload.ts +16 -7
  111. package/src/utils/gsapUndoRestore.test.ts +43 -0
  112. package/src/utils/gsapUndoRestore.ts +6 -3
  113. package/src/utils/sameData.ts +9 -0
  114. package/src/utils/studioFileHistory.ts +1 -0
  115. package/src/utils/studioPendingEdits.ts +15 -1
  116. package/dist/chunk-246VB2G3.js.map +0 -1
  117. package/dist/chunk-3F47EFCT.js.map +0 -1
  118. package/dist/chunk-5EKWJZLM.js.map +0 -1
  119. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -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.filter((script) =>
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 prevDoc = parseRestoreSource(file.previous);
256
- const nextDoc = parseRestoreSource(file.restored);
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);
@@ -0,0 +1,9 @@
1
+ /** Structural equality for plain data; DOM nodes (from any realm) compare by identity. */
2
+ export function sameData(a: unknown, b: unknown): boolean {
3
+ if (Object.is(a, b)) return true;
4
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
5
+ if ("nodeType" in a || "nodeType" in b || Array.isArray(a) !== Array.isArray(b)) return false;
6
+ const keys = Object.keys(a);
7
+ if (keys.length !== Object.keys(b).length) return false;
8
+ return keys.every((key) => sameData((a as never)[key], (b as never)[key]));
9
+ }
@@ -81,6 +81,7 @@ export async function saveServerRewriteWithHistory(input: {
81
81
  path: string;
82
82
  label: string;
83
83
  coalesceKey?: string;
84
+ coalesceMs?: number;
84
85
  writeFile: ProjectFileWriter;
85
86
  recordEdit: (entry: RecordEditInput) => Promise<void>;
86
87
  rewrite: (original: string) => Promise<{ disk: string; after?: string } | null>;
@@ -16,6 +16,7 @@ interface PendingEdit {
16
16
  landed: () => Promise<boolean>;
17
17
  redraws: Array<() => void>;
18
18
  showAgain: (() => void) | null;
19
+ claimsAtBegin: number;
19
20
  }
20
21
 
21
22
  export interface StudioEditInFlight {
@@ -27,6 +28,11 @@ export interface StudioEditInFlight {
27
28
  }
28
29
 
29
30
  const pendingEdits = new Map<Promise<unknown>, PendingEdit>();
31
+ let historyClaims: () => number = () => 0;
32
+
33
+ export function setStudioPendingEditClaimClock(read: (() => number) | null): void {
34
+ historyClaims = read ?? (() => 0);
35
+ }
30
36
  const NOT_SAVED = () => Promise.resolve(false);
31
37
  let adopting: StudioEditInFlight | null = null;
32
38
 
@@ -87,7 +93,13 @@ export function trackStudioPendingEdit(
87
93
  if (!result) return undefined;
88
94
  const promise = Promise.resolve(result);
89
95
  if (adopting) return promise;
90
- pendingEdits.set(promise, { revert: null, landed: NOT_SAVED, redraws: [], showAgain: null });
96
+ pendingEdits.set(promise, {
97
+ revert: null,
98
+ landed: NOT_SAVED,
99
+ redraws: [],
100
+ showAgain: null,
101
+ claimsAtBegin: historyClaims(),
102
+ });
91
103
  promise.then(
92
104
  () => pendingEdits.delete(promise),
93
105
  () => pendingEdits.delete(promise),
@@ -162,6 +174,7 @@ export function beginStudioPendingEdit(revert: StudioEditRevert | null) {
162
174
  export function paintBackNewestStudioPendingEdit(): {
163
175
  showAgain: () => void;
164
176
  landed: () => Promise<boolean>;
177
+ claimsAtBegin: number;
165
178
  } | null {
166
179
  const newest = [...pendingEdits.values()].at(-1);
167
180
  const revert = newest?.revert;
@@ -174,6 +187,7 @@ export function paintBackNewestStudioPendingEdit(): {
174
187
  for (const redraw of newest.redraws.splice(0)) redraw();
175
188
  },
176
189
  landed: newest.landed,
190
+ claimsAtBegin: newest.claimsAtBegin,
177
191
  };
178
192
  }
179
193