Skip to content
2 changes: 1 addition & 1 deletion packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function ProjectPage({ projectId }: { projectId: string }) {
| Prop | What it does |
| --- | --- |
| `loadingSlot` | Your loading surface, shown in the canvas panes of both layouts and in 2D and 3D while the scene loads. The editor still decides when loading is over and fires `onLoaderChange`; a failed load always shows the editor's error and retry screen. Omit it or pass `null` for the default loader. |
| `actionMenuPlacement` | `'bottom'` (default) or `'top'`. With `'top'`, the v2 layout gives the tool menu its own row under the toolbar, centred on the scene panes and following them in split, so the bottom strip is free; v1 floats it at the top edge. Mobile keeps the bottom rail. |
| `actionMenuPlacement` | `'bottom'` (default), `'top'`, `'left'` or `'right'`. With `'top'`, the v2 layout gives the tool menu its own row under the toolbar, centred on the scene panes and following them in split, so the bottom strip is free; v1 floats it at the top edge. `'left'` and `'right'` dock it as a vertical rail. This is only the default: users can move the menu from its own dock picker, and their pick is kept per browser. Mobile keeps the bottom rail. |
| `viewBar` | Each pane's view bar (v2, default `true`). Turn it off for a host that shows one fixed view. |
| `sceneOverlay` | Chrome over the 3D and 2D panes with the level selector and tool dock, hidden while no scene view is on screen. |
| `stageOverlay` | A full-bleed surface over the whole stage; the scene stays mounted underneath. To show something beside the scene, register a view instead. |
Expand Down
43 changes: 32 additions & 11 deletions packages/editor/src/components/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { VectorEdgeExtractor } from './vector-edge-extractor'
import { HostView } from './views/host-view'
import { SceneRegion } from './views/scene-region'
import { SceneRegion, useSceneSpan } from './views/scene-region'
import { useActiveViewLayout, useEditorViews } from './views/use-editor-views'
import { WallMeasurementLabel } from './wall-measurement-label'
import { WallMoveSideHandles } from './wall-move-side-handles'
Expand Down Expand Up @@ -1446,8 +1446,14 @@ function EditorContent({
commandPaletteEmptyAction,
}: EditorProps) {
const isMobile = useIsMobile()
// The user's own pick wins over the host's default placement.
const userMenuPlacement = useEditor((state) => state.actionMenuPlacement)
const menuPlacement = userMenuPlacement ?? actionMenuPlacement ?? 'bottom'
// A top-docked menu sits at the top of the scene panes on desktop (layout v2).
const dockMenuOnTop = actionMenuPlacement === 'top' && !isMobile
const dockMenuOnTop = menuPlacement === 'top' && !isMobile
const dockMenuOnRight = menuPlacement === 'right' && !isMobile
const dockMenuOnLeft = menuPlacement === 'left' && !isMobile
Comment thread
cursor[bot] marked this conversation as resolved.
const reserveBottomMenu = menuPlacement === 'bottom' || isMobile
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const isStudioMode = useEditor((s) => s.workspaceMode === 'studio')
const presentationProjectId = projectId ?? null
Expand Down Expand Up @@ -1504,10 +1510,14 @@ function EditorContent({
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const readOnly = useScene((s) => s.readOnly)
// Chrome only steps down for the top menu while the menu is actually there.
const topMenuShown =
dockMenuOnTop &&
!(isVersionPreviewMode || isCaptureMode || isStudioMode) &&
shouldShowEditingControls(readOnly)
const editingControlsShown = shouldShowEditingControls(readOnly)
// Layout v2 also hides the menu in version preview, capture and studio.
const menuShown = editingControlsShown && !(isVersionPreviewMode || isCaptureMode || isStudioMode)
const topMenuShown = dockMenuOnTop && menuShown
const leftMenuShown = dockMenuOnLeft && menuShown
const hasSceneView = useSceneSpan() !== null
// Every dock is scene chrome: it leaves with the last 3D or 2D pane.
const rightMenuShown = dockMenuOnRight && menuShown && hasSceneView

const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
Expand Down Expand Up @@ -1830,8 +1840,11 @@ function EditorContent({
overlays={
<>
<SceneRegion>
{!(isCaptureMode || stageOverlay) && <FloatingLevelSelector />}
{!(isCaptureMode || stageOverlay) && (
<FloatingLevelSelector besideLeftMenu={leftMenuShown} />
)}
{!(isVersionPreviewMode || isCaptureMode || isStudioMode) &&
!dockMenuOnRight &&
(dockMenuOnTop ? (
// Own row under the toolbar, below the level selector's opening list (z-20).
// No transform: the menu's fixed pill must anchor to the scene region.
Expand All @@ -1842,15 +1855,22 @@ function EditorContent({
</div>
) : (
<div className="pointer-events-auto">
<ActionMenu placement={actionMenuPlacement} />
<ActionMenu placement={menuPlacement} />
</div>
))}
{sceneOverlay}
</SceneRegion>
{/* A right rail sits on the stage edge with the column it pushes aside. */}
{rightMenuShown && (
<div className="pointer-events-auto">
<ActionMenu placement="right" />
</div>
)}
Comment thread
cursor[bot] marked this conversation as resolved.
{/* The inspector and the shortcuts card share one right column. */}
<RightStack
belowTopMenu={topMenuShown}
reserveBottomMenu={!dockMenuOnTop}
besideRightMenu={rightMenuShown}
reserveBottomMenu={reserveBottomMenu}
helper={isCaptureMode ? null : <HelperManager />}
inspector={
isVersionPreviewMode || isCaptureMode || isStudioMode ? null : (
Expand Down Expand Up @@ -1929,12 +1949,13 @@ function EditorContent({
{/* Fixed UI overlays scoped to the viewer area */}
<ViewerOverlays left={overlayLeft}>
<div className="pointer-events-auto">
<ActionMenu placement={actionMenuPlacement} />
<ActionMenu placement={menuPlacement} />
</div>
<RightStack
helper={<HelperManager />}
inspector={<PanelManager />}
reserveBottomMenu={actionMenuPlacement !== 'top' || isMobile}
besideRightMenu={dockMenuOnRight && editingControlsShown}
reserveBottomMenu={reserveBottomMenu}
/>
<RiserDiagramPanel />
{isFirstPersonMode && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

import { emitter } from '@pascal-app/core'
import Image from 'next/image'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { visibleScene } from '../../../store/view-layout'
import { ActionButton } from './action-button'
import { FollowPascalToggle } from './follow-pascal-toggle'
import { useActionMenuVertical } from './placement'

export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
const vertical = useActionMenuVertical()
// Orbit stays useful in 2D-only (it spins the synced floorplan view), but
// top view only tilts the hidden 3D camera — pointless without the canvas.
const is2dOnly = useEditor((s) => visibleScene(s) === '2d')
Expand All @@ -25,7 +28,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
}

return (
<div className="flex items-center gap-1">
<div className={cn('flex items-center gap-1', vertical && 'flex-col')}>
{!hideOrbit && (
<>
{/* Orbit CCW */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type LucideIcon, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { Fragment } from 'react'
import { cn } from './../../../lib/utils'
import { useActionMenuVertical } from './placement'
import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button'
import { MeasurementControl } from './measurement-control'
Expand Down Expand Up @@ -44,6 +45,7 @@ const controls: ControlConfig[] = [
]

export function ControlModes() {
const vertical = useActionMenuVertical()
const mode = useEditor((state) => state.mode)
const selectionTool = useEditor((state) => state.floorplanSelectionTool)
const armToolMode = useEditor((state) => state.armToolMode)
Expand All @@ -68,7 +70,7 @@ export function ControlModes() {
}

return (
<div className="flex items-center gap-1">
<div className={cn('flex items-center gap-1', vertical && 'flex-col')}>
{controls.map((c) => {
const ModeIcon = c.icon
const isImageMode = Boolean(c.imageSrc)
Expand Down
26 changes: 20 additions & 6 deletions packages/editor/src/components/ui/action-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CameraActions } from './camera-actions'
import { ControlModes } from './control-modes'
import { FollowPascalPill } from './follow-pascal-pill'
import { type ActionMenuPlacement, ActionMenuPlacementProvider } from './placement'
import { PlacementControl } from './placement-control'
import { SecondaryToggles } from './view-toggles'

// Mobile bottom offset matches the viewer's overlap behind the sheet's
Expand Down Expand Up @@ -60,6 +61,7 @@ export function ActionMenu({

// Mobile keeps the bottom rail: the sheet owns the rest of the screen.
const effectivePlacement = isMobile ? 'bottom' : placement
const vertical = effectivePlacement === 'left' || effectivePlacement === 'right'

return (
<ActionMenuPlacementProvider value={effectivePlacement}>
Expand All @@ -72,9 +74,14 @@ export function ActionMenu({
? 'absolute left-1/2 origin-bottom -translate-x-1/2 scale-90'
: inline
? 'relative'
: effectivePlacement === 'top'
? 'fixed top-3 left-1/2 -translate-x-1/2'
: 'fixed bottom-6 left-1/2 -translate-x-1/2',
: // Rails centre in a band that keeps the corner chrome clear, scrolling when it is short.
effectivePlacement === 'left'
? 'no-scrollbar fixed top-24 bottom-16 left-4 my-auto h-fit max-h-[calc(100%-10rem)] overflow-y-auto'
: effectivePlacement === 'right'
? 'no-scrollbar fixed top-24 bottom-16 right-4 my-auto h-fit max-h-[calc(100%-10rem)] overflow-y-auto'
: effectivePlacement === 'top'
? 'fixed top-3 left-1/2 -translate-x-1/2'
: 'fixed bottom-6 left-1/2 -translate-x-1/2',
Comment thread
cursor[bot] marked this conversation as resolved.
'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
'transition-colors duration-200 ease-out',
className,
Expand All @@ -95,12 +102,19 @@ export function ActionMenu({
</div>
</div>
) : (
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<div
className={cn(
'flex items-center justify-center gap-1',
vertical ? 'flex-col px-1.5 py-2' : 'px-2 py-1.5',
)}
>
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<div className={vertical ? 'my-1 h-px w-5 bg-border' : 'mx-1 h-5 w-px bg-border'} />
<SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" />
<div className={vertical ? 'my-1 h-px w-5 bg-border' : 'mx-1 h-5 w-px bg-border'} />
<CameraActions />
<div className={vertical ? 'my-1 h-px w-5 bg-border' : 'mx-1 h-5 w-px bg-border'} />
<PlacementControl />
</div>
)}
</motion.div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { VIEW_2D } from '../../../store/view-layout'
import useFloorplanMode from '../../../store/use-floorplan-mode'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
import { useActionMenuPopupSide } from './placement'
import { useActionMenuArrowCorner, useActionMenuArrowRotation, useActionMenuPopupSide, useActionMenuVertical } from './placement'

const measurementOptions = [
{ kind: 'distance', label: 'Distance', icon: Ruler },
Expand Down Expand Up @@ -64,7 +64,10 @@ const constructionDimensionOptions = [

export function MeasurementControl() {
const popupSide = useActionMenuPopupSide()
const vertical = useActionMenuVertical()
const arrowCorner = useActionMenuArrowCorner()
const [isOpen, setIsOpen] = useState(false)
const arrowRotation = useActionMenuArrowRotation(isOpen)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const floorplanMode = useFloorplanMode((state) => state.mode)
Expand Down Expand Up @@ -141,12 +144,12 @@ export function MeasurementControl() {

return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
<div className={vertical ? 'relative' : 'flex items-center'}>
<ActionButton
aria-label={`Measure: ${selectedLabel}`}
aria-pressed={isControlActive}
className={cn(
'rounded-r-none p-0 text-muted-foreground',
vertical ? 'p-0 text-muted-foreground' : 'rounded-r-none p-0 text-muted-foreground',
isControlActive
? 'bg-cyan-500/20 text-cyan-700 hover:bg-cyan-500/20 dark:text-cyan-400 dark:hover:text-accent-foreground'
: 'hover:bg-cyan-500/15 hover:text-cyan-700 dark:hover:text-cyan-400',
Expand All @@ -166,7 +169,9 @@ export function MeasurementControl() {
aria-haspopup="menu"
aria-label="Measurement options"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg text-muted-foreground transition-colors',
vertical
? `absolute top-0.5 ${arrowCorner} flex h-4 w-4 items-center justify-center rounded-sm transition-colors`
: 'flex h-11 w-6 items-center justify-center rounded-r-lg text-muted-foreground transition-colors',
isOpen
? 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-400'
: 'hover:bg-cyan-500/10 hover:text-cyan-700 dark:hover:text-cyan-400',
Expand All @@ -175,15 +180,21 @@ export function MeasurementControl() {
>
<ChevronDown
aria-hidden="true"
className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')}
className={cn(
'h-3 w-3 transition-transform',
arrowRotation,
)}
/>
</button>
</PopoverTrigger>
</div>

<PopoverContent
align="center"
className="max-h-[70vh] w-64 overflow-y-auto rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
className={cn(
'max-h-[70vh] overflow-y-auto rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl',
vertical ? 'w-max min-w-40' : 'w-64',
)}
side={popupSide}
sideOffset={14}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client'

import { PanelBottom, PanelLeft, PanelRight, PanelTop } from 'lucide-react'
import { useState } from 'react'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
import { type ActionMenuPlacement, useActionMenuPlacement, useActionMenuPopupSide } from './placement'

const PLACEMENT_OPTIONS: { value: ActionMenuPlacement; label: string; icon: typeof PanelBottom }[] =
[
{ value: 'bottom', label: 'Bottom', icon: PanelBottom },
{ value: 'top', label: 'Top', icon: PanelTop },
{ value: 'left', label: 'Left', icon: PanelLeft },
{ value: 'right', label: 'Right', icon: PanelRight },
]

/** Lets the user dock the action menu to another edge of the viewer. */
export function PlacementControl() {
const placement = useActionMenuPlacement()
const popupSide = useActionMenuPopupSide()
const setPlacement = useEditor((state) => state.setActionMenuPlacement)
const [isOpen, setIsOpen] = useState(false)
const CurrentIcon =
PLACEMENT_OPTIONS.find((option) => option.value === placement)?.icon ?? PanelBottom

return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<PopoverTrigger asChild>
<ActionButton
aria-label="Menu position"
className="group text-muted-foreground hover:bg-white/5"
isActive={isOpen}
label="Menu position"
size="icon"
variant="ghost"
>
<CurrentIcon aria-hidden="true" className="h-5 w-5" />
</ActionButton>
</PopoverTrigger>
<PopoverContent
align="center"
className="w-max min-w-36 rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
side={popupSide}
sideOffset={14}
>
<div aria-label="Menu position" className="space-y-1" role="menu">
{PLACEMENT_OPTIONS.map((option) => {
const OptionIcon = option.icon
const isSelected = option.value === placement
return (
<button
aria-checked={isSelected}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
)}
key={option.value}
onClick={() => {
setPlacement(option.value)
setIsOpen(false)
}}
role="menuitemradio"
type="button"
>
<OptionIcon aria-hidden="true" className="h-4 w-4" />
<span>{option.label}</span>
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)
}
Loading
Loading