esc key cancel tool

This commit is contained in:
wass08
2026-02-17 12:29:00 +09:00
parent 078b2500a3
commit d833245fbf
14 changed files with 121 additions and 29 deletions
@@ -144,14 +144,20 @@ export const CeilingTool: React.FC = () => {
} }
} }
const onCancel = () => {
setPoints([])
}
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
} }
}, [currentLevelId, points, cursorPosition, setTool]) }, [currentLevelId, points, cursorPosition, setTool])
@@ -1,8 +1,7 @@
import { type AnyNodeId, ItemNode, useScene } from '@pascal-app/core' import { type AnyNodeId, type AssetInput, ItemNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import type { Vector3 } from 'three' import type { Vector3 } from 'three'
import type { AssetInput } from '@pascal-app/core'
import { stripTransient } from './placement-math' import { stripTransient } from './placement-math'
interface OriginalState { interface OriginalState {
@@ -159,14 +158,25 @@ export function useDraftNode(): DraftNodeHandle {
if (adoptedRef.current && originalStateRef.current) { if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting // Move mode: restore original state instead of deleting
const original = originalStateRef.current const original = originalStateRef.current
const id = draftRef.current.id
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
side: original.side, side: original.side,
parentId: original.parentId, parentId: original.parentId,
metadata: original.metadata, metadata: original.metadata,
}) })
// Also reset the Three.js mesh directly — the store update triggers a React
// re-render but the mesh position was mutated by useFrame and may not reset
// until the next render cycle, leaving a visual glitch.
const mesh = sceneRegistry.nodes.get(id as AnyNodeId)
if (mesh) {
mesh.position.set(original.position[0], original.position[1], original.position[2])
mesh.rotation.y = original.rotation[1] ?? 0
mesh.visible = true
}
} else { } else {
// Create mode: delete the transient node // Create mode: delete the transient node
useScene.getState().deleteNode(draftRef.current.id) useScene.getState().deleteNode(draftRef.current.id)
@@ -474,13 +474,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
// Escape / right-click → cancel
if (event.key === 'Escape' && configRef.current.onCancel) {
event.preventDefault()
configRef.current.onCancel()
return
}
const draft = draftNode.current const draft = draftNode.current
if (!draft) return if (!draft) return
@@ -504,6 +497,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
// ---- tool:cancel (Escape / programmatic) ----
const onCancel = () => {
if (configRef.current.onCancel) {
configRef.current.onCancel()
}
}
emitter.on('tool:cancel', onCancel)
// ---- Right-click cancel ---- // ---- Right-click cancel ----
const onContextMenu = (event: MouseEvent) => { const onContextMenu = (event: MouseEvent) => {
if (configRef.current.onCancel) { if (configRef.current.onCancel) {
@@ -547,6 +548,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave) emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('contextmenu', onContextMenu) window.removeEventListener('contextmenu', onContextMenu)
} }
@@ -151,13 +151,23 @@ export const RoofTool: React.FC = () => {
} }
}; };
const onCancel = () => {
if (corner1Ref.current) {
corner1Ref.current = null;
outlineRef.current.visible = false;
setPreview((prev) => ({ ...prev, corner1: null }));
}
};
// Subscribe to events // Subscribe to events
emitter.on("grid:move", onGridMove); emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick); emitter.on("grid:click", onGridClick);
emitter.on("tool:cancel", onCancel);
return () => { return () => {
emitter.off("grid:move", onGridMove); emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
emitter.off("tool:cancel", onCancel);
// Reset state on unmount // Reset state on unmount
corner1Ref.current = null; corner1Ref.current = null;
@@ -138,14 +138,20 @@ export const SlabTool: React.FC = () => {
} }
} }
const onCancel = () => {
setPoints([])
}
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
} }
}, [currentLevelId, points, cursorPosition, setSelection]) }, [currentLevelId, points, cursorPosition, setSelection])
@@ -158,14 +158,23 @@ export const WallTool: React.FC = () => {
} }
} }
const onCancel = () => {
if (buildingState.current === 1) {
buildingState.current = 0
wallPreviewRef.current.visible = false
}
}
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
} }
@@ -0,0 +1,10 @@
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,7 +1,10 @@
'use client' 'use client'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper' import { WallHelper } from './wall-helper'
export function HelperManager() { export function HelperManager() {
@@ -9,7 +12,7 @@ export function HelperManager() {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (movingNode) { if (movingNode) {
return <ItemHelper /> return <ItemHelper showEsc />
} }
// Show appropriate helper based on current tool // Show appropriate helper based on current tool
@@ -18,6 +21,12 @@ export function HelperManager() {
return <WallHelper /> return <WallHelper />
case 'item': case 'item':
return <ItemHelper /> return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default: default:
return null return null
} }
@@ -1,4 +1,8 @@
export function ItemHelper() { interface ItemHelperProps {
showEsc?: boolean
}
export function ItemHelper({ showEsc }: ItemHelperProps) {
return ( return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@@ -9,10 +13,12 @@ export function ItemHelper() {
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd> <kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
<span className="text-muted-foreground">Rotate clockwise</span> <span className="text-muted-foreground">Rotate clockwise</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> {showEsc && (
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd> <div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Cancel</span> <kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
</div> <span className="text-muted-foreground">Cancel</span>
</div>
)}
</div> </div>
) )
} }
@@ -0,0 +1,10 @@
export function RoofHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -0,0 +1,10 @@
export function SlabHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,10 +1,14 @@
export function WallHelper() { export function WallHelper() {
return ( return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-2 shadow-lg backdrop-blur-md"> <div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd> <kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span> <span className="text-muted-foreground">Allow non-45° angles</span>
</div> </div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div> </div>
) )
} }
+5 -10
View File
@@ -1,8 +1,8 @@
import { type AnyNodeId, useScene } from '@pascal-app/core' import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
export const useKeyboard = () => { export const useKeyboard = () => {
useEffect(() => { useEffect(() => {
@@ -14,13 +14,7 @@ export const useKeyboard = () => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
// Emit tool:cancel event - each tool handles its own cancellation logic emitter.emit('tool:cancel')
// if (useEditor.getState().controlMode === 'building') {
// emitter.emit('tool:cancel', undefined)
// }
// if (selectedNodeIds.length > 0) {
// handleClear()
// }
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('site') useEditor.getState().setPhase('site')
@@ -33,7 +27,8 @@ export const useKeyboard = () => {
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
} if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { }
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
+6 -1
View File
@@ -67,6 +67,10 @@ type CameraControlEvents = {
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent 'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
} }
type ToolEvents = {
'tool:cancel': undefined
}
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<'item', ItemEvent> & NodeEvents<'item', ItemEvent> &
@@ -77,6 +81,7 @@ type EditorEvents = GridEvents &
NodeEvents<'slab', SlabEvent> & NodeEvents<'slab', SlabEvent> &
NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> & NodeEvents<'roof', RoofEvent> &
CameraControlEvents CameraControlEvents &
ToolEvents
export const emitter = mitt<EditorEvents>() export const emitter = mitt<EditorEvents>()