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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user