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:
Miguel Ángel
2026-05-16 04:13:06 +02:00
committed by GitHub
parent f84cc492de
commit 58a370939c
11 changed files with 170 additions and 12 deletions
+24 -2
View File
@@ -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,