diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index c5827742bd..b65c6f706b 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -295,6 +295,15 @@ export function StudioApp() { handleCut, }); + const selectSidebarTabStable = useCallback( + (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), + [], + ); + const getSidebarTabStable = useCallback( + () => leftSidebarRef.current?.getTab() ?? "compositions", + [], + ); + const domEditSession = useDomEditSession({ projectId, activeCompPath, @@ -327,7 +336,8 @@ export function StudioApp() { reloadPreview, setRefreshKey, openSourceForSelection: fileManager.openSourceForSelection, - selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), + selectSidebarTab: selectSidebarTabStable, + getSidebarTab: getSidebarTabStable, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 23da8b1056..efee6163f1 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -133,8 +133,8 @@ export const NLELayout = memo(function NLELayout({ // Lightweight reload: change iframe src instead of destroying the Player. // refreshPlayer() saves the seek position and appends a cache-busting _t - // param, avoiding the full web-component teardown + crossfade that the - // key-based path uses. + // param — the Player instance stays alive so the adapter is available for + // saveSeekPosition() to read the current time before the reload. const prevRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey === prevRefreshKeyRef.current) return; @@ -352,7 +352,6 @@ export const NLELayout = memo(function NLELayout({ onCompositionLoadingChange={setCompositionLoading} portrait={portrait} directUrl={directUrl} - refreshKey={refreshKey} suppressLoadingOverlay={hasLoadedOnceRef.current} /> {!isFullscreen && previewOverlay} diff --git a/packages/studio/src/components/nle/NLEPreview.test.ts b/packages/studio/src/components/nle/NLEPreview.test.ts index 2609065794..5cdbd9d773 100644 --- a/packages/studio/src/components/nle/NLEPreview.test.ts +++ b/packages/studio/src/components/nle/NLEPreview.test.ts @@ -112,17 +112,9 @@ function renderPreview() { } describe("getPreviewPlayerKey", () => { - it("keeps the same player identity when only refreshKey changes", () => { - expect( - getPreviewPlayerKey({ - projectId: "timeline-edit-playground", - refreshKey: 1, - }), - ).toBe( - getPreviewPlayerKey({ - projectId: "timeline-edit-playground", - refreshKey: 2, - }), + it("uses projectId as key when no directUrl", () => { + expect(getPreviewPlayerKey({ projectId: "timeline-edit-playground" })).toBe( + "timeline-edit-playground", ); }); diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8b51d8852f..7e829d3982 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -20,7 +20,6 @@ interface NLEPreviewProps { onCompositionLoadingChange?: (loading: boolean) => void; portrait?: boolean; directUrl?: string; - refreshKey?: number; suppressLoadingOverlay?: boolean; } @@ -30,7 +29,6 @@ export function getPreviewPlayerKey({ }: { projectId: string; directUrl?: string; - refreshKey?: number; }): string { return directUrl ?? projectId; } @@ -91,16 +89,12 @@ export const NLEPreview = memo(function NLEPreview({ onCompositionLoadingChange, portrait, directUrl, - refreshKey, suppressLoadingOverlay, }: NLEPreviewProps) { - const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey }); - const prevRefreshKeyRef = useRef(refreshKey); + const activeKey = getPreviewPlayerKey({ projectId, directUrl }); const viewportRef = useRef(null); const stageRef = useRef(null); - const [retiringKey, setRetiringKey] = useState(null); const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait)); - const retiringTimerRef = useRef | null>(null); const zoomRef = useRef(loadInitialZoom()); const [settledZoom, setSettledZoom] = useState(() => zoomRef.current); @@ -120,7 +114,6 @@ export const NLEPreview = memo(function NLEPreview({ return () => { if (settleTimerRef.current) clearTimeout(settleTimerRef.current); if (hudTimerRef.current) clearTimeout(hudTimerRef.current); - if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); }; }, []); @@ -205,14 +198,6 @@ export const NLEPreview = memo(function NLEPreview({ [applyTransform], ); - if (refreshKey !== prevRefreshKeyRef.current) { - const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`; - prevRefreshKeyRef.current = refreshKey; - setRetiringKey(oldKey); - } - - const activeKey = `${baseKey}:${refreshKey ?? 0}`; - const applyInitialZoom = useCallback(() => { const z = zoomRef.current; if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) { @@ -220,16 +205,6 @@ export const NLEPreview = memo(function NLEPreview({ } }, [writeTransform]); - const handleNewPlayerLoad = () => { - onIframeLoad(); - applyInitialZoom(); - if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current); - retiringTimerRef.current = setTimeout(() => { - setRetiringKey(null); - retiringTimerRef.current = null; - }, 160); - }; - useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; @@ -412,32 +387,17 @@ export const NLEPreview = memo(function NLEPreview({ }} data-testid="preview-zoom-stage" > - {retiringKey && ( - {}} - portrait={portrait} - style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }} - /> - )} { - onIframeLoad(); - applyInitialZoom(); - } - } + onLoad={() => { + onIframeLoad(); + applyInitialZoom(); + }} onCompositionLoadingChange={onCompositionLoadingChange} portrait={portrait} - style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined} suppressLoadingOverlay={suppressLoadingOverlay} /> diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 2c8e9f73a9..b3173785e9 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -3,6 +3,7 @@ import { useState, useCallback, useImperativeHandle, + useRef, forwardRef, type ReactNode, } from "react"; @@ -17,6 +18,7 @@ export type SidebarTab = "compositions" | "assets" | "code" | "blocks"; export interface LeftSidebarHandle { selectTab: (tab: SidebarTab) => void; + getTab: () => SidebarTab; } const STORAGE_KEY = "hf-studio-sidebar-tab"; @@ -87,6 +89,8 @@ export const LeftSidebar = memo( ref, ) { const [tab, setTab] = useState(getPersistedTab); + const tabRef = useRef(tab); + tabRef.current = tab; const selectTab = useCallback((t: SidebarTab) => { setTab(t); @@ -94,7 +98,9 @@ export const LeftSidebar = memo( trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t }); }, []); - useImperativeHandle(ref, () => ({ selectTab }), [selectTab]); + const getTab = useCallback(() => tabRef.current, []); + + useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]); return (
>; openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; selectSidebarTab?: (tab: SidebarTab) => void; + getSidebarTab?: () => SidebarTab; } // ── Hook ── @@ -93,6 +94,7 @@ export function useDomEditSession({ setRefreshKey: _setRefreshKey, openSourceForSelection, selectSidebarTab, + getSidebarTab, }: UseDomEditSessionParams) { void _setRefreshKey; @@ -281,6 +283,22 @@ export function useDomEditSession({ applyStudioManualEditsToPreviewRef, ]); + // Auto-reveal source when an element is selected while the Code tab is active. + // Use a ref for the callback so the effect only fires on selection changes, + // not when openSourceForSelection is recreated due to editingFile content updates. + const openSourceRef = useRef(openSourceForSelection); + openSourceRef.current = openSourceForSelection; + useEffect(() => { + if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return; + if (!domEditSelection.sourceFile) return; + if (getSidebarTab() !== "code") return; + openSourceRef.current(domEditSelection.sourceFile, { + id: domEditSelection.id, + selector: domEditSelection.selector, + selectorIndex: domEditSelection.selectorIndex, + }); + }, [domEditSelection, getSidebarTab]); + return { // State domEditSelection, diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index b448d52568..2dc18c52c5 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -122,6 +122,9 @@ export function useFileManager({ const handleFileSelect = useCallback((path: string) => { const pid = projectIdRef.current; if (!pid) return; + revealAbortRef.current?.abort(); + revealAbortRef.current = null; + revealRequestIdRef.current++; // Skip fetching binary content for media files — just set the path for preview if (isMediaFile(path)) { setEditingFile({ path, content: null });