Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/StudioLeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function StudioLeftSidebar({
assets,
editingFile,
fileTree,
revealSourceOffset,
handleFileSelect,
handleCreateFile,
handleCreateFolder,
Expand Down Expand Up @@ -113,6 +114,7 @@ export function StudioLeftSidebar({
content={editingFile.content ?? ""}
filePath={editingFile.path}
onChange={handleContentChange}
revealOffset={revealSourceOffset}
/>
)
) : undefined
Expand Down
14 changes: 14 additions & 0 deletions packages/studio/src/components/editor/SourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface SourceEditorProps {
language?: string;
onChange?: (content: string) => void;
readOnly?: boolean;
revealOffset?: number | null;
}

export const SourceEditor = memo(function SourceEditor({
Expand All @@ -63,6 +64,7 @@ export const SourceEditor = memo(function SourceEditor({
language,
onChange,
readOnly = false,
revealOffset,
}: SourceEditorProps) {
const editorRef = useRef<EditorView | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -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 <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
});
6 changes: 6 additions & 0 deletions packages/studio/src/contexts/FileManagerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
handleContentChange,
refreshFileTree,
Expand Down Expand Up @@ -62,6 +64,8 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
handleContentChange,
refreshFileTree,
Expand Down Expand Up @@ -92,6 +96,8 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
handleContentChange,
refreshFileTree,
Expand Down
26 changes: 24 additions & 2 deletions packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,6 +54,8 @@ export interface UseDomEditSessionParams {
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
reloadPreview: () => void;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
selectSidebarTab?: (tab: SidebarTab) => void;
}

// ── Hook ──
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -164,6 +185,7 @@ export function useDomEditSession({
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
setAgentModalOpen,
onClickToSource,
});

// ── Commit handlers (delegated to useDomEditCommits) ──
Expand Down
42 changes: 42 additions & 0 deletions packages/studio/src/hooks/useFileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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 ──

Expand Down Expand Up @@ -37,6 +38,7 @@
const [projectDir, setProjectDir] = useState<string | null>(null);
const [fileTree, setFileTree] = useState<string[]>([]);
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);

// ── Refs ──

Expand Down Expand Up @@ -169,6 +171,42 @@
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
);

// ── Open source for selection (click-to-source) ──

const revealRequestIdRef = useRef(0);
const revealAbortRef = useRef<AbortController | null>(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,
})

Check warning

Code scanning / CodeQL

Client-side request forgery Medium

The
URL
of this request depends on a
user-provided value
.
Comment on lines +193 to +195
.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 () => {
Expand Down Expand Up @@ -418,6 +456,10 @@
writeProjectFile,
readOptionalProjectFile,

// Click-to-source
revealSourceOffset,
openSourceForSelection,

// Callbacks
handleFileSelect,
handleContentChange,
Expand Down
7 changes: 7 additions & 0 deletions packages/studio/src/hooks/usePreviewInteraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand All @@ -53,6 +55,7 @@ export function usePreviewInteraction({
setAgentPromptSelectionContext,
setAgentModalAnchorPoint,
setAgentModalOpen,
onClickToSource,
}: UsePreviewInteractionParams) {
const handlePreviewCanvasMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
Expand All @@ -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 &&
Expand All @@ -87,6 +93,7 @@ export function usePreviewInteraction({
applyDomSelection,
captionEditMode,
compositionLoading,
onClickToSource,
preloadAgentPromptSnippet,
resolveDomSelectionFromPreviewPoint,
previewIframeRef,
Expand Down
15 changes: 12 additions & 3 deletions packages/studio/src/player/components/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
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<HTMLIFrameElement | null>).current = null;
const mutableRef = ref as React.MutableRefObject<HTMLIFrameElement | null>;
if (mutableRef.current === iframe) {
mutableRef.current = null;
}
}
};
});
Expand Down
31 changes: 29 additions & 2 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 30 additions & 3 deletions packages/studio/src/player/components/useTimelineRangeSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
);
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/utils/sourcePatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading