@hyperframes/studio 0.7.59 → 0.7.61
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-Xvx2hkrc.js +459 -0
- package/dist/assets/{index-CrkAdJkb.js → index-2mxh_HSy.js} +201 -201
- package/dist/assets/{index-B995FG46.js → index-BCpoiv9S.js} +1 -1
- package/dist/assets/index-BhWig0mx.css +1 -0
- package/dist/assets/{index-FvzmPhfG.js → index-D-uFclFj.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1441 -770
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -9
- package/src/components/DesignPanelPromoteProvider.tsx +27 -1
- package/src/components/StudioHeader.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +47 -28
- package/src/components/editor/PromotableControl.tsx +4 -2
- package/src/components/editor/PropertyPanel.tsx +151 -149
- package/src/components/editor/PropertyPanelFlat.tsx +44 -42
- package/src/components/editor/manualEditingAvailability.test.ts +6 -6
- package/src/components/editor/manualEditingAvailability.ts +5 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +10 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +3 -2
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +7 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +0 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +21 -9
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +15 -32
- package/src/components/editor/propertyPanelFlatTextSection.tsx +3 -3
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +14 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -1
- 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/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/ViewModeContext.tsx +60 -7
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/useAddAssetAtPlayhead.test.ts +44 -0
- package/src/hooks/useAddAssetAtPlayhead.ts +21 -0
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +2 -2
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useFileManager.projectOwnership.test.tsx +14 -3
- package/src/hooks/useFileManager.ts +76 -12
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSlideshowTabState.test.ts +96 -0
- package/src/hooks/useSlideshowTabState.ts +61 -0
- package/src/hooks/useTimelineEditing.ts +2 -0
- package/src/hooks/useTimelineEditingTypes.ts +2 -1
- package/src/hooks/useTimelineGroupEditing.ts +1 -1
- package/src/player/lib/playbackTypes.ts +7 -0
- package/src/player/lib/runtimeProtocol.test.ts +6 -1
- package/src/player/lib/timelineDOM.ts +8 -11
- package/src/player/lib/timelineIframeHelpers.ts +153 -107
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.test.ts +21 -5
- package/src/utils/sdkEditTransaction.ts +5 -4
- package/src/utils/studioFileHistory.ts +6 -4
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +21 -0
- package/dist/assets/hyperframes-player-Bm07FLkl.js +0 -459
- package/dist/assets/index-Dj5p8U_A.css +0 -1
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +0 -23
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { deriveStoryboardHandoffStep, deriveStoryboardReviewStage } from "./storyboardReviewStage";
|
|
3
|
+
|
|
4
|
+
describe("deriveStoryboardReviewStage", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
[[], "empty"],
|
|
7
|
+
[["outline", "outline"], "plan-review"],
|
|
8
|
+
[["outline", "built"], "sketch-in-progress"],
|
|
9
|
+
[["outline", "animated"], "sketch-in-progress"],
|
|
10
|
+
[["built", "built"], "sketch-review"],
|
|
11
|
+
[["built", "animated"], "animation-in-progress"],
|
|
12
|
+
[["animated", "animated"], "final-review"],
|
|
13
|
+
] as const)("maps %j to %s", (statuses, expected) => {
|
|
14
|
+
expect(deriveStoryboardReviewStage(statuses.map((status) => ({ status }))).stage).toBe(
|
|
15
|
+
expected,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns counts for the visible progress summary", () => {
|
|
20
|
+
expect(
|
|
21
|
+
deriveStoryboardReviewStage([
|
|
22
|
+
{ status: "outline" },
|
|
23
|
+
{ status: "built" },
|
|
24
|
+
{ status: "animated" },
|
|
25
|
+
{ status: "animated" },
|
|
26
|
+
]).counts,
|
|
27
|
+
).toEqual({ outline: 1, built: 1, animated: 2 });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("storyboard review handoff", () => {
|
|
32
|
+
it.each([
|
|
33
|
+
[0, 0, 1],
|
|
34
|
+
[2, 0, 2],
|
|
35
|
+
[0, 3, 3],
|
|
36
|
+
[1, 3, 2],
|
|
37
|
+
] as const)("maps %i drafts and %i pending comments to step %i", (drafts, pending, step) => {
|
|
38
|
+
expect(deriveStoryboardHandoffStep(drafts, pending)).toBe(step);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FrameStatus } from "@hyperframes/core/storyboard";
|
|
2
|
+
|
|
3
|
+
export type StoryboardReviewStage =
|
|
4
|
+
| "empty"
|
|
5
|
+
| "plan-review"
|
|
6
|
+
| "sketch-in-progress"
|
|
7
|
+
| "sketch-review"
|
|
8
|
+
| "animation-in-progress"
|
|
9
|
+
| "final-review";
|
|
10
|
+
|
|
11
|
+
export interface StoryboardReviewSummary {
|
|
12
|
+
stage: StoryboardReviewStage;
|
|
13
|
+
counts: Record<FrameStatus, number>;
|
|
14
|
+
frameCount: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type StoryboardHandoffStep = 1 | 2 | 3;
|
|
18
|
+
|
|
19
|
+
/** Current user action in the Studio → agent feedback handoff. */
|
|
20
|
+
export function deriveStoryboardHandoffStep(
|
|
21
|
+
draftCount: number,
|
|
22
|
+
pendingCount: number,
|
|
23
|
+
): StoryboardHandoffStep {
|
|
24
|
+
if (draftCount > 0) return 2;
|
|
25
|
+
if (pendingCount > 0) return 3;
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Derive the board-level review moment from agent-owned frame statuses. */
|
|
30
|
+
export function deriveStoryboardReviewStage(
|
|
31
|
+
frames: ReadonlyArray<{ status: FrameStatus }>,
|
|
32
|
+
): StoryboardReviewSummary {
|
|
33
|
+
const counts: Record<FrameStatus, number> = { outline: 0, built: 0, animated: 0 };
|
|
34
|
+
for (const frame of frames) counts[frame.status] += 1;
|
|
35
|
+
|
|
36
|
+
let stage: StoryboardReviewStage;
|
|
37
|
+
if (frames.length === 0) stage = "empty";
|
|
38
|
+
else if (counts.outline === frames.length) stage = "plan-review";
|
|
39
|
+
else if (counts.outline > 0) stage = "sketch-in-progress";
|
|
40
|
+
else if (counts.built === frames.length) stage = "sketch-review";
|
|
41
|
+
else if (counts.built > 0) stage = "animation-in-progress";
|
|
42
|
+
else stage = "final-review";
|
|
43
|
+
|
|
44
|
+
return { stage, counts, frameCount: frames.length };
|
|
45
|
+
}
|
|
@@ -17,8 +17,10 @@ export interface FrameCommentsValue {
|
|
|
17
17
|
/** How many frames currently carry a non-empty draft. */
|
|
18
18
|
draftCount: number;
|
|
19
19
|
submitState: CommentsSubmitState;
|
|
20
|
+
/** Most recent submit failure, shown next to the submit action. */
|
|
21
|
+
submitError: string | null;
|
|
20
22
|
/** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
|
|
21
|
-
submit: () => Promise<
|
|
23
|
+
submit: () => Promise<boolean>;
|
|
22
24
|
/**
|
|
23
25
|
* Comments already submitted but not yet consumed by the agent (the file
|
|
24
26
|
* still exists on disk). Refreshed on mount, after submit, and on window
|
|
@@ -34,6 +36,7 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
|
|
|
34
36
|
const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
|
|
35
37
|
const [drafts, setDrafts] = useState<Record<number, string>>({});
|
|
36
38
|
const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
|
|
39
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
37
40
|
const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
|
|
38
41
|
|
|
39
42
|
const refreshPending = useCallback(async () => {
|
|
@@ -61,22 +64,35 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
|
|
|
61
64
|
[drafts],
|
|
62
65
|
);
|
|
63
66
|
|
|
67
|
+
// One guarded async transaction owns loading, success, failure, and cleanup state.
|
|
68
|
+
// fallow-ignore-next-line complexity
|
|
64
69
|
const submit = useCallback(async () => {
|
|
65
|
-
if (draftCount === 0 || submitState === "saving") return;
|
|
70
|
+
if (draftCount === 0 || submitState === "saving") return false;
|
|
66
71
|
setSubmitState("saving");
|
|
72
|
+
setSubmitError(null);
|
|
67
73
|
try {
|
|
68
74
|
const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
|
69
75
|
const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
|
|
70
76
|
await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
|
|
71
77
|
setDrafts({});
|
|
72
78
|
setPending(file.comments);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
return true;
|
|
80
|
+
} catch (err: unknown) {
|
|
81
|
+
setSubmitError(err instanceof Error ? err.message : "Failed to submit comments");
|
|
82
|
+
return false;
|
|
76
83
|
} finally {
|
|
77
84
|
setSubmitState("idle");
|
|
78
85
|
}
|
|
79
86
|
}, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
|
|
80
87
|
|
|
81
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
drafts,
|
|
90
|
+
setDraft,
|
|
91
|
+
draftCount,
|
|
92
|
+
submitState,
|
|
93
|
+
submitError,
|
|
94
|
+
submit,
|
|
95
|
+
pending,
|
|
96
|
+
refreshPending,
|
|
97
|
+
};
|
|
82
98
|
}
|
|
@@ -30,6 +30,7 @@ export function FileManagerProvider({
|
|
|
30
30
|
readProjectFile,
|
|
31
31
|
writeProjectFile,
|
|
32
32
|
readOptionalProjectFile,
|
|
33
|
+
observeProjectFileVersion,
|
|
33
34
|
updateEditingFileContent,
|
|
34
35
|
revealSourceOffset,
|
|
35
36
|
openSourceForSelection,
|
|
@@ -69,6 +70,7 @@ export function FileManagerProvider({
|
|
|
69
70
|
readProjectFile,
|
|
70
71
|
writeProjectFile,
|
|
71
72
|
readOptionalProjectFile,
|
|
73
|
+
observeProjectFileVersion,
|
|
72
74
|
updateEditingFileContent,
|
|
73
75
|
revealSourceOffset,
|
|
74
76
|
openSourceForSelection,
|
|
@@ -102,6 +104,7 @@ export function FileManagerProvider({
|
|
|
102
104
|
readProjectFile,
|
|
103
105
|
writeProjectFile,
|
|
104
106
|
readOptionalProjectFile,
|
|
107
|
+
observeProjectFileVersion,
|
|
105
108
|
updateEditingFileContent,
|
|
106
109
|
revealSourceOffset,
|
|
107
110
|
openSourceForSelection,
|
|
@@ -25,6 +25,7 @@ export function PanelLayoutProvider({
|
|
|
25
25
|
setRightPanelTab,
|
|
26
26
|
rightInspectorPanes,
|
|
27
27
|
toggleRightInspectorPane,
|
|
28
|
+
setExclusiveRightInspectorPane,
|
|
28
29
|
toggleLeftSidebar,
|
|
29
30
|
handlePanelResizeStart,
|
|
30
31
|
handlePanelResizeMove,
|
|
@@ -49,6 +50,7 @@ export function PanelLayoutProvider({
|
|
|
49
50
|
setRightPanelTab,
|
|
50
51
|
rightInspectorPanes,
|
|
51
52
|
toggleRightInspectorPane,
|
|
53
|
+
setExclusiveRightInspectorPane,
|
|
52
54
|
toggleLeftSidebar,
|
|
53
55
|
handlePanelResizeStart,
|
|
54
56
|
handlePanelResizeMove,
|
|
@@ -67,6 +69,7 @@ export function PanelLayoutProvider({
|
|
|
67
69
|
setRightPanelTab,
|
|
68
70
|
rightInspectorPanes,
|
|
69
71
|
toggleRightInspectorPane,
|
|
72
|
+
setExclusiveRightInspectorPane,
|
|
70
73
|
toggleLeftSidebar,
|
|
71
74
|
handlePanelResizeStart,
|
|
72
75
|
handlePanelResizeMove,
|
|
@@ -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);
|
|
@@ -262,7 +262,7 @@ export interface PersistTimelineEditInput {
|
|
|
262
262
|
activeCompPath: string | null;
|
|
263
263
|
label: string;
|
|
264
264
|
buildPatches: (original: string, target: PatchTarget) => string;
|
|
265
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
265
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
266
266
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
267
267
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
268
268
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
@@ -308,7 +308,7 @@ export interface PersistTimelineBatchEditInput {
|
|
|
308
308
|
activeCompPath: string | null;
|
|
309
309
|
label: string;
|
|
310
310
|
changes: PersistTimelineBatchChange[];
|
|
311
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
311
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
312
312
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
313
313
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
314
314
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
@@ -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
|
+
}
|
|
@@ -34,7 +34,7 @@ interface RenderedDomEditCommits {
|
|
|
34
34
|
|
|
35
35
|
interface RenderDomEditCommitsOptions {
|
|
36
36
|
importedFontAssets?: ImportedFontAsset[];
|
|
37
|
-
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
37
|
+
writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
type FetchHandler = (
|
|
@@ -1050,6 +1050,46 @@ describe("useDomEditCommits style persist handling", () => {
|
|
|
1050
1050
|
}
|
|
1051
1051
|
});
|
|
1052
1052
|
|
|
1053
|
+
it("uses the patched server content as the custom-font write precondition", async () => {
|
|
1054
|
+
const patchedContent =
|
|
1055
|
+
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
|
|
1056
|
+
stubPatchFetch(
|
|
1057
|
+
{ ok: true, changed: true, matched: true, content: patchedContent },
|
|
1058
|
+
patchedContent,
|
|
1059
|
+
);
|
|
1060
|
+
const { iframe, element } = createPreviewElement();
|
|
1061
|
+
const selection = createSelection(element, {
|
|
1062
|
+
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1063
|
+
});
|
|
1064
|
+
const writeProjectFile = vi.fn(async () => {});
|
|
1065
|
+
const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
await act(async () => {
|
|
1069
|
+
await rendered.hook.commitDomTextFields(
|
|
1070
|
+
selection,
|
|
1071
|
+
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
|
1072
|
+
{
|
|
1073
|
+
importedFont: {
|
|
1074
|
+
family: "Imported",
|
|
1075
|
+
path: "fonts/Imported.woff2",
|
|
1076
|
+
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
expect(writeProjectFile).toHaveBeenCalledWith(
|
|
1083
|
+
"index.html",
|
|
1084
|
+
expect.stringContaining("@font-face"),
|
|
1085
|
+
patchedContent,
|
|
1086
|
+
);
|
|
1087
|
+
expect(rendered.showToast).not.toHaveBeenCalled();
|
|
1088
|
+
} finally {
|
|
1089
|
+
rendered.cleanup();
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1053
1093
|
it("keeps a rejected patch request (HTTP error) to one toast", async () => {
|
|
1054
1094
|
const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
|
|
1055
1095
|
const url = requestUrl(input);
|
|
@@ -46,7 +46,7 @@ export interface UseDomEditCommitsParams {
|
|
|
46
46
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
47
47
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
48
48
|
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
|
49
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
49
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
50
50
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
51
51
|
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
52
52
|
fileTree: string[];
|
|
@@ -245,7 +245,7 @@ export function useDomEditCommits({
|
|
|
245
245
|
const preparedContent = options.prepareContent(patchedContent, targetPath);
|
|
246
246
|
if (preparedContent !== patchedContent) {
|
|
247
247
|
try {
|
|
248
|
-
await writeProjectFile(targetPath, preparedContent);
|
|
248
|
+
await writeProjectFile(targetPath, preparedContent, patchedContent);
|
|
249
249
|
finalContent = preparedContent;
|
|
250
250
|
} catch (error) {
|
|
251
251
|
// The patch above already landed on disk — only the prepareContent
|
|
@@ -45,7 +45,7 @@ export interface UseDomEditSessionParams {
|
|
|
45
45
|
refreshPreviewDocumentVersion: () => void;
|
|
46
46
|
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
|
47
47
|
readProjectFile: (path: string) => Promise<string>;
|
|
48
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
48
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
49
49
|
updateEditingFileContent: (path: string, content: string) => void;
|
|
50
50
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
51
51
|
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
@@ -14,7 +14,7 @@ interface UseEditorSaveOptions {
|
|
|
14
14
|
editingPathRef: React.RefObject<string | undefined>;
|
|
15
15
|
projectIdRef: React.RefObject<string | null>;
|
|
16
16
|
readProjectFile: (path: string) => Promise<string>;
|
|
17
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
17
|
+
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
18
18
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
19
19
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
20
20
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
@@ -38,14 +38,17 @@ describe("useFileManager project ownership", () => {
|
|
|
38
38
|
resolveProjectARead = resolve;
|
|
39
39
|
});
|
|
40
40
|
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
|
41
|
+
if (url.endsWith("/files/missing.html") && !init?.method) {
|
|
42
|
+
return Promise.resolve({ ok: false, status: 404 } as Response);
|
|
43
|
+
}
|
|
41
44
|
if (url.includes("project-a") && !init?.method) return projectARead;
|
|
42
45
|
if (!init?.method) {
|
|
43
46
|
return Promise.resolve({
|
|
44
47
|
ok: true,
|
|
45
|
-
json: async () => ({ content: "PROJECT_B" }),
|
|
48
|
+
json: async () => ({ content: "PROJECT_B", version: "b-v1" }),
|
|
46
49
|
} as Response);
|
|
47
50
|
}
|
|
48
|
-
return Promise.resolve({ ok: true } as Response);
|
|
51
|
+
return Promise.resolve({ ok: true, json: async () => ({ version: "a-v2" }) } as Response);
|
|
49
52
|
});
|
|
50
53
|
vi.stubGlobal("fetch", fetchMock);
|
|
51
54
|
|
|
@@ -74,10 +77,11 @@ describe("useFileManager project ownership", () => {
|
|
|
74
77
|
|
|
75
78
|
resolveProjectARead?.({
|
|
76
79
|
ok: true,
|
|
77
|
-
json: async () => ({ content: "PROJECT_A" }),
|
|
80
|
+
json: async () => ({ content: "PROJECT_A", version: "a-v1" }),
|
|
78
81
|
} as Response);
|
|
79
82
|
await expect(delayedRead).resolves.toBe("PROJECT_A");
|
|
80
83
|
await managerA.writeProjectFile("index.html", "A_AFTER");
|
|
84
|
+
await managerA.writeProjectFile("missing.html", "A_NEW");
|
|
81
85
|
await expect(managerB.readProjectFile("index.html")).resolves.toBe("PROJECT_B");
|
|
82
86
|
await expect(managerB.readOptionalProjectFile("index.html")).resolves.toBe("PROJECT_B");
|
|
83
87
|
|
|
@@ -88,6 +92,13 @@ describe("useFileManager project ownership", () => {
|
|
|
88
92
|
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
|
89
93
|
expect.objectContaining({ method: "PUT", body: "A_AFTER" }),
|
|
90
94
|
);
|
|
95
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
96
|
+
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
|
97
|
+
);
|
|
98
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
99
|
+
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
|
100
|
+
expect.objectContaining({ method: "PUT", body: "A_NEW" }),
|
|
101
|
+
);
|
|
91
102
|
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
|
|
92
103
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
93
104
|
"/api/projects/project-b%23fragment/files/index.html?optional=1",
|