Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .filesize-allowlist
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
packages/studio/src/player/hooks/useTimelinePlayer.ts
4 changes: 3 additions & 1 deletion packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,15 @@ export function StudioApp() {
domEditSession.domEditSelection,
)
: null;
const layersPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
const designPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design";
const motionPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED &&
STUDIO_MOTION_PANEL_ENABLED &&
panelLayout.rightPanelTab === "motion";
const inspectorPanelActive = designPanelActive || motionPanelActive;
const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive;
const shouldShowSelectedDomBounds =
inspectorPanelActive && !panelLayout.rightCollapsed && !isPlaying;
const inspectorButtonActive =
Expand Down
16 changes: 15 additions & 1 deletion packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PropertyPanel } from "./editor/PropertyPanel";
import { MotionPanel } from "./editor/MotionPanel";
import { LayersPanel } from "./editor/LayersPanel";
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
import { RenderQueue } from "./renders/RenderQueue";
import type { RenderJob } from "./renders/useRenderQueue";
Expand Down Expand Up @@ -115,6 +116,17 @@ export function StudioRightPanel({
>
Design
</button>
<button
type="button"
onClick={() => setRightPanelTab("layers")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
rightPanelTab === "layers"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
}`}
>
Layers
</button>
{STUDIO_MOTION_PANEL_ENABLED && (
<button
type="button"
Expand Down Expand Up @@ -143,7 +155,9 @@ export function StudioRightPanel({
</button>
</div>
<div className="min-h-0 flex-1">
{designPanelActive ? (
{rightPanelTab === "layers" ? (
<LayersPanel />
) : designPanelActive ? (
<PropertyPanel
projectId={projectId}
assets={assets}
Expand Down
302 changes: 302 additions & 0 deletions packages/studio/src/components/editor/LayersPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
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<string, string> = {
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<DomEditLayerItem[]>([]);
const [collapsed, setCollapsed] = useState<CollapsedState>({});
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<HTMLElement>("[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]);

useEffect(() => {
const ref = hoverSeekTimerRef;
return () => {
if (ref.current) clearTimeout(ref.current);
};
}, []);

const resolveSelection = useCallback(
(layer: DomEditLayerItem) =>
resolveDomEditSelection(layer.element, {
activeCompositionPath: activeCompPath,
isMasterView,
preferClipAncestor: false,
}),
[activeCompPath, isMasterView],
);

const hoverSeekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

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) {
usePlayerStore.getState().requestSeek(el.start + 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) {
if (hoverSeekTimerRef.current) clearTimeout(hoverSeekTimerRef.current);
updateDomEditHoverSelection(null);
return;
}
const selection = resolveSelection(layer);
updateDomEditHoverSelection(selection);
// Debounce hover seeks so brushing past items doesn't thrash the player
if (hoverSeekTimerRef.current) clearTimeout(hoverSeekTimerRef.current);
hoverSeekTimerRef.current = setTimeout(() => seekToLayer(layer), 300);
},
[resolveSelection, updateDomEditHoverSelection, seekToLayer],
);

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 (
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
<Layers size={18} className="mb-3 text-neutral-600" />
<p className="text-sm font-medium text-neutral-200">No layers</p>
<p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
</div>
);
}

return (
<div
className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900"
onPointerLeave={() => handleLayerHover(null)}
>
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
{layers.length} layer{layers.length === 1 ? "" : "s"}
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{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 (
<div
key={layer.key}
role="button"
tabIndex={0}
onClick={() => 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 ? (
<button
type="button"
onClick={(e) => toggleCollapse(layer.key, e)}
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300"
>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
fill="currentColor"
className={`transition-transform ${isCollapsed ? "" : "rotate-90"}`}
>
<path d="M2 1l4 3-4 3z" />
</svg>
</button>
) : (
<span className="w-4 flex-shrink-0" />
)}
<span
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
selected
? "bg-studio-accent/18 text-studio-accent"
: isCompHost
? "bg-blue-900/40 text-blue-400"
: "bg-neutral-800 text-neutral-500"
}`}
>
{getTagBadge(layer.tagName)}
</span>
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
{hasChildren && (
<span className="text-[9px] tabular-nums text-neutral-600">{layer.childCount}</span>
)}
</div>
);
})}
</div>
</div>
);
});

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