From 3a1ba562460cbf9042dca973cee5a776bf3d3f4f Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Wed, 10 Jun 2026 11:28:12 -0400 Subject: [PATCH] Polish editor interaction behavior --- apps/editor/components/viewer-toolbar.tsx | 30 +++++- .../renderers/floorplan-registry-layer.tsx | 3 + .../editor/first-person-controls.tsx | 94 ++++++++++++++++++- .../editor/src/components/editor/index.tsx | 1 + .../components/editor/node-arrow-handles.tsx | 2 + .../ui/action-menu/control-modes.tsx | 77 ++------------- .../ui/action-menu/view-toggles.tsx | 10 +- 7 files changed, 142 insertions(+), 75 deletions(-) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 548c11ef..49638840 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -40,6 +40,7 @@ import { } from 'lucide-react' import Image from 'next/image' import { type ReactNode, useCallback } from 'react' +import { flushSync } from 'react-dom' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip' @@ -49,6 +50,24 @@ const TOOLBAR_CONTAINER = const TOOLBAR_BTN = 'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90' +function requestWalkthroughPointerLock() { + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) + + if (document.pointerLockElement === canvas) return + + try { + canvas.requestPointerLock?.() + } catch { + return + } +} + function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) { return ( @@ -441,6 +460,15 @@ function DisplayMenu() { function WalkthroughButton() { const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode) const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode) + const handleClick = useCallback(() => { + if (isFirstPersonMode) { + setFirstPersonMode(false) + return + } + + flushSync(() => setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }, [isFirstPersonMode, setFirstPersonMode]) return ( @@ -449,7 +477,7 @@ function WalkthroughButton() { TOOLBAR_BTN, isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20', )} - onClick={() => setFirstPersonMode(!isFirstPersonMode)} + onClick={handleClick} type="button" > diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index a6372283..4c9ae1a7 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -32,6 +32,7 @@ import { import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useEditor from '../../../store/use-editor' +import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { useFloorplanRender } from '../floorplan-render-context' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' @@ -493,6 +494,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { event.preventDefault() event.stopPropagation() + suppressBoxSelectForPointer(event) const session = handler.start({ node, @@ -769,6 +771,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (!node) return event.preventDefault() event.stopPropagation() + suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') setMovingNode(node as never) }} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 0bcd06b3..980ae420 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -64,7 +64,7 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' -import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' +import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl' import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 @@ -79,7 +79,10 @@ const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const VOID_FALL_RESPAWN_DEPTH = 12 -const keyboardMap = [ + +type MovementKeyName = Exclude + +const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [ { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, @@ -87,6 +90,36 @@ const keyboardMap = [ { name: 'jump', keys: ['Space'] }, { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, ] +const keyboardMap = movementKeyboardBindings +const movementKeyToName = new Map( + movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)), +) + +const inactiveMovementInput: MovementInput = { + backward: false, + forward: false, + jump: false, + leftward: false, + rightward: false, + run: false, +} + +function getMovementInputForKey(code: string, active: boolean): MovementInput | null { + const name = movementKeyToName.get(code) + return name ? ({ [name]: active } as MovementInput) : null +} + +function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { + const activeElement = document.activeElement + if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) { + activeElement.blur() + } + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) +} const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraEuler = new Euler(0, 0, 0, 'YXZ') @@ -536,6 +569,8 @@ export const FirstPersonControls = () => { const selectedLevelId = useViewer((state) => state.selection.levelId) const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) const controllerRef = useRef(null) + const movementInputRef = useRef({ ...inactiveMovementInput }) + const hadPointerLockRef = useRef(false) const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(null) @@ -578,6 +613,13 @@ export const FirstPersonControls = () => { setIsElevatorRideLocked(locked) }, []) + const setControllerApi = useCallback((api: BVHEcctrlApi | null) => { + controllerRef.current = api + if (api) { + api.setMovement(movementInputRef.current) + } + }, []) + const resolveInteractableDoorId = useCallback((): AnyNodeId | null => { const nodes = useScene.getState().nodes camera.updateMatrixWorld(true) @@ -916,6 +958,14 @@ export const FirstPersonControls = () => { }) }, [camera, controllerStart, placedSpawn, world]) + useEffect(() => { + const canvas = gl.domElement + focusFirstPersonCanvas(canvas) + + const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas)) + return () => window.cancelAnimationFrame(frame) + }, [gl]) + useEffect(() => { const canvas = gl.domElement const handleMouseMove = (e: MouseEvent) => { @@ -946,14 +996,29 @@ export const FirstPersonControls = () => { toggleInteractableTarget() } + const handlePointerLockChange = () => { + const isLocked = document.pointerLockElement === canvas + if (isLocked) { + hadPointerLockRef.current = true + return + } + + if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { + useEditor.getState().setFirstPersonMode(false) + } + } + + handlePointerLockChange() document.addEventListener('mousemove', handleMouseMove) document.addEventListener('click', handleClick) document.addEventListener('mousedown', handleMouseDown, true) + document.addEventListener('pointerlockchange', handlePointerLockChange) return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('click', handleClick) document.removeEventListener('mousedown', handleMouseDown, true) + document.removeEventListener('pointerlockchange', handlePointerLockChange) if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -963,7 +1028,24 @@ export const FirstPersonControls = () => { useEffect(() => { const canvas = gl.domElement + const applyMovementKey = (event: KeyboardEvent, active: boolean) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return false + } + + const movement = getMovementInputForKey(event.code, active) + if (!movement) return false + + event.preventDefault() + Object.assign(movementInputRef.current, movement) + controllerRef.current?.setMovement(movement) + return true + } + const handleKeyDown = (event: KeyboardEvent) => { + const handledMovement = applyMovementKey(event, true) + if (handledMovement) return + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return } @@ -986,9 +1068,15 @@ export const FirstPersonControls = () => { } } + const handleKeyUp = (event: KeyboardEvent) => { + applyMovementKey(event, false) + } + document.addEventListener('keydown', handleKeyDown, true) + document.addEventListener('keyup', handleKeyUp, true) return () => { document.removeEventListener('keydown', handleKeyDown, true) + document.removeEventListener('keyup', handleKeyUp, true) } }, [closeInteractableTarget, gl, toggleInteractableTarget]) @@ -1308,7 +1396,7 @@ export const FirstPersonControls = () => { maxWalkSpeed={4} paused={isElevatorRideLocked} position={controllerStart.position} - ref={controllerRef} + ref={setControllerApi} /> )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 6433940a..fb9c528e 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -887,6 +887,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 0fd56a5b..d8723198 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' +import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' import { ARROW_COLOR, @@ -1169,6 +1170,7 @@ function TranslateArrow({ // 3D translate gizmo and the floating Move button behave identically. const activate = (event: ThreeEvent) => { event.stopPropagation() + suppressBoxSelectForPointer(event) sfxEmitter.emit('sfx:item-pick') useEditor.getState().setMovingNode(node as never) useViewer.getState().setSelection({ selectedIds: [] }) 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 5cd84fcf..15fe4a17 100644 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -1,15 +1,13 @@ 'use client' import { Icon } from '@iconify/react' -import { type LevelNode, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' import { type LucideIcon, Trash2 } from 'lucide-react' import Image from 'next/image' import { cn } from './../../../lib/utils' -import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor' +import useEditor from './../../../store/use-editor' import { ActionButton } from './action-button' -type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete' +type ControlId = 'select' | 'box-select' | 'zone' | 'delete' type ControlConfig = { id: ControlId @@ -32,13 +30,6 @@ const controls: ControlConfig[] = [ color: 'hover:bg-blue-500/20 hover:text-blue-400', activeColor: 'bg-blue-500/20 text-blue-400', }, - { - id: 'site-edit', - imageSrc: '/icons/site-flag.png', - label: 'Edit site', - color: 'hover:bg-white/5', - activeColor: 'bg-white/10 hover:bg-white/10', - }, { id: 'zone', imageSrc: '/icons/zone.png', @@ -65,47 +56,20 @@ export function ControlModes() { const setPhase = useEditor((state) => state.setPhase) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool) - const levelId = useViewer((s) => s.selection.levelId) - - // Only subscribe to the primitive `level` number — when walls are added to - // this level the object ref changes but this number doesn't, so Object.is - // dedupes and we avoid a re-render. - const levelIndex = useScene((state) => { - if (!levelId) return null - const node = state.nodes[levelId] - return node?.type === 'level' ? (node as LevelNode).level : null - }) const isSiteEditing = phase === 'site' - const isGroundFloor = levelIndex === 0 - const canEnterSiteEdit = isGroundFloor || isSiteEditing const structureLayer = useEditor((state) => state.structureLayer) const getIsActive = (id: ControlId): boolean => { - if (isSiteEditing) return id === 'site-edit' if (id === 'select') return mode === 'select' && selectionTool === 'click' if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee' - if (id === 'site-edit') return false if (id === 'zone') return mode === 'build' && phase === 'structure' && structureLayer === 'zones' return mode === id } const handleClick = (id: ControlId) => { - if (id === 'site-edit') { - if (isSiteEditing) { - // Toggle off → back to structure/select - setPhase('structure') - setMode('select') - setStructureLayer('elements') - } else if (isGroundFloor) { - useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null }) - selectSiteFloorplanContext() - } - return - } - // Exit site editing first if needed if (isSiteEditing) { setPhase('structure') @@ -136,36 +100,19 @@ export function ControlModes() { {controls.map((c) => { const ModeIcon = c.icon const isImageMode = Boolean(c.imageSrc) - const isSiteButton = c.id === 'site-edit' const isActive = getIsActive(c.id) - const isDisabled = isSiteButton && !canEnterSiteEdit return ( handleClick(c.id)} shortcut={c.shortcut} size="icon" @@ -176,13 +123,9 @@ export function ControlModes() { alt={c.label} className={cn( 'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200', - isSiteButton - ? isActive - ? 'opacity-100 grayscale-0' - : '' - : isActive - ? 'opacity-100 grayscale-0' - : 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0', + isActive + ? 'opacity-100 grayscale-0' + : 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0', )} height={28} src={c.imageSrc} diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index 1e461661..ee8ebb07 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -26,6 +26,8 @@ import { ActionButton } from './action-button' const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05] +const REFERENCES_EMPTY_TEXT = + 'Upload GLB meshes as scan references or blueprint images as guide references.' function formatGridSnapStep(step: GridSnapStep) { return step.toFixed(2) @@ -342,7 +344,7 @@ function GuidesControl() {
) : (
- No guide images on this level yet. + {REFERENCES_EMPTY_TEXT}
)} @@ -581,7 +583,7 @@ function ScansControl() { ) : (
- No scans on this level yet. + {REFERENCES_EMPTY_TEXT}
)} @@ -805,7 +807,7 @@ function ReferencesControl() { )}