@hyperframes/studio 0.7.57 → 0.7.59
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-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-KsfE1bUu.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-Bf1x1y8H.js → index-FvzmPhfG.js} +1 -1
- 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 +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +18029 -11563
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -6
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- 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/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- 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/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -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/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -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/gsapScriptCommitTypes.ts +6 -4
- 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 +55 -1
- package/src/hooks/timelineTrackVisibility.ts +31 -13
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +81 -162
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +182 -84
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +306 -155
- package/src/hooks/useTimelineEditing.ts +111 -101
- package/src/hooks/useTimelineEditingTypes.ts +4 -1
- package/src/hooks/useTimelineGroupEditing.ts +51 -5
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- 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/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -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/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -0
- package/dist/assets/index-DfmYkU44.js +0 -423
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Soft-apply of undo/redo restores to the live preview: diff a restored file
|
|
2
|
+
// against the live one, sync attribute-only changes onto the live DOM, and
|
|
3
|
+
// refresh the runtime in place — avoiding the full iframe remount (black flash
|
|
4
|
+
// + WebGL context loss) whenever the restore is expressible without one.
|
|
5
|
+
import {
|
|
6
|
+
applySoftReload,
|
|
7
|
+
applySoftReloadFinalization,
|
|
8
|
+
extractGsapScriptText,
|
|
9
|
+
findGsapScriptElements,
|
|
10
|
+
} from "./gsapSoftReload";
|
|
11
|
+
|
|
12
|
+
type PreviewWindow = Window & {
|
|
13
|
+
__player?: { seek?: (t: number) => void };
|
|
14
|
+
__hfStudioManualEditsApply?: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** One file's restore from the edit-history store: before (live) / after (target) bytes. */
|
|
18
|
+
export interface UndoRestoreFile {
|
|
19
|
+
previous: string;
|
|
20
|
+
restored: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Identity for the soft diff: `data-hf-id` when present, else `id`. Nearly
|
|
25
|
+
* every studio-editable element carries one of the two — z-order commits and
|
|
26
|
+
* timeline patches target by id OR hf-id OR stable selector, and hf-ids are
|
|
27
|
+
* stamped uniquely by the SDK — so preferring them keeps duplicate authored
|
|
28
|
+
* ids distinct and selector-targeted clips inside soft-undo's reach.
|
|
29
|
+
*/
|
|
30
|
+
function elementIdentityKey(el: Element): string | null {
|
|
31
|
+
const hfId = el.getAttribute("data-hf-id");
|
|
32
|
+
if (hfId) return `hf:${hfId}`;
|
|
33
|
+
const id = el.getAttribute("id");
|
|
34
|
+
if (id) return `id:${id}`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const IDENTITY_SELECTOR = "[id], [data-hf-id]";
|
|
39
|
+
|
|
40
|
+
function identityElementMap(doc: Document): Map<string, Element> | null {
|
|
41
|
+
const map = new Map<string, Element>();
|
|
42
|
+
for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
|
|
43
|
+
const key = elementIdentityKey(el);
|
|
44
|
+
if (!key) continue;
|
|
45
|
+
// Ambiguous identity must full-reload; silently overwriting would restore
|
|
46
|
+
// one element's attributes onto another element sharing the same key.
|
|
47
|
+
if (map.has(key)) return null;
|
|
48
|
+
map.set(key, el);
|
|
49
|
+
}
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strip identified elements to their bare identity attributes and blank GSAP
|
|
54
|
+
// scripts, in place: docs that differ only in identified-element attributes/
|
|
55
|
+
// inline-style/script text normalize equal; any residual difference is beyond
|
|
56
|
+
// soft-reload's reach → caller full-reloads. Both identity attributes are
|
|
57
|
+
// KEPT, so a change to `id`/`data-hf-id` themselves stays a residual
|
|
58
|
+
// (structural) difference.
|
|
59
|
+
function normalizeSoftResidual(doc: Document): void {
|
|
60
|
+
for (const el of doc.querySelectorAll(IDENTITY_SELECTOR)) {
|
|
61
|
+
const id = el.getAttribute("id");
|
|
62
|
+
const hfId = el.getAttribute("data-hf-id");
|
|
63
|
+
for (const name of [...el.getAttributeNames()]) {
|
|
64
|
+
if (name !== "id" && name !== "data-hf-id") el.removeAttribute(name);
|
|
65
|
+
}
|
|
66
|
+
if (id) el.setAttribute("id", id);
|
|
67
|
+
if (hfId) el.setAttribute("data-hf-id", hfId);
|
|
68
|
+
}
|
|
69
|
+
for (const script of findGsapScriptElements(doc)) script.textContent = "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Same attribute set with identical values (order-insensitive). */
|
|
73
|
+
function attributesEqual(a: Element, b: Element): boolean {
|
|
74
|
+
const aNames = a.getAttributeNames();
|
|
75
|
+
if (aNames.length !== b.getAttributeNames().length) return false;
|
|
76
|
+
for (const name of aNames) {
|
|
77
|
+
if (a.getAttribute(name) !== b.getAttribute(name)) return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Soft-reloadable iff the docs differ SOLELY in identified-element attributes/
|
|
83
|
+
// inline style and/or the GSAP script; returns the changed identity keys to
|
|
84
|
+
// sync onto the live DOM. Structural/text diffs → null → the caller
|
|
85
|
+
// full-reloads. Pure.
|
|
86
|
+
//
|
|
87
|
+
// Change detection deliberately compares each identified element's OWN
|
|
88
|
+
// attribute surface — never its innerHTML. Identified elements NEST (the
|
|
89
|
+
// composition root wraps every clip), so an innerHTML comparison at the parent
|
|
90
|
+
// re-detects every descendant change and rejects the restore; that was the
|
|
91
|
+
// original always-full-reload undo blink. Structure/text integrity is instead
|
|
92
|
+
// guaranteed by the normalize-residual pass below: with identified-element
|
|
93
|
+
// attributes stripped and scripts blanked, ANY remaining difference (text,
|
|
94
|
+
// added/removed/reordered nodes, un-identified element attrs) still fails the
|
|
95
|
+
// docs-equal check and escalates to a full reload.
|
|
96
|
+
export function diffSoftReloadableRestore(
|
|
97
|
+
previous: string,
|
|
98
|
+
restored: string,
|
|
99
|
+
): { changedElementKeys: string[] } | null {
|
|
100
|
+
let prevDoc: Document;
|
|
101
|
+
let nextDoc: Document;
|
|
102
|
+
try {
|
|
103
|
+
prevDoc = new DOMParser().parseFromString(previous, "text/html");
|
|
104
|
+
nextDoc = new DOMParser().parseFromString(restored, "text/html");
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const prevByKey = identityElementMap(prevDoc);
|
|
109
|
+
const nextByKey = identityElementMap(nextDoc);
|
|
110
|
+
if (!prevByKey || !nextByKey) return null;
|
|
111
|
+
// A different identity set means an element was added or removed (e.g. a
|
|
112
|
+
// split, a delete) — structural, so soft-reload can't express it.
|
|
113
|
+
if (prevByKey.size !== nextByKey.size) return null;
|
|
114
|
+
const changedElementKeys: string[] = [];
|
|
115
|
+
for (const [key, nextEl] of nextByKey) {
|
|
116
|
+
const prevEl = prevByKey.get(key);
|
|
117
|
+
if (!prevEl || prevEl.tagName !== nextEl.tagName) return null;
|
|
118
|
+
if (!attributesEqual(prevEl, nextEl)) changedElementKeys.push(key);
|
|
119
|
+
}
|
|
120
|
+
// Confirm nothing OUTSIDE identified-element attributes and GSAP scripts changed.
|
|
121
|
+
normalizeSoftResidual(prevDoc);
|
|
122
|
+
normalizeSoftResidual(nextDoc);
|
|
123
|
+
if (prevDoc.documentElement.outerHTML !== nextDoc.documentElement.outerHTML) return null;
|
|
124
|
+
return { changedElementKeys };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Copy every attribute from `source` onto the live `target`, dropping extras. */
|
|
128
|
+
function syncElementAttributes(target: Element, source: Element): void {
|
|
129
|
+
for (const name of [...target.getAttributeNames()]) {
|
|
130
|
+
if (!source.hasAttribute(name)) target.removeAttribute(name);
|
|
131
|
+
}
|
|
132
|
+
for (const name of source.getAttributeNames()) {
|
|
133
|
+
target.setAttribute(name, source.getAttribute(name) ?? "");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readGsapScriptTexts(html: string): string[] {
|
|
138
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
139
|
+
return findGsapScriptElements(doc).map((script) => script.textContent ?? "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasAmbiguousGsapScriptChange(previous: string, restored: string): boolean {
|
|
143
|
+
const previousScripts = readGsapScriptTexts(previous);
|
|
144
|
+
const restoredScripts = readGsapScriptTexts(restored);
|
|
145
|
+
if (previousScripts.length <= 1 && restoredScripts.length <= 1) return false;
|
|
146
|
+
return (
|
|
147
|
+
previousScripts.length !== restoredScripts.length ||
|
|
148
|
+
previousScripts.some((script, index) => script !== restoredScripts[index])
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
|
|
154
|
+
* remount (which blanks the frame black and re-flashes the WebGL context). Only
|
|
155
|
+
* the active composition — the document living in the root iframe — is eligible;
|
|
156
|
+
* a sub-comp or multi-file restore falls back to `reloadPreview`.
|
|
157
|
+
*
|
|
158
|
+
* The restore is soft-applied when its only differences are identified-element
|
|
159
|
+
* (id / data-hf-id) attributes / inline-style and/or the GSAP script (see
|
|
160
|
+
* diffSoftReloadableRestore):
|
|
161
|
+
* 1. Each changed element's attribute surface (inline style, data-start /
|
|
162
|
+
* -duration, the studio manual-offset props + flags) is synced onto the live
|
|
163
|
+
* element — so a canvas-position revert lands on the live DOM the runtime's
|
|
164
|
+
* seek-reapply reads from, not just on disk.
|
|
165
|
+
* 2. The runtime refresh depends on what changed:
|
|
166
|
+
* - GSAP script text actually CHANGED between previous and restored → the
|
|
167
|
+
* restored script is re-run in place via applySoftReload (re-seeks to
|
|
168
|
+
* `currentTime`, re-folds manual edits).
|
|
169
|
+
* - Script unchanged or absent (the overwhelmingly common undo: z-order,
|
|
170
|
+
* lane move, timing shift, style tweak) → NO script execution — the
|
|
171
|
+
* blink-free finalization only (seek + __hfForceTimelineRebind + manual
|
|
172
|
+
* reapply, exactly the rebindPreviewTiming path), so timing-attribute
|
|
173
|
+
* reverts refresh their visibility windows. Re-running an unchanged
|
|
174
|
+
* script here used to be the biggest undo blink source: it tore down
|
|
175
|
+
* and rebuilt live timelines (and full-reloaded whenever the script
|
|
176
|
+
* couldn't be scoped) for restores that never touched it.
|
|
177
|
+
*
|
|
178
|
+
* Returns "soft" when applied in place, "full" when it escalated to reloadPreview
|
|
179
|
+
* (ineligible restore, missing target, or a permanent soft-reload failure).
|
|
180
|
+
*/
|
|
181
|
+
// fallow-ignore-next-line complexity
|
|
182
|
+
export function applyUndoRestoreToPreview(
|
|
183
|
+
iframe: HTMLIFrameElement | null,
|
|
184
|
+
activeCompPath: string | null,
|
|
185
|
+
files: Record<string, UndoRestoreFile> | undefined,
|
|
186
|
+
currentTime: number,
|
|
187
|
+
reloadPreview: () => void,
|
|
188
|
+
): "soft" | "full" {
|
|
189
|
+
// The master view carries a NULL activeCompPath but the root iframe shows
|
|
190
|
+
// index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
|
|
191
|
+
// Without this normalization every master-view undo failed the path gate and
|
|
192
|
+
// full-reloaded: the original "undo always blinks".
|
|
193
|
+
const activeDocPath = activeCompPath ?? "index.html";
|
|
194
|
+
const paths = files ? Object.keys(files) : [];
|
|
195
|
+
// Soft path only covers the single active-comp document in the root iframe.
|
|
196
|
+
if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
|
|
197
|
+
reloadPreview();
|
|
198
|
+
return "full";
|
|
199
|
+
}
|
|
200
|
+
const doc = iframe.contentDocument;
|
|
201
|
+
const win = iframe.contentWindow as PreviewWindow | null;
|
|
202
|
+
if (!doc || !win) {
|
|
203
|
+
reloadPreview();
|
|
204
|
+
return "full";
|
|
205
|
+
}
|
|
206
|
+
const { previous, restored } = files[activeDocPath]!;
|
|
207
|
+
const diff = diffSoftReloadableRestore(previous, restored);
|
|
208
|
+
if (!diff) {
|
|
209
|
+
reloadPreview();
|
|
210
|
+
return "full";
|
|
211
|
+
}
|
|
212
|
+
// A serialized snapshot cannot identify which of several GSAP scripts owns a
|
|
213
|
+
// rewrite. Keep attribute-only restores soft when every script byte is equal,
|
|
214
|
+
// but fail closed before touching the live DOM when an ambiguous script changed.
|
|
215
|
+
if (hasAmbiguousGsapScriptChange(previous, restored)) {
|
|
216
|
+
reloadPreview();
|
|
217
|
+
return "full";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Resolve every changed pair BEFORE touching the live DOM. A missing target
|
|
221
|
+
// makes the soft restore incomplete, so escalate without leaving a partially
|
|
222
|
+
// restored preview behind.
|
|
223
|
+
const liveByKey = identityElementMap(doc);
|
|
224
|
+
const restoredByKey = identityElementMap(new DOMParser().parseFromString(restored, "text/html"));
|
|
225
|
+
if (!liveByKey || !restoredByKey) {
|
|
226
|
+
reloadPreview();
|
|
227
|
+
return "full";
|
|
228
|
+
}
|
|
229
|
+
const changedTargets: Array<{ live: Element; restored: Element }> = [];
|
|
230
|
+
for (const key of diff.changedElementKeys) {
|
|
231
|
+
const liveEl = liveByKey.get(key);
|
|
232
|
+
const restoredEl = restoredByKey.get(key);
|
|
233
|
+
if (!liveEl || !restoredEl) {
|
|
234
|
+
reloadPreview();
|
|
235
|
+
return "full";
|
|
236
|
+
}
|
|
237
|
+
changedTargets.push({ live: liveEl, restored: restoredEl });
|
|
238
|
+
}
|
|
239
|
+
// Sync each changed element's attributes onto the live DOM from the restored
|
|
240
|
+
// markup, so the runtime's seek-reapply reads the reverted values.
|
|
241
|
+
for (const target of changedTargets) syncElementAttributes(target.live, target.restored);
|
|
242
|
+
|
|
243
|
+
const restoredScript = extractGsapScriptText(restored);
|
|
244
|
+
const previousScript = extractGsapScriptText(previous);
|
|
245
|
+
if (restoredScript && restoredScript !== previousScript) {
|
|
246
|
+
const result = applySoftReload(iframe, restoredScript, {
|
|
247
|
+
onAsyncFailure: reloadPreview,
|
|
248
|
+
currentTimeOverride: currentTime,
|
|
249
|
+
});
|
|
250
|
+
if (result === "cannot-soft-reload") {
|
|
251
|
+
reloadPreview();
|
|
252
|
+
return "full";
|
|
253
|
+
}
|
|
254
|
+
return "soft";
|
|
255
|
+
}
|
|
256
|
+
// Script unchanged or absent — the live timelines are still valid; only the
|
|
257
|
+
// synced attributes need to take effect. Rebind-only finalization (zero
|
|
258
|
+
// script execution); plain seek + manual reapply as a degraded fallback when
|
|
259
|
+
// the runtime rebind hook is unavailable.
|
|
260
|
+
if (applySoftReloadFinalization(iframe, currentTime)) return "soft";
|
|
261
|
+
try {
|
|
262
|
+
win.__player?.seek?.(currentTime);
|
|
263
|
+
win.__hfStudioManualEditsApply?.();
|
|
264
|
+
} catch {
|
|
265
|
+
reloadPreview();
|
|
266
|
+
return "full";
|
|
267
|
+
}
|
|
268
|
+
return "soft";
|
|
269
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
|
|
4
|
-
// persist chokepoint must
|
|
4
|
+
// persist chokepoint must explicitly decline so the caller takes the legacy server
|
|
5
5
|
// path — even when a valid SDK session exists (one always does, for
|
|
6
6
|
// shadow/selection). This is the contract the prod flag-flip rests on; a future
|
|
7
7
|
// refactor of the gate guards that silently re-enables cutover on flag-off
|
|
@@ -31,12 +31,12 @@ const makeDeps = () =>
|
|
|
31
31
|
domEditSaveTimestampRef: { current: 0 },
|
|
32
32
|
}) as never;
|
|
33
33
|
|
|
34
|
-
describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist
|
|
34
|
+
describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist declines", () => {
|
|
35
35
|
it("sdkTimingPersist falls back without writing", async () => {
|
|
36
36
|
const deps = makeDeps();
|
|
37
|
-
expect(
|
|
38
|
-
|
|
39
|
-
);
|
|
37
|
+
expect(
|
|
38
|
+
await sdkTimingPersist("hf-a", "/c.html", { start: 1 }, makeSession(), deps),
|
|
39
|
+
).toMatchObject({ status: "declined", reason: "feature_disabled" });
|
|
40
40
|
expect(
|
|
41
41
|
(deps as unknown as { writeProjectFile: ReturnType<typeof vi.fn> }).writeProjectFile,
|
|
42
42
|
).not.toHaveBeenCalled();
|
|
@@ -50,12 +50,12 @@ describe("dark-launch gate — STUDIO_SDK_CUTOVER_ENABLED=false ⇒ persist retu
|
|
|
50
50
|
makeSession(),
|
|
51
51
|
makeDeps(),
|
|
52
52
|
),
|
|
53
|
-
).
|
|
53
|
+
).toMatchObject({ status: "declined", reason: "feature_disabled" });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it("sdkDeletePersist falls back", async () => {
|
|
57
57
|
expect(
|
|
58
58
|
await sdkDeletePersist("hf-a", "<html></html>", "/c.html", makeSession(), makeDeps()),
|
|
59
|
-
).
|
|
59
|
+
).toMatchObject({ status: "declined", reason: "feature_disabled" });
|
|
60
60
|
});
|
|
61
61
|
});
|