diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 2d7d6c661..a9a4b570c 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, useMemo, useEffect } from "react"; -import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar"; +import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; import { usePlayerStore } from "./player"; import { LintModal } from "./components/LintModal"; @@ -215,6 +215,8 @@ export function StudioApp() { syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, reloadPreview, setRefreshKey, + openSourceForSelection: fileManager.openSourceForSelection, + selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index dd422d861..f83232507 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -35,6 +35,7 @@ export function StudioLeftSidebar({ assets, editingFile, fileTree, + revealSourceOffset, handleFileSelect, handleCreateFile, handleCreateFolder, @@ -113,6 +114,7 @@ export function StudioLeftSidebar({ content={editingFile.content ?? ""} filePath={editingFile.path} onChange={handleContentChange} + revealOffset={revealSourceOffset} /> ) ) : undefined diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index d6c4975ea..6aac6f1fc 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -55,6 +55,7 @@ interface SourceEditorProps { language?: string; onChange?: (content: string) => void; readOnly?: boolean; + revealOffset?: number | null; } export const SourceEditor = memo(function SourceEditor({ @@ -63,6 +64,7 @@ export const SourceEditor = memo(function SourceEditor({ language, onChange, readOnly = false, + revealOffset, }: SourceEditorProps) { const editorRef = useRef(null); const containerRef = useRef(null); @@ -132,5 +134,17 @@ export const SourceEditor = memo(function SourceEditor({ } }, [content]); + useEffect(() => { + const view = editorRef.current; + if (!view || revealOffset == null || revealOffset < 0) return; + const docLen = view.state.doc.length; + const pos = Math.min(revealOffset, docLen); + view.dispatch({ + selection: { anchor: pos }, + effects: EditorView.scrollIntoView(pos, { y: "center" }), + }); + view.focus(); + }, [revealOffset]); + return
; }); diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index 7c40efd2a..bb9cf1176 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -26,6 +26,8 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + revealSourceOffset, + openSourceForSelection, handleFileSelect, handleContentChange, refreshFileTree, @@ -62,6 +64,8 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + revealSourceOffset, + openSourceForSelection, handleFileSelect, handleContentChange, refreshFileTree, @@ -92,6 +96,8 @@ export function FileManagerProvider({ readProjectFile, writeProjectFile, readOptionalProjectFile, + revealSourceOffset, + openSourceForSelection, handleFileSelect, handleContentChange, refreshFileTree, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index a4f395685..61e37553c 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -1,10 +1,12 @@ -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import type { TimelineElement } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; -import { findElementForSelection } from "../components/editor/domEditing"; +import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; +import type { PatchTarget } from "../utils/sourcePatcher"; +import type { SidebarTab } from "../components/sidebar/LeftSidebar"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; @@ -52,6 +54,8 @@ export interface UseDomEditSessionParams { syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void; reloadPreview: () => void; setRefreshKey: React.Dispatch>; + openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void; + selectSidebarTab?: (tab: SidebarTab) => void; } // ── Hook ── @@ -87,8 +91,25 @@ export function useDomEditSession({ syncPreviewHistoryHotkey, reloadPreview, setRefreshKey: _setRefreshKey, + openSourceForSelection, + selectSidebarTab, }: UseDomEditSessionParams) { void _setRefreshKey; + + const onClickToSource = useCallback( + (selection: DomEditSelection) => { + if (!openSourceForSelection || !selectSidebarTab) return; + if (!selection.sourceFile) return; + selectSidebarTab("code"); + openSourceForSelection(selection.sourceFile, { + id: selection.id, + selector: selection.selector, + selectorIndex: selection.selectorIndex, + }); + }, + [openSourceForSelection, selectSidebarTab], + ); + // ── Selection (delegated to useDomSelection) ── const { @@ -164,6 +185,7 @@ export function useDomEditSession({ setAgentPromptSelectionContext, setAgentModalAnchorPoint, setAgentModalOpen, + onClickToSource, }); // ── Commit handlers (delegated to useDomEditCommits) ── diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index a63c5e3ad..c9d4f2ccd 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -4,6 +4,7 @@ import { FONT_EXT, isMediaFile } from "../utils/mediaTypes"; import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import type { EditHistoryKind } from "../utils/editHistory"; +import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher"; // ── Types ── @@ -37,6 +38,7 @@ export function useFileManager({ const [projectDir, setProjectDir] = useState(null); const [fileTree, setFileTree] = useState([]); const [fileTreeLoaded, setFileTreeLoaded] = useState(false); + const [revealSourceOffset, setRevealSourceOffset] = useState(null); // ── Refs ── @@ -169,6 +171,42 @@ export function useFileManager({ [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile], ); + // ── Open source for selection (click-to-source) ── + + const revealRequestIdRef = useRef(0); + const revealAbortRef = useRef(null); + + const openSourceForSelection = useCallback( + (sourceFile: string, target: PatchTarget) => { + const pid = projectIdRef.current; + if (!pid || !sourceFile) return; + revealAbortRef.current?.abort(); + revealAbortRef.current = null; + if (editingPathRef.current === sourceFile && editingFile?.content != null) { + const match = findTagByTarget(editingFile.content, target); + setRevealSourceOffset(match ? match.start : null); + return; + } + const requestId = ++revealRequestIdRef.current; + const controller = new AbortController(); + revealAbortRef.current = controller; + fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, { + signal: controller.signal, + }) + .then((r) => r.json()) + .then((data: { content?: string }) => { + if (requestId !== revealRequestIdRef.current) return; + if (data.content != null) { + setEditingFile({ path: sourceFile, content: data.content }); + const match = findTagByTarget(data.content, target); + setRevealSourceOffset(match ? match.start : null); + } + }) + .catch(() => {}); + }, + [editingFile?.content], + ); + // ── File tree refresh ── const refreshFileTree = useCallback(async () => { @@ -418,6 +456,10 @@ export function useFileManager({ writeProjectFile, readOptionalProjectFile, + // Click-to-source + revealSourceOffset, + openSourceForSelection, + // Callbacks handleFileSelect, handleContentChange, diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index 38266c4dc..289acc1e9 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -37,6 +37,8 @@ export interface UsePreviewInteractionParams { setAgentPromptSelectionContext: (context: string | undefined) => void; setAgentModalAnchorPoint: (point: AgentModalAnchorPoint | null) => void; setAgentModalOpen: (open: boolean) => void; + + onClickToSource?: (selection: DomEditSelection) => void; } // ── Hook ── @@ -53,6 +55,7 @@ export function usePreviewInteraction({ setAgentPromptSelectionContext, setAgentModalAnchorPoint, setAgentModalOpen, + onClickToSource, }: UsePreviewInteractionParams) { const handlePreviewCanvasMouseDown = useCallback( (e: React.MouseEvent, options?: { preferClipAncestor?: boolean }) => { @@ -70,6 +73,9 @@ export function usePreviewInteraction({ ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY) : null; applyDomSelection(nextSelection, { additive: e.shiftKey }); + if (!e.shiftKey && e.altKey && onClickToSource) { + onClickToSource(nextSelection); + } if ( !e.shiftKey && localPointer && @@ -87,6 +93,7 @@ export function usePreviewInteraction({ applyDomSelection, captionEditMode, compositionLoading, + onClickToSource, preloadAgentPromptSnippet, resolveDomSelectionFromPreviewPoint, previewIframeRef, diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx index 6f9f37eda..2ecadc0b4 100644 --- a/packages/studio/src/player/components/Player.tsx +++ b/packages/studio/src/player/components/Player.tsx @@ -268,11 +268,20 @@ export const Player = forwardRef( if (assetPollRef.current) clearInterval(assetPollRef.current); assetPollRef.current = null; container.removeChild(player); - // Clear the forwarded ref + // Clear the forwarded ref only if it still points to THIS iframe. + // During crossfade refreshes the retiring Player unmounts after the + // new Player has already assigned its iframe to the same ref — blindly + // nulling it would break seeking in the new Player. + // Callback refs are skipped — we can't read back the current value to + // guard against clobbering a newer assignment. The mutable-ref branch + // (the only path used today) is guarded by identity check. if (typeof ref === "function") { - ref(null); + // no-op: can't safely guard callback refs } else if (ref) { - (ref as React.MutableRefObject).current = null; + const mutableRef = ref as React.MutableRefObject; + if (mutableRef.current === iframe) { + mutableRef.current = null; + } } }; }); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 027d94e7e..c528cffb8 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -207,12 +207,39 @@ export const PlayerControls = memo(function PlayerControls({ seekFromClientX(e.clientX); + // During drag, update the slider visual immediately on every pointer + // event but RAF-throttle the actual onSeek call. The seek path triggers + // adapter.seek + setCurrentTime + React re-renders which can take >16ms + // on complex compositions — keeping visual feedback on the raw event and + // batching the expensive work to one call per frame keeps scrubbing at + // 60 fps. + let seekRafId = 0; + let pendingClientX = e.clientX; const onMove = (ev: PointerEvent) => { - if (ev.pointerId !== pointerId) return; - if (isDraggingRef.current) seekFromClientX(ev.clientX); + if (ev.pointerId !== pointerId || !isDraggingRef.current) return; + pendingClientX = ev.clientX; + const bar = seekBarRef.current; + const dur = durationRef.current; + if (bar && dur > 0) { + const rect = bar.getBoundingClientRect(); + const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100; + if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; + if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`; + } + if (!seekRafId) { + seekRafId = requestAnimationFrame(() => { + seekRafId = 0; + if (isDraggingRef.current) seekFromClientX(pendingClientX); + }); + } }; const cleanup = () => { isDraggingRef.current = false; + if (seekRafId) { + cancelAnimationFrame(seekRafId); + seekRafId = 0; + } + seekFromClientX(pendingClientX); try { target.releasePointerCapture(pointerId); } catch { diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.ts b/packages/studio/src/player/components/useTimelineRangeSelection.ts index 1b0869216..6bfc2a555 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelection.ts +++ b/packages/studio/src/player/components/useTimelineRangeSelection.ts @@ -38,6 +38,9 @@ export function useTimelineRangeSelection({ anchorY: number; } | null>(null); + const seekRafRef = useRef(0); + const pendingClientXRef = useRef(0); + const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0) return; @@ -80,8 +83,27 @@ export function useTimelineRangeSelection({ return; } if (!isDragging.current) return; - seekFromX(e.clientX); - autoScrollDuringDrag(e.clientX); + pendingClientXRef.current = e.clientX; + // Update the playhead visual immediately via liveTime for smooth feedback, + // then RAF-throttle the full seek (adapter + React state sync). + const el = scrollRef.current; + if (el) { + const rect = el.getBoundingClientRect(); + const x = e.clientX - rect.left + el.scrollLeft - GUTTER; + if (x >= 0) { + const dur = el.scrollWidth / pps; + liveTime.notify(Math.max(0, Math.min(dur, x / pps))); + } + } + if (!seekRafRef.current) { + seekRafRef.current = requestAnimationFrame(() => { + seekRafRef.current = 0; + if (isDragging.current) { + seekFromX(pendingClientXRef.current); + autoScrollDuringDrag(pendingClientXRef.current); + } + }); + } }, [seekFromX, autoScrollDuringDrag, pps, scrollRef, isDragging], ); @@ -104,9 +126,14 @@ export function useTimelineRangeSelection({ }); return; } + if (seekRafRef.current) { + cancelAnimationFrame(seekRafRef.current); + seekRafRef.current = 0; + } + seekFromX(pendingClientXRef.current); isDragging.current = false; cancelAnimationFrame(dragScrollRaf.current); - }, [isDragging, dragScrollRaf, setShowPopover]); + }, [isDragging, dragScrollRaf, setShowPopover, seekFromX]); return { rangeSelection, diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index 69d60b18b..ba142e2c8 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -232,7 +232,7 @@ function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): strin return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`; } -function findTagByTarget(html: string, target: PatchTarget): TagMatch | null { +export function findTagByTarget(html: string, target: PatchTarget): TagMatch | null { if (target.id) { const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(target.id)}\\2[^>]*)>`, "i"); const match = idPattern.exec(html);