diff --git a/packages/editor/README.md b/packages/editor/README.md index bfbc95ac07..885afd2ccd 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -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. | diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 6664b51bab..8006731182 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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' @@ -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 + const reserveBottomMenu = menuPlacement === 'bottom' || isMobile const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isStudioMode = useEditor((s) => s.workspaceMode === 'studio') const presentationProjectId = projectId ?? null @@ -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) @@ -1830,8 +1840,11 @@ function EditorContent({ overlays={ <> - {!(isCaptureMode || stageOverlay) && } + {!(isCaptureMode || stageOverlay) && ( + + )} {!(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. @@ -1842,15 +1855,22 @@ function EditorContent({ ) : (
- +
))} {sceneOverlay}
+ {/* A right rail sits on the stage edge with the column it pushes aside. */} + {rightMenuShown && ( +
+ +
+ )} {/* The inspector and the shortcuts card share one right column. */} } inspector={ isVersionPreviewMode || isCaptureMode || isStudioMode ? null : ( @@ -1929,12 +1949,13 @@ function EditorContent({ {/* Fixed UI overlays scoped to the viewer area */}
- +
} inspector={} - reserveBottomMenu={actionMenuPlacement !== 'top' || isMobile} + besideRightMenu={dockMenuOnRight && editingControlsShown} + reserveBottomMenu={reserveBottomMenu} /> {isFirstPersonMode && ( diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index 3397c6d77f..ae0858bbd6 100644 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -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') @@ -25,7 +28,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { } return ( -
+
{!hideOrbit && ( <> {/* Orbit CCW */} diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx index b95e1e27d8..459a28d36f 100644 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -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' @@ -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) @@ -68,7 +70,7 @@ export function ControlModes() { } return ( -
+
{controls.map((c) => { const ModeIcon = c.icon const isImageMode = Boolean(c.imageSrc) diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index 5eb9312b89..0c06df3d49 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -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 @@ -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 ( @@ -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', 'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md', 'transition-colors duration-200 ease-out', className, @@ -95,12 +102,19 @@ export function ActionMenu({
) : ( -
+
-
+
-
+
+
+
)} diff --git a/packages/editor/src/components/ui/action-menu/measurement-control.tsx b/packages/editor/src/components/ui/action-menu/measurement-control.tsx index ffa064ccd0..c83fb476b9 100644 --- a/packages/editor/src/components/ui/action-menu/measurement-control.tsx +++ b/packages/editor/src/components/ui/action-menu/measurement-control.tsx @@ -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 }, @@ -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) @@ -141,12 +144,12 @@ export function MeasurementControl() { return ( -
+