From e59089bf759b4c4403a98522c1e7f26090a05199 Mon Sep 17 00:00:00 2001 From: Phuong Le <39565248+func25@users.noreply.github.com> Date: Thu, 14 May 2026 21:56:13 +0700 Subject: [PATCH] fix(studio): persist studio state in project URLs (#836) --- packages/studio/src/App.tsx | 78 ++++-- .../components/StudioGlobalDragOverlay.tsx | 26 ++ .../studio/src/contexts/DomEditContext.tsx | 3 + .../src/contexts/FileManagerContext.tsx | 3 + .../studio/src/hooks/useDomEditSession.ts | 1 + packages/studio/src/hooks/useFileManager.ts | 11 +- packages/studio/src/hooks/usePanelLayout.ts | 13 +- .../studio/src/hooks/useStudioUrlState.ts | 188 +++++++++++++ .../hooks/useTimelinePlayer.seek.test.ts | 100 +++++++ .../src/player/hooks/useTimelinePlayer.ts | 8 +- .../player/hooks/useTimelineSyncCallbacks.ts | 5 +- .../studio/src/utils/projectRouting.test.ts | 15 ++ packages/studio/src/utils/projectRouting.ts | 61 ++++- .../studio/src/utils/studioUrlState.test.ts | 249 ++++++++++++++++++ packages/studio/src/utils/studioUrlState.ts | 135 ++++++++++ 15 files changed, 850 insertions(+), 46 deletions(-) create mode 100644 packages/studio/src/components/StudioGlobalDragOverlay.tsx create mode 100644 packages/studio/src/hooks/useStudioUrlState.ts create mode 100644 packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts create mode 100644 packages/studio/src/utils/studioUrlState.test.ts create mode 100644 packages/studio/src/utils/studioUrlState.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index fcfe3caee..eca3c9bb5 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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(null); + const [activeCompPathHydrated, setActiveCompPathHydrated] = useState( + () => initialUrlStateRef.current.activeCompPath == null, + ); const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); const [previewIframe, setPreviewIframe] = useState(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 && ( -
-
- - - - - - - Drop files to import into project - -
-
- )} + {globalDragOver && } {appToast && (
+
+ + + + + + + Drop files to import into project + +
+
+ ); +} diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 8bfb5dc50..d6aba5d6a 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -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, diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index ca50620d6..7c40efd2a 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -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, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 0ecde8c5e..6e7abf51d 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -333,6 +333,7 @@ export function useDomEditSession({ handleBlockedDomMove, handleDomManualDragStart, handleDomEditElementDelete, + buildDomSelectionFromTarget, buildDomSelectionForTimelineElement, updateDomEditHoverSelection, resolveImportedFontAsset, diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index b45a2ff23..a63c5e3ad 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -36,6 +36,7 @@ export function useFileManager({ const [editingFile, setEditingFile] = useState(null); const [projectDir, setProjectDir] = useState(null); const [fileTree, setFileTree] = useState([]); + 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 diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index 61290f5a3..bcdf3e1d5 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -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("renders"); + const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? true); + const [rightPanelTab, setRightPanelTab] = useState( + initialState?.rightPanelTab ?? "renders", + ); const panelDragRef = useRef<{ side: "left" | "right"; startX: number; diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts new file mode 100644 index 000000000..5af4336ff --- /dev/null +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -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; + 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(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]); +} diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts new file mode 100644 index 000000000..de6b9d67d --- /dev/null +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -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) => 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 | 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(); + }); +}); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index ff2d3a026..7358d98a8 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -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). diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 604c50ae4..8775ee017 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -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) && diff --git a/packages/studio/src/utils/projectRouting.test.ts b/packages/studio/src/utils/projectRouting.test.ts index a9c935049..95b1580e5 100644 --- a/packages/studio/src/utils/projectRouting.test.ts +++ b/packages/studio/src/utils/projectRouting.test.ts @@ -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"); diff --git a/packages/studio/src/utils/projectRouting.ts b/packages/studio/src/utils/projectRouting.ts index 772177492..faed3790d 100644 --- a/packages/studio/src/utils/projectRouting.ts +++ b/packages/studio/src/utils/projectRouting.ts @@ -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, +): 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 { + 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 { diff --git a/packages/studio/src/utils/studioUrlState.test.ts b/packages/studio/src/utils/studioUrlState.test.ts new file mode 100644 index 000000000..69efee49a --- /dev/null +++ b/packages/studio/src/utils/studioUrlState.test.ts @@ -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> = {}, +) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const baseProps: React.ComponentProps = { + 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> = {}) => { + act(() => { + root.render( + React.createElement(StudioUrlStateHarness, { + ...baseProps, + ...props, + ...nextProps, + }), + ); + }); + }; + + render(); + return { + rerender: render, + unmount: () => + act(() => { + root.unmount(); + }), + }; +} + +function StudioUrlStateHarness(props: Parameters[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(); + }); +}); diff --git a/packages/studio/src/utils/studioUrlState.ts b/packages/studio/src/utils/studioUrlState.ts new file mode 100644 index 000000000..1eb29326f --- /dev/null +++ b/packages/studio/src/utils/studioUrlState.ts @@ -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); +}