@hyperframes/studio 0.8.108 → 0.8.110
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-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
- package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
- package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
- package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/index.d.ts +64 -9
- package/dist/index.html +2 -2
- package/dist/index.js +8216 -6268
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +7 -11
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaAudioEdits.test.ts +20 -4
- package/src/components/editor/mediaAudioEdits.ts +27 -14
- package/src/components/editor/mediaLinkEdits.test.ts +334 -0
- package/src/components/editor/mediaLinkEdits.ts +303 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +11 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
- package/src/hooks/timelineLinkEditPlan.ts +128 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +12 -1
- package/src/hooks/useRenderClipContent.test.ts +6 -4
- package/src/hooks/useRenderClipContent.ts +9 -8
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +49 -55
- package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
- package/src/hooks/useTimelineLinkEditing.ts +158 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +115 -90
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +58 -0
- package/src/player/components/TimelineClip.tsx +9 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +288 -31
- package/src/player/components/audioClipLink.ts +151 -27
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
- package/src/player/components/clipMenuAudioItems.tsx +44 -57
- package/src/player/components/clipMenuDuck.ts +5 -0
- package/src/player/components/clipMenuLinkItems.test.ts +174 -0
- package/src/player/components/clipMenuLinkItems.tsx +166 -0
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +80 -9
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +17 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +29 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +41 -0
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +28 -0
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +3 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
- package/dist/chunk-AOWVEVEU.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
|
+
readNestedFiles,
|
|
5
6
|
applySoftReload,
|
|
6
7
|
applySoftReloadFinalization,
|
|
7
8
|
ensureMotionPathPluginLoaded,
|
|
@@ -131,10 +132,14 @@ describe("applySoftReload", () => {
|
|
|
131
132
|
const container = document.createElement("div");
|
|
132
133
|
container.appendChild(scriptEl);
|
|
133
134
|
|
|
135
|
+
const root = document.createElement("div");
|
|
136
|
+
root.setAttribute("data-composition-id", "root");
|
|
137
|
+
root.appendChild(orphan);
|
|
138
|
+
|
|
134
139
|
const { iframe } = buildMockIframe({ gsap: { timeline: vi.fn(), set: vi.fn() } });
|
|
135
140
|
(iframe as unknown as { contentDocument: unknown }).contentDocument = {
|
|
136
141
|
querySelectorAll: (sel: string) =>
|
|
137
|
-
sel === "script:not([src])" ? [scriptEl] : sel
|
|
142
|
+
sel === "script:not([src])" ? [scriptEl] : sel.includes("composition-id") ? [root] : [],
|
|
138
143
|
createElement: (tag: string) => document.createElement(tag),
|
|
139
144
|
body: container,
|
|
140
145
|
head: document.createElement("div"),
|
|
@@ -427,6 +432,8 @@ describe("ensureMotionPathPluginLoaded", () => {
|
|
|
427
432
|
// from the parse-time stamp. Otherwise a runtime transient (the color-grading
|
|
428
433
|
// hide's 0, a mid-flight tween value) becomes a permanent tween bound.
|
|
429
434
|
describe("applySoftReload authored-style restore", () => {
|
|
435
|
+
afterEach(() => document.body.replaceChildren());
|
|
436
|
+
|
|
430
437
|
function buildIframeWithTarget(el: Element, overrides: Record<string, unknown> = {}) {
|
|
431
438
|
const scriptEl = document.createElement("script");
|
|
432
439
|
scriptEl.textContent =
|
|
@@ -581,4 +588,233 @@ describe("applySoftReload authored-style restore", () => {
|
|
|
581
588
|
applySoftReload(iframe, SCRIPT_TEXT);
|
|
582
589
|
expect(transformAtRebind).toBe("");
|
|
583
590
|
});
|
|
591
|
+
|
|
592
|
+
// The preview inlines a sub-composition into a host that names its file.
|
|
593
|
+
function inlinedIn(file: string, el: HTMLElement): HTMLElement {
|
|
594
|
+
const host = document.createElement("div");
|
|
595
|
+
host.setAttribute("data-composition-file", file);
|
|
596
|
+
host.appendChild(el);
|
|
597
|
+
document.body.appendChild(host);
|
|
598
|
+
return el;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function answerQueries(iframe: HTMLIFrameElement, styled: Element, root: Element): void {
|
|
602
|
+
const doc = iframe.contentDocument as unknown as { querySelectorAll: (s: string) => unknown };
|
|
603
|
+
const query = doc.querySelectorAll;
|
|
604
|
+
doc.querySelectorAll = (sel: string) =>
|
|
605
|
+
sel.includes("transform") ? [styled] : sel.includes("composition-id") ? [root] : query(sel);
|
|
606
|
+
}
|
|
607
|
+
const SUB_FILE = `<template id="sub-template"><div id="sub" data-composition-id="sub">
|
|
608
|
+
<div id="nroot" data-hf-id="hf-n" style="left: 560px; top: 300px"></div></div></template>`;
|
|
609
|
+
|
|
610
|
+
it("gives a root tween's nested target what its own file authors for what GSAP wrote", () => {
|
|
611
|
+
const el = document.createElement("div");
|
|
612
|
+
el.id = "nroot";
|
|
613
|
+
el.setAttribute("data-hf-id", "hf-n");
|
|
614
|
+
// GSAP folded the stylesheet translate into its transform, masked it, and wrote the tweened width.
|
|
615
|
+
el.style.cssText =
|
|
616
|
+
"left: 560px; top: 300px; width: 337px; translate: none; transform: translate(12.5px, 0px)";
|
|
617
|
+
inlinedIn("compositions/sub.html", el);
|
|
618
|
+
const tween = { targets: () => [el], vars: { x: 50, width: 400, duration: 4 } };
|
|
619
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
620
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
621
|
+
});
|
|
622
|
+
const authoredHtml = `<html><body><div id="root"></div></body></html>`;
|
|
623
|
+
const nestedFiles = new Map([["compositions/sub.html", SUB_FILE]]);
|
|
624
|
+
|
|
625
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml, nestedFiles })).toBe("applied");
|
|
626
|
+
expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("puts back a root element's tweened width from the file instead of the live value", () => {
|
|
630
|
+
const el = document.createElement("div");
|
|
631
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
632
|
+
el.style.cssText = "left: 700px; width: 366px";
|
|
633
|
+
const tween = { targets: () => [el], vars: { width: 450, duration: 4 } };
|
|
634
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
635
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
636
|
+
});
|
|
637
|
+
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 700px"></div></body></html>`;
|
|
638
|
+
|
|
639
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml })).toBe("applied");
|
|
640
|
+
expect(el.getAttribute("style")).toBe("left: 700px;");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("leaves what a nested composition's own timeline animates to that timeline", () => {
|
|
644
|
+
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
|
|
645
|
+
el.id = "nroot";
|
|
646
|
+
el.style.cssText = "translate: none; transform: translate(40px, 10px)";
|
|
647
|
+
const sub = { getChildren: () => [{ targets: () => [el], vars: { x: 100 } }] };
|
|
648
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
649
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [sub] }, sub },
|
|
650
|
+
});
|
|
651
|
+
const readFile = vi.fn(async () => SUB_FILE);
|
|
652
|
+
|
|
653
|
+
expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
|
|
654
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
655
|
+
|
|
656
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
657
|
+
expect(el.style.cssText).toBe("translate: none; transform: translate(40px, 10px);");
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("a nested composition's reload leaves a GSAP transform in the top-level file alone", () => {
|
|
661
|
+
const host = document.createElement("div");
|
|
662
|
+
host.setAttribute("data-composition-id", "root");
|
|
663
|
+
host.setAttribute("data-composition-file", "compositions/sub.html");
|
|
664
|
+
const outside = Object.assign(document.createElement("div"), { _gsap: {} });
|
|
665
|
+
outside.style.cssText = "rotate: none; transform: rotate(30deg)";
|
|
666
|
+
const { iframe } = buildIframeWithTarget(host, {
|
|
667
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [] } },
|
|
668
|
+
});
|
|
669
|
+
answerQueries(iframe, outside, host);
|
|
670
|
+
|
|
671
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
|
|
672
|
+
|
|
673
|
+
expect(outside.style.cssText).toBe("rotate: none; transform: rotate(30deg);");
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("a nested composition's reload leaves its element that the top-level timeline animates alone", () => {
|
|
677
|
+
const host = document.createElement("div");
|
|
678
|
+
host.setAttribute("data-composition-id", "root");
|
|
679
|
+
host.setAttribute("data-composition-file", "compositions/sub.html");
|
|
680
|
+
const el = Object.assign(document.createElement("div"), { _gsap: {} });
|
|
681
|
+
// The top-level tween folded the stylesheet translate into its transform.
|
|
682
|
+
el.style.cssText = "translate: none; transform: translate(23.75px, 5px)";
|
|
683
|
+
host.appendChild(el);
|
|
684
|
+
const main = { getChildren: () => [{ targets: () => [el], vars: { x: 50 } }] };
|
|
685
|
+
const { iframe } = buildIframeWithTarget(host, {
|
|
686
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [] }, main },
|
|
687
|
+
});
|
|
688
|
+
answerQueries(iframe, el, host);
|
|
689
|
+
|
|
690
|
+
applySoftReload(iframe, SCRIPT_TEXT, {
|
|
691
|
+
authoredHtml: SUB_FILE,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
expect(el.style.cssText).toBe("translate: none; transform: translate(23.75px, 5px);");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("a nested composition's own reload restores its element from the file just written", () => {
|
|
698
|
+
const host = document.createElement("div");
|
|
699
|
+
host.setAttribute("data-composition-id", "root");
|
|
700
|
+
host.setAttribute("data-composition-file", "compositions/sub.html");
|
|
701
|
+
const el = document.createElement("div");
|
|
702
|
+
el.id = "nroot";
|
|
703
|
+
el.setAttribute("data-hf-id", "hf-n");
|
|
704
|
+
el.style.cssText = "left: 560px; top: 300px; width: 337px";
|
|
705
|
+
host.appendChild(el);
|
|
706
|
+
const tween = { targets: () => [el], vars: { width: 400 } };
|
|
707
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
708
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
709
|
+
});
|
|
710
|
+
answerQueries(iframe, el, host);
|
|
711
|
+
const readFile = vi.fn(async () => SUB_FILE);
|
|
712
|
+
|
|
713
|
+
expect(readNestedFiles(iframe, SCRIPT_TEXT, readFile)).toBeNull();
|
|
714
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml: SUB_FILE });
|
|
715
|
+
|
|
716
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
717
|
+
expect(el.getAttribute("style")).toBe("left: 560px; top: 300px;");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("reads only the other composition files a top-level re-run resets elements of", async () => {
|
|
721
|
+
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
|
|
722
|
+
const tween = { targets: () => [el], vars: { width: 400 } };
|
|
723
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
724
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
725
|
+
});
|
|
726
|
+
const readFile = vi.fn(async () => SUB_FILE);
|
|
727
|
+
|
|
728
|
+
const files = await readNestedFiles(iframe, SCRIPT_TEXT, readFile);
|
|
729
|
+
|
|
730
|
+
expect(readFile).toHaveBeenCalledTimes(1);
|
|
731
|
+
expect(files).toEqual(new Map([["compositions/sub.html", SUB_FILE]]));
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("escalates, touching nothing, when a nested file the reset needs could not be read", () => {
|
|
735
|
+
const el = inlinedIn("compositions/sub.html", document.createElement("div"));
|
|
736
|
+
el.style.cssText = "width: 337px; translate: none";
|
|
737
|
+
const tween = { targets: () => [el], vars: { width: 400 } };
|
|
738
|
+
const set = vi.fn();
|
|
739
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
740
|
+
gsap: { timeline: vi.fn(), set },
|
|
741
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT, { nestedFiles: null })).toBe("cannot-soft-reload");
|
|
745
|
+
expect(set).not.toHaveBeenCalled();
|
|
746
|
+
expect(el.style.cssText).toBe("width: 337px; translate: none;");
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("keeps the runtime's own inline writes: only what GSAP wrote comes back from the file", () => {
|
|
750
|
+
const el = document.createElement("div");
|
|
751
|
+
el.setAttribute("data-hf-id", "hf-1");
|
|
752
|
+
// The runtime's layout pass wrote position and a data-width size; GSAP wrote the mask and transform.
|
|
753
|
+
el.style.cssText =
|
|
754
|
+
"left: 10px; position: absolute; width: 300px; translate: none; transform: translate(50px, 0px)";
|
|
755
|
+
const tween = { targets: () => [el], vars: { x: 50 } };
|
|
756
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
757
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [tween] } },
|
|
758
|
+
});
|
|
759
|
+
const authoredHtml = `<html><body><div data-hf-id="hf-1" style="left: 10px"></div></body></html>`;
|
|
760
|
+
|
|
761
|
+
applySoftReload(iframe, SCRIPT_TEXT, { authoredHtml });
|
|
762
|
+
|
|
763
|
+
expect(el.getAttribute("style")).toBe("left: 10px; position: absolute; width: 300px;");
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe("applySoftReload over a composition's own markup", () => {
|
|
768
|
+
// A live preview whose top-level timeline has no children; `_gsap` marks what a script set.
|
|
769
|
+
function reloadRoot(markup: string, held: string[]) {
|
|
770
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
771
|
+
doc.body.innerHTML = `${markup}<script>${SCRIPT_TEXT}</script>`;
|
|
772
|
+
for (const id of held) Object.assign(doc.getElementById(id)!, { _gsap: {} });
|
|
773
|
+
const set = vi.fn();
|
|
774
|
+
const contentWindow = {
|
|
775
|
+
gsap: { timeline: vi.fn(), set },
|
|
776
|
+
__hfForceTimelineRebind: vi.fn(),
|
|
777
|
+
__timelines: { root: { kill: vi.fn(), getChildren: () => [] } } as Record<string, unknown>,
|
|
778
|
+
__player: { getTime: () => 0, seek: vi.fn() },
|
|
779
|
+
};
|
|
780
|
+
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
781
|
+
const cleared = () => (set.mock.calls[0]?.[0] as Element[] | undefined)?.map((el) => el.id);
|
|
782
|
+
return { doc, iframe, cleared };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
it("resets an element a standalone gsap.set holds even when no inline transform is left", () => {
|
|
786
|
+
const { iframe, cleared } = reloadRoot(
|
|
787
|
+
`<div data-composition-id="root"><div id="held" style="left: 5px"></div><div id="plain"></div></div>`,
|
|
788
|
+
["held"],
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
792
|
+
|
|
793
|
+
expect(cleared()).toEqual(["held"]);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("leaves what a nested composition's script holds alone, but resets that composition's host", () => {
|
|
797
|
+
const { doc, iframe, cleared } = reloadRoot(
|
|
798
|
+
`<div data-composition-id="root"><div id="host" data-composition-id="sub">` +
|
|
799
|
+
`<div id="nested" style="transform: translate(90px, 60px)"></div></div></div>`,
|
|
800
|
+
["host", "nested"],
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
applySoftReload(iframe, SCRIPT_TEXT);
|
|
804
|
+
|
|
805
|
+
expect(cleared()).toEqual(["host"]);
|
|
806
|
+
expect(doc.getElementById("nested")!.style.transform).toBe("translate(90px, 60px)");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("reloads in full when the re-run builds its DOM a second time", () => {
|
|
810
|
+
const { doc, iframe } = reloadRoot(`<div id="root" data-composition-id="root"></div>`, []);
|
|
811
|
+
const append = doc.body.appendChild.bind(doc.body);
|
|
812
|
+
// What a script's createElement or template clone does each time it runs.
|
|
813
|
+
doc.body.appendChild = <T extends Node>(node: T): T => {
|
|
814
|
+
doc.getElementById("root")!.appendChild(doc.createElement("div"));
|
|
815
|
+
return append(node);
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
819
|
+
});
|
|
584
820
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
2
|
-
import { findAuthoredElement
|
|
2
|
+
import { findAuthoredElement } from "./authoredSource";
|
|
3
3
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
4
|
+
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
|
|
4
5
|
|
|
5
6
|
type IframeWindow = Window & {
|
|
6
7
|
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
|
@@ -131,15 +132,28 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
|
|
|
131
132
|
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
// GSAP masks a folded CSS translate/rotate/scale with `none
|
|
135
|
-
|
|
135
|
+
// GSAP masks a folded CSS translate/rotate/scale with `none`, and a tween writes what it animates
|
|
136
|
+
// inline; a fresh load has only what the file authors. Opacity has its own restore.
|
|
137
|
+
function restoreAuthoredStyle(
|
|
136
138
|
style: CSSStyleDeclaration,
|
|
137
139
|
authored: CSSStyleDeclaration | null,
|
|
140
|
+
tweened: Set<string>,
|
|
138
141
|
) {
|
|
139
142
|
style.transform = authored?.transform ?? "";
|
|
140
143
|
if (!authored) return;
|
|
141
|
-
for (const prop of ["translate", "rotate", "scale"]) {
|
|
142
|
-
|
|
144
|
+
for (const prop of ["translate", "rotate", "scale", ...tweened]) {
|
|
145
|
+
if (prop === "opacity") continue;
|
|
146
|
+
style.setProperty(prop, authored.getPropertyValue(prop), authored.getPropertyPriority(prop));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function runSuppressed(win: IframeWindow, reload: () => void): boolean {
|
|
151
|
+
try {
|
|
152
|
+
if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
|
|
153
|
+
else reload();
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
|
|
@@ -196,6 +210,8 @@ export interface SoftReloadOptions {
|
|
|
196
210
|
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
|
197
211
|
authoredHtml?: string;
|
|
198
212
|
reparse?: Element[];
|
|
213
|
+
/** Other composition files a reset element is written in, by path; null when one could not be read. */
|
|
214
|
+
nestedFiles?: Map<string, string> | null;
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
/**
|
|
@@ -247,6 +263,12 @@ export function applySoftReloadFinalization(
|
|
|
247
263
|
}
|
|
248
264
|
}
|
|
249
265
|
|
|
266
|
+
function timelineKeys(scriptText: string): string[] {
|
|
267
|
+
return [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
|
|
268
|
+
.map((m) => m[1]!)
|
|
269
|
+
.filter((key) => key !== "__proxied");
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
export function applySoftReload(
|
|
251
273
|
iframe: HTMLIFrameElement | null,
|
|
252
274
|
scriptText: string,
|
|
@@ -259,6 +281,8 @@ export function applySoftReload(
|
|
|
259
281
|
const doc = iframe.contentDocument;
|
|
260
282
|
if (!win || !doc) return "cannot-soft-reload";
|
|
261
283
|
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
|
|
284
|
+
// Without the file an element is written in, only a full load restores it.
|
|
285
|
+
if (options.nestedFiles === null) return "cannot-soft-reload";
|
|
262
286
|
|
|
263
287
|
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
|
|
264
288
|
// composition's GSAP script, which re-registers its own window.__timelines[key].
|
|
@@ -267,9 +291,7 @@ export function applySoftReload(
|
|
|
267
291
|
// of the global timeline — so tearing down ALL of them (or the global timeline's
|
|
268
292
|
// children) and re-running a single script wipes every OTHER composition,
|
|
269
293
|
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
|
|
270
|
-
const targetKeys =
|
|
271
|
-
.map((m) => m[1]!)
|
|
272
|
-
.filter((key) => key !== "__proxied");
|
|
294
|
+
const targetKeys = timelineKeys(scriptText);
|
|
273
295
|
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
|
|
274
296
|
const gsapScripts = findGsapScriptElements(doc);
|
|
275
297
|
if (gsapScripts.length === 0) return "cannot-soft-reload";
|
|
@@ -302,6 +324,7 @@ export function applySoftReload(
|
|
|
302
324
|
// once the plugin loads; the alternative (returning false) would trigger a
|
|
303
325
|
// full iframe reload that destroys the very WebGL context we're preserving.
|
|
304
326
|
let deferredToAsync = false;
|
|
327
|
+
let builtDom = false;
|
|
305
328
|
|
|
306
329
|
// Authored-opacity resolution for the restore loop below. Three-state:
|
|
307
330
|
// "0.98" — the element's authored inline opacity
|
|
@@ -311,16 +334,10 @@ export function applySoftReload(
|
|
|
311
334
|
// The just-written file (`authoredHtml`) is the current truth; the runtime's
|
|
312
335
|
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
|
313
336
|
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
|
314
|
-
|
|
337
|
+
const docFor = fileDocs(compositionFile(doc, targetKeys), authoredHtml, options.nestedFiles);
|
|
315
338
|
const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
|
|
319
|
-
} catch {
|
|
320
|
-
authoredDoc = null;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
|
|
339
|
+
const fileDoc = docFor(authoringFile(el));
|
|
340
|
+
const source = fileDoc ? findAuthoredElement(fileDoc, el) : null;
|
|
324
341
|
// The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
|
|
325
342
|
return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
|
|
326
343
|
};
|
|
@@ -330,29 +347,14 @@ export function applySoftReload(
|
|
|
330
347
|
// fallow-ignore-next-line complexity
|
|
331
348
|
const doReload = () => {
|
|
332
349
|
const timelines = win.__timelines;
|
|
333
|
-
const
|
|
350
|
+
const targets = collectResetTargets(win, doc, targetKeys, reparse);
|
|
334
351
|
|
|
335
352
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
336
353
|
// composition's timeline (and its children on the global timeline) intact.
|
|
337
354
|
if (timelines) {
|
|
338
355
|
for (const key of targetKeys) {
|
|
339
|
-
const tl = timelines[key] as
|
|
340
|
-
| {
|
|
341
|
-
kill?: () => void;
|
|
342
|
-
clear?: () => void;
|
|
343
|
-
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
|
344
|
-
}
|
|
345
|
-
| undefined;
|
|
356
|
+
const tl = timelines[key] as { kill?: () => void; clear?: () => void } | undefined;
|
|
346
357
|
if (!tl) continue;
|
|
347
|
-
if (tl.getChildren) {
|
|
348
|
-
try {
|
|
349
|
-
for (const child of tl.getChildren(true)) {
|
|
350
|
-
if (typeof child.targets === "function") {
|
|
351
|
-
for (const t of child.targets()) allTargets.push(t);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
} catch {}
|
|
355
|
-
}
|
|
356
358
|
try {
|
|
357
359
|
// kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
|
|
358
360
|
tl.clear?.();
|
|
@@ -362,29 +364,11 @@ export function applySoftReload(
|
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
366
|
|
|
365
|
-
// Also reset elements carrying a GSAP-applied inline `transform` that the
|
|
366
|
-
// timeline-children sweep above missed — a dragged element whose position
|
|
367
|
-
// was a standalone `gsap.set` (never a timeline child), or one whose
|
|
368
|
-
// keyframes were just removed (no longer in any timeline). Their last
|
|
369
|
-
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
|
|
370
|
-
// and the sweep above can't see it, so the element renders offset from its
|
|
371
|
-
// source position (matching the overlay) until a full reload. The clear
|
|
372
|
-
// below runs BEFORE the re-run, which re-applies the transform for any
|
|
373
|
-
// element the new script still animates.
|
|
374
|
-
const seenTargets = new Set<Element>(allTargets);
|
|
375
|
-
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
|
|
376
|
-
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
|
|
377
|
-
// never strip an authored, non-GSAP inline transform.
|
|
378
|
-
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
|
|
379
|
-
seenTargets.add(el);
|
|
380
|
-
allTargets.push(el);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
367
|
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
|
385
368
|
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
|
386
369
|
// nukes the element's CSS base (position, width, height, etc.) from the
|
|
387
|
-
// HTML `style=""` attribute. Save → clear → restore →
|
|
370
|
+
// HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
|
|
371
|
+
const allTargets = [...targets.keys()];
|
|
388
372
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
389
373
|
const saved: Array<[HTMLElement, string]> = [];
|
|
390
374
|
for (const el of allTargets) {
|
|
@@ -399,7 +383,7 @@ export function applySoftReload(
|
|
|
399
383
|
for (const [el, css] of saved) {
|
|
400
384
|
const s = el.style;
|
|
401
385
|
s.cssText = css;
|
|
402
|
-
|
|
386
|
+
restoreAuthoredStyle(s, findAuthoredStyle(el), targets.get(el)!);
|
|
403
387
|
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
|
404
388
|
// a mid-flight tween's interpolated value, or the color-grading hide
|
|
405
389
|
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
|
@@ -430,7 +414,14 @@ export function applySoftReload(
|
|
|
430
414
|
}
|
|
431
415
|
const s = doc.createElement("script");
|
|
432
416
|
s.textContent = `(function(){${scriptText}\n})();`;
|
|
417
|
+
const ownScriptNode = 1;
|
|
418
|
+
const before = doc.body.querySelectorAll("*").length + ownScriptNode;
|
|
433
419
|
doc.body.appendChild(s);
|
|
420
|
+
// A script that builds DOM builds it again on every run; only a full load starts from the file.
|
|
421
|
+
// Not seen: text-only writes, nodes put in the head or a shadow root, nodes added
|
|
422
|
+
// later, and a builder that removes its old nodes first (which needs no full load).
|
|
423
|
+
builtDom = doc.body.querySelectorAll("*").length > before;
|
|
424
|
+
if (builtDom && deferredToAsync) onAsyncFailure?.();
|
|
434
425
|
finalizeSoftReload(win, currentTime);
|
|
435
426
|
};
|
|
436
427
|
|
|
@@ -479,23 +470,47 @@ export function applySoftReload(
|
|
|
479
470
|
executeScript();
|
|
480
471
|
};
|
|
481
472
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
473
|
+
const run = () => runSuppressed(win, doReload);
|
|
474
|
+
// The synchronous re-run threw or built DOM a second time: the preview is now
|
|
475
|
+
// genuinely broken (no timeline, or duplicated nodes). Escalate to a full reload.
|
|
476
|
+
if (!run() || builtDom) return "cannot-soft-reload";
|
|
477
|
+
// When MotionPath needs async loading, the script hasn't executed yet —
|
|
478
|
+
// skip the __timelines check and report success optimistically (the script
|
|
479
|
+
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
|
|
480
|
+
if (deferredToAsync) return "applied";
|
|
481
|
+
// The re-run executed. If the target keys read back, we're done; otherwise
|
|
482
|
+
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
|
|
483
|
+
// as "verify-failed" so callers know NOT to escalate.
|
|
484
|
+
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Other composition files for a soft reload: undefined when none are needed, null when a read failed. */
|
|
488
|
+
export type NestedFiles = Map<string, string> | null | undefined;
|
|
489
|
+
|
|
490
|
+
export async function settleNestedReads(
|
|
491
|
+
reads: Promise<Map<string, string>> | null,
|
|
492
|
+
): Promise<NestedFiles> {
|
|
493
|
+
return reads ? reads.catch(() => null) : undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Reads the composition files, other than the reloaded one, that the elements `scriptText`'s
|
|
498
|
+
* re-run resets are written in; null when there are none. Read them before the soft reload.
|
|
499
|
+
*/
|
|
500
|
+
export function readNestedFiles(
|
|
501
|
+
iframe: HTMLIFrameElement | null,
|
|
502
|
+
scriptText: string,
|
|
503
|
+
readFile: (path: string) => Promise<string>,
|
|
504
|
+
): Promise<Map<string, string>> | null {
|
|
505
|
+
const win = iframe?.contentWindow as IframeWindow | null;
|
|
506
|
+
const doc = iframe?.contentDocument;
|
|
507
|
+
if (!win || !doc) return null;
|
|
508
|
+
const keys = timelineKeys(scriptText);
|
|
509
|
+
const own = compositionFile(doc, keys);
|
|
510
|
+
const files = [...collectResetTargets(win, doc, keys).keys()].map(authoringFile);
|
|
511
|
+
const paths = [...new Set(files)].filter((file): file is string => file !== null && file !== own);
|
|
512
|
+
if (paths.length === 0) return null;
|
|
513
|
+
return Promise.all(paths.map((path) => readFile(path))).then(
|
|
514
|
+
(texts) => new Map(paths.map((path, i) => [path, texts[i]!])),
|
|
515
|
+
);
|
|
501
516
|
}
|
|
@@ -170,9 +170,10 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
170
170
|
|
|
171
171
|
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
|
|
172
172
|
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
|
|
173
|
+
const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
|
|
173
174
|
const undoScriptEdit = (live: string, authored: string, edit: string) => {
|
|
174
175
|
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
175
|
-
`${live}<script>${script(edit)}</script>`,
|
|
176
|
+
`${root(live)}<script>${script(edit)}</script>`,
|
|
176
177
|
);
|
|
177
178
|
const clearProps = (targets: HTMLElement[]) =>
|
|
178
179
|
targets.forEach((t) => t.removeAttribute("style"));
|
|
@@ -180,8 +181,8 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
180
181
|
for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
|
|
181
182
|
const files = {
|
|
182
183
|
[ROOT]: {
|
|
183
|
-
previous: wrap(`${authored}<script>${script(edit)}</script>`),
|
|
184
|
-
restored: wrap(`${authored}<script>${script("")}</script>`),
|
|
184
|
+
previous: wrap(`${root(authored)}<script>${script(edit)}</script>`),
|
|
185
|
+
restored: wrap(`${root(authored)}<script>${script("")}</script>`),
|
|
185
186
|
},
|
|
186
187
|
};
|
|
187
188
|
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
applySoftReloadFinalization,
|
|
8
8
|
extractGsapScriptText,
|
|
9
9
|
findGsapScriptElements,
|
|
10
|
+
readNestedFiles,
|
|
10
11
|
} from "./gsapSoftReload";
|
|
11
12
|
import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
12
13
|
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
@@ -324,6 +325,18 @@ export function showRestoreInPlace(
|
|
|
324
325
|
return null;
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
/** The other composition files a re-run of the restored script resets elements of; null when none. */
|
|
329
|
+
export function readUndoNestedFiles(
|
|
330
|
+
iframe: HTMLIFrameElement | null,
|
|
331
|
+
activeCompPath: string | null,
|
|
332
|
+
files: RestoreFiles | undefined,
|
|
333
|
+
readFile: (path: string) => Promise<string>,
|
|
334
|
+
): Promise<Map<string, string>> | null {
|
|
335
|
+
const active = files?.[activeCompPath ?? "index.html"];
|
|
336
|
+
const script = active ? extractGsapScriptText(active.restored) : null;
|
|
337
|
+
return script ? readNestedFiles(iframe, script, readFile) : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
327
340
|
/**
|
|
328
341
|
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
|
|
329
342
|
* remount (which blanks the frame black and re-flashes the WebGL context). Eligible
|
|
@@ -358,6 +371,7 @@ export function applyUndoRestoreToPreview(
|
|
|
358
371
|
files: RestoreFiles | undefined,
|
|
359
372
|
currentTime: number,
|
|
360
373
|
reload: () => void,
|
|
374
|
+
nestedFiles?: Map<string, string> | null,
|
|
361
375
|
): "soft" | "full" {
|
|
362
376
|
// The master view carries a NULL activeCompPath but the root iframe shows
|
|
363
377
|
// index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
|
|
@@ -398,6 +412,7 @@ export function applyUndoRestoreToPreview(
|
|
|
398
412
|
currentTimeOverride: currentTime,
|
|
399
413
|
authoredHtml: active.restored,
|
|
400
414
|
reparse,
|
|
415
|
+
nestedFiles,
|
|
401
416
|
});
|
|
402
417
|
if (result === "cannot-soft-reload") {
|
|
403
418
|
reloadPreview();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { STUDIO_PREVIEW_FPS } from "../player/lib/time";
|
|
3
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
|
4
|
+
|
|
5
|
+
interface LinkedClipPreferences {
|
|
6
|
+
/** Premiere's Linked Selection: clicks and edits reach every link partner. */
|
|
7
|
+
linkedSelection: boolean;
|
|
8
|
+
setLinkedSelection: (enabled: boolean) => void;
|
|
9
|
+
/** Red offset badges on a video and audio from one source that drifted apart. */
|
|
10
|
+
syncIndicatorsVisible: boolean;
|
|
11
|
+
setSyncIndicatorsVisible: (visible: boolean) => void;
|
|
12
|
+
compositionFps: number;
|
|
13
|
+
setCompositionFps: (fps: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const useLinkedClipPreferences = create<LinkedClipPreferences>((set) => ({
|
|
17
|
+
linkedSelection: readStudioUiPreferences().linkedSelectionEnabled ?? true,
|
|
18
|
+
setLinkedSelection: (enabled) => {
|
|
19
|
+
writeStudioUiPreferences({ linkedSelectionEnabled: enabled });
|
|
20
|
+
set({ linkedSelection: enabled });
|
|
21
|
+
},
|
|
22
|
+
syncIndicatorsVisible: readStudioUiPreferences().syncIndicatorsVisible ?? true,
|
|
23
|
+
setSyncIndicatorsVisible: (visible) => {
|
|
24
|
+
writeStudioUiPreferences({ syncIndicatorsVisible: visible });
|
|
25
|
+
set({ syncIndicatorsVisible: visible });
|
|
26
|
+
},
|
|
27
|
+
compositionFps: STUDIO_PREVIEW_FPS,
|
|
28
|
+
setCompositionFps: (fps) => {
|
|
29
|
+
if (Number.isFinite(fps) && fps > 0) set({ compositionFps: fps });
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
export const isLinkedSelectionOn = (): boolean =>
|
|
34
|
+
useLinkedClipPreferences.getState().linkedSelection;
|