Skip to content

Commit 58a3709

Browse files
fix(studio): fix seek after code edit, improve scrub perf, add click-to-source (#881)
* feat(studio): html-backed motion panel — persist GSAP motion to element attributes Re-architects the motion panel to store GSAP motion data as a JSON data attribute (data-hf-studio-motion) on each element instead of a .hyperframes/studio-motion.json sidecar file. Follows the same pattern as position/resize/rotation edits: write to DOM, build patches, persist to HTML source via commitPositionPatchToHtml. Render pipeline: the studioPositionSeekReapplyRuntime now queries [data-hf-studio-motion] elements after each seek, parses their JSON, builds a GSAP timeline, and seeks it to the current frame time. Studio preview: motion reapply is integrated into the manual edits seek hook (reapplyPositionEditsAfterSeek). useManifestPersistence is slimmed to only handle save queue and seek hooks. * fix(studio): address PR review — html-escape attrs, cache timeline, migrate sidecar, add tests Blocker: JSON attribute values are now HTML-entity-escaped before being written into source HTML. Read-back unescapes automatically. Perf: motion timeline is cached between seeks at render — only rebuilt when the concatenated JSON key changes, not on every frame. Migration: on mount, empties legacy .hyperframes/studio-motion.json so the legacy render script no-ops. Tests: 46 new tests for motion read/write/clear round-trips, JSON attribute escaping, and source patcher entity handling. Nits: removed unused activeCompositionPath param; tightened htmlCompiler attribute substring check. * fix(studio): fix seek after code edit, improve scrub performance, add click-to-source Three issues addressed: 1. **Seek breaks after code edit**: During crossfade refreshes the retiring Player's cleanup unconditionally nulled `iframeRef.current`, clobbering the reference the new Player had already assigned. Guard the cleanup to only clear the ref when it still points to the retiring Player's own iframe. 2. **Scrubber/timeline drag jank**: Every pointermove during a drag called the full seek pipeline (adapter.seek + setCurrentTime + React re-render cascade). RAF-throttle the expensive onSeek call during drags while keeping slider and playhead visuals updated on every pointer event for instant feedback. 3. **Click-to-source**: Clicking an element in the preview now switches to the Code tab, opens the element's source file, and scrolls the editor to the element's opening tag. Uses the existing `findTagByTarget` source patcher to locate the element by id/selector in the HTML source. * fix(studio): address PR review — gate click-to-source, fix fetch race, guard refs - Gate click-to-source on Alt/Option+click so it doesn't steal the Code tab on every preview click, conflicting with select-to-inspect workflow - Fix fetch race in openSourceForSelection: AbortController cancels the previous in-flight fetch, monotonic request ID prevents stale responses from applying the wrong file/offset - Guard the callback-ref branch in Player cleanup (no-op — can't read back from a callback ref to check identity, and the path is unreachable today since the ref is always a MutableRefObject) - Import SidebarTab type instead of duplicating the literal inline
1 parent f84cc49 commit 58a3709

11 files changed

Lines changed: 170 additions & 12 deletions

File tree

packages/studio/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
2-
import type { LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
2+
import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar";
33
import { useRenderQueue } from "./components/renders/useRenderQueue";
44
import { usePlayerStore } from "./player";
55
import { LintModal } from "./components/LintModal";
@@ -215,6 +215,8 @@ export function StudioApp() {
215215
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
216216
reloadPreview,
217217
setRefreshKey,
218+
openSourceForSelection: fileManager.openSourceForSelection,
219+
selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
218220
});
219221

220222
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;

packages/studio/src/components/StudioLeftSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export function StudioLeftSidebar({
3535
assets,
3636
editingFile,
3737
fileTree,
38+
revealSourceOffset,
3839
handleFileSelect,
3940
handleCreateFile,
4041
handleCreateFolder,
@@ -113,6 +114,7 @@ export function StudioLeftSidebar({
113114
content={editingFile.content ?? ""}
114115
filePath={editingFile.path}
115116
onChange={handleContentChange}
117+
revealOffset={revealSourceOffset}
116118
/>
117119
)
118120
) : undefined

packages/studio/src/components/editor/SourceEditor.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface SourceEditorProps {
5555
language?: string;
5656
onChange?: (content: string) => void;
5757
readOnly?: boolean;
58+
revealOffset?: number | null;
5859
}
5960

6061
export const SourceEditor = memo(function SourceEditor({
@@ -63,6 +64,7 @@ export const SourceEditor = memo(function SourceEditor({
6364
language,
6465
onChange,
6566
readOnly = false,
67+
revealOffset,
6668
}: SourceEditorProps) {
6769
const editorRef = useRef<EditorView | null>(null);
6870
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -132,5 +134,17 @@ export const SourceEditor = memo(function SourceEditor({
132134
}
133135
}, [content]);
134136

137+
useEffect(() => {
138+
const view = editorRef.current;
139+
if (!view || revealOffset == null || revealOffset < 0) return;
140+
const docLen = view.state.doc.length;
141+
const pos = Math.min(revealOffset, docLen);
142+
view.dispatch({
143+
selection: { anchor: pos },
144+
effects: EditorView.scrollIntoView(pos, { y: "center" }),
145+
});
146+
view.focus();
147+
}, [revealOffset]);
148+
135149
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
136150
});

packages/studio/src/contexts/FileManagerContext.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export function FileManagerProvider({
2626
readProjectFile,
2727
writeProjectFile,
2828
readOptionalProjectFile,
29+
revealSourceOffset,
30+
openSourceForSelection,
2931
handleFileSelect,
3032
handleContentChange,
3133
refreshFileTree,
@@ -62,6 +64,8 @@ export function FileManagerProvider({
6264
readProjectFile,
6365
writeProjectFile,
6466
readOptionalProjectFile,
67+
revealSourceOffset,
68+
openSourceForSelection,
6569
handleFileSelect,
6670
handleContentChange,
6771
refreshFileTree,
@@ -92,6 +96,8 @@ export function FileManagerProvider({
9296
readProjectFile,
9397
writeProjectFile,
9498
readOptionalProjectFile,
99+
revealSourceOffset,
100+
openSourceForSelection,
95101
handleFileSelect,
96102
handleContentChange,
97103
refreshFileTree,

packages/studio/src/hooks/useDomEditSession.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { useEffect } from "react";
1+
import { useCallback, useEffect } from "react";
22
import type { TimelineElement } from "../player";
33
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
4-
import { findElementForSelection } from "../components/editor/domEditing";
4+
import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
55
import type { ImportedFontAsset } from "../components/editor/fontAssets";
66
import type { EditHistoryKind } from "../utils/editHistory";
77
import type { RightPanelTab } from "../utils/studioHelpers";
8+
import type { PatchTarget } from "../utils/sourcePatcher";
9+
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
810
import { useAskAgentModal } from "./useAskAgentModal";
911
import { useDomSelection } from "./useDomSelection";
1012
import { usePreviewInteraction } from "./usePreviewInteraction";
@@ -52,6 +54,8 @@ export interface UseDomEditSessionParams {
5254
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
5355
reloadPreview: () => void;
5456
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
57+
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
58+
selectSidebarTab?: (tab: SidebarTab) => void;
5559
}
5660

5761
// ── Hook ──
@@ -87,8 +91,25 @@ export function useDomEditSession({
8791
syncPreviewHistoryHotkey,
8892
reloadPreview,
8993
setRefreshKey: _setRefreshKey,
94+
openSourceForSelection,
95+
selectSidebarTab,
9096
}: UseDomEditSessionParams) {
9197
void _setRefreshKey;
98+
99+
const onClickToSource = useCallback(
100+
(selection: DomEditSelection) => {
101+
if (!openSourceForSelection || !selectSidebarTab) return;
102+
if (!selection.sourceFile) return;
103+
selectSidebarTab("code");
104+
openSourceForSelection(selection.sourceFile, {
105+
id: selection.id,
106+
selector: selection.selector,
107+
selectorIndex: selection.selectorIndex,
108+
});
109+
},
110+
[openSourceForSelection, selectSidebarTab],
111+
);
112+
92113
// ── Selection (delegated to useDomSelection) ──
93114

94115
const {
@@ -164,6 +185,7 @@ export function useDomEditSession({
164185
setAgentPromptSelectionContext,
165186
setAgentModalAnchorPoint,
166187
setAgentModalOpen,
188+
onClickToSource,
167189
});
168190

169191
// ── Commit handlers (delegated to useDomEditCommits) ──

packages/studio/src/hooks/useFileManager.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
44
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
55
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
66
import type { EditHistoryKind } from "../utils/editHistory";
7+
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
78

89
// ── Types ──
910

@@ -37,6 +38,7 @@ export function useFileManager({
3738
const [projectDir, setProjectDir] = useState<string | null>(null);
3839
const [fileTree, setFileTree] = useState<string[]>([]);
3940
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
41+
const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);
4042

4143
// ── Refs ──
4244

@@ -169,6 +171,42 @@ export function useFileManager({
169171
[domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
170172
);
171173

174+
// ── Open source for selection (click-to-source) ──
175+
176+
const revealRequestIdRef = useRef(0);
177+
const revealAbortRef = useRef<AbortController | null>(null);
178+
179+
const openSourceForSelection = useCallback(
180+
(sourceFile: string, target: PatchTarget) => {
181+
const pid = projectIdRef.current;
182+
if (!pid || !sourceFile) return;
183+
revealAbortRef.current?.abort();
184+
revealAbortRef.current = null;
185+
if (editingPathRef.current === sourceFile && editingFile?.content != null) {
186+
const match = findTagByTarget(editingFile.content, target);
187+
setRevealSourceOffset(match ? match.start : null);
188+
return;
189+
}
190+
const requestId = ++revealRequestIdRef.current;
191+
const controller = new AbortController();
192+
revealAbortRef.current = controller;
193+
fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, {
194+
signal: controller.signal,
195+
})
196+
.then((r) => r.json())
197+
.then((data: { content?: string }) => {
198+
if (requestId !== revealRequestIdRef.current) return;
199+
if (data.content != null) {
200+
setEditingFile({ path: sourceFile, content: data.content });
201+
const match = findTagByTarget(data.content, target);
202+
setRevealSourceOffset(match ? match.start : null);
203+
}
204+
})
205+
.catch(() => {});
206+
},
207+
[editingFile?.content],
208+
);
209+
172210
// ── File tree refresh ──
173211

174212
const refreshFileTree = useCallback(async () => {
@@ -418,6 +456,10 @@ export function useFileManager({
418456
writeProjectFile,
419457
readOptionalProjectFile,
420458

459+
// Click-to-source
460+
revealSourceOffset,
461+
openSourceForSelection,
462+
421463
// Callbacks
422464
handleFileSelect,
423465
handleContentChange,

packages/studio/src/hooks/usePreviewInteraction.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface UsePreviewInteractionParams {
3737
setAgentPromptSelectionContext: (context: string | undefined) => void;
3838
setAgentModalAnchorPoint: (point: AgentModalAnchorPoint | null) => void;
3939
setAgentModalOpen: (open: boolean) => void;
40+
41+
onClickToSource?: (selection: DomEditSelection) => void;
4042
}
4143

4244
// ── Hook ──
@@ -53,6 +55,7 @@ export function usePreviewInteraction({
5355
setAgentPromptSelectionContext,
5456
setAgentModalAnchorPoint,
5557
setAgentModalOpen,
58+
onClickToSource,
5659
}: UsePreviewInteractionParams) {
5760
const handlePreviewCanvasMouseDown = useCallback(
5861
(e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
@@ -70,6 +73,9 @@ export function usePreviewInteraction({
7073
? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
7174
: null;
7275
applyDomSelection(nextSelection, { additive: e.shiftKey });
76+
if (!e.shiftKey && e.altKey && onClickToSource) {
77+
onClickToSource(nextSelection);
78+
}
7379
if (
7480
!e.shiftKey &&
7581
localPointer &&
@@ -87,6 +93,7 @@ export function usePreviewInteraction({
8793
applyDomSelection,
8894
captionEditMode,
8995
compositionLoading,
96+
onClickToSource,
9097
preloadAgentPromptSnippet,
9198
resolveDomSelectionFromPreviewPoint,
9299
previewIframeRef,

packages/studio/src/player/components/Player.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
268268
if (assetPollRef.current) clearInterval(assetPollRef.current);
269269
assetPollRef.current = null;
270270
container.removeChild(player);
271-
// Clear the forwarded ref
271+
// Clear the forwarded ref only if it still points to THIS iframe.
272+
// During crossfade refreshes the retiring Player unmounts after the
273+
// new Player has already assigned its iframe to the same ref — blindly
274+
// nulling it would break seeking in the new Player.
275+
// Callback refs are skipped — we can't read back the current value to
276+
// guard against clobbering a newer assignment. The mutable-ref branch
277+
// (the only path used today) is guarded by identity check.
272278
if (typeof ref === "function") {
273-
ref(null);
279+
// no-op: can't safely guard callback refs
274280
} else if (ref) {
275-
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = null;
281+
const mutableRef = ref as React.MutableRefObject<HTMLIFrameElement | null>;
282+
if (mutableRef.current === iframe) {
283+
mutableRef.current = null;
284+
}
276285
}
277286
};
278287
});

packages/studio/src/player/components/PlayerControls.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,39 @@ export const PlayerControls = memo(function PlayerControls({
207207

208208
seekFromClientX(e.clientX);
209209

210+
// During drag, update the slider visual immediately on every pointer
211+
// event but RAF-throttle the actual onSeek call. The seek path triggers
212+
// adapter.seek + setCurrentTime + React re-renders which can take >16ms
213+
// on complex compositions — keeping visual feedback on the raw event and
214+
// batching the expensive work to one call per frame keeps scrubbing at
215+
// 60 fps.
216+
let seekRafId = 0;
217+
let pendingClientX = e.clientX;
210218
const onMove = (ev: PointerEvent) => {
211-
if (ev.pointerId !== pointerId) return;
212-
if (isDraggingRef.current) seekFromClientX(ev.clientX);
219+
if (ev.pointerId !== pointerId || !isDraggingRef.current) return;
220+
pendingClientX = ev.clientX;
221+
const bar = seekBarRef.current;
222+
const dur = durationRef.current;
223+
if (bar && dur > 0) {
224+
const rect = bar.getBoundingClientRect();
225+
const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100;
226+
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
227+
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
228+
}
229+
if (!seekRafId) {
230+
seekRafId = requestAnimationFrame(() => {
231+
seekRafId = 0;
232+
if (isDraggingRef.current) seekFromClientX(pendingClientX);
233+
});
234+
}
213235
};
214236
const cleanup = () => {
215237
isDraggingRef.current = false;
238+
if (seekRafId) {
239+
cancelAnimationFrame(seekRafId);
240+
seekRafId = 0;
241+
}
242+
seekFromClientX(pendingClientX);
216243
try {
217244
target.releasePointerCapture(pointerId);
218245
} catch {

packages/studio/src/player/components/useTimelineRangeSelection.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export function useTimelineRangeSelection({
3838
anchorY: number;
3939
} | null>(null);
4040

41+
const seekRafRef = useRef(0);
42+
const pendingClientXRef = useRef(0);
43+
4144
const handlePointerDown = useCallback(
4245
(e: React.PointerEvent) => {
4346
if (e.button !== 0) return;
@@ -80,8 +83,27 @@ export function useTimelineRangeSelection({
8083
return;
8184
}
8285
if (!isDragging.current) return;
83-
seekFromX(e.clientX);
84-
autoScrollDuringDrag(e.clientX);
86+
pendingClientXRef.current = e.clientX;
87+
// Update the playhead visual immediately via liveTime for smooth feedback,
88+
// then RAF-throttle the full seek (adapter + React state sync).
89+
const el = scrollRef.current;
90+
if (el) {
91+
const rect = el.getBoundingClientRect();
92+
const x = e.clientX - rect.left + el.scrollLeft - GUTTER;
93+
if (x >= 0) {
94+
const dur = el.scrollWidth / pps;
95+
liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
96+
}
97+
}
98+
if (!seekRafRef.current) {
99+
seekRafRef.current = requestAnimationFrame(() => {
100+
seekRafRef.current = 0;
101+
if (isDragging.current) {
102+
seekFromX(pendingClientXRef.current);
103+
autoScrollDuringDrag(pendingClientXRef.current);
104+
}
105+
});
106+
}
85107
},
86108
[seekFromX, autoScrollDuringDrag, pps, scrollRef, isDragging],
87109
);
@@ -104,9 +126,14 @@ export function useTimelineRangeSelection({
104126
});
105127
return;
106128
}
129+
if (seekRafRef.current) {
130+
cancelAnimationFrame(seekRafRef.current);
131+
seekRafRef.current = 0;
132+
}
133+
seekFromX(pendingClientXRef.current);
107134
isDragging.current = false;
108135
cancelAnimationFrame(dragScrollRaf.current);
109-
}, [isDragging, dragScrollRaf, setShowPopover]);
136+
}, [isDragging, dragScrollRaf, setShowPopover, seekFromX]);
110137

111138
return {
112139
rangeSelection,

0 commit comments

Comments
 (0)