From 6fe970651d5b1fbeba6e13b13d2c2e7df7e77a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 08:22:26 +0200 Subject: [PATCH] feat(studio): add Layers panel as new inspector tab (#784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): add Layers panel as new inspector tab Adds a dedicated Layers tab alongside Design and Renders in the right panel inspector. The panel shows the full composition element tree with collapsible hierarchy — clicking a layer selects it without navigating away from the tree view. Closes #783 Co-Authored-By: Claude Opus 4.6 (1M context) * feat(studio): add visual element previews to Layers panel Each layer row now shows a small color/content preview thumbnail: - Text elements show a snippet of their content in the actual font color - Container elements show their background color as a colored swatch - Image elements show a tiny thumbnail of the image - Media elements show an icon indicator Co-Authored-By: Claude Opus 4.6 (1M context) * feat(studio): add hover-to-highlight and auto-seek to Layers panel Replace tiny preview thumbnails with hover highlighting — hovering a layer row highlights the element in the preview canvas. Clicking a layer auto-seeks the playhead to that element's start time in the timeline. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(studio): layers panel tab order, autoseek, and renders toolbar overflow - Reorder inspector tabs to Design → Layers → Renders - Fix autoseek: walk DOM ancestors when selected element has no direct timeline match, so clicking a child like S2 Heading correctly seeks to the start of its parent scene - Fix Renders toolbar overflow: add flex-wrap to the export controls header so selects and the Export button wrap instead of clipping * fix(studio): seek to midpoint of element duration in layers panel autoseek * fix(studio): layers panel seek now drives adapter.seek via requestSeek signal setCurrentTime() only updated the store — adapter.seek() and liveTime.notify() were never called so the iframe never moved. Add requestedSeekTime to the player store; useTimelinePlayer subscribes and calls the real seek() path when it fires. * feat(studio): hover over a layer auto-seeks to element midpoint (300ms debounce) * feat(studio): add collapsible sections to Design panel Section component now supports collapse/expand with a chevron toggle. Text, Layout, and Fill sections stay expanded by default. Less-used sections (Flex, Radius, Stroke, Effects, Clip, Transparency) start collapsed to reduce scrolling. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(studio): remove stale selectedTimelineElement usages dropped in rebase * fix(studio): stabilize resetErrors in useConsoleErrorCapture to break render loop resetErrors was a new function object on every render. handlePreviewIframeRef had it as a dep, so it also changed every render. NLELayout's useEffect watching onIframeRef would re-fire, calling setPreviewIframe again, which re-ran useConsoleErrorCapture with the new iframe — infinite loop. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .filesize-allowlist | 1 + packages/studio/src/App.tsx | 4 +- .../src/components/StudioRightPanel.tsx | 16 +- .../src/components/editor/LayersPanel.tsx | 302 ++++++++++++++++++ .../editor/propertyPanelPrimitives.tsx | 29 +- .../editor/propertyPanelStyleSections.tsx | 12 +- .../src/components/renders/RenderQueue.tsx | 4 +- .../studio/src/contexts/DomEditContext.tsx | 3 + .../src/hooks/useConsoleErrorCapture.ts | 6 +- .../studio/src/hooks/useDomEditSession.ts | 1 + packages/studio/src/hooks/useDomSelection.ts | 33 +- .../src/player/hooks/useTimelinePlayer.ts | 12 +- .../studio/src/player/store/playerStore.ts | 12 + packages/studio/src/utils/studioHelpers.ts | 2 +- 14 files changed, 411 insertions(+), 26 deletions(-) create mode 100644 .filesize-allowlist create mode 100644 packages/studio/src/components/editor/LayersPanel.tsx diff --git a/.filesize-allowlist b/.filesize-allowlist new file mode 100644 index 000000000..bffcc916b --- /dev/null +++ b/.filesize-allowlist @@ -0,0 +1 @@ +packages/studio/src/player/hooks/useTimelinePlayer.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d0491c98e..e9c436b1c 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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 = diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index a1f8da704..e5ee751ca 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -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"; @@ -115,6 +116,17 @@ export function StudioRightPanel({ > Design + {STUDIO_MOTION_PANEL_ENABLED && ( + ) : ( + + )} + + {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/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index 436e4c9b8..6b766310a 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -334,24 +334,43 @@ export function Section({ icon, children, accessory, + defaultCollapsed = false, }: { title: string; icon: ReactNode; children: ReactNode; accessory?: ReactNode; + defaultCollapsed?: boolean; }) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + return ( -
-
+
+
- {children} +
+ {accessory} + + + +
+ + {!collapsed &&
{children}
}
); } diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index ea0d85a4a..5702fe764 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -109,7 +109,7 @@ export function StyleSections({ return ( <> {isFlex && ( -
}> +
} defaultCollapsed>
}> +
} defaultCollapsed> )} -
}> +
} defaultCollapsed>
-
}> +
} defaultCollapsed>
-
}> +
} defaultCollapsed>
-
}> +
} defaultCollapsed>
+
{/* Resolution must remain the leftmost