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:
@@ -20,12 +20,14 @@ import { sfxEmitter } from '../../lib/sfx-bus'
|
|||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
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() {
|
export function FloatingActionMenu() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const nodes = useScene((s) => s.nodes)
|
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 isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
@@ -46,8 +48,10 @@ export function FloatingActionMenu() {
|
|||||||
const box = new THREE.Box3().setFromObject(obj)
|
const box = new THREE.Box3().setFromObject(obj)
|
||||||
if (!box.isEmpty()) {
|
if (!box.isEmpty()) {
|
||||||
const center = box.getCenter(new THREE.Vector3())
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
// Position slightly above the object
|
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
|
||||||
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
|
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(
|
const handleDelete = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!(selectedId && node)) return
|
// Activate delete mode (sledgehammer tool) instead of deleting directly
|
||||||
sfxEmitter.emit('sfx:item-delete')
|
|
||||||
deleteNode(selectedId as AnyNodeId)
|
|
||||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
|
||||||
setSelection({ selectedIds: [] })
|
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 (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef}>
|
||||||
@@ -174,8 +176,8 @@ export function FloatingActionMenu() {
|
|||||||
>
|
>
|
||||||
<NodeActionMenu
|
<NodeActionMenu
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDuplicate={handleDuplicate}
|
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
|
||||||
onMove={handleMove}
|
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onPointerUp={(e) => e.stopPropagation()}
|
onPointerUp={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { MouseEventHandler, PointerEventHandler } from 'react'
|
|||||||
|
|
||||||
type NodeActionMenuProps = {
|
type NodeActionMenuProps = {
|
||||||
onDelete: MouseEventHandler<HTMLButtonElement>
|
onDelete: MouseEventHandler<HTMLButtonElement>
|
||||||
onDuplicate: MouseEventHandler<HTMLButtonElement>
|
onDuplicate?: MouseEventHandler<HTMLButtonElement>
|
||||||
onMove: MouseEventHandler<HTMLButtonElement>
|
onMove?: MouseEventHandler<HTMLButtonElement>
|
||||||
onPointerDown?: PointerEventHandler<HTMLDivElement>
|
onPointerDown?: PointerEventHandler<HTMLDivElement>
|
||||||
onPointerUp?: PointerEventHandler<HTMLDivElement>
|
onPointerUp?: PointerEventHandler<HTMLDivElement>
|
||||||
onPointerEnter?: PointerEventHandler<HTMLDivElement>
|
onPointerEnter?: PointerEventHandler<HTMLDivElement>
|
||||||
@@ -30,24 +30,28 @@ export function NodeActionMenu({
|
|||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
>
|
>
|
||||||
<button
|
{onMove && (
|
||||||
aria-label="Move"
|
<button
|
||||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
aria-label="Move"
|
||||||
onClick={onMove}
|
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
title="Move"
|
onClick={onMove}
|
||||||
type="button"
|
title="Move"
|
||||||
>
|
type="button"
|
||||||
<Move className="h-4 w-4" />
|
>
|
||||||
</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"
|
{onDuplicate && (
|
||||||
onClick={onDuplicate}
|
<button
|
||||||
title="Duplicate"
|
aria-label="Duplicate"
|
||||||
type="button"
|
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
onClick={onDuplicate}
|
||||||
<Copy className="h-4 w-4" />
|
title="Duplicate"
|
||||||
</button>
|
type="button"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
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'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
if (movingNode) return
|
if (movingNode) return
|
||||||
@@ -475,12 +550,30 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<DeleteModeCursor />
|
||||||
<SelectionStateSync />
|
<SelectionStateSync />
|
||||||
<EditorOutlinerSync />
|
<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 = () => {
|
const SelectionStateSync = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe((state) => {
|
return useScene.subscribe((state) => {
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ export const useKeyboard = () => {
|
|||||||
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 === '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) {
|
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('build')
|
useEditor.getState().setMode('build')
|
||||||
|
|||||||
Reference in New Issue
Block a user