From efea6497fc9f9710daac70423cafe6a113f5a85b Mon Sep 17 00:00:00 2001 From: Jefsky Agent Date: Wed, 13 May 2026 19:33:42 +0800 Subject: [PATCH 1/2] fix(studio): keep layer selection seek within clip range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only seek when the current playback time is outside the selected clip's range. When the user clicks a layer whose clip already contains the current time, the seek is skipped — avoiding unwanted preview jumps during playback. Fixes heygen-com/hyperframes#792 --- .../src/components/editor/LayersPanel.tsx | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 packages/studio/src/components/editor/LayersPanel.tsx diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx new file mode 100644 index 000000000..662458d71 --- /dev/null +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -0,0 +1,295 @@ +import { memo, useState, useCallback, useEffect, useRef } from "react"; +import { + collectDomEditLayerItems, + getDomEditLayerKey, + resolveDomEditSelection, + type DomEditLayerItem, +} from "./domEditing"; +import { useStudioContext } from "../../contexts/StudioContext"; +import { useDomEditContext } from "../../contexts/DomEditContext"; +import { usePlayerStore } from "../../player"; +import { findMatchingTimelineElementId } from "../../utils/studioHelpers"; +import { Layers } from "../../icons/SystemIcons"; + +const TAG_ICONS: Record = { + video: "Vi", + audio: "Au", + img: "Im", + svg: "Sv", + canvas: "Cn", + div: "Di", + section: "Se", + span: "Sp", + p: "P", + h1: "H1", + h2: "H2", + h3: "H3", + h4: "H4", + h5: "H5", + h6: "H6", + a: "A", + button: "Bt", + ul: "Ul", + ol: "Ol", + li: "Li", + style: "St", + template: "Te", +}; + +function getTagBadge(tagName: string): string { + return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase(); +} + +function isCompositionHost(el: HTMLElement): boolean { + return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file"); +} + +interface CollapsedState { + [key: string]: boolean; +} + +export const LayersPanel = memo(function LayersPanel() { + const { previewIframeRef, activeCompPath, refreshKey, compositionLoading, timelineElements } = + useStudioContext(); + const { domEditSelection, applyDomSelection, updateDomEditHoverSelection } = useDomEditContext(); + + const [layers, setLayers] = useState([]); + const [collapsed, setCollapsed] = useState({}); + const prevDocVersionRef = useRef(0); + + const isMasterView = !activeCompPath || activeCompPath === "index.html"; + + const collectLayers = useCallback(() => { + const iframe = previewIframeRef.current; + if (!iframe) return; + let doc: Document | null = null; + try { + doc = iframe.contentDocument; + } catch { + return; + } + if (!doc) return; + + const root = + doc.querySelector("[data-composition-id]") ?? doc.documentElement ?? null; + if (!root) return; + + const items = collectDomEditLayerItems(root, { + activeCompositionPath: activeCompPath, + isMasterView, + }); + setLayers(items); + }, [previewIframeRef, activeCompPath, isMasterView]); + + useEffect(() => { + collectLayers(); + }, [collectLayers, refreshKey]); + + useEffect(() => { + const iframe = previewIframeRef.current; + if (!iframe) return; + const handleLoad = () => { + prevDocVersionRef.current += 1; + collectLayers(); + }; + iframe.addEventListener("load", handleLoad); + return () => iframe.removeEventListener("load", handleLoad); + }, [previewIframeRef, collectLayers]); + + useEffect(() => { + if (!compositionLoading) { + const timer = setTimeout(collectLayers, 100); + return () => clearTimeout(timer); + } + }, [compositionLoading, collectLayers]); + + const resolveSelection = useCallback( + (layer: DomEditLayerItem) => + resolveDomEditSelection(layer.element, { + activeCompositionPath: activeCompPath, + isMasterView, + preferClipAncestor: false, + }), + [activeCompPath, isMasterView], + ); + + const seekToLayer = useCallback( + (layer: DomEditLayerItem) => { + const selection = resolveSelection(layer); + if (!selection) return; + + let matchedId = findMatchingTimelineElementId(selection, timelineElements); + + // No direct match — walk up DOM ancestors to find the nearest element + // that has a timeline entry (e.g. a child of scene1 seeks to scene1.start) + if (!matchedId) { + const sourceFile = selection.sourceFile ?? "index.html"; + let ancestor = layer.element.parentElement; + while (ancestor && !matchedId) { + const elId = ancestor.id; + if (elId) { + const found = timelineElements.find( + (e) => e.domId === elId && (e.sourceFile ?? "index.html") === sourceFile, + ); + if (found) matchedId = found.key ?? found.id; + } + ancestor = ancestor.parentElement; + } + } + + if (matchedId) { + const el = timelineElements.find((e) => (e.key ?? e.id) === matchedId); + if (el) { + const { currentTime } = usePlayerStore.getState(); + const clipStart = el.start; + const clipEnd = el.start + el.duration; + // Only seek if current time is outside the clip range + if (currentTime < clipStart || currentTime > clipEnd) { + usePlayerStore.getState().requestSeek(clipStart + el.duration / 2); + } + } + } + }, + [resolveSelection, timelineElements], + ); + + const handleSelectLayer = useCallback( + (layer: DomEditLayerItem) => { + const selection = resolveSelection(layer); + if (!selection) return; + applyDomSelection(selection); + seekToLayer(layer); + }, + [resolveSelection, applyDomSelection, seekToLayer], + ); + + const handleLayerHover = useCallback( + (layer: DomEditLayerItem | null) => { + if (!layer) { + updateDomEditHoverSelection(null); + return; + } + const selection = resolveSelection(layer); + updateDomEditHoverSelection(selection); + }, + [resolveSelection, updateDomEditHoverSelection], + ); + + const toggleCollapse = useCallback((key: string, e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsed((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null; + + const visibleLayers = getVisibleLayers(layers, collapsed); + + if (layers.length === 0) { + return ( +
+ +

No layers

+

Load a composition to see its element tree

+
+ ); + } + + return ( +
handleLayerHover(null)} + > +
+ {layers.length} layer{layers.length === 1 ? "" : "s"} +
+
+ {visibleLayers.map((layer) => { + const selected = layer.key === selectedKey; + const isCollapsed = collapsed[layer.key] ?? false; + const hasChildren = layer.childCount > 0; + const isCompHost = isCompositionHost(layer.element); + + return ( +
handleSelectLayer(layer)} + onPointerEnter={() => handleLayerHover(layer)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleSelectLayer(layer); + } + }} + className={`group flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left transition-colors ${ + selected + ? "bg-studio-accent/14 text-studio-accent" + : "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100" + }`} + style={{ paddingLeft: 8 + layer.depth * 16 }} + > + {hasChildren ? ( + + ) : ( + + )} + + {getTagBadge(layer.tagName)} + + {layer.label} + {hasChildren && ( + {layer.childCount} + )} +
+ ); + })} +
+
+ ); +}); + +function getVisibleLayers( + layers: DomEditLayerItem[], + collapsed: CollapsedState, +): DomEditLayerItem[] { + if (Object.keys(collapsed).length === 0) return layers; + + const result: DomEditLayerItem[] = []; + let skipDepth = -1; + + for (const layer of layers) { + if (skipDepth >= 0 && layer.depth > skipDepth) continue; + skipDepth = -1; + + result.push(layer); + + if (collapsed[layer.key] && layer.childCount > 0) { + skipDepth = layer.depth; + } + } + + return result; +} From 7a68285c506fa0db87bf1b484dac57a46c452a92 Mon Sep 17 00:00:00 2001 From: Jefsky Agent Date: Wed, 13 May 2026 20:03:40 +0800 Subject: [PATCH 2/2] fix(studio): use JSON.stringify for caption text escaping Replace manual backslash/single-quote escaping with JSON.stringify() in caption JS generation. The manual approach missed newlines, carriage returns, and other special characters that can break the generated JavaScript syntax. JSON.stringify handles all special characters and produces a double-quoted string literal that is always valid JS. Fixes heygen-com/hyperframes#625 --- packages/studio/src/captions/generator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/captions/generator.ts b/packages/studio/src/captions/generator.ts index a0a2a45b6..44b8ed075 100644 --- a/packages/studio/src/captions/generator.ts +++ b/packages/studio/src/captions/generator.ts @@ -303,14 +303,14 @@ function generateJs(model: CaptionModel): string { // Build word spans const wordLines: string[] = groupSegments.map((seg) => { - const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + const escaped = JSON.stringify(seg.text); const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`; const idLine = seg.wordId ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId)};` : ""; return ( ` const ${segVar} = document.createElement('span');` + `\n ${segVar}.className = 'word clip';` + idLine + - `\n ${segVar}.textContent = '${escaped}';` + + `\n ${segVar}.textContent = ${escaped};` + `\n ${segVar}.dataset.start = '${seg.start}';` + `\n ${segVar}.dataset.end = '${seg.end}';` + `\n groupEl_${groupVar}.appendChild(${segVar});`