@pascal-app/editor 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -5
- package/src/components/editor/bottom-sheet.tsx +149 -0
- package/src/components/editor/custom-camera-controls.tsx +75 -7
- package/src/components/editor/editor-layout-mobile.tsx +264 -0
- package/src/components/editor/editor-layout-v2.tsx +20 -0
- package/src/components/editor/first-person/build-collider-world.ts +365 -0
- package/src/components/editor/first-person/bvh-ecctrl.tsx +795 -0
- package/src/components/editor/first-person-controls.tsx +496 -143
- package/src/components/editor/floating-action-menu.tsx +32 -55
- package/src/components/editor/floorplan-background-selection.ts +113 -0
- package/src/components/editor/floorplan-panel.tsx +9855 -3298
- package/src/components/editor/index.tsx +269 -21
- package/src/components/editor/selection-manager.tsx +575 -13
- package/src/components/editor/thumbnail-generator.tsx +38 -7
- package/src/components/editor/use-floorplan-background-placement.ts +257 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +171 -0
- package/src/components/editor/use-floorplan-scene-data.ts +189 -0
- package/src/components/editor/wall-measurement-label.tsx +267 -36
- package/src/components/editor-2d/floorplan-action-menu-layer.tsx +95 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +160 -0
- package/src/components/editor-2d/floorplan-hotkey-handlers.tsx +92 -0
- package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +119 -0
- package/src/components/editor-2d/renderers/floorplan-marquee-layer.tsx +58 -0
- package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +197 -0
- package/src/components/editor-2d/renderers/floorplan-roof-layer.tsx +113 -0
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +474 -0
- package/src/components/editor-2d/svg-paths.ts +119 -0
- package/src/components/tools/ceiling/ceiling-boundary-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
- package/src/components/tools/ceiling/ceiling-tool.tsx +5 -5
- package/src/components/tools/column/column-tool.tsx +97 -0
- package/src/components/tools/column/move-column-tool.tsx +105 -0
- package/src/components/tools/door/door-tool.tsx +7 -0
- package/src/components/tools/door/move-door-tool.tsx +28 -8
- package/src/components/tools/fence/fence-drafting.ts +10 -3
- package/src/components/tools/fence/fence-tool.tsx +159 -3
- package/src/components/tools/fence/move-fence-endpoint-tool.tsx +129 -18
- package/src/components/tools/fence/move-fence-tool.tsx +101 -34
- package/src/components/tools/item/move-tool.tsx +10 -1
- package/src/components/tools/item/placement-math.ts +30 -1
- package/src/components/tools/item/placement-strategies.ts +109 -31
- package/src/components/tools/item/placement-types.ts +7 -0
- package/src/components/tools/item/use-draft-node.ts +2 -0
- package/src/components/tools/item/use-placement-coordinator.tsx +660 -52
- package/src/components/tools/roof/move-roof-tool.tsx +22 -15
- package/src/components/tools/shared/polygon-editor.tsx +153 -28
- package/src/components/tools/shared/segment-angle.ts +156 -0
- package/src/components/tools/slab/slab-boundary-editor.tsx +1 -0
- package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
- package/src/components/tools/spawn/move-spawn-tool.tsx +101 -0
- package/src/components/tools/spawn/spawn-tool.tsx +130 -0
- package/src/components/tools/tool-manager.tsx +18 -3
- package/src/components/tools/wall/move-wall-endpoint-tool.tsx +121 -20
- package/src/components/tools/wall/wall-drafting.ts +18 -9
- package/src/components/tools/wall/wall-tool.tsx +134 -2
- package/src/components/tools/window/move-window-tool.tsx +18 -0
- package/src/components/tools/window/window-tool.tsx +5 -0
- package/src/components/ui/action-menu/camera-actions.tsx +37 -33
- package/src/components/ui/action-menu/control-modes.tsx +28 -1
- package/src/components/ui/action-menu/index.tsx +91 -1
- package/src/components/ui/action-menu/structure-tools.tsx +2 -0
- package/src/components/ui/action-menu/view-toggles.tsx +424 -35
- package/src/components/ui/command-palette/editor-commands.tsx +18 -1
- package/src/components/ui/controls/material-picker.tsx +152 -165
- package/src/components/ui/controls/slider-control.tsx +66 -18
- package/src/components/ui/floating-level-selector.tsx +286 -55
- package/src/components/ui/helpers/helper-manager.tsx +5 -0
- package/src/components/ui/item-catalog/catalog-items.tsx +1116 -1219
- package/src/components/ui/item-catalog/item-catalog.tsx +42 -175
- package/src/components/ui/level-duplicate-dialog.tsx +115 -0
- package/src/components/ui/panels/ceiling-panel.tsx +1 -25
- package/src/components/ui/panels/column-panel.tsx +715 -0
- package/src/components/ui/panels/door-panel.tsx +981 -289
- package/src/components/ui/panels/fence-panel.tsx +3 -45
- package/src/components/ui/panels/mobile-panel-sheet.tsx +108 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +100 -0
- package/src/components/ui/panels/node-display.ts +39 -0
- package/src/components/ui/panels/paint-panel.tsx +138 -0
- package/src/components/ui/panels/panel-manager.tsx +210 -1
- package/src/components/ui/panels/panel-wrapper.tsx +48 -39
- package/src/components/ui/panels/reference-panel.tsx +238 -5
- package/src/components/ui/panels/roof-panel.tsx +4 -105
- package/src/components/ui/panels/roof-segment-panel.tsx +0 -25
- package/src/components/ui/panels/slab-panel.tsx +4 -30
- package/src/components/ui/panels/spawn-panel.tsx +155 -0
- package/src/components/ui/panels/stair-panel.tsx +11 -117
- package/src/components/ui/panels/stair-segment-panel.tsx +0 -25
- package/src/components/ui/panels/wall-panel.tsx +1 -95
- package/src/components/ui/panels/window-panel.tsx +660 -139
- package/src/components/ui/sidebar/mobile-tab-bar.tsx +46 -0
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +77 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +109 -24
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +82 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +9 -3
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +8 -5
- package/src/components/ui/sidebar/tab-bar.tsx +3 -0
- package/src/components/ui/viewer-toolbar.tsx +42 -1
- package/src/hooks/use-auto-frame.ts +45 -0
- package/src/hooks/use-keyboard.ts +64 -7
- package/src/hooks/use-mobile.ts +12 -12
- package/src/lib/door-interaction.ts +88 -0
- package/src/lib/floorplan/geometry.ts +263 -0
- package/src/lib/floorplan/index.ts +38 -0
- package/src/lib/floorplan/items.ts +179 -0
- package/src/lib/floorplan/selection-tool.ts +231 -0
- package/src/lib/floorplan/stairs.ts +478 -0
- package/src/lib/floorplan/types.ts +57 -0
- package/src/lib/floorplan/walls.ts +23 -0
- package/src/lib/guide-events.ts +10 -0
- package/src/lib/level-duplication.test.ts +72 -0
- package/src/lib/level-duplication.ts +153 -0
- package/src/lib/local-guide-image.ts +42 -0
- package/src/lib/material-paint.ts +284 -0
- package/src/lib/roof-duplication.ts +214 -0
- package/src/lib/scene-bounds.test.ts +183 -0
- package/src/lib/scene-bounds.ts +169 -0
- package/src/lib/stair-duplication.ts +126 -0
- package/src/lib/window-interaction.ts +86 -0
- package/src/store/use-editor.tsx +164 -8
|
@@ -1,178 +1,490 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import '../../three-types'
|
|
4
|
+
import { type AnyNodeId, emitter, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
|
|
5
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
6
|
+
import { KeyboardControls } from '@react-three/drei'
|
|
3
7
|
import { useFrame, useThree } from '@react-three/fiber'
|
|
4
|
-
import { useCallback, useEffect, useRef } from 'react'
|
|
5
|
-
import { Euler, Vector3 } from 'three'
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
9
|
+
import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
|
|
10
|
+
import {
|
|
11
|
+
closeDoorOpenState,
|
|
12
|
+
DOOR_SWING_OPEN_ANGLE,
|
|
13
|
+
isOperationDoorType,
|
|
14
|
+
toggleDoorOpenState,
|
|
15
|
+
} from '../../lib/door-interaction'
|
|
16
|
+
import {
|
|
17
|
+
closeWindowOpenState,
|
|
18
|
+
isOperableWindowType,
|
|
19
|
+
toggleWindowOpenState,
|
|
20
|
+
} from '../../lib/window-interaction'
|
|
6
21
|
import useEditor from '../../store/use-editor'
|
|
22
|
+
import {
|
|
23
|
+
buildFirstPersonColliderWorldFromRegistry,
|
|
24
|
+
deriveFirstPersonSpawn,
|
|
25
|
+
FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
26
|
+
type FirstPersonColliderWorld,
|
|
27
|
+
type FirstPersonSpawn,
|
|
28
|
+
} from './first-person/build-collider-world'
|
|
29
|
+
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
|
|
30
|
+
import BVHEcctrl from './first-person/bvh-ecctrl'
|
|
31
|
+
|
|
32
|
+
const CAMERA_EYE_OFFSET = 0.45
|
|
33
|
+
const LOOK_SENSITIVITY = 0.002
|
|
34
|
+
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
|
35
|
+
const DOOR_INTERACTION_DISTANCE = 2.5
|
|
36
|
+
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
|
|
37
|
+
const keyboardMap = [
|
|
38
|
+
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
|
39
|
+
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
|
40
|
+
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
|
41
|
+
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
|
42
|
+
{ name: 'jump', keys: ['Space'] },
|
|
43
|
+
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
|
47
|
+
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
|
48
|
+
const centerScreenPoint = new Vector2(0, 0)
|
|
49
|
+
const doorInteractionRaycaster = new Raycaster()
|
|
50
|
+
const doorLeafBox = new Box3()
|
|
51
|
+
const doorLeafInverseMatrix = new Matrix4()
|
|
52
|
+
const doorLeafLocalHit = new Vector3()
|
|
53
|
+
const doorLeafLocalRay = new Ray()
|
|
54
|
+
const doorLeafMatrix = new Matrix4()
|
|
55
|
+
const doorLeafWorldHit = new Vector3()
|
|
56
|
+
const doorOpeningBox = new Box3()
|
|
57
|
+
const doorOpeningInverseMatrix = new Matrix4()
|
|
58
|
+
const doorOpeningLocalHit = new Vector3()
|
|
59
|
+
const doorOpeningLocalRay = new Ray()
|
|
60
|
+
const doorOpeningMatrix = new Matrix4()
|
|
61
|
+
const doorOpeningWorldHit = new Vector3()
|
|
62
|
+
const spawnWorldPosition = new Vector3()
|
|
63
|
+
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
|
64
|
+
const windowInteractionRaycaster = new Raycaster()
|
|
65
|
+
|
|
66
|
+
type FirstPersonInteractableTarget = {
|
|
67
|
+
id: AnyNodeId
|
|
68
|
+
type: 'door' | 'window'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolvePlacedSpawnNode = (
|
|
72
|
+
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
73
|
+
_levelId: string | null,
|
|
74
|
+
) => {
|
|
75
|
+
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
|
|
76
|
+
if (candidates.length === 0) return null
|
|
7
77
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// Movement speed in meters per second
|
|
11
|
-
const MOVE_SPEED = 5
|
|
12
|
-
// Sprint multiplier when holding Shift
|
|
13
|
-
const SPRINT_MULTIPLIER = 2
|
|
14
|
-
// Vertical float speed in meters per second
|
|
15
|
-
const VERTICAL_SPEED = 3
|
|
16
|
-
// Mouse look sensitivity
|
|
17
|
-
const MOUSE_SENSITIVITY = 0.002
|
|
18
|
-
// Min Y position (eye height above ground)
|
|
19
|
-
const MIN_Y = EYE_HEIGHT
|
|
20
|
-
|
|
21
|
-
// Reusable vectors to avoid allocations in the render loop
|
|
22
|
-
const _forward = new Vector3()
|
|
23
|
-
const _right = new Vector3()
|
|
24
|
-
const _moveVector = new Vector3()
|
|
25
|
-
const _euler = new Euler(0, 0, 0, 'YXZ')
|
|
78
|
+
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
|
|
79
|
+
}
|
|
26
80
|
|
|
27
81
|
export const FirstPersonControls = () => {
|
|
28
82
|
const { camera, gl } = useThree()
|
|
29
|
-
const
|
|
83
|
+
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
84
|
+
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
|
85
|
+
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
|
30
86
|
const yawRef = useRef(0)
|
|
31
87
|
const pitchRef = useRef(0)
|
|
32
|
-
const
|
|
33
|
-
const
|
|
88
|
+
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
|
89
|
+
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
|
90
|
+
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
|
91
|
+
const [controllerStart, setControllerStart] = useState<{
|
|
92
|
+
position: [number, number, number]
|
|
93
|
+
yaw: number
|
|
94
|
+
} | null>(null)
|
|
95
|
+
|
|
96
|
+
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
|
|
97
|
+
worldRef.current?.dispose()
|
|
98
|
+
worldRef.current = nextWorld
|
|
99
|
+
setWorld(nextWorld)
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const rebuildColliderWorld = useCallback(() => {
|
|
103
|
+
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
|
|
104
|
+
}, [replaceColliderWorld])
|
|
105
|
+
|
|
106
|
+
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
|
107
|
+
const nodes = useScene.getState().nodes
|
|
108
|
+
camera.updateMatrixWorld(true)
|
|
109
|
+
doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
110
|
+
|
|
111
|
+
let closestDoorId: AnyNodeId | null = null
|
|
112
|
+
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
113
|
+
|
|
114
|
+
for (const doorId of sceneRegistry.byType.door) {
|
|
115
|
+
const node = nodes[doorId as AnyNodeId]
|
|
116
|
+
if (node?.type !== 'door') continue
|
|
117
|
+
if (node.openingKind === 'opening') continue
|
|
118
|
+
if (node.segments.every((segment) => segment.type === 'empty')) continue
|
|
119
|
+
|
|
120
|
+
const object = sceneRegistry.nodes.get(doorId)
|
|
121
|
+
if (!object) continue
|
|
122
|
+
|
|
123
|
+
object.updateWorldMatrix(true, true)
|
|
124
|
+
|
|
125
|
+
const placementHit = doorInteractionRaycaster
|
|
126
|
+
.intersectObject(object, true)
|
|
127
|
+
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
|
|
128
|
+
if (placementHit && placementHit.distance < closestDistance) {
|
|
129
|
+
closestDoorId = doorId as AnyNodeId
|
|
130
|
+
closestDistance = placementHit.distance
|
|
131
|
+
}
|
|
34
132
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
133
|
+
const leafW = node.width - 2 * node.frameThickness
|
|
134
|
+
const leafH = node.height - node.frameThickness
|
|
135
|
+
if (leafW <= 0 || leafH <= 0) continue
|
|
136
|
+
|
|
137
|
+
const leafCenterY = -node.frameThickness / 2
|
|
138
|
+
|
|
139
|
+
if (isOperationDoorType(node.doorType)) {
|
|
140
|
+
doorOpeningMatrix
|
|
141
|
+
.copy(object.matrixWorld)
|
|
142
|
+
.multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
|
|
143
|
+
doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
|
|
144
|
+
doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
145
|
+
doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
146
|
+
doorOpeningLocalRay
|
|
147
|
+
.copy(doorInteractionRaycaster.ray)
|
|
148
|
+
.applyMatrix4(doorOpeningInverseMatrix)
|
|
149
|
+
|
|
150
|
+
const localOpeningHit = doorOpeningLocalRay.intersectBox(
|
|
151
|
+
doorOpeningBox,
|
|
152
|
+
doorOpeningLocalHit,
|
|
153
|
+
)
|
|
154
|
+
if (!localOpeningHit) continue
|
|
155
|
+
|
|
156
|
+
doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
|
|
157
|
+
const openingHitDistance = doorOpeningWorldHit.distanceTo(
|
|
158
|
+
doorInteractionRaycaster.ray.origin,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
|
|
163
|
+
openingHitDistance < closestDistance
|
|
164
|
+
) {
|
|
165
|
+
closestDoorId = doorId as AnyNodeId
|
|
166
|
+
closestDistance = openingHitDistance
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
39
170
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
171
|
+
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
|
|
172
|
+
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
|
|
173
|
+
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
|
|
174
|
+
const currentSwingAngle =
|
|
175
|
+
useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0
|
|
176
|
+
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle))
|
|
177
|
+
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
|
178
|
+
|
|
179
|
+
doorLeafMatrix
|
|
180
|
+
.copy(object.matrixWorld)
|
|
181
|
+
.multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
|
|
182
|
+
.multiply(new Matrix4().makeRotationY(leafSwingRotation))
|
|
183
|
+
.multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
|
|
184
|
+
doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
|
|
185
|
+
doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
186
|
+
doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
187
|
+
doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
|
|
188
|
+
|
|
189
|
+
const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
|
|
190
|
+
if (!localHit) continue
|
|
191
|
+
|
|
192
|
+
doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
|
|
193
|
+
const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
|
|
194
|
+
|
|
195
|
+
if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
|
|
196
|
+
closestDoorId = doorId as AnyNodeId
|
|
197
|
+
closestDistance = hitDistance
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return closestDoorId
|
|
44
202
|
}, [camera])
|
|
45
203
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
204
|
+
const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
|
|
205
|
+
const nodes = useScene.getState().nodes
|
|
206
|
+
camera.updateMatrixWorld(true)
|
|
207
|
+
windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
49
208
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
canvas.requestPointerLock()
|
|
53
|
-
}
|
|
54
|
-
}
|
|
209
|
+
let closestWindowId: AnyNodeId | null = null
|
|
210
|
+
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
55
211
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
212
|
+
for (const windowId of sceneRegistry.byType.window) {
|
|
213
|
+
const node = nodes[windowId as AnyNodeId]
|
|
214
|
+
if (node?.type !== 'window') continue
|
|
215
|
+
if (node.openingKind === 'opening') continue
|
|
216
|
+
if (!isOperableWindowType(node.windowType)) continue
|
|
59
217
|
|
|
60
|
-
|
|
61
|
-
if (!
|
|
218
|
+
const object = sceneRegistry.nodes.get(windowId)
|
|
219
|
+
if (!object) continue
|
|
62
220
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
221
|
+
const hit = windowInteractionRaycaster
|
|
222
|
+
.intersectObject(object, true)
|
|
223
|
+
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
|
|
224
|
+
if (!(hit && hit.distance < closestDistance)) continue
|
|
225
|
+
|
|
226
|
+
closestWindowId = windowId as AnyNodeId
|
|
227
|
+
closestDistance = hit.distance
|
|
70
228
|
}
|
|
71
229
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
230
|
+
return closestWindowId
|
|
231
|
+
}, [camera])
|
|
232
|
+
|
|
233
|
+
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
|
|
234
|
+
const doorId = resolveInteractableDoorId()
|
|
235
|
+
if (doorId) return { id: doorId, type: 'door' }
|
|
236
|
+
|
|
237
|
+
const windowId = resolveInteractableWindowId()
|
|
238
|
+
if (windowId) return { id: windowId, type: 'window' }
|
|
239
|
+
|
|
240
|
+
return null
|
|
241
|
+
}, [resolveInteractableDoorId, resolveInteractableWindowId])
|
|
242
|
+
|
|
243
|
+
const toggleInteractableTarget = useCallback(() => {
|
|
244
|
+
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
245
|
+
if (!target) return
|
|
246
|
+
|
|
247
|
+
if (target.type === 'window') {
|
|
248
|
+
const node = useScene.getState().nodes[target.id]
|
|
249
|
+
if (
|
|
250
|
+
node?.type !== 'window' ||
|
|
251
|
+
node.openingKind === 'opening' ||
|
|
252
|
+
!isOperableWindowType(node.windowType)
|
|
253
|
+
) {
|
|
75
254
|
return
|
|
76
255
|
}
|
|
77
256
|
|
|
78
|
-
|
|
257
|
+
toggleWindowOpenState(target.id, { persist: false })
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const doorId = target.id
|
|
79
262
|
|
|
80
|
-
|
|
263
|
+
const node = useScene.getState().nodes[doorId]
|
|
264
|
+
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
265
|
+
|
|
266
|
+
toggleDoorOpenState(doorId, { persist: false })
|
|
267
|
+
}, [resolveInteractableTarget])
|
|
268
|
+
|
|
269
|
+
const closeInteractableTarget = useCallback(() => {
|
|
270
|
+
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
271
|
+
if (!target) return
|
|
272
|
+
|
|
273
|
+
if (target.type === 'window') {
|
|
274
|
+
const node = useScene.getState().nodes[target.id]
|
|
81
275
|
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
code === 'KeyD' ||
|
|
86
|
-
code === 'KeyQ' ||
|
|
87
|
-
code === 'KeyE' ||
|
|
88
|
-
code === 'ShiftLeft' ||
|
|
89
|
-
code === 'ShiftRight'
|
|
276
|
+
node?.type !== 'window' ||
|
|
277
|
+
node.openingKind === 'opening' ||
|
|
278
|
+
!isOperableWindowType(node.windowType)
|
|
90
279
|
) {
|
|
91
|
-
|
|
92
|
-
e.stopPropagation()
|
|
93
|
-
keysRef.current.add(code)
|
|
280
|
+
return
|
|
94
281
|
}
|
|
95
282
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
283
|
+
closeWindowOpenState(target.id, { persist: false })
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const node = useScene.getState().nodes[target.id]
|
|
288
|
+
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
289
|
+
|
|
290
|
+
closeDoorOpenState(target.id, { persist: false })
|
|
291
|
+
}, [resolveInteractableTarget])
|
|
292
|
+
|
|
293
|
+
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
|
294
|
+
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
|
295
|
+
|
|
296
|
+
const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
|
|
297
|
+
if (spawnObject) {
|
|
298
|
+
spawnObject.updateWorldMatrix(true, false)
|
|
299
|
+
spawnObject.getWorldPosition(spawnWorldPosition)
|
|
300
|
+
spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
position: [
|
|
304
|
+
spawnWorldPosition.x,
|
|
305
|
+
spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
306
|
+
spawnWorldPosition.z,
|
|
307
|
+
],
|
|
308
|
+
yaw: spawnWorldEuler.y,
|
|
104
309
|
}
|
|
105
310
|
}
|
|
106
311
|
|
|
107
|
-
|
|
108
|
-
|
|
312
|
+
return {
|
|
313
|
+
position: [
|
|
314
|
+
placedSpawnNode.position[0],
|
|
315
|
+
placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
316
|
+
placedSpawnNode.position[2],
|
|
317
|
+
],
|
|
318
|
+
yaw: placedSpawnNode.rotation,
|
|
319
|
+
}
|
|
320
|
+
}, [placedSpawnNode])
|
|
321
|
+
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
rebuildColliderWorld()
|
|
324
|
+
|
|
325
|
+
return () => {
|
|
326
|
+
worldRef.current?.dispose()
|
|
327
|
+
worldRef.current = null
|
|
328
|
+
setWorld(null)
|
|
329
|
+
}
|
|
330
|
+
}, [rebuildColliderWorld])
|
|
331
|
+
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
emitter.on('door:animation-completed', rebuildColliderWorld)
|
|
334
|
+
emitter.on('window:animation-completed', rebuildColliderWorld)
|
|
335
|
+
return () => {
|
|
336
|
+
emitter.off('door:animation-completed', rebuildColliderWorld)
|
|
337
|
+
emitter.off('window:animation-completed', rebuildColliderWorld)
|
|
338
|
+
}
|
|
339
|
+
}, [rebuildColliderWorld])
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (!world) return
|
|
343
|
+
if (controllerStart) return
|
|
344
|
+
|
|
345
|
+
const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
|
|
346
|
+
const [x, y, z] = spawn.position
|
|
347
|
+
yawRef.current = spawn.yaw
|
|
348
|
+
pitchRef.current = 0
|
|
349
|
+
setControllerStart({
|
|
350
|
+
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
|
|
351
|
+
yaw: spawn.yaw,
|
|
352
|
+
})
|
|
353
|
+
}, [camera, controllerStart, placedSpawn, world])
|
|
354
|
+
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
const canvas = gl.domElement
|
|
357
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
358
|
+
if (document.pointerLockElement !== canvas) return
|
|
359
|
+
|
|
360
|
+
yawRef.current -= e.movementX * LOOK_SENSITIVITY
|
|
361
|
+
pitchRef.current = Math.max(
|
|
362
|
+
-(Math.PI / 2 - 0.05),
|
|
363
|
+
Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY),
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const handleClick = (event: MouseEvent) => {
|
|
368
|
+
const target = event.target
|
|
369
|
+
if (!(target instanceof HTMLElement)) return
|
|
370
|
+
if (!canvas.contains(target)) return
|
|
371
|
+
if (document.pointerLockElement !== canvas) {
|
|
372
|
+
canvas.requestPointerLock?.()
|
|
373
|
+
}
|
|
109
374
|
}
|
|
110
375
|
|
|
111
|
-
canvas.addEventListener('click', requestLock)
|
|
112
|
-
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
113
376
|
document.addEventListener('mousemove', handleMouseMove)
|
|
114
|
-
|
|
115
|
-
document.addEventListener('keydown', handleKeyDown, true)
|
|
116
|
-
document.addEventListener('keyup', handleKeyUp)
|
|
377
|
+
document.addEventListener('click', handleClick)
|
|
117
378
|
|
|
118
379
|
return () => {
|
|
119
|
-
canvas.removeEventListener('click', requestLock)
|
|
120
|
-
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
121
380
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
122
|
-
document.removeEventListener('
|
|
123
|
-
document.removeEventListener('keyup', handleKeyUp)
|
|
381
|
+
document.removeEventListener('click', handleClick)
|
|
124
382
|
if (document.pointerLockElement === canvas) {
|
|
125
383
|
document.exitPointerLock()
|
|
126
384
|
}
|
|
127
|
-
keysRef.current.clear()
|
|
128
385
|
}
|
|
129
386
|
}, [gl])
|
|
130
387
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Clamp delta to avoid huge jumps (e.g. tab switching)
|
|
134
|
-
const dt = Math.min(delta, 0.1)
|
|
135
|
-
const keys = keysRef.current
|
|
136
|
-
|
|
137
|
-
const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight')
|
|
138
|
-
const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1)
|
|
139
|
-
|
|
140
|
-
// Calculate forward and right vectors on the XZ plane (ignore pitch for movement)
|
|
141
|
-
_forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current))
|
|
142
|
-
_right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current))
|
|
143
|
-
|
|
144
|
-
_moveVector.set(0, 0, 0)
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
const canvas = gl.domElement
|
|
145
390
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
391
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
392
|
+
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
|
393
|
+
return
|
|
394
|
+
}
|
|
150
395
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
396
|
+
if (event.code === 'Escape') {
|
|
397
|
+
event.preventDefault()
|
|
398
|
+
event.stopPropagation()
|
|
399
|
+
if (document.pointerLockElement === canvas) {
|
|
400
|
+
document.exitPointerLock()
|
|
401
|
+
}
|
|
402
|
+
useEditor.getState().setFirstPersonMode(false)
|
|
403
|
+
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
|
404
|
+
event.preventDefault()
|
|
405
|
+
event.stopPropagation()
|
|
406
|
+
toggleInteractableTarget()
|
|
407
|
+
} else if (event.code === 'KeyT') {
|
|
408
|
+
event.preventDefault()
|
|
409
|
+
event.stopPropagation()
|
|
410
|
+
closeInteractableTarget()
|
|
411
|
+
}
|
|
155
412
|
}
|
|
156
413
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
414
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
415
|
+
return () => {
|
|
416
|
+
document.removeEventListener('keydown', handleKeyDown, true)
|
|
160
417
|
}
|
|
161
|
-
|
|
162
|
-
|
|
418
|
+
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
|
419
|
+
|
|
420
|
+
useFrame((_, delta) => {
|
|
421
|
+
if (!controllerRef.current?.group) return
|
|
422
|
+
|
|
423
|
+
const group = controllerRef.current.group
|
|
424
|
+
group.rotation.y = 0
|
|
425
|
+
camera.position.copy(group.position).add(cameraOffset)
|
|
426
|
+
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
|
427
|
+
camera.quaternion.setFromEuler(cameraEuler)
|
|
428
|
+
camera.updateMatrixWorld(true)
|
|
429
|
+
|
|
430
|
+
const nextInteractableTarget = resolveInteractableTarget()
|
|
431
|
+
const previousInteractableTarget = interactableTargetRef.current
|
|
432
|
+
if (
|
|
433
|
+
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
|
|
434
|
+
previousInteractableTarget?.type !== nextInteractableTarget?.type
|
|
435
|
+
) {
|
|
436
|
+
interactableTargetRef.current = nextInteractableTarget
|
|
437
|
+
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
|
163
438
|
}
|
|
439
|
+
})
|
|
164
440
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
return () => {
|
|
443
|
+
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
444
|
+
useViewer.getState().setHoveredId(null)
|
|
445
|
+
}
|
|
168
446
|
}
|
|
447
|
+
}, [])
|
|
169
448
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
})
|
|
449
|
+
if (!world) {
|
|
450
|
+
return null
|
|
451
|
+
}
|
|
174
452
|
|
|
175
|
-
return
|
|
453
|
+
return (
|
|
454
|
+
<>
|
|
455
|
+
{controllerStart && (
|
|
456
|
+
<KeyboardControls map={keyboardMap}>
|
|
457
|
+
<BVHEcctrl
|
|
458
|
+
ref={controllerRef}
|
|
459
|
+
key="first-person-controller"
|
|
460
|
+
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
|
461
|
+
colliderMeshes={[world.mesh]}
|
|
462
|
+
collisionCheckIteration={3}
|
|
463
|
+
collisionPushBackDamping={0.1}
|
|
464
|
+
collisionPushBackThreshold={0.001}
|
|
465
|
+
debug={false}
|
|
466
|
+
delay={0}
|
|
467
|
+
fallGravityFactor={4}
|
|
468
|
+
floatCheckType="BOTH"
|
|
469
|
+
floatDampingC={36}
|
|
470
|
+
floatHeight={0.5}
|
|
471
|
+
floatPullBackHeight={0.35}
|
|
472
|
+
floatSensorRadius={0.15}
|
|
473
|
+
floatSpringK={1200}
|
|
474
|
+
gravity={9.81}
|
|
475
|
+
jumpVel={6}
|
|
476
|
+
maxRunSpeed={5.5}
|
|
477
|
+
maxSlope={1.2}
|
|
478
|
+
maxWalkSpeed={4}
|
|
479
|
+
position={controllerStart.position}
|
|
480
|
+
acceleration={26}
|
|
481
|
+
airDragFactor={0.3}
|
|
482
|
+
deceleration={30}
|
|
483
|
+
/>
|
|
484
|
+
</KeyboardControls>
|
|
485
|
+
)}
|
|
486
|
+
</>
|
|
487
|
+
)
|
|
176
488
|
}
|
|
177
489
|
|
|
178
490
|
/**
|
|
@@ -180,6 +492,23 @@ export const FirstPersonControls = () => {
|
|
|
180
492
|
* Rendered as a regular DOM overlay (not inside the Canvas).
|
|
181
493
|
*/
|
|
182
494
|
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
495
|
+
const [isLocked, setIsLocked] = useState(false)
|
|
496
|
+
const hasPlacedSpawn = useScene((state) =>
|
|
497
|
+
Object.values(state.nodes).some((node) => node.type === 'spawn'),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
const handlePointerLockChange = () => {
|
|
502
|
+
setIsLocked(document.pointerLockElement != null)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
handlePointerLockChange()
|
|
506
|
+
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
507
|
+
return () => {
|
|
508
|
+
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
509
|
+
}
|
|
510
|
+
}, [])
|
|
511
|
+
|
|
183
512
|
const handleExit = useCallback(() => {
|
|
184
513
|
if (document.pointerLockElement) {
|
|
185
514
|
document.exitPointerLock()
|
|
@@ -189,15 +518,15 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
|
189
518
|
|
|
190
519
|
return (
|
|
191
520
|
<>
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
521
|
+
{isLocked && (
|
|
522
|
+
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center">
|
|
523
|
+
<div className="relative h-7 w-7">
|
|
524
|
+
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
|
|
525
|
+
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
|
|
526
|
+
</div>
|
|
197
527
|
</div>
|
|
198
|
-
|
|
528
|
+
)}
|
|
199
529
|
|
|
200
|
-
{/* Exit button — top-right */}
|
|
201
530
|
<div className="fixed top-4 right-4 z-50">
|
|
202
531
|
<button
|
|
203
532
|
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
|
|
@@ -211,30 +540,41 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
|
|
211
540
|
</button>
|
|
212
541
|
</div>
|
|
213
542
|
|
|
214
|
-
{
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<ControlHint label="Up" keys={['Q']} />
|
|
220
|
-
<ControlHint label="Down" keys={['E']} />
|
|
221
|
-
<div className="h-5 w-px bg-border/30" />
|
|
222
|
-
<ControlHint label="Sprint" keys={['Shift']} />
|
|
223
|
-
<div className="h-5 w-px bg-border/30" />
|
|
224
|
-
<span className="text-muted-foreground/60 text-xs">Click to look around</span>
|
|
543
|
+
{!hasPlacedSpawn && (
|
|
544
|
+
<div className="fixed top-4 left-1/2 z-50 -translate-x-1/2">
|
|
545
|
+
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
|
|
546
|
+
Place a Spawn Point from the Build tab to control where walkthrough starts.
|
|
547
|
+
</div>
|
|
225
548
|
</div>
|
|
226
|
-
|
|
549
|
+
)}
|
|
550
|
+
|
|
551
|
+
{isLocked && (
|
|
552
|
+
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
|
|
553
|
+
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
|
|
554
|
+
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
|
|
555
|
+
<div className="h-px w-full bg-border/30" />
|
|
556
|
+
<InlineControlHint label="Jump" keyLabel="Space" />
|
|
557
|
+
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
|
558
|
+
<InlineControlHint label="Interact" keyLabel="E / R" />
|
|
559
|
+
<InlineControlHint label="Close" keyLabel="T" />
|
|
560
|
+
<div className="h-px w-full bg-border/30" />
|
|
561
|
+
<span className="text-center text-muted-foreground/60 text-xs">
|
|
562
|
+
Click to look around
|
|
563
|
+
</span>
|
|
564
|
+
</div>
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
227
567
|
</>
|
|
228
568
|
)
|
|
229
569
|
}
|
|
230
570
|
|
|
231
571
|
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
|
232
572
|
return (
|
|
233
|
-
<div className="flex flex-col items-center gap-1.5">
|
|
573
|
+
<div className="flex flex-col items-center gap-1.5 text-center">
|
|
234
574
|
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
|
|
235
575
|
{label}
|
|
236
576
|
</span>
|
|
237
|
-
<div className="flex items-center gap-1">
|
|
577
|
+
<div className="flex flex-wrap items-center justify-center gap-1">
|
|
238
578
|
{keys.map((key) => (
|
|
239
579
|
<kbd
|
|
240
580
|
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
|
|
@@ -247,3 +587,16 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
|
|
|
247
587
|
</div>
|
|
248
588
|
)
|
|
249
589
|
}
|
|
590
|
+
|
|
591
|
+
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
|
|
592
|
+
return (
|
|
593
|
+
<div className="flex items-center justify-between gap-3">
|
|
594
|
+
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase">
|
|
595
|
+
{label}
|
|
596
|
+
</span>
|
|
597
|
+
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
|
|
598
|
+
{keyLabel}
|
|
599
|
+
</kbd>
|
|
600
|
+
</div>
|
|
601
|
+
)
|
|
602
|
+
}
|