fix: allow deleting walls and slabs via floating action menu (#180)

* fix: allow deleting walls and slabs via floating action menu

Walls and slabs were excluded from ALLOWED_TYPES in FloatingActionMenu,
so no delete UI appeared when selecting them. Added wall/slab to the
allowed types and show only the delete button (no move/duplicate) since
they are structural elements.

Usage: switch to select mode (V), click a wall or slab, then use the
floating trash icon or press Delete/Backspace to remove it.

Closes #156

* fix: offset delete button higher on walls/slabs to avoid covering measurement labels

* feat: implement sledgehammer-style delete mode

Clicking the trash icon in the floating action menu now activates a
persistent delete mode (like The Sims sledgehammer tool) instead of
deleting immediately. Users can then click objects on the canvas to
delete them one by one. Press V or Escape to exit delete mode.

- Wire delete mode clicks/hover in SelectionManager
- Add D keyboard shortcut to activate delete mode
- Change floating menu delete button to activate delete mode
- Add crosshair cursor when delete mode is active
This commit is contained in:
Huy Hoang
2026-03-28 18:27:44 -04:00
committed by GitHub
parent b024aaf91a
commit c05ae9d38e
4 changed files with 138 additions and 32 deletions
@@ -20,12 +20,14 @@ import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment']
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab']
const DELETE_ONLY_TYPES = ['wall', 'slab']
export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes)
const deleteNode = useScene((s) => s.deleteNode)
const mode = useEditor((s) => s.mode)
const setMode = useEditor((s) => s.setMode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
@@ -46,8 +48,10 @@ export function FloatingActionMenu() {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position slightly above the object
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)
const yOffset = isDeleteOnly ? 0.8 : 0.3
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
}
}
})
@@ -151,16 +155,14 @@ export function FloatingActionMenu() {
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!(selectedId && node)) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
// Activate delete mode (sledgehammer tool) instead of deleting directly
setSelection({ selectedIds: [] })
setMode('delete')
},
[selectedId, node, deleteNode, setSelection],
[setSelection, setMode],
)
if (!(selectedId && node && isValidType && !isFloorplanHovered)) return null
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
return (
<group ref={groupRef}>
@@ -174,8 +176,8 @@ export function FloatingActionMenu() {
>
<NodeActionMenu
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onMove={handleMove}
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
@@ -5,8 +5,8 @@ import type { MouseEventHandler, PointerEventHandler } from 'react'
type NodeActionMenuProps = {
onDelete: MouseEventHandler<HTMLButtonElement>
onDuplicate: MouseEventHandler<HTMLButtonElement>
onMove: MouseEventHandler<HTMLButtonElement>
onDuplicate?: MouseEventHandler<HTMLButtonElement>
onMove?: MouseEventHandler<HTMLButtonElement>
onPointerDown?: PointerEventHandler<HTMLDivElement>
onPointerUp?: PointerEventHandler<HTMLDivElement>
onPointerEnter?: PointerEventHandler<HTMLDivElement>
@@ -30,24 +30,28 @@ export function NodeActionMenu({
onPointerLeave={onPointerLeave}
onPointerUp={onPointerUp}
>
<button
aria-label="Move"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onMove}
title="Move"
type="button"
>
<Move className="h-4 w-4" />
</button>
<button
aria-label="Duplicate"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onDuplicate}
title="Duplicate"
type="button"
>
<Copy className="h-4 w-4" />
</button>
{onMove && (
<button
aria-label="Move"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onMove}
title="Move"
type="button"
>
<Move className="h-4 w-4" />
</button>
)}
{onDuplicate && (
<button
aria-label="Duplicate"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onDuplicate}
title="Duplicate"
type="button"
>
<Copy className="h-4 w-4" />
</button>
)}
<button
aria-label="Delete"
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
@@ -11,7 +11,9 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -264,6 +266,79 @@ export const SelectionManager = () => {
}
}, [])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
if (mode !== 'delete') return
const onClick = (event: NodeEvent) => {
const node = event.node
if (!isNodeInCurrentLevel(node)) return
event.stopPropagation()
// Play appropriate SFX
if (node.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNode(node.id as AnyNodeId)
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
// Clear hover since the node is gone
if (useViewer.getState().hoveredId === node.id) {
useViewer.setState({ hoveredId: null })
}
}
const onEnter = (event: NodeEvent) => {
const node = event.node
if (!isNodeInCurrentLevel(node)) return
if (node.type === 'building' || node.type === 'site') return
event.stopPropagation()
useViewer.setState({ hoveredId: node.id })
}
const onLeave = (event: NodeEvent) => {
const nodeId = event?.node?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
}
}
const onGridClick = () => {
// Clicking empty space in delete mode does nothing (stay in delete mode)
}
const allTypes = [
'wall',
'item',
'slab',
'ceiling',
'roof',
'roof-segment',
'window',
'door',
'zone',
]
allTypes.forEach((type) => {
emitter.on(`${type}:click` as any, onClick as any)
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
})
emitter.on('grid:click', onGridClick)
return () => {
allTypes.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any)
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
})
emitter.off('grid:click', onGridClick)
}
}, [mode])
useEffect(() => {
if (mode !== 'select') return
if (movingNode) return
@@ -475,12 +550,30 @@ export const SelectionManager = () => {
return (
<>
<DeleteModeCursor />
<SelectionStateSync />
<EditorOutlinerSync />
</>
)
}
const DeleteModeCursor = () => {
const mode = useEditor((s) => s.mode)
const gl = useThree((s) => s.gl)
useEffect(() => {
const canvas = gl.domElement
if (mode === 'delete') {
canvas.style.cursor = 'crosshair'
return () => {
canvas.style.cursor = ''
}
}
}, [mode, gl])
return null
}
const SelectionStateSync = () => {
useEffect(() => {
return useScene.subscribe((state) => {
@@ -62,6 +62,13 @@ export const useKeyboard = () => {
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
const phase = useEditor.getState().phase
if (phase === 'structure' || phase === 'furnish') {
useEditor.getState().setMode('delete')
useViewer.getState().setSelection({ selectedIds: [] })
}
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('build')