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
@@ -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);
}