mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(studio): persist studio state in project URLs (#836)
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user