import { useCallback, useEffect, useRef, useState } from "react"; import { usePlayerStore } from "../player"; import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; import { clampNumber, type RightPanelTab } from "../utils/studioHelpers"; import { parseProjectIdFromHash } from "../utils/projectRouting"; import { buildStudioHash, parseStudioUrlStateFromHash, type StudioUrlSelectionState, type StudioUrlState, } from "../utils/studioUrlState"; interface UseStudioUrlStateParams { projectId: string | null; activeCompPath: string | null; 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 }, ) => Promise; applyDomSelection: ( selection: DomEditSelection | null, options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean; }, ) => void; setRightPanelTab: (tab: RightPanelTab) => 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, duration, isPlaying, compositionLoading, refreshKey, previewIframeRef, rightPanelTab, rightCollapsed, timelineVisible, activeCompPathHydrated, domEditSelection, buildDomSelectionFromTarget, applyDomSelection, setRightPanelTab, initialState, }: UseStudioUrlStateParams) { const currentTime = usePlayerStore((s) => s.currentTime); const hydratedSeekRef = useRef(initialState.currentTime == null); const hydratedInitialTimeRef = useRef(initialState.currentTime == null); const hydratedSelectionRef = useRef(initialState.selection == null); // Mirrors hydratedSelectionRef as state so the selection-hydration effect can // drop its currentTime subscription once hydration completes — otherwise it // re-runs on every playhead tick for the lifetime of the session. const [selectionHydrated, setSelectionHydrated] = useState(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], ); // Resolve a URL selection to a live element and apply it. Shared by the initial // hydration effect and the external-navigation (hashchange) handler. Returns // false ONLY when the iframe document isn't ready yet (caller should retry); // a missing element or null selection clears the selection and returns true. const applyUrlSelection = useCallback( (selection: StudioUrlSelectionState | null): boolean => { if (!selection) { applyDomSelection(null, { revealPanel: false }); return true; } let doc: Document | null = null; try { doc = previewIframeRef.current?.contentDocument ?? null; } catch { return false; } if (!doc) return false; const element = findElementForSelection( doc, { sourceFile: selection.sourceFile ?? "", id: selection.id, selector: selection.selector, selectorIndex: selection.selectorIndex, }, activeCompPath, ); if (!element) { applyDomSelection(null, { revealPanel: false }); return true; } void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((resolved) => { applyDomSelection(resolved, { revealPanel: false }); }); return true; }, [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef], ); useEffect(() => { if (!projectId || hydratedSeekRef.current || compositionLoading) return; const nextTime = duration > 0 ? clampNumber(initialState.currentTime ?? 0, 0, duration) : Math.max(0, initialState.currentTime ?? 0); // The request is honored even if it fires before the player runtime mounts: // initializeAdapter reconciles the store's requestedSeekTime when the adapter // becomes ready. currentTime then settles to nextTime, releasing the selection // hydration below. usePlayerStore.getState().requestSeek(nextTime); stableTimeRef.current = nextTime; hydratedSeekRef.current = true; }, [projectId, compositionLoading, duration, initialState.currentTime]); // Once hydration completes the selection effect no longer needs the playhead, // so freeze its time dependency. This stops the effect re-running on every tick // for the rest of the session (cosmetic perf) while still retrying as the seek // settles before hydration. const selectionHydrationTime = selectionHydrated ? 0 : currentTime; useEffect(() => { if (!projectId || hydratedSelectionRef.current || compositionLoading) return; if (!hydratedSeekRef.current) return; const targetTime = initialState.currentTime; if (targetTime != null && Math.abs(selectionHydrationTime - stableTimeRef.current!) > 0.05) { return; } const markHydrated = () => { hydratedSelectionRef.current = true; setSelectionHydrated(true); }; const pendingSelection = pendingSelectionRef.current; if (!pendingSelection) { markHydrated(); return; } // Doc not ready yet → leave hydration pending so a later tick retries. if (!applyUrlSelection(pendingSelection)) return; markHydrated(); pendingSelectionRef.current = null; }, [ applyUrlSelection, compositionLoading, selectionHydrationTime, initialState.currentTime, 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]); // Re-apply URL state when the hash changes externally (pasting a new link, // back/forward) AFTER initial load. The app only reads the URL once on mount // and otherwise WRITES the hash via replaceState (which never fires // `hashchange`), so this listener sees only genuine external navigations — // without it, opening a same-project deep link (different `t`, element, or // tab) is silently ignored and then overwritten by the next hash-sync. useEffect(() => { if (!projectId) return; const onHashChange = () => { if (parseProjectIdFromHash(window.location.hash) !== projectId) return; // different project → remount handles it const parsed = parseStudioUrlStateFromHash(window.location.hash); if (parsed.currentTime != null) { const clamped = duration > 0 ? clampNumber(parsed.currentTime, 0, duration) : Math.max(0, parsed.currentTime); if (Math.abs(usePlayerStore.getState().currentTime - clamped) > 0.05) { usePlayerStore.getState().requestSeek(clamped); stableTimeRef.current = clamped; } } applyUrlSelection(parsed.selection); if (parsed.rightPanelTab) setRightPanelTab(parsed.rightPanelTab); }; window.addEventListener("hashchange", onHashChange); return () => window.removeEventListener("hashchange", onHashChange); }, [projectId, duration, applyUrlSelection, setRightPanelTab]); }