Polish editor interaction behavior

This commit is contained in:
Aymeric Rabot
2026-06-10 11:28:12 -04:00
parent bdfee058bd
commit 3a1ba56246
7 changed files with 142 additions and 75 deletions
+29 -1
View File
@@ -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<HTMLCanvasElement>('[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 (
<Tooltip>
@@ -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 (
<ToolbarTooltip label="Walkthrough">
@@ -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"
>
<Footprints className="h-4 w-4" />
@@ -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)
}}
@@ -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<keyof MovementInput, 'joystick'>
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<string, MovementKeyName>(
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<BVHEcctrlApi | null>(null)
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
const hadPointerLockRef = useRef(false)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(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}
/>
</KeyboardControls>
)}
@@ -887,6 +887,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
<div
className="relative min-w-0 flex-1 overflow-hidden"
data-pascal-viewer-3d
ref={viewer3dRef}
style={{ display: show3d ? undefined : 'none' }}
>
@@ -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<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as never)
useViewer.getState().setSelection({ selectedIds: [] })
@@ -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 (
<ActionButton
className={cn(
'group text-muted-foreground',
isSiteButton
? isActive
? c.activeColor
: canEnterSiteEdit
? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
: 'cursor-not-allowed opacity-35 grayscale'
: !(isImageMode || isActive) && c.color,
!(isSiteButton || isImageMode) && isActive && c.activeColor,
!isSiteButton && isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
!isSiteButton && isImageMode && !isActive && 'hover:bg-white/5',
!(isImageMode || isActive) && c.color,
!isImageMode && isActive && c.activeColor,
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
isImageMode && !isActive && 'hover:bg-white/5',
)}
disabled={isDisabled}
key={c.id}
label={
isSiteButton
? isActive
? 'Exit site editing'
: canEnterSiteEdit
? 'Edit site'
: 'Site editing (ground level only)'
: c.label
}
label={c.label}
onClick={() => handleClick(c.id)}
shortcut={c.shortcut}
size="icon"
@@ -176,11 +123,7 @@ 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
isActive
? 'opacity-100 grayscale-0'
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
)}
@@ -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() {
</div>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No guide images on this level yet.
{REFERENCES_EMPTY_TEXT}
</div>
)}
</div>
@@ -581,7 +583,7 @@ function ScansControl() {
</div>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No scans on this level yet.
{REFERENCES_EMPTY_TEXT}
</div>
)}
</div>
@@ -805,7 +807,7 @@ function ReferencesControl() {
</div>
)}
<ReferenceListSection
emptyText="No scans on this level yet."
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/mesh.png"
nodes={scans}
noun="scan"
@@ -816,7 +818,7 @@ function ReferencesControl() {
/>
<div className="h-px bg-border/45" />
<ReferenceListSection
emptyText="No guide images on this level yet."
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/floorplan.png"
nodes={guides}
noun="guide image"