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 useEditor from '../../store/use-editor'
import { useSidebarStore } from '../ui/primitives/sidebar' 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' import { EditorLayoutMobile } from './editor-layout-mobile'
const SIDEBAR_MIN_WIDTH = 300 const SIDEBAR_MIN_WIDTH = 300
const SIDEBAR_MAX_WIDTH = 800 const SIDEBAR_MAX_WIDTH = 800
const SIDEBAR_COLLAPSE_THRESHOLD = 220 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 ──────────────────────────────── // ── Left column: resizable panel with tab bar ────────────────────────────────
@@ -33,7 +35,6 @@ function LeftColumn({
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
const isResizing = useRef(false) const isResizing = useRef(false)
const isExpanding = useRef(false)
// Ensure active panel is a valid tab // Ensure active panel is a valid tab
useEffect(() => { useEffect(() => {
@@ -62,35 +63,40 @@ function LeftColumn({
[setIsDragging], [setIsDragging],
) )
const handleGrabDown = useCallback( // Rail click: reopen a collapsed panel, collapse when re-clicking the open
(e: React.PointerEvent) => { // tab, otherwise switch tabs. Reopening clamps below-min persisted widths
e.preventDefault() // up to the minimum so the panel always returns to a usable size.
isExpanding.current = true const handleRailClick = useCallback(
setIsDragging(true) (id: string) => {
document.body.style.cursor = 'col-resize' if (isCollapsed) {
document.body.style.userSelect = 'none' 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(() => { useEffect(() => {
const handlePointerMove = (e: PointerEvent) => { const handlePointerMove = (e: PointerEvent) => {
if (isResizing.current) { if (!isResizing.current) return
const newWidth = e.clientX // Rail occupies the leftmost 48px; the panel starts after it.
if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) { const newWidth = e.clientX - RAIL_WIDTH
setIsCollapsed(true) if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) {
} else { setIsCollapsed(true)
setIsCollapsed(false) } else {
setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
}
} else if (isExpanding.current && e.clientX > 60) {
setIsCollapsed(false) setIsCollapsed(false)
setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH))) setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH)))
} }
} }
const handlePointerUp = () => { const handlePointerUp = () => {
isResizing.current = false isResizing.current = false
isExpanding.current = false
setIsDragging(false) setIsDragging(false)
document.body.style.cursor = '' document.body.style.cursor = ''
document.body.style.userSelect = '' document.body.style.userSelect = ''
@@ -103,37 +109,36 @@ function LeftColumn({
} }
}, [setWidth, setIsCollapsed, setIsDragging]) }, [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"
/>
)
}
return ( return (
<div <div className="relative z-10 flex h-full flex-shrink-0 bg-sidebar text-sidebar-foreground">
className="relative z-10 flex h-full flex-shrink-0 flex-col bg-sidebar text-sidebar-foreground" <IconRail
style={{ activeTab={activePanel}
width, collapsed={isCollapsed}
transition: isDragging ? 'none' : 'width 150ms ease', onIconClick={handleRailClick}
}} tabs={tabs}
> />
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} /> {!isCollapsed && (
<div className="relative flex flex-1 flex-col overflow-hidden"> <div
{renderTabContent(activePanel)} className="relative flex h-full flex-col"
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>} style={{
</div> width,
transition: isDragging ? 'none' : 'width 150ms ease',
}}
>
<div className="relative flex flex-1 flex-col overflow-hidden">
{renderTabContent(activePanel)}
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
</div>
{/* Resize handle + hit area */} {/* Resize handle + hit area */}
<div <div
className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center" className="absolute inset-y-0 -right-3 z-[100] flex w-6 cursor-col-resize items-center justify-center"
onPointerDown={handleResizerDown} onPointerDown={handleResizerDown}
> >
<div className="h-8 w-1 rounded-full bg-neutral-500" /> <div className="h-8 w-1 rounded-full bg-neutral-500" />
</div> </div>
</div>
)}
</div> </div>
) )
} }
@@ -125,6 +125,12 @@ export interface EditorProps {
sidebarTabs?: (SidebarTab & { component: React.ComponentType })[] sidebarTabs?: (SidebarTab & { component: React.ComponentType })[]
viewerToolbarLeft?: ReactNode viewerToolbarLeft?: ReactNode
viewerToolbarRight?: 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 projectId?: string | null
@@ -930,6 +936,7 @@ export default function Editor({
sidebarTabs, sidebarTabs,
viewerToolbarLeft, viewerToolbarLeft,
viewerToolbarRight, viewerToolbarRight,
inspectorFooter,
projectId, projectId,
onLoad, onLoad,
onSave, onSave,
@@ -1124,11 +1131,12 @@ export default function Editor({
} }
const tabBarTabs = const tabBarTabs =
sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({ sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({
id, id,
label, label,
mobileDefaultSnap, mobileDefaultSnap,
mobileIcon, mobileIcon,
icon,
})) ?? [] })) ?? []
return ( return (
@@ -1158,7 +1166,7 @@ export default function Editor({
)} )}
{!(isVersionPreviewMode || isCaptureMode) && ( {!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<PanelManager /> <PanelManager inspectorFooter={inspectorFooter} />
</div> </div>
)} )}
{!isCaptureMode && ( {!isCaptureMode && (
@@ -74,7 +74,7 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
return !!node && MOVABLE_TYPES.has(node.type) return !!node && MOVABLE_TYPES.has(node.type)
} }
function panelForType(type: string | null) { function panelForType(type: string | null, footer?: React.ReactNode) {
if (!type) return null if (!type) return null
// Every kind now renders through `<ParametricInspector>`, which either // Every kind now renders through `<ParametricInspector>`, which either
// composes auto-derived editors from `parametrics.groups` or lazy- // 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. // future cases where we might want a non-registry fallback (e.g.
// reference scale, paint mode); leave the function shape intact. // reference scale, paint mode); leave the function shape intact.
void type void type
return <ParametricInspector /> return <ParametricInspector footer={footer} />
} }
function MobilePanelLayer({ function MobilePanelLayer({
@@ -168,7 +168,7 @@ function MobilePanelLayer({
) )
} }
export function PanelManager() { export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
@@ -215,5 +215,5 @@ export function PanelManager() {
return <PaintPanel /> return <PaintPanel />
} }
return panelForType(selectedNodeType) return panelForType(selectedNodeType, inspectorFooter)
} }
@@ -16,6 +16,8 @@ interface PanelWrapperProps {
onReset?: () => void onReset?: () => void
onBack?: () => void onBack?: () => void
children: React.ReactNode children: React.ReactNode
/** Pinned below the scrollable body, inside the panel card. */
footer?: React.ReactNode
className?: string className?: string
width?: number | string width?: number | string
} }
@@ -27,6 +29,7 @@ export function PanelWrapper({
onReset, onReset,
onBack, onBack,
children, children,
footer,
className, className,
width = 320, // default width width = 320, // default width
}: PanelWrapperProps) { }: PanelWrapperProps) {
@@ -104,6 +107,8 @@ export function PanelWrapper({
{/* Content */} {/* Content */}
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div> <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> </div>
) )
} }
@@ -37,7 +37,7 @@ import { PanelWrapper } from './panel-wrapper'
* `parametrics.customPanel?` escape hatch for kinds whose parametric editor * `parametrics.customPanel?` escape hatch for kinds whose parametric editor
* can't be auto-generated (topology editors etc.). * 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 selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
// Subscribe only to the *type* — a string primitive that doesn't change // 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 const canDelete = def.capabilities.deletable !== false
return ( 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) => ( {parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}> <PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => ( {group.fields.map((field, fi) => (
@@ -8,6 +8,8 @@ export type SidebarTab = {
label: string label: string
mobileDefaultSnap?: number mobileDefaultSnap?: number
mobileIcon?: ReactNode mobileIcon?: ReactNode
/** Desktop icon shown in the vertical rail (v2 layout). */
icon?: ReactNode
} }
interface TabBarProps { interface TabBarProps {
@@ -40,3 +42,48 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
</div> </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>
)
}