@hyperframes/studio 0.7.54 → 0.7.56
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/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +158 -126
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +43 -1
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +240 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.tsx +326 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +39 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +303 -0
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
6
|
+
import { splitElementInHtml } from "@hyperframes/studio-server/source-mutation";
|
|
7
|
+
import type { TimelineElement } from "../player";
|
|
8
|
+
import { usePlayerStore } from "../player";
|
|
9
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
10
|
+
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
|
|
11
|
+
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
|
|
12
|
+
import {
|
|
13
|
+
createEmptyEditHistory,
|
|
14
|
+
hashEditHistoryContent,
|
|
15
|
+
undoEditHistory,
|
|
16
|
+
} from "../utils/editHistory";
|
|
17
|
+
import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
|
|
18
|
+
|
|
19
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4" data-hf-id="hf-clip">hi</div>`;
|
|
22
|
+
const SPLIT = splitElementInHtml(ORIGINAL, { id: "clip1" }, 2, "clip1-split").html;
|
|
23
|
+
|
|
24
|
+
const element: TimelineElement = {
|
|
25
|
+
id: "clip1",
|
|
26
|
+
tag: "div",
|
|
27
|
+
start: 0,
|
|
28
|
+
duration: 4,
|
|
29
|
+
track: 0,
|
|
30
|
+
domId: "clip1",
|
|
31
|
+
sourceFile: "index.html",
|
|
32
|
+
timingSource: "authored",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
36
|
+
type SplitAll = (splitTime: number) => Promise<void>;
|
|
37
|
+
|
|
38
|
+
interface Harness {
|
|
39
|
+
disk: Record<string, string>;
|
|
40
|
+
store: ReturnType<typeof createPersistentEditHistoryStore>;
|
|
41
|
+
splitRef: { current: Split | undefined };
|
|
42
|
+
root: ReturnType<typeof mountProbe>;
|
|
43
|
+
expected: string;
|
|
44
|
+
previewWrites: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SPLIT_GSAP = SPLIT.replace(
|
|
48
|
+
"</div>",
|
|
49
|
+
"</div><script>window.__timelines={};const tl=gsap.timeline({paused:true});" +
|
|
50
|
+
'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}): Harness {
|
|
54
|
+
const disk: Record<string, string> = { "index.html": ORIGINAL };
|
|
55
|
+
const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
|
|
56
|
+
const previewWrites: string[] = [];
|
|
57
|
+
|
|
58
|
+
const storage = createMemoryEditHistoryStorage();
|
|
59
|
+
const store = createPersistentEditHistoryStore({
|
|
60
|
+
projectId: "p1",
|
|
61
|
+
storage,
|
|
62
|
+
initialState: createEmptyEditHistory(),
|
|
63
|
+
now: (() => {
|
|
64
|
+
let t = 1000;
|
|
65
|
+
return () => (t += 10);
|
|
66
|
+
})(),
|
|
67
|
+
onChange: () => {},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Faithful stand-in for the studio-server file-mutation endpoints: the server
|
|
71
|
+
// writes the split to disk itself, then returns the patched content.
|
|
72
|
+
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
73
|
+
const u = String(url);
|
|
74
|
+
if (u.includes("/gsap-mutations/")) {
|
|
75
|
+
if (opts.gsap) {
|
|
76
|
+
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
|
77
|
+
// disk, returns the final content.
|
|
78
|
+
disk["index.html"] = SPLIT_GSAP;
|
|
79
|
+
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
|
|
80
|
+
status: 200,
|
|
81
|
+
headers: { "Content-Type": "application/json" },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// The fixture has no GSAP script — mirror the server's 400 response.
|
|
85
|
+
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
|
|
86
|
+
status: 400,
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (u.includes("/file-mutations/split-element/")) {
|
|
91
|
+
disk["index.html"] = SPLIT;
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
|
|
94
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (u.includes("/files/")) {
|
|
98
|
+
return new Response(JSON.stringify({ content: disk["index.html"] }), {
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
void init;
|
|
104
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
105
|
+
});
|
|
106
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
107
|
+
|
|
108
|
+
const splitRef: { current: Split | undefined } = { current: undefined };
|
|
109
|
+
|
|
110
|
+
function Component() {
|
|
111
|
+
const { handleRazorSplit } = useRazorSplit({
|
|
112
|
+
projectId: "p1",
|
|
113
|
+
activeCompPath: "index.html",
|
|
114
|
+
showToast: () => {},
|
|
115
|
+
writeProjectFile: async (path, content) => {
|
|
116
|
+
disk[path] = content;
|
|
117
|
+
},
|
|
118
|
+
recordEdit: store.recordEdit,
|
|
119
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
120
|
+
reloadPreview: () => {
|
|
121
|
+
if (!opts.previewStamp) return;
|
|
122
|
+
const stamped = ensureHfIds(disk["index.html"]);
|
|
123
|
+
const idsBefore = (disk["index.html"].match(/\bdata-hf-id=/g) ?? []).length;
|
|
124
|
+
const idsAfter = (stamped.match(/\bdata-hf-id=/g) ?? []).length;
|
|
125
|
+
if (idsAfter > idsBefore) {
|
|
126
|
+
disk["index.html"] = stamped;
|
|
127
|
+
previewWrites.push(stamped);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
forceReloadSdkSession: () => {},
|
|
131
|
+
});
|
|
132
|
+
splitRef.current = handleRazorSplit;
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const root = mountProbe(Component);
|
|
137
|
+
|
|
138
|
+
return { disk, store, splitRef, root, expected: finalContent, previewWrites };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function undoViaDisk(harness: Pick<Harness, "disk" | "store">) {
|
|
142
|
+
return harness.store.undo({
|
|
143
|
+
readFile: async (path) => harness.disk[path],
|
|
144
|
+
writeFile: async (path, content) => {
|
|
145
|
+
harness.disk[path] = content;
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
document.body.innerHTML = "";
|
|
152
|
+
vi.unstubAllGlobals();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("useRazorSplit — split is undoable via edit history", () => {
|
|
156
|
+
it("keeps history aligned when preview reload checks hf-id persistence", async () => {
|
|
157
|
+
const harness = mountRazorSplit({ previewStamp: true });
|
|
158
|
+
|
|
159
|
+
await act(async () => {
|
|
160
|
+
await harness.splitRef.current!(element, 2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const snapshot = harness.store.snapshot().state;
|
|
164
|
+
const entry = snapshot.undo.at(-1)!;
|
|
165
|
+
const currentHash = hashEditHistoryContent(harness.disk["index.html"]);
|
|
166
|
+
expect(entry.files["index.html"].afterHash).toBe(currentHash);
|
|
167
|
+
|
|
168
|
+
const undo = undoEditHistory(snapshot, { "index.html": currentHash }, 2000);
|
|
169
|
+
expect(undo.ok).toBe(true);
|
|
170
|
+
expect(undo.filesToWrite).toEqual({ "index.html": ORIGINAL });
|
|
171
|
+
expect(harness.previewWrites).toHaveLength(0);
|
|
172
|
+
|
|
173
|
+
act(() => harness.root.unmount());
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
for (const gsap of [false, true]) {
|
|
177
|
+
describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
|
|
178
|
+
let harness: Harness;
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
harness = mountRazorSplit({ gsap });
|
|
181
|
+
});
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
act(() => harness.root.unmount());
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("records a single 'Split timeline clip' history entry that undo restores", async () => {
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await harness.splitRef.current!(element, 2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// The split reached disk.
|
|
192
|
+
expect(harness.disk["index.html"]).toBe(harness.expected);
|
|
193
|
+
|
|
194
|
+
// The split must be the top of the undo stack — not a prior/other entry.
|
|
195
|
+
expect(harness.store.snapshot().canUndo).toBe(true);
|
|
196
|
+
expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
|
|
197
|
+
|
|
198
|
+
// Undo restores the exact pre-split file.
|
|
199
|
+
const result = await undoViaDisk(harness);
|
|
200
|
+
expect(result.ok).toBe(true);
|
|
201
|
+
expect(result.label).toBe("Split timeline clip");
|
|
202
|
+
expect(harness.disk["index.html"]).toBe(ORIGINAL);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const BATCH_ORIGINALS = {
|
|
209
|
+
"index.html": `<div class="clip" id="clip1" data-start="0" data-duration="4">one</div>`,
|
|
210
|
+
"scenes/two.html": `<div class="clip" id="clip2" data-start="0" data-duration="4">two</div>`,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const batchElements: TimelineElement[] = [
|
|
214
|
+
element,
|
|
215
|
+
{
|
|
216
|
+
...element,
|
|
217
|
+
id: "clip2",
|
|
218
|
+
domId: "clip2",
|
|
219
|
+
sourceFile: "scenes/two.html",
|
|
220
|
+
track: 1,
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
interface SplitAllHarness {
|
|
225
|
+
disk: Record<string, string>;
|
|
226
|
+
store: ReturnType<typeof createPersistentEditHistoryStore>;
|
|
227
|
+
splitAllRef: { current: SplitAll | undefined };
|
|
228
|
+
root: ReturnType<typeof mountProbe>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function mountRazorSplitAll(failOnSplit?: number): SplitAllHarness {
|
|
232
|
+
const disk: Record<string, string> = { ...BATCH_ORIGINALS };
|
|
233
|
+
const store = createPersistentEditHistoryStore({
|
|
234
|
+
projectId: "p1",
|
|
235
|
+
storage: createMemoryEditHistoryStorage(),
|
|
236
|
+
initialState: createEmptyEditHistory(),
|
|
237
|
+
now: () => 1000,
|
|
238
|
+
onChange: () => {},
|
|
239
|
+
});
|
|
240
|
+
let splitCount = 0;
|
|
241
|
+
|
|
242
|
+
vi.stubGlobal(
|
|
243
|
+
"fetch",
|
|
244
|
+
createSplitFetchMock(disk, () => {
|
|
245
|
+
splitCount++;
|
|
246
|
+
if (splitCount === failOnSplit) throw new Error("simulated split failure");
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const splitAllRef: { current: SplitAll | undefined } = { current: undefined };
|
|
251
|
+
function Component() {
|
|
252
|
+
const { handleRazorSplitAll } = useRazorSplit({
|
|
253
|
+
projectId: "p1",
|
|
254
|
+
activeCompPath: "index.html",
|
|
255
|
+
showToast: () => {},
|
|
256
|
+
writeProjectFile: async (path, content) => {
|
|
257
|
+
disk[path] = content;
|
|
258
|
+
},
|
|
259
|
+
recordEdit: store.recordEdit,
|
|
260
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
261
|
+
reloadPreview: () => {},
|
|
262
|
+
});
|
|
263
|
+
splitAllRef.current = handleRazorSplitAll;
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const root = mountProbe(Component);
|
|
268
|
+
usePlayerStore.setState({ elements: batchElements });
|
|
269
|
+
return { disk, store, splitAllRef, root };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
describe("useRazorSplit — split-all batch history", () => {
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
usePlayerStore.setState({ elements: [] });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
async function runSplitAll(failOnSplit?: number) {
|
|
278
|
+
const harness = mountRazorSplitAll(failOnSplit);
|
|
279
|
+
await act(async () => {
|
|
280
|
+
await harness.splitAllRef.current!(2);
|
|
281
|
+
});
|
|
282
|
+
return harness;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
it("records one undo entry that restores every split file", async () => {
|
|
286
|
+
const harness = await runSplitAll();
|
|
287
|
+
|
|
288
|
+
expect(harness.store.snapshot().canUndo).toBe(true);
|
|
289
|
+
const result = await undoViaDisk(harness);
|
|
290
|
+
expect(result.ok).toBe(true);
|
|
291
|
+
expect(harness.disk).toEqual(BATCH_ORIGINALS);
|
|
292
|
+
expect(harness.store.snapshot().canUndo).toBe(false);
|
|
293
|
+
act(() => harness.root.unmount());
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("restores completed writes and records no undo when a later split fails", async () => {
|
|
297
|
+
const harness = await runSplitAll(2);
|
|
298
|
+
|
|
299
|
+
expect(harness.disk).toEqual(BATCH_ORIGINALS);
|
|
300
|
+
expect(harness.store.snapshot().canUndo).toBe(false);
|
|
301
|
+
act(() => harness.root.unmount());
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { TimelineElement } from "../player";
|
|
6
|
+
import { usePlayerStore } from "../player";
|
|
7
|
+
import { useRazorSplit } from "./useRazorSplit";
|
|
8
|
+
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
|
|
9
|
+
import { createEmptyEditHistory } from "../utils/editHistory";
|
|
10
|
+
import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
|
|
11
|
+
import { createSplitFetchMock, mountProbe } from "./useRazorSplit.testHelpers";
|
|
12
|
+
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
const ROOT_FILE = "index.html";
|
|
16
|
+
const SUBCOMP_FILE = "scenes/intro.html";
|
|
17
|
+
|
|
18
|
+
// A root-level clip lives in index.html and is authored in local time already.
|
|
19
|
+
const rootElement: TimelineElement = {
|
|
20
|
+
id: "root-clip",
|
|
21
|
+
tag: "div",
|
|
22
|
+
start: 0,
|
|
23
|
+
duration: 10,
|
|
24
|
+
track: 0,
|
|
25
|
+
domId: "root-clip",
|
|
26
|
+
sourceFile: ROOT_FILE,
|
|
27
|
+
timingSource: "authored",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
|
|
31
|
+
// host's master start), `sourceFile` is the sub-comp, and `expandedParentStart`
|
|
32
|
+
// is that host master start. Its authored time in the file is start - basis.
|
|
33
|
+
const expandedChild: TimelineElement = {
|
|
34
|
+
id: "child-clip",
|
|
35
|
+
tag: "div",
|
|
36
|
+
start: 2,
|
|
37
|
+
duration: 6,
|
|
38
|
+
track: 1,
|
|
39
|
+
domId: "child-clip",
|
|
40
|
+
sourceFile: SUBCOMP_FILE,
|
|
41
|
+
timingSource: "authored",
|
|
42
|
+
expandedParentStart: 2,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface SplitRequest {
|
|
46
|
+
path: string;
|
|
47
|
+
splitTime: number;
|
|
48
|
+
elementStart: number;
|
|
49
|
+
elementDuration: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type SingleSplit = (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
53
|
+
type SplitAll = (splitTime: number) => Promise<void>;
|
|
54
|
+
|
|
55
|
+
interface Harness {
|
|
56
|
+
splitRequests: SplitRequest[];
|
|
57
|
+
singleRef: { current: SingleSplit | undefined };
|
|
58
|
+
allRef: { current: SplitAll | undefined };
|
|
59
|
+
root: ReturnType<typeof mountProbe>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mountRazorSplit(): Harness {
|
|
63
|
+
const disk: Record<string, string> = {
|
|
64
|
+
[ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
|
|
65
|
+
[SUBCOMP_FILE]: `<div class="clip" id="child-clip" data-start="0" data-duration="6"></div>`,
|
|
66
|
+
};
|
|
67
|
+
const splitRequests: SplitRequest[] = [];
|
|
68
|
+
|
|
69
|
+
const fetchMock = createSplitFetchMock(disk, (path, body) => {
|
|
70
|
+
splitRequests.push({
|
|
71
|
+
path,
|
|
72
|
+
splitTime: body.splitTime,
|
|
73
|
+
elementStart: body.elementStart,
|
|
74
|
+
elementDuration: body.elementDuration,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
78
|
+
|
|
79
|
+
const singleRef: { current: SingleSplit | undefined } = { current: undefined };
|
|
80
|
+
const allRef: { current: SplitAll | undefined } = { current: undefined };
|
|
81
|
+
|
|
82
|
+
function Component() {
|
|
83
|
+
const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
|
|
84
|
+
projectId: "p1",
|
|
85
|
+
activeCompPath: ROOT_FILE,
|
|
86
|
+
showToast: () => {},
|
|
87
|
+
writeProjectFile: async (path, content) => {
|
|
88
|
+
disk[path] = content;
|
|
89
|
+
},
|
|
90
|
+
recordEdit: async () => {},
|
|
91
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
92
|
+
reloadPreview: () => {},
|
|
93
|
+
});
|
|
94
|
+
singleRef.current = handleRazorSplit;
|
|
95
|
+
allRef.current = handleRazorSplitAll;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const root = mountProbe(Component);
|
|
100
|
+
return { splitRequests, singleRef, allRef, root };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
document.body.innerHTML = "";
|
|
105
|
+
usePlayerStore.setState({ elements: [] });
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("useRazorSplit — sub-comp coordinate rebasing", () => {
|
|
110
|
+
let harness: Harness;
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
harness = mountRazorSplit();
|
|
113
|
+
});
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
act(() => harness.root.unmount());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rebases an expanded sub-comp child split into the sub-comp's local time", async () => {
|
|
119
|
+
// Master split time T = 5; host starts at 2, so local time is 3.
|
|
120
|
+
await act(async () => {
|
|
121
|
+
await harness.singleRef.current!(expandedChild, 5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(harness.splitRequests).toHaveLength(1);
|
|
125
|
+
const req = harness.splitRequests[0];
|
|
126
|
+
expect(req.path).toBe(SUBCOMP_FILE);
|
|
127
|
+
expect(req.splitTime).toBe(3); // 5 - expandedParentStart(2), NOT 5
|
|
128
|
+
expect(req.elementStart).toBe(0); // 2 - 2, NOT the master start 2
|
|
129
|
+
expect(req.elementDuration).toBe(6);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("leaves a root-level clip's coordinates unchanged", async () => {
|
|
133
|
+
// Master split time T = 4; no expandedParentStart, so nothing is rebased.
|
|
134
|
+
await act(async () => {
|
|
135
|
+
await harness.singleRef.current!(rootElement, 4);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(harness.splitRequests).toHaveLength(1);
|
|
139
|
+
const req = harness.splitRequests[0];
|
|
140
|
+
expect(req.path).toBe(ROOT_FILE);
|
|
141
|
+
expect(req.splitTime).toBe(4);
|
|
142
|
+
expect(req.elementStart).toBe(0);
|
|
143
|
+
expect(req.elementDuration).toBe(10);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rebases each element individually in a mixed razor-split-all gesture", async () => {
|
|
147
|
+
usePlayerStore.setState({ elements: [rootElement, expandedChild] });
|
|
148
|
+
|
|
149
|
+
// Master split time T = 3 lies inside both clips.
|
|
150
|
+
await act(async () => {
|
|
151
|
+
await harness.allRef.current!(3);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(harness.splitRequests).toHaveLength(2);
|
|
155
|
+
const rootReq = harness.splitRequests.find((r) => r.path === ROOT_FILE)!;
|
|
156
|
+
const childReq = harness.splitRequests.find((r) => r.path === SUBCOMP_FILE)!;
|
|
157
|
+
|
|
158
|
+
// Root clip: already local — master coordinates pass through untouched.
|
|
159
|
+
expect(rootReq.splitTime).toBe(3);
|
|
160
|
+
expect(rootReq.elementStart).toBe(0);
|
|
161
|
+
|
|
162
|
+
// Expanded child: rebased by its OWN expandedParentStart, not the root's.
|
|
163
|
+
expect(childReq.splitTime).toBe(1); // 3 - 2
|
|
164
|
+
expect(childReq.elementStart).toBe(0); // 2 - 2
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Bug 1: split must resync the SDK session so undo isn't refused ────────────
|
|
169
|
+
|
|
170
|
+
const memoryStorage = (): EditHistoryStorageAdapter => {
|
|
171
|
+
const store = new Map<string, string>();
|
|
172
|
+
return {
|
|
173
|
+
load: async (k) => store.get(k) ?? null,
|
|
174
|
+
save: async (k, v) => {
|
|
175
|
+
store.set(k, v);
|
|
176
|
+
},
|
|
177
|
+
} as unknown as EditHistoryStorageAdapter;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
interface UndoHarness {
|
|
181
|
+
singleRef: { current: SingleSplit | undefined };
|
|
182
|
+
disk: Record<string, string>;
|
|
183
|
+
store: ReturnType<typeof createPersistentEditHistoryStore>;
|
|
184
|
+
forceReloadSdkSession: ReturnType<typeof vi.fn>;
|
|
185
|
+
root: ReturnType<typeof mountProbe>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mountRazorSplitWithHistory(): UndoHarness {
|
|
189
|
+
const disk: Record<string, string> = {
|
|
190
|
+
[ROOT_FILE]: `<div class="clip" id="root-clip" data-start="0" data-duration="10"></div>`,
|
|
191
|
+
};
|
|
192
|
+
const store = createPersistentEditHistoryStore({
|
|
193
|
+
projectId: "p1",
|
|
194
|
+
storage: memoryStorage(),
|
|
195
|
+
initialState: createEmptyEditHistory(),
|
|
196
|
+
now: () => Date.now(),
|
|
197
|
+
onChange: () => {},
|
|
198
|
+
});
|
|
199
|
+
const forceReloadSdkSession = vi.fn();
|
|
200
|
+
|
|
201
|
+
const fetchMock = createSplitFetchMock(disk);
|
|
202
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
203
|
+
|
|
204
|
+
const singleRef: { current: SingleSplit | undefined } = { current: undefined };
|
|
205
|
+
function Component() {
|
|
206
|
+
const { handleRazorSplit } = useRazorSplit({
|
|
207
|
+
projectId: "p1",
|
|
208
|
+
activeCompPath: ROOT_FILE,
|
|
209
|
+
showToast: () => {},
|
|
210
|
+
writeProjectFile: async (path, content) => {
|
|
211
|
+
disk[path] = content;
|
|
212
|
+
},
|
|
213
|
+
recordEdit: (input) => store.recordEdit(input),
|
|
214
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
215
|
+
reloadPreview: () => {},
|
|
216
|
+
forceReloadSdkSession,
|
|
217
|
+
});
|
|
218
|
+
singleRef.current = handleRazorSplit;
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const root = mountProbe(Component);
|
|
222
|
+
return { singleRef, disk, store, forceReloadSdkSession, root };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("useRazorSplit — undo integrity after split (Bug 1)", () => {
|
|
226
|
+
let h: UndoHarness;
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
h = mountRazorSplitWithHistory();
|
|
229
|
+
});
|
|
230
|
+
afterEach(() => {
|
|
231
|
+
act(() => h.root.unmount());
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const readFile = () => ({
|
|
235
|
+
readFile: async (p: string) => h.disk[p],
|
|
236
|
+
writeFile: async (p: string, c: string) => {
|
|
237
|
+
h.disk[p] = c;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("resyncs the SDK session after a split (matches every other server-write path)", async () => {
|
|
242
|
+
await act(async () => {
|
|
243
|
+
await h.singleRef.current!(rootElement, 4);
|
|
244
|
+
});
|
|
245
|
+
expect(h.forceReloadSdkSession).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("applies undo after a split without an external-change refusal", async () => {
|
|
249
|
+
await act(async () => {
|
|
250
|
+
await h.singleRef.current!(rootElement, 4);
|
|
251
|
+
});
|
|
252
|
+
const result = await h.store.undo(readFile());
|
|
253
|
+
expect(result.ok).toBe(true);
|
|
254
|
+
expect(result.reason).toBeUndefined();
|
|
255
|
+
// The file is restored to its pre-split bytes.
|
|
256
|
+
expect(h.disk[ROOT_FILE]).not.toContain("<!--split-->");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("still trips the guard when the file is edited externally after a split", async () => {
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await h.singleRef.current!(rootElement, 4);
|
|
262
|
+
});
|
|
263
|
+
// Simulate the user editing the file in their own editor after the split.
|
|
264
|
+
h.disk[ROOT_FILE] = `${h.disk[ROOT_FILE]}<!--hand-edit-->`;
|
|
265
|
+
const result = await h.store.undo(readFile());
|
|
266
|
+
expect(result.ok).toBe(false);
|
|
267
|
+
expect(result.reason).toBe("content-mismatch");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { act } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
export interface SplitBody {
|
|
6
|
+
splitTime: number;
|
|
7
|
+
elementStart: number;
|
|
8
|
+
elementDuration: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function decodePathFromUrl(url: string, marker: string): string {
|
|
12
|
+
const encoded = url.slice(url.indexOf(marker) + marker.length);
|
|
13
|
+
return decodeURIComponent(encoded);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch mock shared by both harnesses: GSAP mutations 400 (no script in fixtures),
|
|
18
|
+
* split-element writes a `<!--split-->` marker so `changed` is true, and file reads
|
|
19
|
+
* echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
|
|
20
|
+
*/
|
|
21
|
+
export function createSplitFetchMock(
|
|
22
|
+
disk: Record<string, string>,
|
|
23
|
+
onSplit?: (path: string, body: SplitBody) => void,
|
|
24
|
+
) {
|
|
25
|
+
return vi.fn(async (url: string, init?: RequestInit) => {
|
|
26
|
+
const u = String(url);
|
|
27
|
+
if (u.includes("/gsap-mutations/")) {
|
|
28
|
+
// No GSAP script in the fixtures — mirror the server's 400 response.
|
|
29
|
+
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
|
|
30
|
+
status: 400,
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (u.includes("/file-mutations/split-element/")) {
|
|
35
|
+
const path = decodePathFromUrl(u, "/file-mutations/split-element/");
|
|
36
|
+
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
|
|
37
|
+
// Return content that differs from the original so `changed` is true.
|
|
38
|
+
const after = `${disk[path]}<!--split-->`;
|
|
39
|
+
disk[path] = after; // server writes the split to disk
|
|
40
|
+
return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
|
|
41
|
+
status: 200,
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (u.includes("/files/")) {
|
|
46
|
+
const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
|
|
47
|
+
return new Response(JSON.stringify({ content: disk[path] ?? "" }), {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
void init;
|
|
53
|
+
throw new Error(`unexpected fetch: ${u}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mount a render-only probe component into a fresh host and return its root. */
|
|
58
|
+
export function mountProbe(Component: React.ComponentType): ReturnType<typeof createRoot> {
|
|
59
|
+
const host = document.createElement("div");
|
|
60
|
+
document.body.append(host);
|
|
61
|
+
const root = createRoot(host);
|
|
62
|
+
act(() => root.render(React.createElement(Component)));
|
|
63
|
+
return root;
|
|
64
|
+
}
|