diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 0428bbe4..e3b9e95e 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -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 ( - - - - - - - - {SHADING_OPTIONS.map((option) => { - const OptionIcon = option.icon - return ( - setShading(option.id)}> - -
- {option.name} - {option.detail} -
- {shading === option.id ? : null} -
- ) - })} -
-
- ) -} - -function SceneThemeMenu() { - const sceneTheme = useViewer((state) => state.sceneTheme) - const setSceneTheme = useViewer((state) => state.setSceneTheme) - const active = getSceneTheme(sceneTheme) - - return ( - - - - - - - - {SCENE_THEMES.map((theme) => { - const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( - (role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role], - ) - return ( - setSceneTheme(theme.id)}> - - {swatches.map((color, index) => ( - - ))} - - {theme.name} - {sceneTheme === theme.id ? ( - - ) : null} - - ) - })} - - - ) -} +// 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 ( - - - - - - - - {EDGE_OPTIONS.map((option) => ( - setEdges(option.id)}> -
- {option.name} - {option.detail} -
- {edges === option.id ? : null} -
- ))} -
-
- ) -} - -function GridVisibilityToggle() { +function DisplayMenu() { const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) - - return ( - - - - ) -} - -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 ( - - - - ) -} + 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 ( - - + + + - {unit === 'metric' ? 'm' : 'ft'} - - - ) -} + keepOpen(e, () => setShowGrid(!showGrid))}> + + Grid + {showGrid ? ( + + ) : ( + + )} + + keepOpen(e, () => setShadows(!shadows))}> + + Shadows + {shadows ? 'On' : 'Off'} + + + keepOpen(e, () => + setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'), + ) + } + > + + Camera + + {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + + + keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))} + > + + {unit === 'metric' ? 'm' : 'ft'} + + Units + + {unit === 'metric' ? 'Metric' : 'Imperial'} + + -function CameraModeToggle() { - const cameraMode = useViewer((state) => state.cameraMode) - const setCameraMode = useViewer((state) => state.setCameraMode) + - return ( - - - + + + + Render + {activeShading.name} + + + {SHADING_OPTIONS.map((option) => { + const OptionIcon = option.icon + return ( + setShading(option.id)}> + +
+ {option.name} + {option.detail} +
+ {shading === option.id ? ( + + ) : null} +
+ ) + })} +
+
+ + + + + Edges + {activeEdges.name} + + + {EDGE_OPTIONS.map((option) => ( + setEdges(option.id)}> +
+ {option.name} + {option.detail} +
+ {edges === option.id ? : null} +
+ ))} +
+
+ + + + + Theme + + {activeTheme.name} + + + + {SCENE_THEMES.map((theme) => { + const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( + (role) => theme.clayTints?.[role] ?? CLAY_PALETTE[role], + ) + return ( + setSceneTheme(theme.id)}> + + {swatches.map((color, index) => ( + + ))} + + {theme.name} + {sceneTheme === theme.id ? ( + + ) : null} + + ) + })} + + +
+ ) } @@ -507,14 +477,8 @@ export function CommunityViewerToolbarRight() {
- - - - -
- - +
diff --git a/packages/editor/src/components/ui/primitives/dropdown-menu.tsx b/packages/editor/src/components/ui/primitives/dropdown-menu.tsx index 3c42767e..2de3cd85 100644 --- a/packages/editor/src/components/ui/primitives/dropdown-menu.tsx +++ b/packages/editor/src/components/ui/primitives/dropdown-menu.tsx @@ -197,15 +197,19 @@ function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { + // Portalled so the side-opening submenu escapes the parent content's + // `overflow-x-hidden` clip (otherwise it renders but is invisible). return ( - + + + ) } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 2e546ac6..19049e8a 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -138,6 +138,10 @@ export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from './components/ui/primitives/dropdown-menu' export { useSidebarStore } from './components/ui/primitives/sidebar'