diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index 3a794308..498e7251 100644 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -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 ; 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 (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) { + 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(e.clientX, SIDEBAR_MAX_WIDTH))) + setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH))) } } const handlePointerUp = () => { isResizing.current = false - isExpanding.current = false setIsDragging(false) document.body.style.cursor = '' document.body.style.userSelect = '' @@ -103,37 +109,36 @@ function LeftColumn({ } }, [setWidth, setIsCollapsed, setIsDragging]) - if (isCollapsed) { - return ( -
- ) - } - return ( -
- -
- {renderTabContent(activePanel)} - {sidebarOverlay &&
{sidebarOverlay}
} -
+
+ + {!isCollapsed && ( +
+
+ {renderTabContent(activePanel)} + {sidebarOverlay &&
{sidebarOverlay}
} +
- {/* Resize handle + hit area */} -
-
-
+ {/* Resize handle + hit area */} +
+
+
+
+ )}
) } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index ac11aacf..f21499a0 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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) && (
- +
)} {!isCaptureMode && ( diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index ad8b0fc9..1d58f585 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -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 ``, 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 + return } 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 } - return panelForType(selectedNodeType) + return panelForType(selectedNodeType, inspectorFooter) } diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index 951a0127..d389f5aa 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -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 */}
{children}
+ + {footer &&
{footer}
}
) } diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index fe7d5d17..5f4a38fc 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -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 ( - + {parametrics.groups.map((group, gi) => ( {group.fields.map((field, fi) => ( diff --git a/packages/editor/src/components/ui/sidebar/tab-bar.tsx b/packages/editor/src/components/ui/sidebar/tab-bar.tsx index 89393c0e..32807eb5 100644 --- a/packages/editor/src/components/ui/sidebar/tab-bar.tsx +++ b/packages/editor/src/components/ui/sidebar/tab-bar.tsx @@ -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) {
) } + +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 ( +
+ {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 ( + + ) + })} +
+ ) +}