diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 855ecc53f..2f873e69e 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,15 +5,24 @@ "moduleResolution": "bundler", "baseUrl": ".", "paths": { - "@hyperframes/producer": ["../producer/src/index.ts"] + "@hyperframes/producer": [ + "../producer/src/index.ts" + ] }, "strict": true, "noUncheckedIndexedAccess": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "./dist", - "declaration": true + "declaration": true, + "ignoreDeprecations": "5.0", + "rootDir": ".." }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file 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});` 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; +} diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts index e777206bb..f4ab27d1f 100644 --- a/packages/studio/src/hooks/useFrameCapture.ts +++ b/packages/studio/src/hooks/useFrameCapture.ts @@ -51,7 +51,7 @@ export function useFrameCapture({ document.body.appendChild(link); link.click(); link.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 0); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } catch (err) { showToast(err instanceof Error ? err.message : "Capture failed"); } diff --git a/packages/studio/tsconfig.json b/packages/studio/tsconfig.json index 2d6dc608f..f720d6c76 100644 --- a/packages/studio/tsconfig.json +++ b/packages/studio/tsconfig.json @@ -5,7 +5,9 @@ "moduleResolution": "bundler", "baseUrl": ".", "paths": { - "@hyperframes/player": ["../player/src/hyperframes-player.ts"] + "@hyperframes/player": [ + "../player/src/hyperframes-player.ts" + ] }, "jsx": "react-jsx", "strict": true, @@ -17,14 +19,27 @@ "sourceMap": true, "outDir": "dist", "rootDir": "..", - "types": ["vite/client"], - "lib": ["dom", "dom.iterable", "esnext"], + "types": [ + "vite/client" + ], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "noEmit": true, "incremental": true, "resolveJsonModule": true, - "isolatedModules": true + "isolatedModules": true, + "ignoreDeprecations": "5.0" }, - "include": ["src"], - "exclude": ["dist", "node_modules", "src/**/*.test.ts"] -} + "include": [ + "src" + ], + "exclude": [ + "dist", + "node_modules", + "src/**/*.test.ts" + ] +} \ No newline at end of file