@hyperframes/studio 0.8.51 → 0.8.52
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-Cn_PpHqP.js → hyperframes-player-I5YRzB02.js} +1 -1
- package/dist/assets/{index-Clrb11lj.js → index-BTAfgesM.js} +1 -1
- package/dist/assets/index-CxAu_Okg.css +1 -0
- package/dist/assets/index-DocJlW2v.js +376 -0
- package/dist/assets/{index-CPS3Cvj8.js → index-Du5x7KUR.js} +1 -1
- package/dist/index.d.ts +401 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6545 -5869
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
- package/src/App.tsx +3 -2
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.tsx +109 -149
- package/src/components/StudioRightPanel.tsx +47 -35
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +3 -7
- package/src/components/dock/Dock.test.tsx +84 -16
- package/src/components/dock/Dock.tsx +19 -2
- package/src/components/dock/dock.css +5 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +53 -6
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +4 -2
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +3 -1
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewPane.tsx +1 -1
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/RenderQueue.tsx +1 -1
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +222 -30
- package/src/components/ui/Button.tsx +88 -125
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/hooks/useAppHotkeys.ts +20 -79
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/index.ts +51 -0
- package/src/player/components/AutomationSelectionMenu.tsx +1 -1
- package/src/player/components/TimelineCanvas.tsx +4 -4
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/theme.css +30 -0
- package/src/styles/theme.test.ts +2 -15
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -1
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-C05SNrvY.js +0 -360
- package/dist/assets/index-DUR9qzQM.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
|
@@ -3,6 +3,7 @@ import { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { expect, it, vi } from "vitest";
|
|
5
5
|
import { useCaptionDetection } from "./useCaptionDetection";
|
|
6
|
+
import { usePreviewIframeStore } from "../player/store/previewIframeStore";
|
|
6
7
|
|
|
7
8
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
9
|
|
|
@@ -52,3 +53,48 @@ it("does not let a foreign window trigger caption activation", () => {
|
|
|
52
53
|
vi.unstubAllGlobals();
|
|
53
54
|
}
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
it("re-runs caption activation on the promoted iframe's document", () => {
|
|
58
|
+
const host = document.createElement("div");
|
|
59
|
+
const a = document.createElement("iframe");
|
|
60
|
+
const b = document.createElement("iframe");
|
|
61
|
+
document.body.append(host, a, b);
|
|
62
|
+
const ref = { current: a };
|
|
63
|
+
const root = createRoot(host);
|
|
64
|
+
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
|
|
65
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
66
|
+
const params = {
|
|
67
|
+
projectId: "demo",
|
|
68
|
+
activeCompPath: "captions.html",
|
|
69
|
+
compIdToSrc: new Map<string, string>(),
|
|
70
|
+
captionEditMode: false,
|
|
71
|
+
captionHasSelection: false,
|
|
72
|
+
previewIframeRef: ref,
|
|
73
|
+
captionSync: { save: vi.fn(), loadOverrides: vi.fn(async () => {}) },
|
|
74
|
+
setRightCollapsed: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
function Harness() {
|
|
77
|
+
useCaptionDetection(params);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
act(() => usePreviewIframeStore.getState().setIframe(a));
|
|
82
|
+
act(() => root.render(<Harness />));
|
|
83
|
+
const group = b.contentDocument!.createElement("div");
|
|
84
|
+
group.className = "caption-group";
|
|
85
|
+
b.contentDocument!.body.append(group);
|
|
86
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
87
|
+
act(() => {
|
|
88
|
+
ref.current = b;
|
|
89
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
90
|
+
});
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
92
|
+
} finally {
|
|
93
|
+
act(() => root.unmount());
|
|
94
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
95
|
+
host.remove();
|
|
96
|
+
a.remove();
|
|
97
|
+
b.remove();
|
|
98
|
+
vi.unstubAllGlobals();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLivePreviewIframe } from "../player/store/previewIframeStore";
|
|
1
2
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
3
|
import { useEffect, useRef } from "react";
|
|
3
4
|
import { useCaptionStore } from "../captions/store";
|
|
@@ -26,6 +27,7 @@ export function useCaptionDetection({
|
|
|
26
27
|
captionSync,
|
|
27
28
|
setRightCollapsed,
|
|
28
29
|
}: UseCaptionDetectionParams) {
|
|
30
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
29
31
|
// Switching compositions must drop the previous comp's caption state — a
|
|
30
32
|
// stale model + full-canvas overlay otherwise blocks normal element editing
|
|
31
33
|
// on the new composition (and edit mode could never be exited).
|
|
@@ -145,7 +147,7 @@ export function useCaptionDetection({
|
|
|
145
147
|
return () => {
|
|
146
148
|
window.removeEventListener("message", handleMessage);
|
|
147
149
|
};
|
|
148
|
-
}, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef]);
|
|
150
|
+
}, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef, livePreviewIframe]);
|
|
149
151
|
|
|
150
152
|
// eslint-disable-next-line no-restricted-syntax
|
|
151
153
|
useEffect(() => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
7
|
+
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
let root: Root | null = null;
|
|
12
|
+
afterEach(() => act(() => root?.unmount()));
|
|
13
|
+
|
|
14
|
+
function mount(result: { ok: boolean; reason?: string; label?: string; paths?: string[] }) {
|
|
15
|
+
const editHistory = {
|
|
16
|
+
undo: vi.fn<EditHistoryHandle["undo"]>(async (cb) => {
|
|
17
|
+
if (result.ok) await cb.writeFile("index.html", "before");
|
|
18
|
+
return result;
|
|
19
|
+
}),
|
|
20
|
+
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
21
|
+
};
|
|
22
|
+
const deps = {
|
|
23
|
+
editHistory,
|
|
24
|
+
readOptionalProjectFile: vi.fn(async () => ""),
|
|
25
|
+
readProjectFile: vi.fn(async () => ""),
|
|
26
|
+
writeProjectFile: vi.fn(async () => undefined),
|
|
27
|
+
showToast: vi.fn(),
|
|
28
|
+
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
29
|
+
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
30
|
+
onAfterUndoRedo: vi.fn(),
|
|
31
|
+
activeCompPath: "index.html",
|
|
32
|
+
forceReloadSdkSession: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
let actions!: ReturnType<typeof useEditHistoryActions>;
|
|
35
|
+
function Probe() {
|
|
36
|
+
actions = useEditHistoryActions(deps);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
root = createRoot(document.createElement("div"));
|
|
40
|
+
act(() => root!.render(createElement(Probe)));
|
|
41
|
+
return { deps, actions };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("useEditHistoryActions", () => {
|
|
45
|
+
it("undo writes through the host writer, resyncs the preview and toasts the label", async () => {
|
|
46
|
+
const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
|
|
47
|
+
await act(() => actions.undo());
|
|
48
|
+
expect(deps.waitForPendingDomEditSaves).toHaveBeenCalled();
|
|
49
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("index.html", "before");
|
|
50
|
+
expect(deps.onAfterUndoRedo).toHaveBeenCalled();
|
|
51
|
+
expect(deps.forceReloadSdkSession).toHaveBeenCalled();
|
|
52
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalled();
|
|
53
|
+
expect(deps.showToast).toHaveBeenCalledWith("Undid Move clip", "info");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("redo reports the redone label and skips the SDK reload for other files", async () => {
|
|
57
|
+
const { deps, actions } = mount({ ok: true, label: "Split clip", paths: ["other.html"] });
|
|
58
|
+
await act(() => actions.redo());
|
|
59
|
+
expect(deps.forceReloadSdkSession).not.toHaveBeenCalled();
|
|
60
|
+
expect(deps.showToast).toHaveBeenCalledWith("Redid Split clip", "info");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("explains a refused undo when the file changed on disk", async () => {
|
|
64
|
+
const { deps, actions } = mount({ ok: false, reason: "content-mismatch" });
|
|
65
|
+
await act(() => actions.undo());
|
|
66
|
+
expect(deps.showToast).toHaveBeenCalledWith(
|
|
67
|
+
"File changed outside Studio. Undo history was not applied.",
|
|
68
|
+
"info",
|
|
69
|
+
);
|
|
70
|
+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("waits for pending saves first and reads the motion file through the optional reader", async () => {
|
|
74
|
+
const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
|
|
75
|
+
const order: string[] = [];
|
|
76
|
+
deps.waitForPendingDomEditSaves.mockImplementation(async () => void order.push("wait"));
|
|
77
|
+
deps.editHistory.undo.mockImplementation(async (cb) => {
|
|
78
|
+
order.push("undo");
|
|
79
|
+
await cb.readFile(STUDIO_MOTION_PATH);
|
|
80
|
+
await cb.readFile("index.html");
|
|
81
|
+
await cb.serialize?.(["index.html"], async () => order.push("serialized"));
|
|
82
|
+
return { ok: true };
|
|
83
|
+
});
|
|
84
|
+
await act(() => actions.undo());
|
|
85
|
+
expect(order).toEqual(["wait", "undo", "serialized"]);
|
|
86
|
+
expect(deps.readOptionalProjectFile).toHaveBeenCalledWith(STUDIO_MOTION_PATH);
|
|
87
|
+
expect(deps.readProjectFile).toHaveBeenCalledWith("index.html");
|
|
88
|
+
expect(deps.readProjectFile).not.toHaveBeenCalledWith(STUDIO_MOTION_PATH);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// fallow-ignore-file complexity
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
|
+
|
|
6
|
+
interface HistoryResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
paths?: string[];
|
|
11
|
+
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
12
|
+
files?: Record<string, { previous: string; restored: string }>;
|
|
13
|
+
}
|
|
14
|
+
interface HistoryFileCallbacks {
|
|
15
|
+
readFile: (path: string) => Promise<string>;
|
|
16
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
17
|
+
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
export interface EditHistoryHandle {
|
|
20
|
+
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
21
|
+
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
22
|
+
state: {
|
|
23
|
+
undo: ReadonlyArray<{ createdAt: number }>;
|
|
24
|
+
redo: ReadonlyArray<{ createdAt: number }>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseEditHistoryActionsOptions {
|
|
29
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
|
|
30
|
+
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
31
|
+
readProjectFile: (path: string) => Promise<string>;
|
|
32
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
33
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
|
+
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
35
|
+
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
|
+
onAfterUndoRedo?: () => void;
|
|
37
|
+
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
38
|
+
activeCompPath?: string | null;
|
|
39
|
+
/** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
|
|
40
|
+
forceReloadSdkSession?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Applies one persisted file-history step: the single owner of undo/redo over project files. */
|
|
44
|
+
export function useEditHistoryActions({
|
|
45
|
+
editHistory,
|
|
46
|
+
readOptionalProjectFile,
|
|
47
|
+
readProjectFile,
|
|
48
|
+
writeProjectFile,
|
|
49
|
+
showToast,
|
|
50
|
+
syncHistoryPreviewAfterApply,
|
|
51
|
+
waitForPendingDomEditSaves,
|
|
52
|
+
onAfterUndoRedo,
|
|
53
|
+
activeCompPath,
|
|
54
|
+
forceReloadSdkSession,
|
|
55
|
+
}: UseEditHistoryActionsOptions) {
|
|
56
|
+
const readHistoryFile = useCallback(
|
|
57
|
+
(path: string): Promise<string> =>
|
|
58
|
+
path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
|
|
59
|
+
[readOptionalProjectFile, readProjectFile],
|
|
60
|
+
);
|
|
61
|
+
const serializeHistoryFiles = useCallback(
|
|
62
|
+
<T>(paths: readonly string[], task: () => Promise<T>) =>
|
|
63
|
+
serializeStudioFileMutations(writeProjectFile, paths, task),
|
|
64
|
+
[writeProjectFile],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const apply = useCallback(
|
|
68
|
+
async (direction: "undo" | "redo") => {
|
|
69
|
+
const [noun, verb] = direction === "undo" ? ["Undo", "Undid"] : ["Redo", "Redid"];
|
|
70
|
+
await waitForPendingDomEditSaves();
|
|
71
|
+
const result = await editHistory[direction]({
|
|
72
|
+
readFile: readHistoryFile,
|
|
73
|
+
writeFile: writeProjectFile,
|
|
74
|
+
serialize: serializeHistoryFiles,
|
|
75
|
+
});
|
|
76
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
77
|
+
showToast(`File changed outside Studio. ${noun} history was not applied.`, "info");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (result.ok && result.label) {
|
|
81
|
+
onAfterUndoRedo?.();
|
|
82
|
+
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
83
|
+
forceReloadSdkSession?.();
|
|
84
|
+
}
|
|
85
|
+
await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
|
|
86
|
+
showToast(`${verb} ${result.label}`, "info");
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[
|
|
90
|
+
editHistory,
|
|
91
|
+
readHistoryFile,
|
|
92
|
+
showToast,
|
|
93
|
+
syncHistoryPreviewAfterApply,
|
|
94
|
+
waitForPendingDomEditSaves,
|
|
95
|
+
writeProjectFile,
|
|
96
|
+
serializeHistoryFiles,
|
|
97
|
+
onAfterUndoRedo,
|
|
98
|
+
activeCompPath,
|
|
99
|
+
forceReloadSdkSession,
|
|
100
|
+
],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const undo = useCallback(() => apply("undo"), [apply]);
|
|
104
|
+
const redo = useCallback(() => apply("redo"), [apply]);
|
|
105
|
+
return useMemo(() => ({ undo, redo }), [undo, redo]);
|
|
106
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useServerConnection } from "./useServerConnection";
|
|
6
|
+
|
|
7
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
const captured: { projectId: string | null } = { projectId: null };
|
|
10
|
+
|
|
11
|
+
function Probe() {
|
|
12
|
+
captured.projectId = useServerConnection().projectId;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function flush(): Promise<void> {
|
|
17
|
+
await act(async () => {
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The hash project id outlives the project it names — a renamed folder, or a
|
|
24
|
+
* bookmark from a project that is gone. It used to be trusted unconditionally,
|
|
25
|
+
* so every later /api/projects/<id>/... request 404'd for the life of the tab,
|
|
26
|
+
* including the composition read that opens the SDK session. Telemetry after
|
|
27
|
+
* the read-reason split: 120 http_error/404 reads across 5 users in 24h.
|
|
28
|
+
*/
|
|
29
|
+
function stubFetch(projectRoute: { status: number } | "reject") {
|
|
30
|
+
vi.stubGlobal(
|
|
31
|
+
"fetch",
|
|
32
|
+
vi.fn(async (url: string) => {
|
|
33
|
+
if (url === "/api/projects") {
|
|
34
|
+
return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response;
|
|
35
|
+
}
|
|
36
|
+
if (projectRoute === "reject") throw new TypeError("Failed to fetch");
|
|
37
|
+
return { ok: projectRoute.status === 200, status: projectRoute.status } as Response;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function renderWithHash(hash: string) {
|
|
43
|
+
window.location.hash = hash;
|
|
44
|
+
const root = createRoot(document.createElement("div"));
|
|
45
|
+
await act(async () => root.render(<Probe />));
|
|
46
|
+
await flush();
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("useServerConnection hash project id", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
captured.projectId = null;
|
|
53
|
+
window.location.hash = "";
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a hash id the server can resolve", async () => {
|
|
61
|
+
stubFetch({ status: 200 });
|
|
62
|
+
const root = await renderWithHash("#project/stale-or-not");
|
|
63
|
+
|
|
64
|
+
expect(captured.projectId).toBe("stale-or-not");
|
|
65
|
+
await act(async () => root.unmount());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to the first project when the hash id is gone", async () => {
|
|
69
|
+
stubFetch({ status: 404 });
|
|
70
|
+
const root = await renderWithHash("#project/deleted-project");
|
|
71
|
+
|
|
72
|
+
expect(captured.projectId).toBe("real-project");
|
|
73
|
+
expect(window.location.hash).toContain("real-project");
|
|
74
|
+
await act(async () => root.unmount());
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The important one: a blip must not rewrite the user's hash out from under a
|
|
78
|
+
// project that is actually fine. Only a definite 404 is "missing".
|
|
79
|
+
it("keeps the hash id when the check itself fails", async () => {
|
|
80
|
+
stubFetch("reject");
|
|
81
|
+
const root = await renderWithHash("#project/unreachable-check");
|
|
82
|
+
|
|
83
|
+
expect(captured.projectId).toBe("unreachable-check");
|
|
84
|
+
await act(async () => root.unmount());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the hash id on a non-404 error status", async () => {
|
|
88
|
+
stubFetch({ status: 500 });
|
|
89
|
+
const root = await renderWithHash("#project/server-erroring");
|
|
90
|
+
|
|
91
|
+
expect(captured.projectId).toBe("server-erroring");
|
|
92
|
+
await act(async () => root.unmount());
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -18,6 +18,26 @@ interface ServerConnectionState {
|
|
|
18
18
|
* Polls every 2 s until the server responds, then transitions automatically.
|
|
19
19
|
* Cleans up pending timers on unmount so it is safe under React StrictMode.
|
|
20
20
|
*/
|
|
21
|
+
/**
|
|
22
|
+
* Whether a hash-supplied project id still names something the server can
|
|
23
|
+
* resolve. Three answers, not two: a failed request must NOT be read as
|
|
24
|
+
* "missing", or one network blip would discard a perfectly good deep link.
|
|
25
|
+
*
|
|
26
|
+
* Resolved through `/api/projects/:id`, which calls the same
|
|
27
|
+
* `adapter.resolveProject` the file routes use. A match against the
|
|
28
|
+
* `/api/projects` list would be wrong twice over: that list omits session ids
|
|
29
|
+
* (which resolve fine) and skips project dirs without an `index.html`.
|
|
30
|
+
*/
|
|
31
|
+
async function resolveHashProject(id: string): Promise<"ok" | "missing" | "unknown"> {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
|
34
|
+
if (res.ok) return "ok";
|
|
35
|
+
return res.status === 404 ? "missing" : "unknown";
|
|
36
|
+
} catch {
|
|
37
|
+
return "unknown";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export function useServerConnection(): ServerConnectionState {
|
|
22
42
|
const [projectId, setProjectId] = useState<string | null>(null);
|
|
23
43
|
const [resolving, setResolving] = useState(true);
|
|
@@ -39,21 +59,34 @@ export function useServerConnection(): ServerConnectionState {
|
|
|
39
59
|
function tryConnect() {
|
|
40
60
|
fetch("/api/projects")
|
|
41
61
|
.then((r) => r.json())
|
|
42
|
-
.then((data) => {
|
|
62
|
+
.then(async (data) => {
|
|
43
63
|
if (cancelled) return;
|
|
64
|
+
// A hash project id outlives the project it names — a renamed folder,
|
|
65
|
+
// or a bookmark from a project that is gone. Trusting it blindly made
|
|
66
|
+
// every later /api/projects/<id>/... request 404 for the life of the
|
|
67
|
+
// tab, including the composition read that opens the SDK session, so
|
|
68
|
+
// every edit fell back to the server path with nothing to show why.
|
|
69
|
+
// Telemetry after the read-reason split: 120 http_error/404 reads
|
|
70
|
+
// across 5 users in 24h, ~24 each, never recovering.
|
|
44
71
|
if (hashProjectId) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
setProjectId(
|
|
72
|
+
const state = await resolveHashProject(hashProjectId);
|
|
73
|
+
if (cancelled) return;
|
|
74
|
+
// "unknown" keeps the old behaviour: a transient failure must not
|
|
75
|
+
// rewrite the user's hash out from under a valid project.
|
|
76
|
+
if (state !== "missing") {
|
|
77
|
+
setProjectId(hashProjectId);
|
|
51
78
|
setWaitingForServer(false);
|
|
52
|
-
|
|
53
|
-
} else {
|
|
54
|
-
scheduleRetry();
|
|
79
|
+
return;
|
|
55
80
|
}
|
|
56
81
|
}
|
|
82
|
+
const first = (data.projects ?? [])[0];
|
|
83
|
+
if (first) {
|
|
84
|
+
setProjectId(first.id);
|
|
85
|
+
setWaitingForServer(false);
|
|
86
|
+
window.location.hash = buildProjectHash(first.id);
|
|
87
|
+
} else {
|
|
88
|
+
scheduleRetry();
|
|
89
|
+
}
|
|
57
90
|
})
|
|
58
91
|
.catch(() => {
|
|
59
92
|
if (!cancelled) scheduleRetry();
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
|
+
// UI primitives
|
|
2
|
+
export { Button, buttonBase, buttonVariants } from "./components/ui/Button";
|
|
3
|
+
export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
|
|
4
|
+
export { IconButton } from "./components/ui/IconButton";
|
|
5
|
+
export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
|
|
6
|
+
export { Tooltip } from "./components/ui/Tooltip";
|
|
7
|
+
export { cn } from "./components/ui/cn";
|
|
8
|
+
export {
|
|
9
|
+
ContextMenu,
|
|
10
|
+
Menu,
|
|
11
|
+
MenuItem,
|
|
12
|
+
MenuRadioGroup,
|
|
13
|
+
MenuRadioItem,
|
|
14
|
+
MenuSeparator,
|
|
15
|
+
MenuShortcut,
|
|
16
|
+
popupSurface,
|
|
17
|
+
} from "./components/ui/Menu";
|
|
18
|
+
export type { MenuItemTone, PopupPreviewState } from "./components/ui/Menu";
|
|
19
|
+
export { Popover } from "./components/ui/Popover";
|
|
20
|
+
export { Input, fieldBase, fieldText } from "./components/ui/Input";
|
|
21
|
+
export type { InputProps } from "./components/ui/Input";
|
|
22
|
+
export { NumberField } from "./components/ui/NumberField";
|
|
23
|
+
export type { NumberFieldProps } from "./components/ui/NumberField";
|
|
24
|
+
export { Select } from "./components/ui/Select";
|
|
25
|
+
export type { SelectOption, SelectProps } from "./components/ui/Select";
|
|
26
|
+
export { Slider } from "./components/ui/Slider";
|
|
27
|
+
export type { SliderProps } from "./components/ui/Slider";
|
|
28
|
+
export { Toggle } from "./components/ui/Toggle";
|
|
29
|
+
export type { ToggleProps } from "./components/ui/Toggle";
|
|
30
|
+
|
|
1
31
|
// NLE Layout
|
|
2
32
|
export { EditorShell } from "./components/EditorShell";
|
|
33
|
+
export type { EditorShellProps } from "./components/EditorShell";
|
|
3
34
|
export { NLEPreview } from "./components/nle/NLEPreview";
|
|
4
35
|
export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
|
|
5
36
|
export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
|
|
@@ -19,6 +50,10 @@ export {
|
|
|
19
50
|
} from "./player";
|
|
20
51
|
export type { TimelineElement } from "./player";
|
|
21
52
|
|
|
53
|
+
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
54
|
+
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
55
|
+
export type { PreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
|
56
|
+
|
|
22
57
|
// Editor
|
|
23
58
|
export { SourceEditor } from "./components/editor/SourceEditor";
|
|
24
59
|
export { PropertyPanel } from "./components/editor/PropertyPanel";
|
|
@@ -37,6 +72,22 @@ export {
|
|
|
37
72
|
} from "./components/editor/domEditingAgentPrompt";
|
|
38
73
|
export type { AgentPromptElementInfo } from "./components/editor/domEditingAgentPrompt";
|
|
39
74
|
|
|
75
|
+
// Render queue
|
|
76
|
+
export { RenderQueue } from "./components/renders/RenderQueue";
|
|
77
|
+
export type { RenderQueueProps, CompositionDimensions } from "./components/renders/RenderQueue";
|
|
78
|
+
export { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
79
|
+
export type { FfmpegStatus } from "./components/renders/useFfmpegStatus";
|
|
80
|
+
export type {
|
|
81
|
+
RenderJob,
|
|
82
|
+
ResolutionPreset,
|
|
83
|
+
StartRenderOptions,
|
|
84
|
+
} from "./components/renders/useRenderQueue";
|
|
85
|
+
export {
|
|
86
|
+
getPersistedRenderSettings,
|
|
87
|
+
persistRenderSettings,
|
|
88
|
+
} from "./components/renders/renderSettings";
|
|
89
|
+
export type { PersistedRenderSettings } from "./components/renders/renderSettings";
|
|
90
|
+
|
|
40
91
|
// Hooks
|
|
41
92
|
export { useElementPicker } from "./hooks/useElementPicker";
|
|
42
93
|
export type { PickedElement } from "./hooks/useElementPicker";
|
|
@@ -29,7 +29,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
|
|
|
29
29
|
}: AutomationSelectionMenuProps) {
|
|
30
30
|
const menuRef = useContextMenuDismiss(onClose);
|
|
31
31
|
const row =
|
|
32
|
-
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-
|
|
32
|
+
"block w-full px-2 py-1 text-left text-[11px] text-panel-text-1 hover:bg-panel-hover disabled:opacity-40";
|
|
33
33
|
// Same edge-clamping precedent as TrackGapContextMenu: without it a
|
|
34
34
|
// right-click near the bottom/right of the timeline renders this menu
|
|
35
35
|
// partially off-screen.
|
|
@@ -179,8 +179,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
179
179
|
left: props.contentOrigin + draggedClip.previewStart * props.pps,
|
|
180
180
|
width: Math.max(draggedClip.element.duration * props.pps, 4),
|
|
181
181
|
height: draggedClipHeight,
|
|
182
|
-
border: "1px solid
|
|
183
|
-
background: "
|
|
182
|
+
border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
|
|
183
|
+
background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
|
|
184
184
|
borderRadius: 4,
|
|
185
185
|
zIndex: 30,
|
|
186
186
|
}}
|
|
@@ -199,8 +199,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
|
|
|
199
199
|
left: props.contentOrigin + props.dropPreview.start * props.pps,
|
|
200
200
|
width: DROP_PREVIEW_SECONDS * props.pps,
|
|
201
201
|
height: TRACK_H - CLIP_Y * 2,
|
|
202
|
-
border: "1px solid
|
|
203
|
-
background: "
|
|
202
|
+
border: "1px solid color-mix(in srgb, var(--color-accent) 55%, transparent)",
|
|
203
|
+
background: "color-mix(in srgb, var(--color-accent) 12%, transparent)",
|
|
204
204
|
borderRadius: 4,
|
|
205
205
|
zIndex: 30,
|
|
206
206
|
}}
|
|
@@ -71,7 +71,6 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
71
71
|
isSelected ? "is-selected" : "",
|
|
72
72
|
isHovered ? "is-hovered" : "",
|
|
73
73
|
isDragging ? "is-dragging" : "",
|
|
74
|
-
showDefaultText ? "" : "is-micro",
|
|
75
74
|
isAudioTimelineElement(el) ? "is-audio" : "",
|
|
76
75
|
]
|
|
77
76
|
.filter((className) => className.length > 0)
|
|
@@ -17,6 +17,8 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
|
17
17
|
"[role='checkbox']",
|
|
18
18
|
"[role='combobox']",
|
|
19
19
|
"[role='menuitem']",
|
|
20
|
+
// Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
|
|
21
|
+
"[role='menuitemradio']",
|
|
20
22
|
"[role='radio']",
|
|
21
23
|
"[role='slider']",
|
|
22
24
|
"[role='spinbutton']",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, StrictMode, useCallback, useEffect, useRef } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { useOwnPreviewIframe, usePreviewIframeStore } from "./previewIframeStore";
|
|
6
|
+
|
|
7
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
|
+
|
|
9
|
+
// Mirrors NLEContext.tsx: a ref callback pushes the element on attach
|
|
10
|
+
// (~L126, onIframeLoad) and a separate mount/dep effect re-pushes the SAME
|
|
11
|
+
// ref value (~L304-306, deps include an unrelated counter like refreshKey).
|
|
12
|
+
function Child({ tick }: { tick: number }) {
|
|
13
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
14
|
+
const setIframe = usePreviewIframeStore((s) => s.setIframe);
|
|
15
|
+
|
|
16
|
+
const handleRef = useCallback(
|
|
17
|
+
(el: HTMLIFrameElement | null) => {
|
|
18
|
+
iframeRef.current = el;
|
|
19
|
+
setIframe(el);
|
|
20
|
+
},
|
|
21
|
+
[setIframe],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setIframe(iframeRef.current);
|
|
26
|
+
}, [tick, setIframe]);
|
|
27
|
+
|
|
28
|
+
return <iframe ref={handleRef} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Owner({ tick }: { tick: number }) {
|
|
32
|
+
useOwnPreviewIframe();
|
|
33
|
+
return <Child tick={tick} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("does not notify raw subscribers for a repeated setIframe(sameValue)", () => {
|
|
37
|
+
let notifyCount = 0;
|
|
38
|
+
const unsubscribe = usePreviewIframeStore.subscribe(() => {
|
|
39
|
+
notifyCount += 1;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const container = document.createElement("div");
|
|
43
|
+
const root = createRoot(container);
|
|
44
|
+
act(() => {
|
|
45
|
+
root.render(
|
|
46
|
+
<StrictMode>
|
|
47
|
+
<Owner tick={0} />
|
|
48
|
+
</StrictMode>,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
// A dep bump on the mount effect with the iframe DOM node unchanged -
|
|
52
|
+
// the shape NLEContext hits every time refreshKey moves without a new
|
|
53
|
+
// <iframe>.
|
|
54
|
+
act(() => {
|
|
55
|
+
root.render(
|
|
56
|
+
<StrictMode>
|
|
57
|
+
<Owner tick={1} />
|
|
58
|
+
</StrictMode>,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const iframe = usePreviewIframeStore.getState().iframe;
|
|
63
|
+
expect(iframe).not.toBeNull();
|
|
64
|
+
|
|
65
|
+
// 3 real Object.is-distinct transitions happen here: the ref-callback's
|
|
66
|
+
// null -> element attach, then StrictMode's mount-effect replay drives
|
|
67
|
+
// useOwnPreviewIframe's unmount cleanup (element -> null) and the
|
|
68
|
+
// re-run mount effect (null -> element) once each. Measured on the
|
|
69
|
+
// unfixed store: 7 (four of those are the SAME value re-pushed by the
|
|
70
|
+
// ref callback's own redundant effect run and the tick-bump update).
|
|
71
|
+
expect(notifyCount).toBe(3);
|
|
72
|
+
|
|
73
|
+
act(() => root.unmount());
|
|
74
|
+
unsubscribe();
|
|
75
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { useOwnPreviewIframe, usePreviewIframeStore } from "./previewIframeStore";
|
|
6
|
+
|
|
7
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
|
+
|
|
9
|
+
it("clears the store on owner unmount so a remount starts from null", () => {
|
|
10
|
+
const seen: Array<HTMLIFrameElement | null> = [];
|
|
11
|
+
const Owner = () => {
|
|
12
|
+
seen.push(useOwnPreviewIframe());
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
const iframe = document.createElement("iframe");
|
|
16
|
+
const first = createRoot(document.createElement("div"));
|
|
17
|
+
act(() => first.render(<Owner />));
|
|
18
|
+
act(() => usePreviewIframeStore.getState().setIframe(iframe));
|
|
19
|
+
expect(seen.at(-1)).toBe(iframe);
|
|
20
|
+
|
|
21
|
+
act(() => first.unmount());
|
|
22
|
+
expect(usePreviewIframeStore.getState().iframe).toBeNull();
|
|
23
|
+
|
|
24
|
+
seen.length = 0;
|
|
25
|
+
const second = createRoot(document.createElement("div"));
|
|
26
|
+
act(() => second.render(<Owner />));
|
|
27
|
+
expect(seen).toEqual([null]);
|
|
28
|
+
act(() => second.unmount());
|
|
29
|
+
});
|