mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<EditorView | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(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 <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<React.SetStateAction<number>>;
|
||||
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) ──
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [fileTree, setFileTree] = useState<string[]>([]);
|
||||
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
|
||||
const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(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<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,
|
||||
})
|
||||
.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,
|
||||
|
||||
@@ -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<HTMLDivElement>, 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user