@hyperframes/studio 0.7.60 → 0.7.62
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-CWpmBjzv.js +459 -0
- package/dist/assets/{index-D6etaey-.js → index-BJzSiIBj.js} +1 -1
- package/dist/assets/index-Ceyz8Qt2.css +1 -0
- package/dist/assets/{index-Dh_WhagG.js → index-DJQPB78e.js} +1 -1
- package/dist/assets/index-vDRVQtTK.js +426 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.html +2 -2
- package/dist/index.js +4392 -3314
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +15 -15
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/EditorShell.tsx +12 -0
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioLeftSidebar.tsx +3 -0
- package/src/components/StudioRightPanel.tsx +34 -26
- package/src/components/editor/DomEditSelectionChrome.test.tsx +51 -0
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +50 -42
- package/src/components/editor/compositionReliabilityFixture.integration.test.ts +105 -0
- package/src/components/editor/domEditing.test.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelColor.test.tsx +31 -0
- package/src/components/editor/propertyPanelColor.tsx +150 -28
- package/src/components/editor/propertyPanelCommitField.tsx +178 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +44 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +26 -10
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +21 -32
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +47 -4
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -2
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +18 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -92
- package/src/components/editor/propertyPanelSections.tsx +2 -2
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +37 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +6 -0
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +87 -0
- package/src/components/sidebar/CompositionsTab.tsx +45 -2
- package/src/components/sidebar/LeftSidebar.tsx +3 -0
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +47 -0
- package/src/components/storyboard/AgentChatMessageButton.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +189 -66
- package/src/components/storyboard/StoryboardLoaded.tsx +121 -23
- package/src/components/storyboard/StoryboardReviewGuide.tsx +300 -0
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +170 -0
- package/src/components/storyboard/storyboardReviewStage.test.ts +40 -0
- package/src/components/storyboard/storyboardReviewStage.ts +45 -0
- package/src/components/storyboard/useFrameComments.ts +22 -6
- package/src/contexts/StudioContext.tsx +4 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +10 -4
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/usePreviewPersistence.ts +7 -1
- package/src/hooks/useRazorSplit.history.test.tsx +21 -28
- package/src/hooks/useRazorSplit.test.tsx +20 -9
- package/src/hooks/useRazorSplit.testHelpers.ts +34 -22
- package/src/hooks/useRazorSplit.ts +78 -350
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +52 -2
- package/src/hooks/useTimelineEditing.ts +19 -17
- package/src/hooks/useTimelineGroupEditing.ts +6 -7
- package/src/player/components/Timeline.tsx +12 -1
- package/src/player/components/timelineAuthoredMoveTarget.ts +77 -0
- package/src/player/components/timelineAuthoredTrack.ts +29 -0
- package/src/player/components/timelineCallbacks.ts +4 -0
- package/src/player/components/timelineClipDragCommit.test.ts +176 -0
- package/src/player/components/timelineClipDragCommit.ts +77 -105
- package/src/player/components/timelineClipDragPreview.test.ts +33 -1
- package/src/player/components/timelineClipDragPreview.ts +2 -1
- package/src/player/components/timelineCollision.test.ts +18 -0
- package/src/player/components/timelineCollision.ts +12 -9
- package/src/player/components/timelineDragDrop.ts +28 -7
- package/src/player/components/timelineEditCapabilities.ts +4 -1
- package/src/player/components/timelineGroupEditing.test.ts +10 -0
- package/src/player/components/timelineGroupEditing.ts +4 -3
- package/src/player/components/timelineLaneMoveRefresh.ts +14 -0
- package/src/player/components/timelineLayout.ts +3 -1
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.ts +5 -0
- package/src/player/components/useTimelineEditPinning.ts +12 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +42 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/lib/playbackTypes.ts +9 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.test.ts +58 -0
- package/src/player/lib/timelineDOM.ts +26 -12
- package/src/player/lib/timelineElementHelpers.ts +21 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/player/store/playerStore.ts +2 -1
- package/src/utils/razorSplitTransaction.test.ts +175 -0
- package/src/utils/razorSplitTransaction.ts +200 -0
- package/src/utils/studioPreviewHelpers.test.ts +27 -0
- package/src/utils/timelineCompositionDrop.test.ts +12 -0
- package/src/utils/timelineCompositionDrop.ts +16 -0
- package/src/utils/timelineCompositionInsert.test.ts +142 -0
- package/src/utils/timelineCompositionInsert.ts +90 -0
- package/src/utils/timelineElementSplit.test.ts +14 -2
- package/src/utils/timelineElementSplit.ts +5 -1
- package/dist/assets/hyperframes-player-3XTTaVNf.js +0 -459
- package/dist/assets/index-DXbu6IPT.css +0 -1
- package/dist/assets/index-cH6NfVV_.js +0 -426
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
useContext,
|
|
5
5
|
useEffect,
|
|
6
6
|
useMemo,
|
|
7
|
+
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
type ReactNode,
|
|
9
10
|
} from "react";
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
* user straight into the storyboard by navigating the tab to `?view=storyboard`.
|
|
18
19
|
*/
|
|
19
20
|
export type StudioViewMode = "timeline" | "storyboard";
|
|
21
|
+
export type ViewModeGuard = (nextMode: StudioViewMode) => boolean;
|
|
20
22
|
|
|
21
23
|
const VIEW_QUERY_PARAM = "view";
|
|
22
24
|
|
|
@@ -38,9 +40,24 @@ function writeViewModeToUrl(mode: StudioViewMode): void {
|
|
|
38
40
|
window.history.replaceState(window.history.state, "", url);
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
interface HistoryLocation {
|
|
44
|
+
href: string;
|
|
45
|
+
state: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readHistoryLocation(): HistoryLocation | null {
|
|
49
|
+
if (typeof window === "undefined") return null;
|
|
50
|
+
return {
|
|
51
|
+
href: window.location.href,
|
|
52
|
+
state: window.history.state,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
export interface ViewModeValue {
|
|
42
57
|
viewMode: StudioViewMode;
|
|
43
|
-
|
|
58
|
+
/** Returns false when an active editor vetoes the transition. */
|
|
59
|
+
setViewMode: (mode: StudioViewMode) => boolean;
|
|
60
|
+
registerViewModeGuard: (guard: ViewModeGuard) => () => void;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/**
|
|
@@ -49,6 +66,15 @@ export interface ViewModeValue {
|
|
|
49
66
|
*/
|
|
50
67
|
export function useViewModeState(): ViewModeValue {
|
|
51
68
|
const [viewMode, setMode] = useState<StudioViewMode>(() => readViewModeFromUrl());
|
|
69
|
+
const guardsRef = useRef(new Set<ViewModeGuard>());
|
|
70
|
+
const acceptedLocationRef = useRef<HistoryLocation | null>(readHistoryLocation());
|
|
71
|
+
|
|
72
|
+
const canSetViewMode = useCallback((mode: StudioViewMode) => {
|
|
73
|
+
for (const guard of guardsRef.current) {
|
|
74
|
+
if (!guard(mode)) return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}, []);
|
|
52
78
|
|
|
53
79
|
// Reflect genuine browser back/forward between history entries with a different
|
|
54
80
|
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
|
|
@@ -57,17 +83,44 @@ export function useViewModeState(): ViewModeValue {
|
|
|
57
83
|
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
|
58
84
|
// reflected here, by design.
|
|
59
85
|
useEffect(() => {
|
|
60
|
-
const onPopState = () =>
|
|
86
|
+
const onPopState = () => {
|
|
87
|
+
const mode = readViewModeFromUrl();
|
|
88
|
+
if (mode !== viewMode && !canSetViewMode(mode)) {
|
|
89
|
+
const acceptedLocation = acceptedLocationRef.current;
|
|
90
|
+
if (acceptedLocation) {
|
|
91
|
+
window.history.pushState(acceptedLocation.state, "", acceptedLocation.href);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setMode(mode);
|
|
96
|
+
acceptedLocationRef.current = readHistoryLocation();
|
|
97
|
+
};
|
|
61
98
|
window.addEventListener("popstate", onPopState);
|
|
62
99
|
return () => window.removeEventListener("popstate", onPopState);
|
|
63
|
-
}, []);
|
|
100
|
+
}, [canSetViewMode, viewMode]);
|
|
101
|
+
|
|
102
|
+
const setViewMode = useCallback(
|
|
103
|
+
(mode: StudioViewMode) => {
|
|
104
|
+
if (!canSetViewMode(mode)) return false;
|
|
105
|
+
setMode(mode);
|
|
106
|
+
writeViewModeToUrl(mode);
|
|
107
|
+
acceptedLocationRef.current = readHistoryLocation();
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
[canSetViewMode],
|
|
111
|
+
);
|
|
64
112
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
113
|
+
const registerViewModeGuard = useCallback((guard: ViewModeGuard) => {
|
|
114
|
+
guardsRef.current.add(guard);
|
|
115
|
+
return () => {
|
|
116
|
+
guardsRef.current.delete(guard);
|
|
117
|
+
};
|
|
68
118
|
}, []);
|
|
69
119
|
|
|
70
|
-
return useMemo(
|
|
120
|
+
return useMemo(
|
|
121
|
+
() => ({ viewMode, setViewMode, registerViewModeGuard }),
|
|
122
|
+
[viewMode, setViewMode, registerViewModeGuard],
|
|
123
|
+
);
|
|
71
124
|
}
|
|
72
125
|
|
|
73
126
|
const ViewModeContext = createContext<ViewModeValue | null>(null);
|
|
@@ -166,6 +166,14 @@ export function patchIframeDomTiming(
|
|
|
166
166
|
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
|
|
170
|
+
export function playbackStartAttributeForElement(
|
|
171
|
+
element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
|
|
172
|
+
): "data-media-start" | "data-playback-start" {
|
|
173
|
+
return element.playbackStartAttr === "playback-start" || element.kind === "composition"
|
|
174
|
+
? "data-playback-start"
|
|
175
|
+
: "data-media-start";
|
|
176
|
+
}
|
|
169
177
|
// fallow-ignore-next-line complexity
|
|
170
178
|
function resolveResizePlaybackStart(
|
|
171
179
|
original: string,
|
|
@@ -174,8 +182,7 @@ function resolveResizePlaybackStart(
|
|
|
174
182
|
updates: Pick<TimelineElement, "start" | "playbackStart">,
|
|
175
183
|
): { attrName: string; value: number } | null {
|
|
176
184
|
if (updates.playbackStart != null) {
|
|
177
|
-
const attrName =
|
|
178
|
-
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
185
|
+
const attrName = playbackStartAttributeForElement(element).slice("data-".length);
|
|
179
186
|
return { attrName, value: updates.playbackStart };
|
|
180
187
|
}
|
|
181
188
|
const trimDelta = updates.start - element.start;
|
|
@@ -185,8 +192,7 @@ function resolveResizePlaybackStart(
|
|
|
185
192
|
readAttributeByTarget(original, target, "media-start");
|
|
186
193
|
const current = raw != null ? parseFloat(raw) : undefined;
|
|
187
194
|
if (current == null || !Number.isFinite(current)) return null;
|
|
188
|
-
const attrName =
|
|
189
|
-
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
195
|
+
const attrName = playbackStartAttributeForElement(element).slice("data-".length);
|
|
190
196
|
return {
|
|
191
197
|
attrName,
|
|
192
198
|
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../player";
|
|
7
|
+
import { useAddAssetAtPlayhead } from "./useAddAssetAtPlayhead";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("useAddAssetAtPlayhead", () => {
|
|
16
|
+
it("drops the asset on track 0 at the current playhead time", () => {
|
|
17
|
+
usePlayerStore.getState().setCurrentTime(4.5);
|
|
18
|
+
const handleTimelineAssetDrop = vi.fn();
|
|
19
|
+
let addAsset: (assetPath: string) => unknown = () => undefined;
|
|
20
|
+
|
|
21
|
+
function Harness() {
|
|
22
|
+
addAsset = useAddAssetAtPlayhead(handleTimelineAssetDrop);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const host = document.createElement("div");
|
|
27
|
+
document.body.append(host);
|
|
28
|
+
const root = createRoot(host);
|
|
29
|
+
act(() => {
|
|
30
|
+
root.render(React.createElement(Harness));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
act(() => {
|
|
34
|
+
addAsset("assets/clip.mp4");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(handleTimelineAssetDrop).toHaveBeenCalledWith("assets/clip.mp4", {
|
|
38
|
+
start: 4.5,
|
|
39
|
+
track: 0,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
act(() => root.unmount());
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { usePlayerStore } from "../player";
|
|
4
|
+
|
|
5
|
+
/** Drops an asset onto track 0 at the current playhead time. */
|
|
6
|
+
export function useAddAssetAtPlayhead(
|
|
7
|
+
handleTimelineAssetDrop: (
|
|
8
|
+
assetPath: string,
|
|
9
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
10
|
+
durationOverride?: number,
|
|
11
|
+
) => unknown,
|
|
12
|
+
) {
|
|
13
|
+
return useCallback(
|
|
14
|
+
(assetPath: string) =>
|
|
15
|
+
handleTimelineAssetDrop(assetPath, {
|
|
16
|
+
start: usePlayerStore.getState().currentTime,
|
|
17
|
+
track: 0,
|
|
18
|
+
}),
|
|
19
|
+
[handleTimelineAssetDrop],
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -208,13 +208,19 @@ export function usePreviewPersistence({
|
|
|
208
208
|
// attributes onto the live DOM and re-runs the timeline at the SAME playhead,
|
|
209
209
|
// falling back to reloadPreview for anything structural (split/delete undo),
|
|
210
210
|
// multi-file, sub-comp, or a permanent soft-reload failure.
|
|
211
|
-
applyUndoRestoreToPreview(
|
|
211
|
+
const strategy = applyUndoRestoreToPreview(
|
|
212
212
|
previewIframeRef.current,
|
|
213
213
|
activeCompPathRef.current,
|
|
214
214
|
restore.files,
|
|
215
215
|
usePlayerStore.getState().currentTime,
|
|
216
216
|
reloadPreview,
|
|
217
217
|
);
|
|
218
|
+
if (strategy === "full") {
|
|
219
|
+
const player = usePlayerStore.getState();
|
|
220
|
+
player.setElements([]);
|
|
221
|
+
player.setSelectedElementId(null);
|
|
222
|
+
player.setTimelineReady(false);
|
|
223
|
+
}
|
|
218
224
|
},
|
|
219
225
|
[previewIframeRef, activeCompPathRef, reloadPreview],
|
|
220
226
|
);
|
|
@@ -67,43 +67,36 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
|
|
67
67
|
onChange: () => {},
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
// Faithful stand-in for the
|
|
71
|
-
//
|
|
70
|
+
// Faithful stand-in for the atomic server cut: one forward file write and one
|
|
71
|
+
// response carrying the canonical history snapshots.
|
|
72
72
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
|
73
73
|
const u = String(url);
|
|
74
|
-
if (u.includes("/
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
disk["index.html"] = SPLIT_GSAP;
|
|
79
|
-
const version = `"test-gsap-${SPLIT_GSAP.length}"`;
|
|
80
|
-
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
|
|
81
|
-
status: 200,
|
|
82
|
-
headers: { "Content-Type": "application/json", ETag: version },
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
// The fixture has no GSAP script — mirror the server's 400 response.
|
|
86
|
-
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
|
|
87
|
-
status: 400,
|
|
88
|
-
headers: { "Content-Type": "application/json" },
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
if (u.includes("/file-mutations/split-element/")) {
|
|
92
|
-
disk["index.html"] = SPLIT;
|
|
93
|
-
const version = `"test-split-${SPLIT.length}"`;
|
|
74
|
+
if (u.includes("/file-mutations/split-batch")) {
|
|
75
|
+
const before = disk["index.html"];
|
|
76
|
+
disk["index.html"] = finalContent;
|
|
77
|
+
const version = `"test-cut-${finalContent.length}"`;
|
|
94
78
|
return new Response(
|
|
95
79
|
JSON.stringify({
|
|
96
80
|
ok: true,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
81
|
+
outcome: "committed",
|
|
82
|
+
files: [
|
|
83
|
+
{
|
|
84
|
+
path: "index.html",
|
|
85
|
+
before,
|
|
86
|
+
after: finalContent,
|
|
87
|
+
version,
|
|
88
|
+
writeToken: "test-cut",
|
|
89
|
+
splitCount: 1,
|
|
90
|
+
skippedSelectors: [],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
101
93
|
}),
|
|
102
|
-
{ status: 200, headers: { "Content-Type": "application/json"
|
|
94
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
103
95
|
);
|
|
104
96
|
}
|
|
105
97
|
if (u.includes("/files/")) {
|
|
106
|
-
|
|
98
|
+
const content = disk["index.html"];
|
|
99
|
+
return new Response(JSON.stringify({ content, version: `"test-${content.length}"` }), {
|
|
107
100
|
status: 200,
|
|
108
101
|
headers: { "Content-Type": "application/json" },
|
|
109
102
|
});
|
|
@@ -17,19 +17,30 @@ function jsonResponse(body: unknown, status = 200): Response {
|
|
|
17
17
|
describe("useRazorSplit mutation versions", () => {
|
|
18
18
|
afterEach(() => vi.restoreAllMocks());
|
|
19
19
|
|
|
20
|
-
it("observes
|
|
20
|
+
it("observes the batch version without a redundant client forward write", async () => {
|
|
21
21
|
const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
|
|
22
22
|
const htmlSplit =
|
|
23
23
|
'<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
|
|
24
24
|
const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
|
|
25
25
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
|
26
26
|
const url = String(input);
|
|
27
|
-
if (url.includes("/files/")) return jsonResponse({ content: original });
|
|
28
|
-
if (url.includes("/file-mutations/split-
|
|
29
|
-
return jsonResponse({
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
if (url.includes("/files/")) return jsonResponse({ content: original, version: '"v0"' });
|
|
28
|
+
if (url.includes("/file-mutations/split-batch")) {
|
|
29
|
+
return jsonResponse({
|
|
30
|
+
ok: true,
|
|
31
|
+
outcome: "committed",
|
|
32
|
+
files: [
|
|
33
|
+
{
|
|
34
|
+
path: "index.html",
|
|
35
|
+
before: original,
|
|
36
|
+
after: final,
|
|
37
|
+
version: '"v-cut"',
|
|
38
|
+
writeToken: "cut-1",
|
|
39
|
+
splitCount: 1,
|
|
40
|
+
skippedSelectors: [],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
});
|
|
33
44
|
}
|
|
34
45
|
throw new Error(`Unexpected request: ${url}`);
|
|
35
46
|
});
|
|
@@ -78,8 +89,8 @@ describe("useRazorSplit mutation versions", () => {
|
|
|
78
89
|
);
|
|
79
90
|
});
|
|
80
91
|
|
|
81
|
-
expect(order).toEqual(['observe:index.html:"v-
|
|
82
|
-
expect(writeProjectFile).
|
|
92
|
+
expect(order).toEqual(['observe:index.html:"v-cut"']);
|
|
93
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
83
94
|
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
84
95
|
|
|
85
96
|
act(() => root.unmount());
|
|
@@ -8,15 +8,19 @@ export interface SplitBody {
|
|
|
8
8
|
elementDuration: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
interface BatchFileBody {
|
|
12
|
+
path: string;
|
|
13
|
+
targets: SplitBody[];
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
function decodePathFromUrl(url: string, marker: string): string {
|
|
12
17
|
const encoded = url.slice(url.indexOf(marker) + marker.length);
|
|
13
18
|
return decodeURIComponent(encoded);
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
|
-
* Fetch mock shared by both harnesses:
|
|
18
|
-
*
|
|
19
|
-
* echo the in-memory `disk`. `onSplit` (when set) records each split request's body.
|
|
22
|
+
* Fetch mock shared by both harnesses: the atomic split batch writes each file
|
|
23
|
+
* once and returns its canonical snapshots; file reads echo the in-memory disk.
|
|
20
24
|
*/
|
|
21
25
|
export function createSplitFetchMock(
|
|
22
26
|
disk: Record<string, string>,
|
|
@@ -24,31 +28,39 @@ export function createSplitFetchMock(
|
|
|
24
28
|
) {
|
|
25
29
|
return vi.fn(async (url: string, init?: RequestInit) => {
|
|
26
30
|
const u = String(url);
|
|
27
|
-
if (u.includes("/
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if (u.includes("/file-mutations/split-batch")) {
|
|
32
|
+
const body = JSON.parse(String(init?.body)) as { files: BatchFileBody[] };
|
|
33
|
+
const files = body.files.map((file) => {
|
|
34
|
+
const before = disk[file.path];
|
|
35
|
+
for (const target of file.targets) onSplit?.(file.path, target);
|
|
36
|
+
const after = `${before}${"<!--split-->".repeat(file.targets.length)}`;
|
|
37
|
+
const version = `"test-${file.path}-${after.length}"`;
|
|
38
|
+
return {
|
|
39
|
+
path: file.path,
|
|
40
|
+
before,
|
|
41
|
+
after,
|
|
42
|
+
version,
|
|
43
|
+
writeToken: "test-cut",
|
|
44
|
+
splitCount: file.targets.length,
|
|
45
|
+
skippedSelectors: [],
|
|
46
|
+
};
|
|
32
47
|
});
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
const version = `"test-${path}-${after.length}"`;
|
|
40
|
-
disk[path] = after; // server writes the split to disk
|
|
41
|
-
return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
|
|
48
|
+
for (const file of files) disk[file.path] = file.after;
|
|
49
|
+
return new Response(JSON.stringify({ ok: true, outcome: "committed", files }), {
|
|
42
50
|
status: 200,
|
|
43
|
-
headers: { "Content-Type": "application/json"
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
if (u.includes("/files/")) {
|
|
47
55
|
const path = decodePathFromUrl(u, "/files/").replace(/\?.*$/, "");
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
const content = disk[path] ?? "";
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({ content, version: `"test-${path}-${content.length}"` }),
|
|
59
|
+
{
|
|
60
|
+
status: 200,
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
},
|
|
63
|
+
);
|
|
52
64
|
}
|
|
53
65
|
void init;
|
|
54
66
|
throw new Error(`unexpected fetch: ${u}`);
|