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
@@ -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,