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:
co-authored by
Claude Opus 4.7
parent
f51cea9a85
commit
891e578481
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user