feat(editor): vertical icon rail + headless inspector footer slot (v2) (#350)

Replace the v2 left column's horizontal tab bar with an always-visible
vertical icon rail. Clicking the active icon collapses the panel (rail
stays); clicking any icon while collapsed reopens it at the persisted
width (clamped to the minimum). Resizer-drag collapse is preserved.

Add an `inspectorFooter` slot to <Editor> (v2), threaded through
PanelManager → ParametricInspector → PanelWrapper, so embedders can dock
an affordance (e.g. "save as preset") below the node inspector.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-29 15:37:03 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent f51cea9a85
commit 891e578481
6 changed files with 123 additions and 58 deletions
@@ -5,12 +5,14 @@ import { useIsMobile } from '../../hooks/use-mobile'
import useEditor from '../../store/use-editor'
import { useSidebarStore } from '../ui/primitives/sidebar'
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
import { IconRail, type SidebarTab } from '../ui/sidebar/tab-bar'
import { EditorLayoutMobile } from './editor-layout-mobile'
const SIDEBAR_MIN_WIDTH = 300
const SIDEBAR_MAX_WIDTH = 800
const SIDEBAR_COLLAPSE_THRESHOLD = 220
// Matches the `w-12` rail in <IconRail>; the resize math is relative to it.
const RAIL_WIDTH = 48
// ── Left column: resizable panel with tab bar ────────────────────────────────
@@ -33,7 +35,6 @@ function LeftColumn({
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
const isResizing = useRef(false)
const isExpanding = useRef(false)
// Ensure active panel is a valid tab
useEffect(() => {
@@ -62,35 +63,40 @@ function LeftColumn({
[setIsDragging],
)
const handleGrabDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault()
isExpanding.current = true
setIsDragging(true)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
// Rail click: reopen a collapsed panel, collapse when re-clicking the open
// tab, otherwise switch tabs. Reopening clamps below-min persisted widths
// up to the minimum so the panel always returns to a usable size.
const handleRailClick = useCallback(
(id: string) => {
if (isCollapsed) {
setIsCollapsed(false)
if (width < SIDEBAR_MIN_WIDTH) setWidth(SIDEBAR_MIN_WIDTH)
setActivePanel(id)
return
}
if (id === activePanel) {
setIsCollapsed(true)
return
}
setActivePanel(id)
},
[setIsDragging],
[isCollapsed, width, activePanel, setIsCollapsed, setWidth, setActivePanel],
)
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
if (isResizing.current) {
const newWidth = e.clientX
if (!isResizing.current) return
// Rail occupies the leftmost 48px; the panel starts after it.
const newWidth = e.clientX - RAIL_WIDTH
if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
setIsCollapsed(true)
} else {
setIsCollapsed(false)
setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
}
} else if (isExpanding.current && e.clientX > 60) {
setIsCollapsed(false)
setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH)))
}
}
const handlePointerUp = () => {
isResizing.current = false
isExpanding.current = false
setIsDragging(false)
document.body.style.cursor = ''
document.body.style.userSelect = ''
@@ -103,25 +109,22 @@ function LeftColumn({
}
}, [setWidth, setIsCollapsed, setIsDragging])
if (isCollapsed) {
return (
<div
className="relative h-full w-2 flex-shrink-0 cursor-col-resize transition-colors hover:bg-primary/20"
onPointerDown={handleGrabDown}
title="Expand sidebar"
<div className="relative z-10 flex h-full flex-shrink-0 bg-sidebar text-sidebar-foreground">
<IconRail
activeTab={activePanel}
collapsed={isCollapsed}
onIconClick={handleRailClick}
tabs={tabs}
/>
)
}
return (
{!isCollapsed && (
<div
className="relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground"
className="relative flex h-full flex-col"
style={{
width,
transition: isDragging ? 'none' : 'width 150ms ease',
}}
>
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
<div className="relative flex flex-1 flex-col overflow-hidden">
{renderTabContent(activePanel)}
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
@@ -135,6 +138,8 @@ function LeftColumn({
<div className="h-8 w-1 rounded-full bg-neutral-500" />
</div>
</div>
)}
</div>
)
}
@@ -125,6 +125,12 @@ export interface EditorProps {
sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
viewerToolbarLeft?: ReactNode
viewerToolbarRight?: ReactNode
/**
* Docked below the node inspector (v2). Hosts mount the "save as preset"
* affordance here so it reads as part of the inspector surface and shows
* only while a node is selected.
*/
inspectorFooter?: ReactNode
projectId?: string | null
@@ -930,6 +936,7 @@ export default function Editor({
sidebarTabs,
viewerToolbarLeft,
viewerToolbarRight,
inspectorFooter,
projectId,
onLoad,
onSave,
@@ -1124,11 +1131,12 @@ export default function Editor({
}
const tabBarTabs =
sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({
sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({
id,
label,
mobileDefaultSnap,
mobileIcon,
icon,
})) ?? []
return (
@@ -1158,7 +1166,7 @@ export default function Editor({
)}
{!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto">
<PanelManager />
<PanelManager inspectorFooter={inspectorFooter} />
</div>
)}
{!isCaptureMode && (
@@ -74,7 +74,7 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
return !!node && MOVABLE_TYPES.has(node.type)
}
function panelForType(type: string | null) {
function panelForType(type: string | null, footer?: React.ReactNode) {
if (!type) return null
// Every kind now renders through `<ParametricInspector>`, which either
// composes auto-derived editors from `parametrics.groups` or lazy-
@@ -84,7 +84,7 @@ function panelForType(type: string | null) {
// future cases where we might want a non-registry fallback (e.g.
// reference scale, paint mode); leave the function shape intact.
void type
return <ParametricInspector />
return <ParametricInspector footer={footer} />
}
function MobilePanelLayer({
@@ -168,7 +168,7 @@ function MobilePanelLayer({
)
}
export function PanelManager() {
export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
const isMobile = useIsMobile()
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
@@ -215,5 +215,5 @@ export function PanelManager() {
return <PaintPanel />
}
return panelForType(selectedNodeType)
return panelForType(selectedNodeType, inspectorFooter)
}
@@ -16,6 +16,8 @@ interface PanelWrapperProps {
onReset?: () => void
onBack?: () => void
children: React.ReactNode
/** Pinned below the scrollable body, inside the panel card. */
footer?: React.ReactNode
className?: string
width?: number | string
}
@@ -27,6 +29,7 @@ export function PanelWrapper({
onReset,
onBack,
children,
footer,
className,
width = 320, // default width
}: PanelWrapperProps) {
@@ -104,6 +107,8 @@ export function PanelWrapper({
{/* Content */}
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
{footer && <div className="shrink-0 border-border/50 border-t p-3">{footer}</div>}
</div>
)
}
@@ -37,7 +37,7 @@ import { PanelWrapper } from './panel-wrapper'
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor
* can't be auto-generated (topology editors etc.).
*/
export function ParametricInspector() {
export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {}) {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const setSelection = useViewer((s) => s.setSelection)
// Subscribe only to the *type* — a string primitive that doesn't change
@@ -101,7 +101,7 @@ export function ParametricInspector() {
const canDelete = def.capabilities.deletable !== false
return (
<PanelWrapper icon={iconNode} onClose={handleClose} title={title} width={320}>
<PanelWrapper footer={footer} icon={iconNode} onClose={handleClose} title={title} width={320}>
{parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => (
@@ -8,6 +8,8 @@ export type SidebarTab = {
label: string
mobileDefaultSnap?: number
mobileIcon?: ReactNode
/** Desktop icon shown in the vertical rail (v2 layout). */
icon?: ReactNode
}
interface TabBarProps {
@@ -40,3 +42,48 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
</div>
)
}
interface IconRailProps {
tabs: SidebarTab[]
/** Highlighted tab. Stays highlighted while the panel is collapsed. */
activeTab: string
/** True when the panel beside the rail is collapsed. */
collapsed: boolean
/** Clicking a rail icon: switch tab, or toggle the panel (see layout). */
onIconClick: (id: string) => void
}
/**
* Vertical icon rail for the v2 left column. Always visible (even when the
* panel is collapsed) so the user can reopen the panel by clicking an icon.
* The label renders as a hover tooltip via the native `title`.
*/
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
return (
<div className="flex h-full w-12 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
{tabs.map((tab) => {
// While expanded, the active tab is filled. While collapsed, nothing
// is "open", so the active tab reads as a muted highlight instead.
const isActive = activeTab === tab.id
return (
<button
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg transition-colors',
isActive && !collapsed
? 'bg-accent text-foreground'
: isActive
? 'text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)}
key={tab.id}
onClick={() => onIconClick(tab.id)}
title={tab.label}
type="button"
>
{tab.icon ?? tab.label.charAt(0)}
</button>
)
})}
</div>
)
}