From 6278f4c4066ff385ff4b772786d74b3dabc2fea5 Mon Sep 17 00:00:00 2001 From: Ivn Nv Date: Wed, 14 Oct 2026 23:05:04 +0300 Subject: [PATCH 1/7] Let users dock the action menu to any edge from the menu itself Adds a dock picker at the end of the action menu with Bottom, Top, Left and Right. The pick is saved per browser and wins over the host's actionMenuPlacement, which stays the default until the user chooses. Left and right dock the menu as a vertical rail, and the inspector column steps inwards when the rail is on the right. --- .../editor/src/components/editor/index.tsx | 18 +++-- .../ui/action-menu/camera-actions.tsx | 5 +- .../ui/action-menu/control-modes.tsx | 4 +- .../src/components/ui/action-menu/index.tsx | 25 ++++-- .../ui/action-menu/measurement-control.tsx | 23 ++++-- .../ui/action-menu/placement-control.tsx | 77 +++++++++++++++++++ .../ui/action-menu/placement-preference.ts | 19 +++++ .../components/ui/action-menu/placement.ts | 28 ++++++- .../ui/action-menu/view-toggles.tsx | 26 +++++-- .../editor/src/components/ui/right-stack.tsx | 6 +- 10 files changed, 201 insertions(+), 30 deletions(-) create mode 100644 packages/editor/src/components/ui/action-menu/placement-control.tsx create mode 100644 packages/editor/src/components/ui/action-menu/placement-preference.ts diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 3fb99bbbed..fa8764708f 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -78,6 +78,7 @@ import { BoxSelectTool } from '../tools/select/box-select-tool' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' import { type ActionMenuPlacement, TOP_MENU_CLEARANCE } from '../ui/action-menu/placement' +import { useActionMenuPlacementPreference } from '../ui/action-menu/placement-preference' import { CommandPalette, type CommandPaletteEmptyAction } from '../ui/command-palette' import { EditorCommands } from '../ui/command-palette/editor-commands' import { FloatingLevelSelector } from '../ui/floating-level-selector' @@ -1445,8 +1446,13 @@ function EditorContent({ commandPaletteEmptyAction, }: EditorProps) { const isMobile = useIsMobile() + // The user's own pick wins over the host's default placement. + const userMenuPlacement = useActionMenuPlacementPreference((state) => state.placement) + 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 reserveBottomMenu = menuPlacement === 'bottom' || isMobile const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isStudioMode = useEditor((s) => s.workspaceMode === 'studio') const presentationProjectId = projectId ?? null @@ -1841,7 +1847,7 @@ function EditorContent({ ) : (
- +
))} {sceneOverlay} @@ -1849,7 +1855,8 @@ function EditorContent({ {/* The inspector and the shortcuts card share one right column. */} } inspector={ isVersionPreviewMode || isCaptureMode || isStudioMode ? null : ( @@ -1928,12 +1935,13 @@ function EditorContent({ {/* Fixed UI overlays scoped to the viewer area */}
- +
} inspector={} - reserveBottomMenu={actionMenuPlacement !== 'top' || isMobile} + besideRightMenu={dockMenuOnRight} + 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..66c260b134 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,13 @@ 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', + : effectivePlacement === 'left' + ? 'fixed top-1/2 left-4 -translate-y-1/2' + : effectivePlacement === 'right' + ? 'fixed top-1/2 right-4 -translate-y-1/2' + : 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 +101,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 ( -
+