fix(studio): persist studio state in project URLs (#836)

This commit is contained in:
Phuong Le
2026-05-14 16:56:13 +02:00
committed by GitHub
parent 3aa5cf3ab3
commit e59089bf75
15 changed files with 850 additions and 46 deletions
+51 -27
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
@@ -20,6 +20,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
import { useLintModal } from "./hooks/useLintModal";
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
import { useToast } from "./hooks/useToast";
import { useStudioUrlState } from "./hooks/useStudioUrlState";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MOTION_PANEL_ENABLED,
@@ -27,6 +28,7 @@ import {
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
import type { DomEditSelection } from "./components/editor/domEditing";
import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
import { StudioHeader } from "./components/StudioHeader";
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
import { StudioPreviewArea } from "./components/StudioPreviewArea";
@@ -38,11 +40,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash";
import { useServerConnection } from "./hooks/useServerConnection";
import {
normalizeStudioCompositionPath,
readStudioUrlStateFromWindow,
} from "./utils/studioUrlState";
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null,
);
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
const [compositionLoading, setCompositionLoading] = useState(true);
@@ -80,7 +90,10 @@ export function StudioApp() {
}, []);
const [timelineVisible, setTimelineVisible] = useState(
() => readStudioUiPreferences().timelineVisible ?? true,
() =>
initialUrlStateRef.current.timelineVisible ??
readStudioUiPreferences().timelineVisible ??
true,
);
const toggleTimelineVisibility = useCallback(() => {
setTimelineVisible((v) => {
@@ -89,7 +102,10 @@ export function StudioApp() {
});
}, []);
const { appToast, showToast } = useToast();
const panelLayout = usePanelLayout();
const panelLayout = usePanelLayout({
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
});
const editHistory = usePersistentEditHistory({ projectId });
const domEditSaveTimestampRef = useRef(0);
const reloadPreview = useCallback(() => {
@@ -108,6 +124,18 @@ export function StudioApp() {
setRefreshKey,
});
useEffect(() => {
if (activeCompPathHydrated) return;
if (!fileManager.fileTreeLoaded) return;
const nextCompPath = normalizeStudioCompositionPath(
initialUrlStateRef.current.activeCompPath,
fileManager.fileTree,
);
setActiveCompPath((current) => (current === nextCompPath ? current : nextCompPath));
setActiveCompPathHydrated(true);
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
const manifestPersistence = useManifestPersistence({
projectId,
showToast,
@@ -284,6 +312,25 @@ export function StudioApp() {
const inspectorButtonActive =
STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
useStudioUrlState({
projectId,
activeCompPath,
currentTime,
duration: effectiveTimelineDuration,
isPlaying,
compositionLoading,
refreshKey,
previewIframeRef,
rightPanelTab: panelLayout.rightPanelTab,
rightCollapsed: panelLayout.rightCollapsed,
timelineVisible,
activeCompPathHydrated,
domEditSelection: domEditSession.domEditSelection,
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
applyDomSelection: domEditSession.applyDomSelection,
initialState: initialUrlStateRef.current,
});
// StudioProvider performs its own useMemo — no need for a second memo here.
const studioCtxValue: StudioContextValue = {
projectId: projectId!,
@@ -420,30 +467,7 @@ export function StudioApp() {
/>
)}
{globalDragOver && (
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-studio-accent"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-sm font-medium text-studio-accent">
Drop files to import into project
</span>
</div>
</div>
)}
{globalDragOver && <StudioGlobalDragOverlay />}
{appToast && (
<div
@@ -0,0 +1,26 @@
export function StudioGlobalDragOverlay() {
return (
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-studio-accent"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-sm font-medium text-studio-accent">
Drop files to import into project
</span>
</div>
</div>
);
}
@@ -44,6 +44,7 @@ export function DomEditProvider({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
@@ -89,6 +90,7 @@ export function DomEditProvider({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
@@ -128,6 +130,7 @@ export function DomEditProvider({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
@@ -17,6 +17,7 @@ export function FileManagerProvider({
setEditingFile,
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
editingPathRef,
projectIdRef,
@@ -52,6 +53,7 @@ export function FileManagerProvider({
setEditingFile,
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
editingPathRef,
projectIdRef,
@@ -81,6 +83,7 @@ export function FileManagerProvider({
setEditingFile,
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
editingPathRef,
projectIdRef,
@@ -333,6 +333,7 @@ export function useDomEditSession({
handleBlockedDomMove,
handleDomManualDragStart,
handleDomEditElementDelete,
buildDomSelectionFromTarget,
buildDomSelectionForTimelineElement,
updateDomEditHoverSelection,
resolveImportedFontAsset,
+10 -1
View File
@@ -36,6 +36,7 @@ export function useFileManager({
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
const [projectDir, setProjectDir] = useState<string | null>(null);
const [fileTree, setFileTree] = useState<string[]>([]);
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
// ── Refs ──
@@ -53,8 +54,12 @@ export function useFileManager({
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!projectId) return;
if (!projectId) {
setFileTreeLoaded(false);
return;
}
let cancelled = false;
setFileTreeLoaded(false);
fetch(`/api/projects/${projectId}`)
.then((r) => r.json())
.then((data: { files?: string[]; dir?: string }) => {
@@ -63,6 +68,9 @@ export function useFileManager({
})
.catch(() => {
if (!cancelled) setProjectDir(null);
})
.finally(() => {
if (!cancelled) setFileTreeLoaded(true);
});
return () => {
cancelled = true;
@@ -396,6 +404,7 @@ export function useFileManager({
setEditingFile,
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
// Refs
+10 -3
View File
@@ -2,14 +2,21 @@ import { useState, useCallback, useRef } from "react";
import type { RightPanelTab } from "../utils/studioHelpers";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences";
export function usePanelLayout() {
export interface InitialPanelLayoutState {
rightCollapsed?: boolean | null;
rightPanelTab?: RightPanelTab | null;
}
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
const [leftWidth, setLeftWidth] = useState(240);
const [rightWidth, setRightWidth] = useState(400);
const [leftCollapsed, setLeftCollapsed] = useState(
() => readStudioUiPreferences().leftCollapsed ?? false,
);
const [rightCollapsed, setRightCollapsed] = useState(true);
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true);
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
initialState?.rightPanelTab ?? "renders",
);
const panelDragRef = useRef<{
side: "left" | "right";
startX: number;
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useRef } from "react";
import { usePlayerStore } from "../player";
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
import { clampNumber, type RightPanelTab } from "../utils/studioHelpers";
import {
buildStudioHash,
type StudioUrlSelectionState,
type StudioUrlState,
} from "../utils/studioUrlState";
interface UseStudioUrlStateParams {
projectId: string | null;
activeCompPath: string | null;
currentTime: number;
duration: number;
isPlaying: boolean;
compositionLoading: boolean;
refreshKey: number;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
rightPanelTab: RightPanelTab;
rightCollapsed: boolean;
timelineVisible: boolean;
activeCompPathHydrated: boolean;
domEditSelection: DomEditSelection | null;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
options?: {
revealPanel?: boolean;
additive?: boolean;
preserveGroup?: boolean;
},
) => void;
initialState: StudioUrlState;
}
function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSelectionState | null {
if (!selection) return null;
if (!selection.id && !selection.selector) return null;
return {
sourceFile: selection.sourceFile || undefined,
id: selection.id || undefined,
selector: selection.selector || undefined,
selectorIndex: selection.selectorIndex ?? undefined,
};
}
function replaceHash(nextHash: string) {
if (typeof window === "undefined") return;
if (window.location.hash === nextHash) return;
window.history.replaceState(null, "", nextHash);
}
export function useStudioUrlState({
projectId,
activeCompPath,
currentTime,
duration,
isPlaying,
compositionLoading,
refreshKey,
previewIframeRef,
rightPanelTab,
rightCollapsed,
timelineVisible,
activeCompPathHydrated,
domEditSelection,
buildDomSelectionFromTarget,
applyDomSelection,
initialState,
}: UseStudioUrlStateParams) {
const hydratedSeekRef = useRef(initialState.currentTime == null);
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
const hydratedSelectionRef = useRef(initialState.selection == null);
const pendingSelectionRef = useRef(initialState.selection);
const stableTimeRef = useRef<number | null>(initialState.currentTime);
const buildUrlState = useCallback(
(): StudioUrlState => ({
activeCompPath,
currentTime: stableTimeRef.current,
rightPanelTab,
rightCollapsed,
timelineVisible,
selection: hydratedSelectionRef.current
? toPersistedSelection(domEditSelection)
: pendingSelectionRef.current,
}),
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
);
useEffect(() => {
if (!projectId || hydratedSeekRef.current || compositionLoading) return;
const nextTime =
duration > 0
? clampNumber(initialState.currentTime ?? 0, 0, duration)
: Math.max(0, initialState.currentTime ?? 0);
usePlayerStore.getState().requestSeek(nextTime);
stableTimeRef.current = nextTime;
hydratedSeekRef.current = true;
}, [projectId, compositionLoading, duration, initialState.currentTime]);
useEffect(() => {
if (!projectId || hydratedSelectionRef.current || compositionLoading) return;
if (!hydratedSeekRef.current) return;
const targetTime = initialState.currentTime;
if (targetTime != null && Math.abs(currentTime - stableTimeRef.current!) > 0.05) return;
const pendingSelection = pendingSelectionRef.current;
if (!pendingSelection) {
hydratedSelectionRef.current = true;
return;
}
let doc: Document | null = null;
try {
doc = previewIframeRef.current?.contentDocument ?? null;
} catch {
return;
}
if (!doc) return;
const element = findElementForSelection(
doc,
{
sourceFile: pendingSelection.sourceFile ?? "",
id: pendingSelection.id,
selector: pendingSelection.selector,
selectorIndex: pendingSelection.selectorIndex,
},
activeCompPath,
);
if (!element) {
applyDomSelection(null, { revealPanel: false });
hydratedSelectionRef.current = true;
pendingSelectionRef.current = null;
return;
}
const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
applyDomSelection(selection, { revealPanel: false });
hydratedSelectionRef.current = true;
pendingSelectionRef.current = null;
}, [
activeCompPath,
applyDomSelection,
buildDomSelectionFromTarget,
compositionLoading,
currentTime,
initialState.currentTime,
previewIframeRef,
projectId,
refreshKey,
]);
useEffect(() => {
if (hydratedInitialTimeRef.current) return;
const targetTime = stableTimeRef.current;
if (targetTime == null) {
hydratedInitialTimeRef.current = true;
return;
}
if (Math.abs(currentTime - targetTime) > 0.05) return;
hydratedInitialTimeRef.current = true;
}, [currentTime]);
useEffect(() => {
if (!activeCompPathHydrated) return;
if (!hydratedSeekRef.current) return;
if (!hydratedInitialTimeRef.current) return;
if (!projectId || isPlaying) return;
const handle = window.setTimeout(() => {
stableTimeRef.current = clampNumber(currentTime, 0, Math.max(0, duration));
replaceHash(buildStudioHash(projectId, buildUrlState()));
}, 200);
return () => window.clearTimeout(handle);
}, [activeCompPathHydrated, buildUrlState, currentTime, duration, isPlaying, projectId]);
useEffect(() => {
if (!activeCompPathHydrated) return;
if (!projectId) return;
replaceHash(buildStudioHash(projectId, buildUrlState()));
}, [activeCompPathHydrated, buildUrlState, projectId]);
}
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { useTimelinePlayer } from "./useTimelinePlayer";
import { liveTime, usePlayerStore } from "../store/playerStore";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function resetPlayerStore() {
usePlayerStore.getState().reset();
usePlayerStore.setState({ requestedSeekTime: null });
}
function TimelinePlayerHarness({
onValue,
}: {
onValue: (value: ReturnType<typeof useTimelinePlayer>) => void;
}) {
const value = useTimelinePlayer();
useEffect(() => {
onValue(value);
}, [onValue, value]);
return null;
}
afterEach(() => {
document.body.innerHTML = "";
resetPlayerStore();
});
describe("useTimelinePlayer seek hydration", () => {
it("keeps an external seek request until the iframe adapter is ready", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const observedTimes: number[] = [];
const unsubscribe = liveTime.subscribe((time) => {
observedTimes.push(time);
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
act(() => {
usePlayerStore.getState().requestSeek(4.2);
});
expect(api).not.toBeNull();
expect(usePlayerStore.getState().currentTime).toBe(0);
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
const iframe = document.createElement("iframe");
let currentTime = 0;
const adapter = {
play: () => {},
pause: () => {},
seek: (time: number) => {
currentTime = time;
},
getTime: () => currentTime,
getDuration: () => 30,
isPlaying: () => false,
};
Object.defineProperty(iframe, "contentWindow", {
value: {
__player: adapter,
postMessage: () => {},
scrollTo: () => {},
addEventListener: () => {},
removeEventListener: () => {},
},
configurable: true,
});
Object.defineProperty(iframe, "contentDocument", {
value: document.implementation.createHTMLDocument("preview"),
configurable: true,
});
act(() => {
api!.iframeRef.current = iframe;
api!.onIframeLoad();
});
expect(currentTime).toBe(4.2);
expect(usePlayerStore.getState().currentTime).toBe(4.2);
expect(usePlayerStore.getState().timelineReady).toBe(true);
expect(observedTimes).toContain(4.2);
act(() => {
root.unmount();
});
unsubscribe();
});
});
@@ -324,7 +324,10 @@ export function useTimelinePlayer() {
(time: number) => {
stopReverseLoop();
const adapter = getAdapter();
if (!adapter) return;
if (!adapter) {
pendingSeekRef.current = Math.max(0, time);
return false;
}
const duration = Math.max(0, adapter.getDuration());
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
adapter.seek(nextTime);
@@ -334,8 +337,9 @@ export function useTimelinePlayer() {
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
shuttleDirectionRef.current = null;
shuttleSpeedIndexRef.current = 0;
return true;
},
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
[getAdapter, pendingSeekRef, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
);
// Handle seek requests from outside the player loop (e.g. LayersPanel).
@@ -9,7 +9,7 @@
*/
import { useCallback } from "react";
import { usePlayerStore } from "../store/playerStore";
import { liveTime, usePlayerStore } from "../store/playerStore";
import type { TimelineElement } from "../store/playerStore";
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
import {
@@ -158,6 +158,9 @@ export function useTimelineSyncCallbacks({
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
adapter.seek(startTime);
// Keep non-React listeners such as the capture link and time display in sync
// with the initial adapter seek on iframe load.
liveTime.notify(startTime);
const adapterDur = adapter.getDuration();
if (
Number.isFinite(adapterDur) &&
@@ -4,6 +4,7 @@ import {
buildProjectApiPath,
buildProjectHash,
encodeProjectId,
parseProjectHashRoute,
parseProjectIdFromHash,
} from "./projectRouting";
@@ -61,6 +62,20 @@ describe("project routing utilities", () => {
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
});
it("parses project hash routes with query params", () => {
const route = parseProjectHashRoute("#project/Notion%20Showcase?tab=design&t=4.2");
expect(route?.projectId).toBe("Notion Showcase");
expect(route?.params.get("tab")).toBe("design");
expect(route?.params.get("t")).toBe("4.2");
});
it("builds hash routes with query params", () => {
expect(buildProjectHash("Notion Showcase", { tab: "design", t: "4.2" })).toBe(
"#project/Notion%20Showcase?tab=design&t=4.2",
);
});
it("encodes project ids as one API path segment", () => {
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
+49 -12
View File
@@ -1,24 +1,61 @@
const PROJECT_HASH_PREFIX = "#project/";
export interface ProjectHashRoute {
projectId: string;
params: URLSearchParams;
}
function decodeHashProjectId(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeHashParams(
params?: URLSearchParams | Record<string, string | null | undefined>,
): URLSearchParams {
if (!params) return new URLSearchParams();
if (params instanceof URLSearchParams) return params;
const next = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (!key || value == null || value === "") continue;
next.set(key, value);
}
return next;
}
export function encodeProjectId(projectId: string): string {
return encodeURIComponent(projectId);
}
export function buildProjectHash(projectId: string): string {
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
export function buildProjectHash(
projectId: string,
params?: URLSearchParams | Record<string, string | null | undefined>,
): string {
const search = normalizeHashParams(params).toString();
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : ""}`;
}
export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
const route = hash.slice(PROJECT_HASH_PREFIX.length);
const queryIndex = route.indexOf("?");
const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
return {
projectId: decodeHashProjectId(encodedProjectId),
params: new URLSearchParams(rawParams),
};
}
export function parseProjectIdFromHash(hash: string): string | null {
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
try {
return decodeURIComponent(encodedProjectId);
} catch {
return encodedProjectId;
}
return parseProjectHashRoute(hash)?.projectId ?? null;
}
export function buildProjectApiPath(projectId: string, suffix = ""): string {
@@ -0,0 +1,249 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildStudioHash,
normalizeStudioCompositionPath,
normalizeStudioUrlPanelTab,
parseStudioUrlStateFromHash,
} from "./studioUrlState";
import { useStudioUrlState } from "../hooks/useStudioUrlState";
import { usePlayerStore } from "../player";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function resetPlayerStore() {
usePlayerStore.setState({
isPlaying: false,
currentTime: 0,
duration: 0,
timelineReady: false,
elements: [],
selectedElementId: null,
requestedSeekTime: null,
});
}
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = "";
window.history.replaceState(null, "", "/");
resetPlayerStore();
});
function renderStudioUrlStateHarness(
props: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {},
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const baseProps: React.ComponentProps<typeof StudioUrlStateHarness> = {
projectId: "demo",
activeCompPath: null,
currentTime: 0,
duration: 30,
isPlaying: false,
compositionLoading: false,
refreshKey: 0,
previewIframeRef: { current: null },
rightPanelTab: "renders",
rightCollapsed: true,
timelineVisible: true,
activeCompPathHydrated: true,
domEditSelection: null,
buildDomSelectionFromTarget: () => null,
applyDomSelection: () => {},
initialState: {
activeCompPath: null,
currentTime: 4.2,
rightPanelTab: null,
rightCollapsed: null,
timelineVisible: null,
selection: null,
},
};
const render = (nextProps: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {}) => {
act(() => {
root.render(
React.createElement(StudioUrlStateHarness, {
...baseProps,
...props,
...nextProps,
}),
);
});
};
render();
return {
rerender: render,
unmount: () =>
act(() => {
root.unmount();
}),
};
}
function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
useStudioUrlState(props);
return null;
}
describe("studio url state", () => {
it("parses persisted studio state from project hash", () => {
const state = parseStudioUrlStateFromHash(
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.25&tab=design&rc=0&tv=1&selFile=index.html&selId=hero",
);
expect(state.activeCompPath).toBe("compositions/title.html");
expect(state.currentTime).toBe(4.25);
expect(state.rightPanelTab).toBe("design");
expect(state.rightCollapsed).toBe(false);
expect(state.timelineVisible).toBe(true);
expect(state.selection).toEqual({
sourceFile: "index.html",
id: "hero",
selector: undefined,
selectorIndex: undefined,
});
});
it("builds a project hash with persisted studio state", () => {
expect(
buildStudioHash("demo", {
activeCompPath: "compositions/title.html",
currentTime: 4.2571,
rightPanelTab: "layers",
rightCollapsed: true,
timelineVisible: false,
selection: {
sourceFile: "index.html",
selector: ".card",
selectorIndex: 2,
},
}),
).toBe(
"#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.257&tab=layers&rc=1&tv=0&selFile=index.html&selSelector=.card&selIndex=2",
);
});
it("falls back cleanly on invalid values", () => {
const state = parseStudioUrlStateFromHash("#project/demo?tab=nope&t=abc&rc=9&tv=7");
expect(state.activeCompPath).toBeNull();
expect(state.currentTime).toBeNull();
expect(state.rightPanelTab).toBeNull();
expect(state.rightCollapsed).toBeNull();
expect(state.timelineVisible).toBeNull();
expect(state.selection).toBeNull();
});
it("normalizes stale composition paths to the master composition", () => {
expect(
normalizeStudioCompositionPath("compositions/missing.html", [
"index.html",
"compositions/title.html",
]),
).toBeNull();
expect(
normalizeStudioCompositionPath("compositions/title.html", [
"index.html",
"compositions/title.html",
]),
).toBe("compositions/title.html");
});
it("normalizes url tabs against feature flags", () => {
expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
});
it("hydrates seek first, preserves the initial url state, then restores selection", () => {
vi.useFakeTimers();
window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
const requestSeek = vi.fn();
usePlayerStore.setState({ requestSeek });
const selectedElement = document.createElement("div");
selectedElement.id = "hero";
document.body.append(selectedElement);
const previewDoc = document.implementation.createHTMLDocument("preview");
previewDoc.body.append(selectedElement);
const applyDomSelection = vi.fn();
const restoredSelection = {
element: selectedElement,
id: "hero",
selector: "#hero",
selectorIndex: 0,
sourceFile: "index.html",
tagName: "div",
label: "Hero",
textContent: "",
textFields: [],
capabilities: {
canEditText: false,
canEditLayout: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
canAdjustOpacity: true,
canAdjustFill: true,
canAdjustBorderRadius: true,
canAdjustStroke: true,
canAdjustShadow: true,
canAdjustZIndex: true,
},
computedStyle: {
display: "block",
position: "absolute",
},
};
const harness = renderStudioUrlStateHarness({
previewIframeRef: {
current: { contentDocument: previewDoc } as HTMLIFrameElement,
},
rightPanelTab: "design",
rightCollapsed: false,
applyDomSelection,
buildDomSelectionFromTarget: () => restoredSelection,
initialState: {
activeCompPath: null,
currentTime: 4.2,
rightPanelTab: "design",
rightCollapsed: false,
timelineVisible: true,
selection: { id: "hero" },
},
});
expect(requestSeek).toHaveBeenCalledWith(4.2);
expect(applyDomSelection).not.toHaveBeenCalled();
expect(window.location.hash).toContain("t=4.2");
expect(window.location.hash).toContain("tab=design");
act(() => {
vi.advanceTimersByTime(250);
});
expect(window.location.hash).toContain("t=4.2");
expect(applyDomSelection).not.toHaveBeenCalled();
harness.rerender({ currentTime: 4.2 });
act(() => {
vi.advanceTimersByTime(250);
});
expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
harness.rerender({ currentTime: 4.2, domEditSelection: restoredSelection });
act(() => {
vi.advanceTimersByTime(250);
});
expect(window.location.hash).toContain("t=4.2");
expect(window.location.hash).toContain("selId=hero");
harness.unmount();
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { RightPanelTab } from "./studioHelpers";
import { buildProjectHash, parseProjectHashRoute } from "./projectRouting";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MOTION_PANEL_ENABLED,
} from "../components/editor/manualEditingAvailability";
export interface StudioUrlSelectionState {
sourceFile?: string;
id?: string;
selector?: string;
selectorIndex?: number;
}
export interface StudioUrlState {
activeCompPath: string | null;
currentTime: number | null;
rightPanelTab: RightPanelTab | null;
rightCollapsed: boolean | null;
timelineVisible: boolean | null;
selection: StudioUrlSelectionState | null;
}
const VALID_TABS: RightPanelTab[] = ["layers", "design", "motion", "renders"];
export function normalizeStudioUrlPanelTab(
tab: RightPanelTab | null,
options: {
inspectorPanelsEnabled?: boolean;
motionPanelEnabled?: boolean;
} = {},
): RightPanelTab | null {
if (!tab) return null;
if (!VALID_TABS.includes(tab)) return null;
const inspectorPanelsEnabled = options.inspectorPanelsEnabled ?? STUDIO_INSPECTOR_PANELS_ENABLED;
const motionPanelEnabled = options.motionPanelEnabled ?? STUDIO_MOTION_PANEL_ENABLED;
if (!inspectorPanelsEnabled && tab !== "renders") return "renders";
if (tab === "motion" && !motionPanelEnabled) return "design";
return tab;
}
export function normalizeStudioCompositionPath(
activeCompPath: string | null,
fileTree: string[],
): string | null {
if (!activeCompPath || activeCompPath === "index.html") return null;
return fileTree.includes(activeCompPath) ? activeCompPath : null;
}
function parseBoolean(value: string | null): boolean | null {
if (value === "1") return true;
if (value === "0") return false;
return null;
}
function parseNumber(value: string | null): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseTab(value: string | null): RightPanelTab | null {
return VALID_TABS.includes(value as RightPanelTab) ? (value as RightPanelTab) : null;
}
function normalizeSelection(params: URLSearchParams): StudioUrlSelectionState | null {
const sourceFile = params.get("selFile") || undefined;
const id = params.get("selId") || undefined;
const selector = params.get("selSelector") || undefined;
const selectorIndex = parseNumber(params.get("selIndex"));
if (!sourceFile && !id && !selector) return null;
return {
sourceFile,
id,
selector,
selectorIndex: selectorIndex != null ? Math.max(0, Math.floor(selectorIndex)) : undefined,
};
}
export function defaultStudioUrlState(): StudioUrlState {
return {
activeCompPath: null,
currentTime: null,
rightPanelTab: null,
rightCollapsed: null,
timelineVisible: null,
selection: null,
};
}
export function parseStudioUrlStateFromHash(hash: string): StudioUrlState {
const route = parseProjectHashRoute(hash);
if (!route) return defaultStudioUrlState();
const { params } = route;
return {
activeCompPath: params.get("comp") || null,
currentTime: parseNumber(params.get("t")),
rightPanelTab: normalizeStudioUrlPanelTab(parseTab(params.get("tab"))),
rightCollapsed: parseBoolean(params.get("rc")),
timelineVisible: parseBoolean(params.get("tv")),
selection: normalizeSelection(params),
};
}
export function readStudioUrlStateFromWindow(): StudioUrlState {
if (typeof window === "undefined") return defaultStudioUrlState();
return parseStudioUrlStateFromHash(window.location.hash);
}
export function buildStudioHash(projectId: string, state: StudioUrlState): string {
const params = new URLSearchParams();
params.set("v", "1");
if (state.activeCompPath) params.set("comp", state.activeCompPath);
if (state.currentTime != null && Number.isFinite(state.currentTime)) {
params.set("t", String(Math.max(0, Math.round(state.currentTime * 1000) / 1000)));
}
if (state.rightPanelTab) params.set("tab", state.rightPanelTab);
if (state.rightCollapsed != null) params.set("rc", state.rightCollapsed ? "1" : "0");
if (state.timelineVisible != null) params.set("tv", state.timelineVisible ? "1" : "0");
if (state.selection) {
if (state.selection.sourceFile) params.set("selFile", state.selection.sourceFile);
if (state.selection.id) params.set("selId", state.selection.id);
if (state.selection.selector) params.set("selSelector", state.selection.selector);
if (typeof state.selection.selectorIndex === "number") {
params.set("selIndex", String(Math.max(0, Math.floor(state.selection.selectorIndex))));
}
}
return buildProjectHash(projectId, params);
}