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:
co-authored by
Claude Opus 4.8
parent
ee98c55b65
commit
86db5decb8
@@ -5,6 +5,10 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
useEditor,
|
||||
useSidebarStore,
|
||||
@@ -29,6 +33,7 @@ import {
|
||||
Footprints,
|
||||
Grid2X2,
|
||||
PenLine,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
SwatchBook,
|
||||
} from 'lucide-react'
|
||||
@@ -239,94 +244,8 @@ function WallModeToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function RenderModeMenu() {
|
||||
const shading = useViewer((state) => state.shading)
|
||||
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>
|
||||
)
|
||||
}
|
||||
// One dropdown that gathers every "how the scene looks" control: grid, shadows,
|
||||
// camera projection, units, render mode, edges and scene theme.
|
||||
|
||||
const EDGE_OPTIONS = [
|
||||
{ id: 'off', name: 'Off', detail: 'No edge lines' },
|
||||
@@ -334,127 +253,178 @@ const EDGE_OPTIONS = [
|
||||
{ id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' },
|
||||
] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[]
|
||||
|
||||
function EdgesMenu() {
|
||||
const edges = useViewer((state) => state.edges)
|
||||
const setEdges = useViewer((state) => state.setEdges)
|
||||
const active = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
|
||||
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
|
||||
|
||||
return (
|
||||
<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() {
|
||||
function DisplayMenu() {
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
const setShowGrid = useViewer((state) => state.setShowGrid)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}>
|
||||
<button
|
||||
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}
|
||||
aria-pressed={showGrid}
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
'w-auto gap-1.5 px-2.5',
|
||||
showGrid
|
||||
? '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 unit = useViewer((state) => state.unit)
|
||||
const setUnit = useViewer((state) => state.setUnit)
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
||||
const shading = useViewer((state) => state.shading)
|
||||
const setShading = useViewer((state) => state.setShading)
|
||||
const sceneTheme = useViewer((state) => state.sceneTheme)
|
||||
const setSceneTheme = useViewer((state) => state.setSceneTheme)
|
||||
const edges = useViewer((state) => state.edges)
|
||||
const setEdges = useViewer((state) => state.setEdges)
|
||||
const shadows = useViewer((state) => state.shadows)
|
||||
const setShadows = useViewer((state) => state.setShadows)
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={`Shadows: ${shadows ? 'On' : 'Off'}`}>
|
||||
<button
|
||||
aria-label={`Shadows: ${shadows ? 'On' : 'Off'}`}
|
||||
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>
|
||||
)
|
||||
}
|
||||
const activeShading =
|
||||
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
|
||||
const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
|
||||
const activeTheme = getSceneTheme(sceneTheme)
|
||||
|
||||
function UnitToggle() {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const setUnit = useViewer((state) => state.setUnit)
|
||||
// Keep the menu open when flipping a toggle.
|
||||
const keepOpen = (event: Event, fn: () => void) => {
|
||||
event.preventDefault()
|
||||
fn()
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}>
|
||||
<button
|
||||
className={TOOLBAR_BTN}
|
||||
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
|
||||
type="button"
|
||||
<DropdownMenu>
|
||||
<ToolbarTooltip label="Display settings">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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>
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
)
|
||||
}
|
||||
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setShowGrid(!showGrid))}>
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
<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() {
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const setCameraMode = useViewer((state) => state.setCameraMode)
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
return (
|
||||
<ToolbarTooltip label={cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}>
|
||||
<button
|
||||
className={cn(
|
||||
TOOLBAR_BTN,
|
||||
cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',
|
||||
)}
|
||||
onClick={() => setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
||||
type="button"
|
||||
>
|
||||
{cameraMode === 'perspective' ? (
|
||||
<IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} />
|
||||
) : (
|
||||
<IconifyIcon height={16} icon="vaadin:grid" width={16} />
|
||||
)}
|
||||
</button>
|
||||
</ToolbarTooltip>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<activeShading.icon className="h-4 w-4" />
|
||||
<span>Render</span>
|
||||
<span className="ml-auto text-muted-foreground text-xs">{activeShading.name}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className={SUBMENU_CONTENT_CLASS}>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</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}>
|
||||
<LevelModeToggle />
|
||||
<WallModeToggle />
|
||||
<RenderModeMenu />
|
||||
<SceneThemeMenu />
|
||||
<EdgesMenu />
|
||||
<GridVisibilityToggle />
|
||||
<ShadowsToggle />
|
||||
<div className="my-1.5 w-px bg-border/50" />
|
||||
<UnitToggle />
|
||||
<CameraModeToggle />
|
||||
<DisplayMenu />
|
||||
<div className="my-1.5 w-px bg-border/50" />
|
||||
<WalkthroughButton />
|
||||
<PreviewButton />
|
||||
|
||||
Reference in New Issue
Block a user