From 6af3c3f349051e4617f2ca670c4a9a5d0ab3f098 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 2 Apr 2026 04:28:35 +0000 Subject: [PATCH 1/7] feat: editor layout redesign v2 + 3D box select New v2 layout with navbar + two-column structure: - Resizable left sidebar with horizontal tab bar - Right viewer panel with floating toolbar slots - Floating level selector on viewer panel - 3D box select tool with marquee selection - View mode system (3D/2D/Split) replacing old floorplan toggle - Fixed 5-button control bar: Select, Box Select, Site Edit, Build, Delete - Simplified view toggles (scans/guides only) - Always-mounted viewers (display:none) to preserve WebGL context - Floorplan top toolbar removed, controls moved to viewer toolbar - Site edit as permanent peer in ControlModes (phase='site' is sole signal) - Site edge labels respect metric/imperial unit toggle --- apps/editor/app/page.tsx | 16 +- bun.lock | 4 +- .../components/editor/editor-layout-v2.tsx | 209 +++ .../src/components/editor/floorplan-panel.tsx | 769 ++-------- .../editor/src/components/editor/index.tsx | 1273 ++++++++++------- .../components/editor/selection-manager.tsx | 166 +-- .../components/editor/site-edge-labels.tsx | 28 +- .../tools/select/box-select-tool.tsx | 561 ++++++++ .../components/tools/shared/cursor-sphere.tsx | 30 +- .../src/components/tools/tool-manager.tsx | 4 +- .../ui/action-menu/control-modes.tsx | 178 ++- .../ui/action-menu/view-toggles.tsx | 468 +++--- .../components/ui/floating-level-selector.tsx | 76 + .../src/components/ui/sidebar/tab-bar.tsx | 39 + packages/editor/src/hooks/use-keyboard.ts | 28 +- packages/editor/src/index.tsx | 13 +- packages/editor/src/store/use-editor.tsx | 855 ++++++----- 17 files changed, 2821 insertions(+), 1896 deletions(-) create mode 100644 packages/editor/src/components/editor/editor-layout-v2.tsx create mode 100644 packages/editor/src/components/tools/select/box-select-tool.tsx create mode 100644 packages/editor/src/components/ui/floating-level-selector.tsx create mode 100644 packages/editor/src/components/ui/sidebar/tab-bar.tsx diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index a7e850fe..5ddbf834 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,11 +1,23 @@ 'use client' -import { Editor } from '@pascal-app/editor' +import { Editor, type SidebarTab } from '@pascal-app/editor' + +const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ + { + id: 'site', + label: 'Scene', + component: () => null, // Built-in SitePanel handles this + }, +] export default function Home() { return (
- +
) } diff --git a/bun.lock b/bun.lock index 91bc61f7..ebf1df96 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.3.1", + "version": "0.3.3", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -166,7 +166,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.3.1", + "version": "0.3.3", "dependencies": { "polygon-clipping": "^0.15.7", "zustand": "^5", diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx new file mode 100644 index 00000000..a2fc0d26 --- /dev/null +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -0,0 +1,209 @@ +'use client' + +import { type ReactNode, useCallback, useEffect, useRef } from 'react' +import useEditor from '../../store/use-editor' +import { useSidebarStore } from '../ui/primitives/sidebar' +import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar' + +const SIDEBAR_MIN_WIDTH = 300 +const SIDEBAR_MAX_WIDTH = 800 +const SIDEBAR_COLLAPSE_THRESHOLD = 220 + +// ── Left column: resizable panel with tab bar ──────────────────────────────── + +function LeftColumn({ + tabs, + renderTabContent, +}: { + tabs: SidebarTab[] + renderTabContent: (tabId: string) => ReactNode +}) { + const width = useSidebarStore((s) => s.width) + const isCollapsed = useSidebarStore((s) => s.isCollapsed) + const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed) + const setWidth = useSidebarStore((s) => s.setWidth) + const isDragging = useSidebarStore((s) => s.isDragging) + const setIsDragging = useSidebarStore((s) => s.setIsDragging) + const activePanel = useEditor((s) => s.activeSidebarPanel) + const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) + + const isResizing = useRef(false) + const isExpanding = useRef(false) + + // Ensure active panel is a valid tab + useEffect(() => { + if (tabs.length > 0 && !tabs.some((t) => t.id === activePanel)) { + setActivePanel(tabs[0]!.id) + } + }, [tabs, activePanel, setActivePanel]) + + const handleResizerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault() + isResizing.current = true + setIsDragging(true) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, + [setIsDragging], + ) + + const handleGrabDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault() + isExpanding.current = true + setIsDragging(true) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, + [setIsDragging], + ) + + useEffect(() => { + const handlePointerMove = (e: PointerEvent) => { + if (isResizing.current) { + const newWidth = e.clientX + if (newWidth < SIDEBAR_COLLAPSE_THRESHOLD) { + setIsCollapsed(true) + } else { + setIsCollapsed(false) + setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(newWidth, SIDEBAR_MAX_WIDTH))) + } + } else if (isExpanding.current && e.clientX > 60) { + setIsCollapsed(false) + setWidth(Math.max(SIDEBAR_MIN_WIDTH, Math.min(e.clientX, SIDEBAR_MAX_WIDTH))) + } + } + const handlePointerUp = () => { + isResizing.current = false + isExpanding.current = false + setIsDragging(false) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + }, [setWidth, setIsCollapsed, setIsDragging]) + + if (isCollapsed) { + return ( +
+ ) + } + + return ( +
+ +
{renderTabContent(activePanel)}
+ + {/* Resize handle + hit area */} +
+
+
+
+ ) +} + +// ── Right column: viewer area with toolbar ─────────────────────────────────── + +function RightColumn({ + toolbarLeft, + toolbarRight, + children, + overlays, +}: { + toolbarLeft?: ReactNode + toolbarRight?: ReactNode + children: ReactNode + overlays?: ReactNode +}) { + return ( +
+ {/* Viewer toolbar */} + {(toolbarLeft || toolbarRight) && ( +
+
{toolbarLeft}
+
{toolbarRight}
+
+ )} + {/* Canvas area */} +
{children}
+ {/* Overlays scoped to the viewer column */} + {overlays && ( +
+ {overlays} +
+ )} +
+ ) +} + +// ── Main v2 layout ─────────────────────────────────────────────────────────── + +export interface EditorLayoutV2Props { + navbarSlot?: ReactNode + sidebarTabs?: SidebarTab[] + renderTabContent: (tabId: string) => ReactNode + viewerToolbarLeft?: ReactNode + viewerToolbarRight?: ReactNode + viewerContent: ReactNode + overlays?: ReactNode +} + +export function EditorLayoutV2({ + navbarSlot, + sidebarTabs = [], + renderTabContent, + viewerToolbarLeft, + viewerToolbarRight, + viewerContent, + overlays, +}: EditorLayoutV2Props) { + return ( +
+ {/* Top navbar */} + {navbarSlot} + + {/* Main content: left column + right column */} +
+ {sidebarTabs.length > 0 && ( + + )} + + {viewerContent} + +
+
+ ) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 57c2b4d7..69579334 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -21,7 +21,7 @@ import { type ZoneNode as ZoneNodeType, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { ChevronDown, Command, X } from 'lucide-react' +import { Command } from 'lucide-react' import { memo, type MouseEvent as ReactMouseEvent, @@ -46,15 +46,8 @@ import { } from '../tools/wall/wall-drafting' import { furnishTools } from '../ui/action-menu/furnish-tools' import { tools as structureTools } from '../ui/action-menu/structure-tools' -import { SliderControl } from '../ui/controls/slider-control' + import { PALETTE_COLORS } from '../ui/primitives/color-dot' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from '../ui/primitives/dropdown-menu' import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { NodeActionMenu } from './node-action-menu' @@ -109,7 +102,7 @@ const FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING = 60 const FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y = 56 const FLOORPLAN_ACTION_MENU_OFFSET_Y = 10 const FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y = 1.5 -const FLOORPLAN_LEVEL_MENU_CLOSE_DELAY_MS = 120 + // Match the guide plane footprint used in the 3D renderer so the 2D overlay aligns. const FLOORPLAN_GUIDE_BASE_WIDTH = 10 const FLOORPLAN_GUIDE_MIN_SCALE = 0.01 @@ -173,8 +166,6 @@ type OpeningNode = WindowNode | DoorNode type WallEndpoint = 'start' | 'end' -type FloorplanSelectionTool = 'click' | 'marquee' - type FloorplanCursorIndicator = | { kind: 'asset' @@ -185,40 +176,6 @@ type FloorplanCursorIndicator = icon: string } -const FLOORPLAN_QUICK_BUILD_TOOL_IDS = ['wall', 'door', 'window', 'slab', 'zone'] as const - -type FloorplanQuickBuildTool = (typeof FLOORPLAN_QUICK_BUILD_TOOL_IDS)[number] - -const FLOORPLAN_QUICK_BUILD_TOOL_LABELS: Record = { - wall: 'Wall', - door: 'Door', - window: 'Window', - slab: 'Floor', - zone: 'Zone', -} - -const FLOORPLAN_QUICK_BUILD_TOOL_FALLBACK_ICONS: Record = { - wall: '/icons/wall.png', - door: '/icons/door.png', - window: '/icons/window.png', - slab: '/icons/floor.png', - zone: '/icons/zone.png', -} - -const FLOORPLAN_QUICK_BUILD_TOOLS = FLOORPLAN_QUICK_BUILD_TOOL_IDS.map((id) => { - const toolConfig = structureTools.find((entry) => entry.id === id) - - return { - id, - iconSrc: toolConfig?.iconSrc ?? FLOORPLAN_QUICK_BUILD_TOOL_FALLBACK_ICONS[id], - label: FLOORPLAN_QUICK_BUILD_TOOL_LABELS[id], - } -}) - -function getLevelDisplayLabel(level: LevelNode) { - return level.name || `Level ${level.level}` -} - type PersistedPanelLayout = { rect: PanelRect viewport: ViewportBounds @@ -2116,6 +2073,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ onSlabSelect, onOpeningDoubleClick, onOpeningHoverChange, + onOpeningPointerDown, onOpeningSelect, onWallClick, onWallDoubleClick, @@ -2134,6 +2092,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent) => void onOpeningDoubleClick: (opening: OpeningNode) => void onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void + onOpeningPointerDown: (openingId: OpeningNode['id'], event: ReactPointerEvent) => void onOpeningSelect: (openingId: OpeningNode['id'], event: ReactMouseEvent) => void hoveredWallId: WallNode['id'] | null onWallClick: (wall: WallNode, event: ReactMouseEvent) => void @@ -2368,6 +2327,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ } : undefined } + onPointerDown={ + canSelectGeometry && isSelected + ? (event) => { + if (event.button === 0) { + onOpeningPointerDown(opening.id, event) + } + } + : undefined + } onPointerEnter={ canSelectGeometry ? () => { @@ -2499,6 +2467,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ } : undefined } + onPointerDown={ + canSelectGeometry && isSelected + ? (event) => { + if (event.button === 0) { + onOpeningPointerDown(opening.id, event) + } + } + : undefined + } onPointerEnter={ canSelectGeometry ? () => { @@ -3031,9 +3008,9 @@ export function FloorplanPanel() { const gestureScaleRef = useRef(1) const panelInteractionRef = useRef(null) const panelBoundsRef = useRef(null) + const containerRef = useRef(null) const hasUserAdjustedViewportRef = useRef(false) const previousLevelIdRef = useRef(null) - const levelMenuCloseTimeoutRef = useRef(null) const levelId = useViewer((state) => state.selection.levelId) const buildingId = useViewer((state) => state.selection.buildingId) const selectedZoneId = useViewer((state) => state.selection.zoneId) @@ -3046,7 +3023,7 @@ export function FloorplanPanel() { const setShowGuides = useViewer((state) => state.setShowGuides) const catalogCategory = useEditor((state) => state.catalogCategory) const setCatalogCategory = useEditor((state) => state.setCatalogCategory) - const setFloorplanOpen = useEditor((state) => state.setFloorplanOpen) + const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered) const selectedReferenceId = useEditor((state) => state.selectedReferenceId) @@ -3205,8 +3182,8 @@ export function FloorplanPanel() { const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState(null) const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState(null) const [hoveredGuideCorner, setHoveredGuideCorner] = useState(null) - const [floorplanSelectionTool, setFloorplanSelectionTool] = - useState('click') + const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool) + const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool) const [floorplanMarqueeState, setFloorplanMarqueeState] = useState( null, ) @@ -3222,8 +3199,7 @@ export function FloorplanPanel() { width: PANEL_DEFAULT_WIDTH, height: PANEL_DEFAULT_HEIGHT, }) - const [isLevelMenuOpen, setIsLevelMenuOpen] = useState(false) - const [isGuideQuickAccessOpen, setIsGuideQuickAccessOpen] = useState(false) + const [isPanelReady, setIsPanelReady] = useState(false) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [viewport, setViewport] = useState(null) @@ -3238,11 +3214,6 @@ export function FloorplanPanel() { setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC')) }, []) - // biome-ignore lint/correctness/useExhaustiveDependencies: reset guide panel when level changes - useEffect(() => { - setIsGuideQuickAccessOpen(false) - }, [levelId]) - const sitePolygonEntry = useMemo(() => { const polygonPoints = site?.polygon?.points if (!(site && polygonPoints)) { @@ -3356,20 +3327,6 @@ export function FloorplanPanel() { const activeGuideInteractionMode = guideTransformDraft ? (guideInteractionRef.current?.mode ?? null) : null - const hasGuideImages = levelGuides.length > 0 - const guideImagesDescription = hasGuideImages - ? `${levelGuides.length} guide image${levelGuides.length === 1 ? '' : 's'} on this level` - : 'No guide images on this level' - - const handleGuideOpacityChange = useCallback( - (guideId: GuideNode['id'], opacity: number) => { - updateNode(guideId, { - opacity: Math.round(clamp(opacity, 0, 100)), - }) - }, - [updateNode], - ) - const floorplanWalls = useMemo(() => walls.map(getFloorplanWall), [walls]) const wallMiterData = useMemo(() => calculateLevelMiters(floorplanWalls), [floorplanWalls]) const wallById = useMemo(() => new Map(walls.map((wall) => [wall.id, wall] as const)), [walls]) @@ -3559,7 +3516,7 @@ export function FloorplanPanel() { return displayZonePolygons.find(({ zone }) => zone.id === selectedZoneId) ?? null }, [displayZonePolygons, selectedZoneId]) - const isSiteEditActive = phase === 'site' && mode === 'edit' + const isSiteEditActive = phase === 'site' const isWallBuildActive = phase === 'structure' && mode === 'build' && tool === 'wall' const isSlabBuildActive = phase === 'structure' && mode === 'build' && tool === 'slab' const isZoneBuildActive = phase === 'structure' && mode === 'build' && tool === 'zone' @@ -3891,46 +3848,25 @@ export function FloorplanPanel() { } }, []) + // Track actual container position and size for SVG coordinate transforms useEffect(() => { - const currentBounds = getViewportBounds() - const persistedRect = readPersistedPanelLayout(currentBounds) - setPanelRect(persistedRect ?? getInitialPanelRect(currentBounds)) - panelBoundsRef.current = currentBounds - setIsPanelReady(true) - }, []) - - useEffect(() => { - const handleWindowResize = () => { - const nextBounds = getViewportBounds() - const previousBounds = panelBoundsRef.current ?? nextBounds - setPanelRect((currentRect) => adaptPanelRectToBounds(currentRect, previousBounds, nextBounds)) - panelBoundsRef.current = nextBounds - } - - window.addEventListener('resize', handleWindowResize) + const el = containerRef.current + if (!el) return + const update = () => { + const rect = el.getBoundingClientRect() + setPanelRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height }) + setIsPanelReady(true) + } + const observer = new ResizeObserver(update) + observer.observe(el) + window.addEventListener('resize', update) + update() return () => { - window.removeEventListener('resize', handleWindowResize) + observer.disconnect() + window.removeEventListener('resize', update) } }, []) - useEffect(() => { - if (!isPanelReady) { - return - } - - const timeoutId = window.setTimeout(() => { - const currentBounds = panelBoundsRef.current ?? getViewportBounds() - writePersistedPanelLayout({ - rect: panelRect, - viewport: currentBounds, - }) - }, 120) - - return () => { - window.clearTimeout(timeoutId) - } - }, [isPanelReady, panelRect]) - useEffect(() => { const levelChanged = previousLevelIdRef.current !== (levelId ?? null) @@ -4030,7 +3966,6 @@ export function FloorplanPanel() { } }, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox]) - // biome-ignore lint/correctness/useExhaustiveDependencies: reset hovered corner when selected guide changes useEffect(() => { setHoveredGuideCorner(null) }, [selectedGuide?.id]) @@ -4182,12 +4117,6 @@ export function FloorplanPanel() { }, [theme], ) - const floorplanLevelLabel = - levelNode?.type === 'level' ? getLevelDisplayLabel(levelNode) : 'Select a level' - const isGroundFloorSelected = levelNode?.type === 'level' && levelNode.level === 0 - const isSiteEditShortcutActive = phase === 'site' && mode === 'edit' - const canUseSiteEditShortcut = isGroundFloorSelected - const hasFloorplanLevelSwitcher = floorplanLevels.length > 1 const gridSteps = useMemo( () => getVisibleGridSteps(viewBox.width, surfaceSize.width), [surfaceSize.width, viewBox.width], @@ -4277,50 +4206,6 @@ export function FloorplanPanel() { document.body.style.cursor = '' }, []) - const clearLevelMenuCloseTimeout = useCallback(() => { - if (levelMenuCloseTimeoutRef.current !== null) { - window.clearTimeout(levelMenuCloseTimeoutRef.current) - levelMenuCloseTimeoutRef.current = null - } - }, []) - - const openLevelMenu = useCallback(() => { - if (!hasFloorplanLevelSwitcher) { - return - } - - clearLevelMenuCloseTimeout() - setIsLevelMenuOpen(true) - }, [clearLevelMenuCloseTimeout, hasFloorplanLevelSwitcher]) - - const scheduleLevelMenuClose = useCallback(() => { - clearLevelMenuCloseTimeout() - - levelMenuCloseTimeoutRef.current = window.setTimeout(() => { - setIsLevelMenuOpen(false) - levelMenuCloseTimeoutRef.current = null - }, FLOORPLAN_LEVEL_MENU_CLOSE_DELAY_MS) - }, [clearLevelMenuCloseTimeout]) - - const handleFloorplanLevelSelect = useCallback( - (nextLevelId: string) => { - const resolvedLevelId = nextLevelId as LevelNode['id'] - - if (currentBuildingId) { - setSelection({ - buildingId: currentBuildingId, - levelId: resolvedLevelId, - }) - } else { - setSelection({ levelId: resolvedLevelId }) - } - - clearLevelMenuCloseTimeout() - setIsLevelMenuOpen(false) - }, - [clearLevelMenuCloseTimeout, currentBuildingId, setSelection], - ) - const finishPanelInteraction = useCallback(() => { panelInteractionRef.current = null setIsDraggingPanel(false) @@ -4391,12 +4276,6 @@ export function FloorplanPanel() { } }, [finishPanelInteraction]) - useEffect(() => { - return () => { - clearLevelMenuCloseTimeout() - } - }, [clearLevelMenuCloseTimeout]) - useEffect(() => { const interaction = guideInteractionRef.current if (interaction && !guideById.has(interaction.guideId)) { @@ -4416,12 +4295,6 @@ export function FloorplanPanel() { } }, [clearGuideInteraction]) - useEffect(() => { - if (!hasFloorplanLevelSwitcher) { - setIsLevelMenuOpen(false) - } - }, [hasFloorplanLevelSwitcher]) - const handlePanelDragStart = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) { @@ -4845,7 +4718,6 @@ export function FloorplanPanel() { walls, ]) - // biome-ignore lint/correctness/useExhaustiveDependencies: clear drag state when level changes useEffect(() => { clearWallEndpointDrag() }, [clearWallEndpointDrag, levelId]) @@ -5955,6 +5827,38 @@ export function FloorplanPanel() { }, [emitFloorplanNodeClick], ) + const handleOpeningPointerDown = useCallback( + (openingId: OpeningNode['id'], event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + const opening = selectedOpeningEntry?.opening + if (!opening || opening.id !== openingId) { + return + } + + event.preventDefault() + event.stopPropagation() + + // Suppress the click event that follows this pointer interaction so it + // doesn't re-select or interfere with placement. + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(opening) + setSelection({ selectedIds: [] }) + }, + [selectedOpeningEntry, setMovingNode, setSelection], + ) const handleSlabSelect = useCallback( (slabId: SlabNode['id'], event: ReactMouseEvent) => { emitFloorplanNodeClick(slabId, event) @@ -5988,32 +5892,34 @@ export function FloorplanPanel() { }, [selectedOpeningEntry, setMovingNode, setSelection], ) - const handleSelectedOpeningDuplicate = useCallback( - (event: ReactMouseEvent) => { - event.stopPropagation() - - const opening = selectedOpeningEntry?.opening - if (!opening?.parentId) { - return - } + const duplicateSelectedOpening = useCallback(() => { + const opening = selectedOpeningEntry?.opening + if (!opening?.parentId) { + return + } - sfxEmitter.emit('sfx:item-pick') - useScene.temporal.getState().pause() + sfxEmitter.emit('sfx:item-pick') + useScene.temporal.getState().pause() - const cloned = structuredClone(opening) as Record - delete cloned.id - cloned.metadata = { - ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - isNew: true, - } + const cloned = structuredClone(opening) as Record + delete cloned.id + cloned.metadata = { + ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), + isNew: true, + } - const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned) + const duplicate = opening.type === 'door' ? DoorNode.parse(cloned) : WindowNode.parse(cloned) - useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId) - setMovingNode(duplicate) - setSelection({ selectedIds: [] }) + useScene.getState().createNode(duplicate, opening.parentId as AnyNodeId) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + }, [selectedOpeningEntry, setMovingNode, setSelection]) + const handleSelectedOpeningDuplicate = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + duplicateSelectedOpening() }, - [selectedOpeningEntry, setMovingNode, setSelection], + [duplicateSelectedOpening], ) const handleSelectedOpeningDelete = useCallback( (event: ReactMouseEvent) => { @@ -6718,68 +6624,6 @@ export function FloorplanPanel() { setStructureLayer, site, ]) - const handleFloorplanSelectionToolChange = useCallback( - (nextTool: FloorplanSelectionTool) => { - setFloorplanSelectionTool(nextTool) - - if (phase === 'site') { - restoreGroundLevelStructureSelection() - return - } - - if (mode !== 'select') { - setMode('select') - } - }, - [mode, phase, restoreGroundLevelStructureSelection, setMode], - ) - const handleQuickBuildToolSelect = useCallback( - (nextTool: FloorplanQuickBuildTool) => { - setPhase('structure') - setStructureLayer(nextTool === 'zone' ? 'zones' : 'elements') - setMode('build') - setTool(nextTool) - setCatalogCategory(null) - }, - [setCatalogCategory, setMode, setPhase, setStructureLayer, setTool], - ) - const handleSiteEditShortcutSelect = useCallback(() => { - if (!(levelNode?.type === 'level' && levelNode.level === 0)) { - return - } - - if (isSiteEditShortcutActive) { - restoreGroundLevelStructureSelection() - return - } - - setPhase('site') - setMode('edit') - - if (currentBuildingId) { - setSelection({ - buildingId: currentBuildingId, - levelId: levelNode.id, - selectedIds: [], - zoneId: null, - }) - return - } - - setSelection({ - levelId: levelNode.id, - selectedIds: [], - zoneId: null, - }) - }, [ - currentBuildingId, - isSiteEditShortcutActive, - levelNode, - setMode, - setPhase, - setSelection, - restoreGroundLevelStructureSelection, - ]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null @@ -6810,6 +6654,36 @@ export function FloorplanPanel() { window.removeEventListener('keydown', handleKeyDown, true) } }, [isFloorplanHovered, phase, restoreGroundLevelStructureSelection]) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') { + return + } + + if (!(isFloorplanHovered && selectedOpeningEntry)) { + return + } + + const target = event.target as HTMLElement | null + const isEditableTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + Boolean(target?.isContentEditable) + + if (isEditableTarget) { + return + } + + event.preventDefault() + duplicateSelectedOpening() + } + + window.addEventListener('keydown', handleKeyDown, true) + + return () => { + window.removeEventListener('keydown', handleKeyDown, true) + } + }, [duplicateSelectedOpening, isFloorplanHovered, selectedOpeningEntry]) const activeDraftAnchorPoint = draftStart ?? activePolygonDraftPoints[0] ?? null const floorplanCursorColor = wallEndpointDraft ? palette.editCursor @@ -6819,396 +6693,14 @@ export function FloorplanPanel() { return (
setFloorplanHovered(true)} onPointerLeave={() => { setFloorplanHovered(false) setFloorplanCursorPosition(null) }} - style={{ - cursor: activeResizeDirection ? resizeCursorByDirection[activeResizeDirection] : undefined, - height: panelRect.height, - left: panelRect.x, - top: panelRect.y, - visibility: isPanelReady ? 'visible' : 'hidden', - width: panelRect.width, - }} + ref={containerRef} > - {resizeHandleConfigurations.map((handle) => ( -