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; +}