feat(editor): fold canvas display controls into one Display dropdown (#370)

Replace the seven separate display-control buttons in the canvas toolbar
(render, theme, edges, grid, shadows, units, camera) with a single
"Display" dropdown, matching the simplification already shipped in the
hosted editor. Grid, shadows, camera and units are direct toggles that
keep the menu open; render, edges and theme are submenus.

Portal DropdownMenuSubContent so the side-opening submenus escape the
parent content's overflow-x-hidden clip, and re-export the submenu
primitives (Sub/SubTrigger/SubContent/Separator) from @pascal-app/editor.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-03 17:36:40 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ee98c55b65
commit 86db5decb8
3 changed files with 184 additions and 212 deletions
+168 -204
View File
@@ -5,6 +5,10 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
useEditor, useEditor,
useSidebarStore, useSidebarStore,
@@ -29,6 +33,7 @@ import {
Footprints, Footprints,
Grid2X2, Grid2X2,
PenLine, PenLine,
SlidersHorizontal,
Sparkles, Sparkles,
SwatchBook, SwatchBook,
} from 'lucide-react' } from 'lucide-react'
@@ -239,94 +244,8 @@ function WallModeToggle() {
) )
} }
function RenderModeMenu() { // One dropdown that gathers every "how the scene looks" control: grid, shadows,
const shading = useViewer((state) => state.shading) // camera projection, units, render mode, edges and scene theme.
const setShading = useViewer((state) => state.setShading)
const active = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
const ActiveIcon = active.icon
return (
<DropdownMenu>
<ToolbarTooltip label={`Render: ${active.name}`}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Render: ${active.name}`}
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
shading === 'rendered' && 'bg-white/10 text-foreground/90',
)}
type="button"
>
<ActiveIcon className="h-3.5 w-3.5" />
<span className="font-medium text-xs">{active.name}</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent align="center" className="min-w-56" side="bottom">
{SHADING_OPTIONS.map((option) => {
const OptionIcon = option.icon
return (
<DropdownMenuItem key={option.id} onSelect={() => setShading(option.id)}>
<OptionIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{shading === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
function SceneThemeMenu() {
const sceneTheme = useViewer((state) => state.sceneTheme)
const setSceneTheme = useViewer((state) => state.setSceneTheme)
const active = getSceneTheme(sceneTheme)
return (
<DropdownMenu>
<ToolbarTooltip label={`Scene theme: ${active.name}`}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Scene theme: ${active.name}`}
className={cn(TOOLBAR_BTN, 'w-28 gap-1.5 px-2.5 text-foreground/90')}
type="button"
>
<SwatchBook className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium text-xs">{active.name}</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent align="center" className="min-w-48" side="bottom">
{SCENE_THEMES.map((theme) => {
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
(role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
)
return (
<DropdownMenuItem key={theme.id} onSelect={() => setSceneTheme(theme.id)}>
<span
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
style={{ backgroundColor: theme.background }}
>
{swatches.map((color, index) => (
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
))}
</span>
<span className="text-foreground">{theme.name}</span>
{sceneTheme === theme.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
const EDGE_OPTIONS = [ const EDGE_OPTIONS = [
{ id: 'off', name: 'Off', detail: 'No edge lines' }, { id: 'off', name: 'Off', detail: 'No edge lines' },
@@ -334,127 +253,178 @@ const EDGE_OPTIONS = [
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] ] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
function EdgesMenu() { const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
const edges = useViewer((state) => state.edges)
const setEdges = useViewer((state) => state.setEdges)
const active = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
return ( function DisplayMenu() {
<DropdownMenu>
<ToolbarTooltip label={`Edges: ${active.name}`}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Edges: ${active.name}`}
className={cn(TOOLBAR_BTN, edges !== 'off' && 'bg-white/10 text-foreground/90')}
type="button"
>
<PenLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent align="center" className="min-w-56" side="bottom">
{EDGE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setEdges(option.id)}>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function GridVisibilityToggle() {
const showGrid = useViewer((state) => state.showGrid) const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid) const setShowGrid = useViewer((state) => state.setShowGrid)
const unit = useViewer((state) => state.unit)
return ( const setUnit = useViewer((state) => state.setUnit)
<ToolbarTooltip label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}> const cameraMode = useViewer((state) => state.cameraMode)
<button const setCameraMode = useViewer((state) => state.setCameraMode)
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`} const shading = useViewer((state) => state.shading)
aria-pressed={showGrid} const setShading = useViewer((state) => state.setShading)
className={cn( const sceneTheme = useViewer((state) => state.sceneTheme)
TOOLBAR_BTN, const setSceneTheme = useViewer((state) => state.setSceneTheme)
'w-auto gap-1.5 px-2.5', const edges = useViewer((state) => state.edges)
showGrid const setEdges = useViewer((state) => state.setEdges)
? 'bg-white/10 text-foreground/90'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={() => setShowGrid(!showGrid)}
type="button"
>
<Grid2X2 className="h-3.5 w-3.5" />
{showGrid ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</ToolbarTooltip>
)
}
function ShadowsToggle() {
const shadows = useViewer((state) => state.shadows) const shadows = useViewer((state) => state.shadows)
const setShadows = useViewer((state) => state.setShadows) const setShadows = useViewer((state) => state.setShadows)
return ( const activeShading =
<ToolbarTooltip label={`Shadows: ${shadows ? 'On' : 'Off'}`}> SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
<button const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
aria-label={`Shadows: ${shadows ? 'On' : 'Off'}`} const activeTheme = getSceneTheme(sceneTheme)
aria-pressed={shadows}
className={cn(
TOOLBAR_BTN,
shadows
? 'bg-white/10 text-foreground/90'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={() => setShadows(!shadows)}
type="button"
>
<Contrast className="h-3.5 w-3.5" />
</button>
</ToolbarTooltip>
)
}
function UnitToggle() { // Keep the menu open when flipping a toggle.
const unit = useViewer((state) => state.unit) const keepOpen = (event: Event, fn: () => void) => {
const setUnit = useViewer((state) => state.setUnit) event.preventDefault()
fn()
}
return ( return (
<ToolbarTooltip label={unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}> <DropdownMenu>
<button <ToolbarTooltip label="Display settings">
className={TOOLBAR_BTN} <DropdownMenuTrigger asChild>
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')} <button
type="button" aria-label="Display settings"
className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5 text-foreground/90')}
type="button"
>
<SlidersHorizontal className="h-3.5 w-3.5 shrink-0" />
<span className="font-medium text-xs">Display</span>
</button>
</DropdownMenuTrigger>
</ToolbarTooltip>
<DropdownMenuContent
align="end"
className="w-60 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl"
side="bottom"
sideOffset={8}
> >
<span className="font-semibold text-[10px]">{unit === 'metric' ? 'm' : 'ft'}</span> <DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShowGrid(!showGrid))}>
</button> <Grid2X2 className="h-4 w-4" />
</ToolbarTooltip> <span>Grid</span>
) {showGrid ? (
} <Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShadows(!shadows))}>
<Contrast className="h-4 w-4" />
<span>Shadows</span>
<span className="ml-auto text-muted-foreground text-xs">{shadows ? 'On' : 'Off'}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) =>
keepOpen(e, () =>
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'),
)
}
>
<IconifyIcon
height={16}
icon={cameraMode === 'perspective' ? 'icon-park-outline:perspective' : 'vaadin:grid'}
width={16}
/>
<span>Camera</span>
<span className="ml-auto text-muted-foreground text-xs">
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
>
<span className="flex h-4 w-4 items-center justify-center font-semibold text-[10px]">
{unit === 'metric' ? 'm' : 'ft'}
</span>
<span>Units</span>
<span className="ml-auto text-muted-foreground text-xs">
{unit === 'metric' ? 'Metric' : 'Imperial'}
</span>
</DropdownMenuItem>
function CameraModeToggle() { <DropdownMenuSeparator />
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
return ( <DropdownMenuSub>
<ToolbarTooltip label={cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}> <DropdownMenuSubTrigger>
<button <activeShading.icon className="h-4 w-4" />
className={cn( <span>Render</span>
TOOLBAR_BTN, <span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90', </DropdownMenuSubTrigger>
)} <DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
onClick={() => setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')} {SHADING_OPTIONS.map((option) => {
type="button" const OptionIcon = option.icon
> return (
{cameraMode === 'perspective' ? ( <DropdownMenuItem key={option.id} onSelect={() => setShading(option.id)}>
<IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} /> <OptionIcon className="h-4 w-4" />
) : ( <div className="flex flex-col">
<IconifyIcon height={16} icon="vaadin:grid" width={16} /> <span className="text-foreground">{option.name}</span>
)} <span className="text-muted-foreground text-xs">{option.detail}</span>
</button> </div>
</ToolbarTooltip> {shading === option.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PenLine className="h-4 w-4" />
<span>Edges</span>
<span className="ml-auto text-muted-foreground text-xs">{activeEdges.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
{EDGE_OPTIONS.map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setEdges(option.id)}>
<div className="flex flex-col">
<span className="text-foreground">{option.name}</span>
<span className="text-muted-foreground text-xs">{option.detail}</span>
</div>
{edges === option.id ? <Check className="ml-auto h-4 w-4 text-foreground" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<SwatchBook className="h-4 w-4" />
<span>Theme</span>
<span className="ml-auto truncate text-muted-foreground text-xs">
{activeTheme.name}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-48 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl">
{SCENE_THEMES.map((theme) => {
const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map(
(role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role],
)
return (
<DropdownMenuItem key={theme.id} onSelect={() => setSceneTheme(theme.id)}>
<span
className="grid h-5 w-5 shrink-0 grid-cols-2 overflow-hidden rounded-sm border border-black/10"
style={{ backgroundColor: theme.background }}
>
{swatches.map((color, index) => (
<span key={`${theme.id}-${index}`} style={{ backgroundColor: color }} />
))}
</span>
<span className="text-foreground">{theme.name}</span>
{sceneTheme === theme.id ? (
<Check className="ml-auto h-4 w-4 text-foreground" />
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
) )
} }
@@ -507,14 +477,8 @@ export function CommunityViewerToolbarRight() {
<div className={TOOLBAR_CONTAINER}> <div className={TOOLBAR_CONTAINER}>
<LevelModeToggle /> <LevelModeToggle />
<WallModeToggle /> <WallModeToggle />
<RenderModeMenu />
<SceneThemeMenu />
<EdgesMenu />
<GridVisibilityToggle />
<ShadowsToggle />
<div className="my-1.5 w-px bg-border/50" /> <div className="my-1.5 w-px bg-border/50" />
<UnitToggle /> <DisplayMenu />
<CameraModeToggle />
<div className="my-1.5 w-px bg-border/50" /> <div className="my-1.5 w-px bg-border/50" />
<WalkthroughButton /> <WalkthroughButton />
<PreviewButton /> <PreviewButton />
@@ -197,15 +197,19 @@ function DropdownMenuSubContent({
className, className,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
// Portalled so the side-opening submenu escapes the parent content's
// `overflow-x-hidden` clip (otherwise it renders but is invisible).
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.Portal>
className={cn( <DropdownMenuPrimitive.SubContent
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in', className={cn(
className, 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=closed]:animate-out data-[state=open]:animate-in',
)} className,
data-slot="dropdown-menu-sub-content" )}
{...props} data-slot="dropdown-menu-sub-content"
/> {...props}
/>
</DropdownMenuPrimitive.Portal>
) )
} }
+4
View File
@@ -138,6 +138,10 @@ export {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from './components/ui/primitives/dropdown-menu' } from './components/ui/primitives/dropdown-menu'
export { useSidebarStore } from './components/ui/primitives/sidebar' export { useSidebarStore } from './components/ui/primitives/sidebar'