@hyperframes/studio 0.7.57 → 0.7.58
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--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -90,7 +90,7 @@ function isGsapScript(text: string): boolean {
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|
93
|
+
export function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|
94
94
|
const results: HTMLScriptElement[] = [];
|
|
95
95
|
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
|
|
96
96
|
for (const script of scripts) {
|
|
@@ -102,8 +102,9 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|
|
102
102
|
/**
|
|
103
103
|
* Extract the GSAP timeline script text from a serialized HTML document, for
|
|
104
104
|
* feeding into applySoftReload. Returns null when zero or multiple GSAP scripts
|
|
105
|
-
* are present (ambiguous —
|
|
106
|
-
*
|
|
105
|
+
* are present (ambiguous — a serialized snapshot can't say WHICH script a
|
|
106
|
+
* single rewritten text corresponds to; caller should fall back to a full
|
|
107
|
+
* reload), matching applySoftReload's own single-script requirement.
|
|
107
108
|
*/
|
|
108
109
|
export function extractGsapScriptText(html: string): string | null {
|
|
109
110
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
@@ -183,157 +184,53 @@ export interface SoftReloadOptions {
|
|
|
183
184
|
authoredHtml?: string;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// Strip id'd elements to bare `id` and blank GSAP scripts, in place: docs that
|
|
201
|
-
// differ only in id'd attributes/inline-style/script text normalize equal; any
|
|
202
|
-
// residual difference is beyond soft-reload's reach → caller full-reloads.
|
|
203
|
-
function normalizeSoftResidual(doc: Document): void {
|
|
204
|
-
for (const el of doc.querySelectorAll("[id]")) {
|
|
205
|
-
const id = el.getAttribute("id");
|
|
206
|
-
for (const name of [...el.getAttributeNames()]) {
|
|
207
|
-
if (name !== "id") el.removeAttribute(name);
|
|
208
|
-
}
|
|
209
|
-
if (id) el.setAttribute("id", id);
|
|
210
|
-
}
|
|
211
|
-
for (const script of findGsapScriptElements(doc)) script.textContent = "";
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Soft-reloadable iff the docs differ SOLELY in id'd-element attributes/inline
|
|
215
|
-
// style and/or the GSAP script; returns the changed ids to sync onto the live
|
|
216
|
-
// DOM. Structural/text diffs → null → the caller full-reloads. Pure.
|
|
217
|
-
export function diffSoftReloadableRestore(
|
|
218
|
-
previous: string,
|
|
219
|
-
restored: string,
|
|
220
|
-
): { changedElementIds: string[] } | null {
|
|
221
|
-
let prevDoc: Document;
|
|
222
|
-
let nextDoc: Document;
|
|
223
|
-
try {
|
|
224
|
-
prevDoc = new DOMParser().parseFromString(previous, "text/html");
|
|
225
|
-
nextDoc = new DOMParser().parseFromString(restored, "text/html");
|
|
226
|
-
} catch {
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
const prevById = idElementMap(prevDoc);
|
|
230
|
-
const nextById = idElementMap(nextDoc);
|
|
231
|
-
// A different id set means an element was added or removed (e.g. a split, a
|
|
232
|
-
// delete) — structural, so soft-reload can't express it.
|
|
233
|
-
if (prevById.size !== nextById.size) return null;
|
|
234
|
-
const changedElementIds: string[] = [];
|
|
235
|
-
for (const [id, nextEl] of nextById) {
|
|
236
|
-
const prevEl = prevById.get(id);
|
|
237
|
-
if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
|
|
238
|
-
// A change inside the element (text / children) is out of soft scope; only
|
|
239
|
-
// its own attributes may differ. (GSAP scripts are handled via re-run.)
|
|
240
|
-
if (prevEl.innerHTML !== nextEl.innerHTML) return null;
|
|
241
|
-
if (prevEl.outerHTML !== nextEl.outerHTML) changedElementIds.push(id);
|
|
242
|
-
}
|
|
243
|
-
// Confirm nothing OUTSIDE id'd-element attributes and GSAP scripts changed.
|
|
244
|
-
normalizeSoftResidual(prevDoc);
|
|
245
|
-
normalizeSoftResidual(nextDoc);
|
|
246
|
-
if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
|
|
247
|
-
return { changedElementIds };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Copy every attribute from `source` onto the live `target`, dropping extras. */
|
|
251
|
-
function syncElementAttributes(target: Element, source: Element): void {
|
|
252
|
-
for (const name of [...target.getAttributeNames()]) {
|
|
253
|
-
if (!source.hasAttribute(name)) target.removeAttribute(name);
|
|
254
|
-
}
|
|
255
|
-
for (const name of source.getAttributeNames()) {
|
|
256
|
-
target.setAttribute(name, source.getAttribute(name) ?? "");
|
|
257
|
-
}
|
|
187
|
+
/**
|
|
188
|
+
* The soft reload's finalization step, shared with the rebind-only preview sync
|
|
189
|
+
* below: seek → force timeline rebind → reapply studio manual edits.
|
|
190
|
+
*
|
|
191
|
+
* Seek BEFORE rebind: __hfForceTimelineRebind's own internal force-render
|
|
192
|
+
* (see init.ts) renders the freshly-created timeline at whatever the
|
|
193
|
+
* runtime's internal scrub position already is, not at whatever we pass
|
|
194
|
+
* here afterward — a redundant seek() call after rebind can be a GSAP
|
|
195
|
+
* no-op if the timeline already reports being at that time internally.
|
|
196
|
+
*/
|
|
197
|
+
function finalizeSoftReload(win: IframeWindow, currentTime: number): void {
|
|
198
|
+
win.__player?.seek?.(currentTime);
|
|
199
|
+
win.__hfForceTimelineRebind?.();
|
|
200
|
+
win.__hfStudioManualEditsApply?.();
|
|
258
201
|
}
|
|
259
202
|
|
|
260
203
|
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
204
|
+
* Run ONLY applySoftReload's finalization (seek → __hfForceTimelineRebind →
|
|
205
|
+
* manual-edits reapply) against the live iframe — executing NO scripts and
|
|
206
|
+
* touching NO script elements. `__hfForceTimelineRebind` makes the runtime
|
|
207
|
+
* re-derive every clip's visibility window from the live DOM's `data-start` /
|
|
208
|
+
* `data-duration` attributes (init.ts: bindRootTimelineIfAvailable +
|
|
209
|
+
* syncTimedElementVisibility), so this is the flashless sync for a timing edit
|
|
210
|
+
* whose attributes were already live-patched and whose GSAP scripts are
|
|
211
|
+
* unchanged (`window.__timelines` still valid). Works for compositions with
|
|
212
|
+
* zero GSAP scripts too — the rebind hook is installed unconditionally by the
|
|
213
|
+
* runtime, independent of any animation library.
|
|
265
214
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* 1. Each changed element's attribute surface (inline style, data-start /
|
|
269
|
-
* -duration, the studio manual-offset props + flags) is synced onto the live
|
|
270
|
-
* element — so a canvas-position revert lands on the live DOM the runtime's
|
|
271
|
-
* seek-reapply reads from, not just on disk.
|
|
272
|
-
* 2. The restored GSAP script is re-run in place via applySoftReload, which
|
|
273
|
-
* re-seeks to `currentTime` (playhead-invariant) and re-folds manual edits.
|
|
274
|
-
* With no single script, the manual-edit reapply is invoked directly.
|
|
275
|
-
*
|
|
276
|
-
* Returns "soft" when applied in place, "full" when it escalated to reloadPreview
|
|
277
|
-
* (ineligible restore, missing target, or a permanent soft-reload failure).
|
|
215
|
+
* Returns false when the iframe/runtime hook is unavailable or the run threw —
|
|
216
|
+
* the caller should escalate to a full reload.
|
|
278
217
|
*/
|
|
279
|
-
export function
|
|
218
|
+
export function applySoftReloadFinalization(
|
|
280
219
|
iframe: HTMLIFrameElement | null,
|
|
281
|
-
activeCompPath: string | null,
|
|
282
|
-
files: Record<string, UndoRestoreFile> | undefined,
|
|
283
220
|
currentTime: number,
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
// Soft path only covers the single active-comp document in the root iframe.
|
|
288
|
-
if (!iframe || !activeCompPath || !files || paths.length !== 1 || paths[0] !== activeCompPath) {
|
|
289
|
-
reloadPreview();
|
|
290
|
-
return "full";
|
|
291
|
-
}
|
|
292
|
-
const doc = iframe.contentDocument;
|
|
293
|
-
const win = iframe.contentWindow as IframeWindow | null;
|
|
294
|
-
if (!doc || !win) {
|
|
295
|
-
reloadPreview();
|
|
296
|
-
return "full";
|
|
297
|
-
}
|
|
298
|
-
const { previous, restored } = files[activeCompPath]!;
|
|
299
|
-
const diff = diffSoftReloadableRestore(previous, restored);
|
|
300
|
-
if (!diff) {
|
|
301
|
-
reloadPreview();
|
|
302
|
-
return "full";
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Sync each changed element's attributes onto the live DOM from the restored
|
|
306
|
-
// markup, so the runtime's seek-reapply (which reads inline offset props off
|
|
307
|
-
// the live element) folds the REVERTED values, not the stale current ones.
|
|
308
|
-
const restoredById = idElementMap(new DOMParser().parseFromString(restored, "text/html"));
|
|
309
|
-
for (const id of diff.changedElementIds) {
|
|
310
|
-
const liveEl = doc.getElementById(id);
|
|
311
|
-
const restoredEl = restoredById.get(id);
|
|
312
|
-
if (liveEl && restoredEl) syncElementAttributes(liveEl, restoredEl);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const script = extractGsapScriptText(restored);
|
|
316
|
-
if (script) {
|
|
317
|
-
const result = applySoftReload(iframe, script, {
|
|
318
|
-
onAsyncFailure: reloadPreview,
|
|
319
|
-
currentTimeOverride: currentTime,
|
|
320
|
-
});
|
|
321
|
-
if (result === "cannot-soft-reload") {
|
|
322
|
-
reloadPreview();
|
|
323
|
-
return "full";
|
|
324
|
-
}
|
|
325
|
-
return "soft";
|
|
326
|
-
}
|
|
327
|
-
// No single GSAP script to re-run — the change was pure attribute/style. Re-fold
|
|
328
|
-
// manual edits and hold the playhead so the synced attributes take visible effect.
|
|
221
|
+
): boolean {
|
|
222
|
+
const win = iframe?.contentWindow as IframeWindow | null;
|
|
223
|
+
if (!win?.__hfForceTimelineRebind) return false;
|
|
329
224
|
try {
|
|
330
|
-
win.
|
|
331
|
-
|
|
225
|
+
if (win.__hfSuppressSceneMutations) {
|
|
226
|
+
win.__hfSuppressSceneMutations(() => finalizeSoftReload(win, currentTime));
|
|
227
|
+
} else {
|
|
228
|
+
finalizeSoftReload(win, currentTime);
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
332
231
|
} catch {
|
|
333
|
-
|
|
334
|
-
return "full";
|
|
232
|
+
return false;
|
|
335
233
|
}
|
|
336
|
-
return "soft";
|
|
337
234
|
}
|
|
338
235
|
|
|
339
236
|
export function applySoftReload(
|
|
@@ -523,14 +420,7 @@ export function applySoftReload(
|
|
|
523
420
|
const s = doc.createElement("script");
|
|
524
421
|
s.textContent = `(function(){${scriptText}\n})();`;
|
|
525
422
|
doc.body.appendChild(s);
|
|
526
|
-
|
|
527
|
-
// (see init.ts) renders the freshly-created timeline at whatever the
|
|
528
|
-
// runtime's internal scrub position already is, not at whatever we pass
|
|
529
|
-
// here afterward — a redundant seek() call after rebind can be a GSAP
|
|
530
|
-
// no-op if the timeline already reports being at that time internally.
|
|
531
|
-
win.__player?.seek?.(currentTime);
|
|
532
|
-
win.__hfForceTimelineRebind?.();
|
|
533
|
-
win.__hfStudioManualEditsApply?.();
|
|
423
|
+
finalizeSoftReload(win, currentTime);
|
|
534
424
|
};
|
|
535
425
|
|
|
536
426
|
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { applyUndoRestoreToPreview, diffSoftReloadableRestore } from "./gsapUndoRestore";
|
|
5
|
+
|
|
6
|
+
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
|
9
|
+
|
|
10
|
+
describe("diffSoftReloadableRestore", () => {
|
|
11
|
+
it("reports the changed id for an attribute/inline-style-only diff", () => {
|
|
12
|
+
const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
|
|
13
|
+
const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
|
|
14
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementKeys: ["id:a"] });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("identifies id-less elements by data-hf-id (selector-targeted clips)", () => {
|
|
18
|
+
const prev = wrap(`<div class="sub" data-hf-id="hf-x1" style="z-index: 3">t</div>`);
|
|
19
|
+
const next = wrap(`<div class="sub" data-hf-id="hf-x1" style="z-index: 8">t</div>`);
|
|
20
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementKeys: ["hf:hf-x1"] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("fails closed when fallback ids are duplicated without unique data-hf-ids", () => {
|
|
24
|
+
const prev = wrap(
|
|
25
|
+
`<div id="dup" style="z-index: 8">first</div><div id="dup" style="z-index: 9">last</div>`,
|
|
26
|
+
);
|
|
27
|
+
const next = wrap(
|
|
28
|
+
`<div id="dup" style="z-index: 3">first</div><div id="dup" style="z-index: 9">last</div>`,
|
|
29
|
+
);
|
|
30
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("a child change inside an identified CONTAINER is soft (nested identities)", () => {
|
|
34
|
+
// The composition root wraps every clip — the old innerHTML comparison at
|
|
35
|
+
// the root re-detected the child's change and forced a full reload.
|
|
36
|
+
const prev = wrap(
|
|
37
|
+
`<div id="main" data-duration="42"><div class="sub" data-hf-id="hf-x1" data-start="39">t</div></div>`,
|
|
38
|
+
);
|
|
39
|
+
const next = wrap(
|
|
40
|
+
`<div id="main" data-duration="39"><div class="sub" data-hf-id="hf-x1" data-start="26">t</div></div>`,
|
|
41
|
+
);
|
|
42
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({
|
|
43
|
+
changedElementKeys: ["id:main", "hf:hf-x1"],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("a changed data-hf-id itself is structural — NOT soft-reloadable", () => {
|
|
48
|
+
const prev = wrap(`<div data-hf-id="hf-x1">t</div>`);
|
|
49
|
+
const next = wrap(`<div data-hf-id="hf-x2">t</div>`);
|
|
50
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("treats a structural change (added element) as NOT soft-reloadable", () => {
|
|
54
|
+
const prev = wrap(`<div id="a">t</div>`);
|
|
55
|
+
const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
56
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("treats an element text/child change as NOT soft-reloadable", () => {
|
|
60
|
+
const prev = wrap(`<div id="a">one</div>`);
|
|
61
|
+
const next = wrap(`<div id="a">two</div>`);
|
|
62
|
+
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
|
|
66
|
+
const prev = wrap(
|
|
67
|
+
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
|
68
|
+
);
|
|
69
|
+
const next = wrap(
|
|
70
|
+
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
|
|
71
|
+
);
|
|
72
|
+
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementKeys: [] });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function buildLiveIframe(bodyHtml: string) {
|
|
77
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
78
|
+
doc.body.innerHTML = bodyHtml;
|
|
79
|
+
const contentWindow = {
|
|
80
|
+
gsap: { timeline: () => {} },
|
|
81
|
+
__hfForceTimelineRebind: () => {},
|
|
82
|
+
__timelines: {} as Record<string, unknown>,
|
|
83
|
+
__player: { getTime: () => 3, seek: vi.fn() },
|
|
84
|
+
__hfStudioManualEditsApply: vi.fn(),
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
|
|
88
|
+
contentWindow,
|
|
89
|
+
doc,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("applyUndoRestoreToPreview", () => {
|
|
94
|
+
const ROOT = "index.html";
|
|
95
|
+
|
|
96
|
+
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
|
|
97
|
+
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
98
|
+
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
99
|
+
);
|
|
100
|
+
const reloadPreview = vi.fn();
|
|
101
|
+
const files = {
|
|
102
|
+
[ROOT]: {
|
|
103
|
+
previous: wrap(
|
|
104
|
+
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
105
|
+
),
|
|
106
|
+
restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
|
|
110
|
+
expect(outcome).toBe("soft");
|
|
111
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
112
|
+
// Live element reverted to the restored inline style.
|
|
113
|
+
expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
|
|
114
|
+
// No GSAP script in the restore → the manual-edit reapply runs, playhead held.
|
|
115
|
+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
|
|
116
|
+
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("syncs a data-hf-id-identified live element (no DOM id) without reloading", () => {
|
|
120
|
+
const { iframe, doc } = buildLiveIframe(
|
|
121
|
+
`<div class="sub" data-hf-id="hf-x1" style="z-index: 8">t</div>`,
|
|
122
|
+
);
|
|
123
|
+
const reloadPreview = vi.fn();
|
|
124
|
+
const files = {
|
|
125
|
+
[ROOT]: {
|
|
126
|
+
previous: wrap(`<div class="sub" data-hf-id="hf-x1" style="z-index: 8">t</div>`),
|
|
127
|
+
restored: wrap(`<div class="sub" data-hf-id="hf-x1" style="z-index: 3">t</div>`),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
|
|
131
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
132
|
+
expect(doc.querySelector('[data-hf-id="hf-x1"]')!.getAttribute("style")).toBe("z-index: 3");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
{ label: "first", firstRestored: 3, lastRestored: 9 },
|
|
137
|
+
{ label: "last", firstRestored: 8, lastRestored: 3 },
|
|
138
|
+
])(
|
|
139
|
+
"soft-restores the $label element when authored ids are duplicated",
|
|
140
|
+
({ firstRestored, lastRestored }) => {
|
|
141
|
+
const markup = (firstZ: number, lastZ: number) =>
|
|
142
|
+
`<div id="dup" data-hf-id="hf-first" style="z-index: ${firstZ}">first</div>` +
|
|
143
|
+
`<div id="dup" data-hf-id="hf-last" style="z-index: ${lastZ}">last</div>`;
|
|
144
|
+
const { iframe, doc } = buildLiveIframe(markup(8, 9));
|
|
145
|
+
const reloadPreview = vi.fn();
|
|
146
|
+
const files = {
|
|
147
|
+
[ROOT]: {
|
|
148
|
+
previous: wrap(markup(8, 9)),
|
|
149
|
+
restored: wrap(markup(firstRestored, lastRestored)),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
|
|
154
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
155
|
+
expect(doc.querySelector('[data-hf-id="hf-first"]')!.getAttribute("style")).toBe(
|
|
156
|
+
`z-index: ${firstRestored}`,
|
|
157
|
+
);
|
|
158
|
+
expect(doc.querySelector('[data-hf-id="hf-last"]')!.getAttribute("style")).toBe(
|
|
159
|
+
`z-index: ${lastRestored}`,
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
it("full-reloads without partially restoring when any changed live target is missing", () => {
|
|
165
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a" style="z-index: 8">a</div>`);
|
|
166
|
+
const reloadPreview = vi.fn();
|
|
167
|
+
const files = {
|
|
168
|
+
[ROOT]: {
|
|
169
|
+
previous: wrap(
|
|
170
|
+
`<div id="a" style="z-index: 8">a</div><div id="b" style="z-index: 8">b</div>`,
|
|
171
|
+
),
|
|
172
|
+
restored: wrap(
|
|
173
|
+
`<div id="a" style="z-index: 3">a</div><div id="b" style="z-index: 3">b</div>`,
|
|
174
|
+
),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
179
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
180
|
+
// Target a resolved first, but the preflight found missing b before syncing either.
|
|
181
|
+
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
|
|
185
|
+
// The live doc holds a script element; a re-run would mutate/remove it
|
|
186
|
+
// (applySoftReload removes stale script elements before re-running).
|
|
187
|
+
const script = `window.__timelines["root"]=gsap.timeline().to("#a",{x:1});`;
|
|
188
|
+
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
189
|
+
`<div id="a" style="z-index: 8">t</div><script>${script}</script>`,
|
|
190
|
+
);
|
|
191
|
+
const reloadPreview = vi.fn();
|
|
192
|
+
const files = {
|
|
193
|
+
[ROOT]: {
|
|
194
|
+
previous: wrap(`<div id="a" style="z-index: 8">t</div><script>${script}</script>`),
|
|
195
|
+
restored: wrap(`<div id="a" style="z-index: 3">t</div><script>${script}</script>`),
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("soft");
|
|
199
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
200
|
+
// The live script element is untouched — rebind-only finalization ran instead.
|
|
201
|
+
expect(doc.querySelectorAll("script")).toHaveLength(1);
|
|
202
|
+
expect(doc.querySelector("script")!.textContent).toBe(script);
|
|
203
|
+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
|
|
204
|
+
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
205
|
+
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 3");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("full-reloads a changed two-script restore without partially touching the live DOM", () => {
|
|
209
|
+
const previousScripts = [
|
|
210
|
+
`window.__timelines["root"]=gsap.timeline().to("#a",{x:1});`,
|
|
211
|
+
`window.__timelines["captions"]=gsap.timeline().to("#a",{y:1});`,
|
|
212
|
+
];
|
|
213
|
+
const restoredScripts = previousScripts.map((script) => script.replace(":1", ":9"));
|
|
214
|
+
const scripts = (values: string[]) =>
|
|
215
|
+
values.map((value) => `<script>${value}</script>`).join("");
|
|
216
|
+
const { iframe, doc } = buildLiveIframe(
|
|
217
|
+
`<div id="a" style="z-index: 8">t</div>${scripts(previousScripts)}`,
|
|
218
|
+
);
|
|
219
|
+
const reloadPreview = vi.fn();
|
|
220
|
+
const files = {
|
|
221
|
+
[ROOT]: {
|
|
222
|
+
previous: wrap(`<div id="a" style="z-index: 8">t</div>${scripts(previousScripts)}`),
|
|
223
|
+
restored: wrap(`<div id="a" style="z-index: 3">t</div>${scripts(restoredScripts)}`),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
228
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
229
|
+
expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
|
|
230
|
+
expect([...doc.querySelectorAll("script")].map((script) => script.textContent)).toEqual(
|
|
231
|
+
previousScripts,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("full-reloads a multi-file restore", () => {
|
|
236
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
237
|
+
const reloadPreview = vi.fn();
|
|
238
|
+
const files = {
|
|
239
|
+
[ROOT]: {
|
|
240
|
+
previous: wrap(`<div id="a" style="x">t</div>`),
|
|
241
|
+
restored: wrap(`<div id="a">t</div>`),
|
|
242
|
+
},
|
|
243
|
+
"scenes/intro.html": { previous: "a", restored: "b" },
|
|
244
|
+
};
|
|
245
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
246
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("full-reloads a structural restore (split/delete undo)", () => {
|
|
250
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
251
|
+
const reloadPreview = vi.fn();
|
|
252
|
+
const files = {
|
|
253
|
+
[ROOT]: {
|
|
254
|
+
previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
|
|
255
|
+
restored: wrap(`<div id="a">t</div>`),
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
259
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
263
|
+
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
264
|
+
const reloadPreview = vi.fn();
|
|
265
|
+
const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
|
|
266
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
267
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
268
|
+
});
|
|
269
|
+
});
|