Improve editor placement and selection workflows (#543)
* feat: expose accepted canvas node selections * feat(editor): replace bulk delete alert with dialog * fix(editor): allow immediate selection after opening placement * feat(spawn): preview model during placement * feat(editor): add cross-project selection clipboard * fix(editor): harden placement and clipboard previews * fix(editor): preserve carried clipboard state * fix(editor): replace active paste drafts safely
This commit is contained in:
@@ -276,6 +276,12 @@ type RoomPresetEvents = {
|
||||
}
|
||||
|
||||
type SelectionEvents = {
|
||||
/**
|
||||
* A node click accepted by an editor canvas selection path after proxy and
|
||||
* phase routing. Hosts can react to the user's 2D/3D selection intent
|
||||
* without treating programmatic selection changes as canvas clicks.
|
||||
*/
|
||||
'selection:canvas-node-click': AnyNode
|
||||
/**
|
||||
* "Reveal this node" intent — the editor's node action menu emits it with the
|
||||
* selected node; whoever owns the node's catalog/panel (host browser, a
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
computeAffectedSiblingIds,
|
||||
floorplanHandleDoubleClickAffordance,
|
||||
InteractiveGeometry,
|
||||
isFloorplanOpeningPlacementState,
|
||||
splitFloorplanOverlay,
|
||||
subscribeFloorplanAffordanceToolCancel,
|
||||
} from './floorplan-registry-layer'
|
||||
@@ -195,6 +196,35 @@ describe('floorplan affordance cancellation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan opening placement interaction routing', () => {
|
||||
test('passes entries through only while an opening tool or moving opening is active', () => {
|
||||
expect(
|
||||
isFloorplanOpeningPlacementState({
|
||||
phase: 'structure',
|
||||
mode: 'build',
|
||||
tool: 'window',
|
||||
movingNodeHasWallOpeningPlacement: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isFloorplanOpeningPlacementState({
|
||||
phase: 'structure',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
movingNodeHasWallOpeningPlacement: true,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isFloorplanOpeningPlacementState({
|
||||
phase: 'structure',
|
||||
mode: 'select',
|
||||
tool: null,
|
||||
movingNodeHasWallOpeningPlacement: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan vertex double-click routing', () => {
|
||||
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
||||
expect(
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
isIdle,
|
||||
tangentReshapeScope,
|
||||
} from '../../../lib/interaction/scope'
|
||||
import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
||||
@@ -77,6 +78,7 @@ import useEditor from '../../../store/use-editor'
|
||||
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
|
||||
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
|
||||
import useInteractionScope, {
|
||||
getMovingNode,
|
||||
useEndpointReshape,
|
||||
useMovingNode,
|
||||
} from '../../../store/use-interaction-scope'
|
||||
@@ -348,6 +350,35 @@ const POINTER_CURSOR_STYLE = { cursor: 'pointer' } as const
|
||||
const MOVE_CURSOR_STYLE = { cursor: 'move' } as const
|
||||
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
|
||||
|
||||
export function isFloorplanOpeningPlacementState({
|
||||
phase,
|
||||
mode,
|
||||
tool,
|
||||
movingNodeHasWallOpeningPlacement,
|
||||
}: {
|
||||
phase: string
|
||||
mode: string
|
||||
tool: string | null
|
||||
movingNodeHasWallOpeningPlacement: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
(phase === 'structure' && mode === 'build' && (tool === 'door' || tool === 'window')) ||
|
||||
movingNodeHasWallOpeningPlacement
|
||||
)
|
||||
}
|
||||
|
||||
function isFloorplanOpeningPlacementActiveNow(): boolean {
|
||||
const { phase, mode, tool } = useEditor.getState()
|
||||
const movingNode = getMovingNode()
|
||||
return isFloorplanOpeningPlacementState({
|
||||
phase,
|
||||
mode,
|
||||
tool,
|
||||
movingNodeHasWallOpeningPlacement:
|
||||
movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement,
|
||||
})
|
||||
}
|
||||
|
||||
function snapshotNode(node: AnyNode): NodeSnapshot {
|
||||
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
||||
// deep-cloned to detach from the live store reference.
|
||||
@@ -431,17 +462,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// wall's registry entry would otherwise swallow the click via
|
||||
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
||||
// Pass clicks through in that case.
|
||||
const editorPhase = useEditor((s) => s.phase)
|
||||
const editorMode = useEditor((s) => s.mode)
|
||||
const editorTool = useEditor((s) => s.tool)
|
||||
const structureLayer = useEditor((s) => s.structureLayer)
|
||||
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||
const endpointReshape = useEndpointReshape()
|
||||
const isOpeningPlacementActive =
|
||||
(editorPhase === 'structure' &&
|
||||
editorMode === 'build' &&
|
||||
(editorTool === 'door' || editorTool === 'window')) ||
|
||||
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
||||
const isMarqueeSelectionActive =
|
||||
editorMode === 'select' &&
|
||||
floorplanSelectionTool === 'marquee' &&
|
||||
@@ -557,13 +581,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const applyEntrySelection = useCallback(
|
||||
(id: AnyNodeId, shouldToggle: boolean) => {
|
||||
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
||||
setSelection({
|
||||
selectedIds: shouldToggle
|
||||
const nextSelectedIds = shouldToggle
|
||||
? currentSelectedIds.includes(id)
|
||||
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
||||
: [...currentSelectedIds, id]
|
||||
: [id],
|
||||
})
|
||||
: [id]
|
||||
setSelection({ selectedIds: nextSelectedIds })
|
||||
if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
|
||||
const node = useScene.getState().nodes[id]
|
||||
if (node) emitCanvasNodeSelection(node)
|
||||
}
|
||||
// Setting selection re-renders the entry — the overlay pass mounts
|
||||
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
||||
// pointerdown and click. If the click target ends up on the SVG
|
||||
@@ -587,6 +614,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
)
|
||||
|
||||
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
||||
if (isFloorplanOpeningPlacementActiveNow()) return
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
|
||||
@@ -829,6 +857,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
const handleEntryPointerDown = useCallback(
|
||||
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
||||
// Keep this handler mounted during opening placement and arbitrate from
|
||||
// the stores at event time. Commit clears the interaction scope before
|
||||
// React paints the next frame; a render-time `undefined` handler leaves
|
||||
// a short dead zone where the first post-placement selection is lost.
|
||||
if (isFloorplanOpeningPlacementActiveNow()) return
|
||||
if (startDirectMoveDrag(id, event)) return
|
||||
if (startDirectRotateDrag(id, event)) return
|
||||
if (startGroupMoveDrag(id, event)) return
|
||||
@@ -1325,7 +1358,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// still propagate normally inside the registry tree.
|
||||
<g
|
||||
className="floorplan-registry-layer"
|
||||
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
||||
onClick={handleClickStop}
|
||||
opacity={isAmbient ? 0.3 : undefined}
|
||||
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
||||
>
|
||||
@@ -1347,7 +1380,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
||||
interactiveElevators={interactiveElevators}
|
||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||
isOpeningPlacementActive={isOpeningPlacementActive}
|
||||
key={`base-${entry.id}`}
|
||||
levelDataCacheRef={levelDataCacheRef}
|
||||
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
||||
@@ -1401,7 +1433,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
||||
interactiveElevators={interactiveElevators}
|
||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||
isOpeningPlacementActive={isOpeningPlacementActive}
|
||||
key={`overlay-${entry.id}`}
|
||||
levelDataCacheRef={levelDataCacheRef}
|
||||
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
||||
@@ -1735,7 +1766,6 @@ type FloorplanRegistryEntryProps = {
|
||||
hoveredHandleId: string | null
|
||||
interactiveElevators: unknown
|
||||
isMarqueeSelectionActive: boolean
|
||||
isOpeningPlacementActive: boolean
|
||||
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
||||
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
||||
moving: boolean
|
||||
@@ -1792,7 +1822,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
hoveredHandleId,
|
||||
interactiveElevators,
|
||||
isMarqueeSelectionActive,
|
||||
isOpeningPlacementActive,
|
||||
levelDataCacheRef,
|
||||
levelNodeIdsByType,
|
||||
moving,
|
||||
@@ -1941,9 +1970,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
: visibleGeometry
|
||||
if (!geometry) return null
|
||||
|
||||
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
|
||||
const entryPointerDown =
|
||||
isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
|
||||
const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
|
||||
const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
|
||||
|
||||
return (
|
||||
<g
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/primitives/dialog'
|
||||
|
||||
export function DeleteConfirmationDialog() {
|
||||
const request = useDeleteConfirmation((state) => state.request)
|
||||
const cancel = useDeleteConfirmation((state) => state.cancel)
|
||||
const confirm = useDeleteConfirmation((state) => state.confirm)
|
||||
|
||||
useEffect(() => cancel, [cancel])
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(open) => !open && cancel()} open={request !== null}>
|
||||
<DialogContent
|
||||
className="border-border/70 bg-background/95 shadow-2xl backdrop-blur-xl sm:max-w-md"
|
||||
data-delete-confirmation-dialog
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {request?.count ?? 0} elements?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes every selected element. You can undo the deletion while it remains in the
|
||||
editor history.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
|
||||
onClick={cancel}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
|
||||
onClick={confirm}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import {
|
||||
type AnyNodeId,
|
||||
bboxCornerAnchors,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
pauseSceneHistory,
|
||||
pauseSpaceDetection,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
resumeSpaceDetection,
|
||||
type SceneMaterialId,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
@@ -16,9 +18,15 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { Plane, Vector2, Vector3 } from 'three'
|
||||
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||
import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import {
|
||||
copySelectedNodesToEditorClipboard,
|
||||
duplicateNodesToLevel,
|
||||
getEditorClipboardSnapshot,
|
||||
pasteSystemEditorClipboardToLevel,
|
||||
} from '../../lib/scene-clipboard'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
||||
import useEditor, {
|
||||
isAlignmentGuideActive,
|
||||
isGridSnapActive,
|
||||
@@ -79,7 +87,7 @@ export function canGroupPickUp(): boolean {
|
||||
* and drag them along with the copies.
|
||||
*/
|
||||
export function startGroupPickUp(
|
||||
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
|
||||
opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
|
||||
): boolean {
|
||||
const { selectedIds, levelId } = useViewer.getState().selection
|
||||
const participantIds = groupParticipantIds()
|
||||
@@ -202,16 +210,18 @@ export function startGroupPickUp(
|
||||
const applyMove = (e: PointerEvent) => {
|
||||
const plan = resolvePlanPoint(e)
|
||||
if (!plan) return
|
||||
// Delta-relative to where tracking starts so the group never teleports
|
||||
// to the cursor.
|
||||
if (!startPlan) {
|
||||
// Ordinary moves are delta-relative so the group never teleports. A
|
||||
// pasted selection instead arrives centered under the cursor.
|
||||
if (!opts.positionAtCursor && !startPlan) {
|
||||
startPlan = plan
|
||||
return
|
||||
}
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
const snap = isGridSnapActive() && step > 0
|
||||
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
|
||||
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
|
||||
const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
|
||||
const rawDz = opts.positionAtCursor ? plan[1] - restCenter[1] : plan[1] - startPlan![1]
|
||||
let dx = snap ? Math.round(rawDx / step) * step : rawDx
|
||||
let dz = snap ? Math.round(rawDz / step) * step : rawDz
|
||||
|
||||
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
||||
const result = resolveAlignment({
|
||||
@@ -361,6 +371,7 @@ export function startGroupPickUp(
|
||||
// Only a press over a tracked surface commits; a click on side panels or
|
||||
// the toolbar keeps the pick-up alive.
|
||||
if (!resolvePlanPoint(e)) return
|
||||
if (opts.positionAtCursor && !lastDelta) applyMove(e)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
commitPointerId = e.pointerId
|
||||
@@ -380,6 +391,17 @@ export function startGroupPickUp(
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase()
|
||||
if ((e.metaKey || e.ctrlKey) && (key === 'c' || key === 'v' || key === 'x')) {
|
||||
// A clipboard chord replaces the current carry. Let the global keyboard
|
||||
// arm receive the same event after this cancellation. Capture C/X first:
|
||||
// pasted or duplicated carries delete their transient selection while
|
||||
// cancelling, so the global arm would otherwise see nothing.
|
||||
if (key === 'c' || key === 'x') {
|
||||
copySelectedNodesToEditorClipboard()
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -437,6 +459,103 @@ export function duplicateSelectionAndPickUp(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function sceneReferencesMaterial(materialId: string) {
|
||||
const reference = `scene:${materialId}`
|
||||
const containsReference = (value: unknown): boolean => {
|
||||
if (value === reference) return true
|
||||
if (Array.isArray(value)) return value.some(containsReference)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(containsReference)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return Object.values(useScene.getState().nodes).some(containsReference)
|
||||
}
|
||||
|
||||
function removeUnusedPasteMaterials(materialIds: SceneMaterialId[]) {
|
||||
for (const materialId of materialIds) {
|
||||
if (!sceneReferencesMaterial(materialId)) {
|
||||
useScene.getState().removeSceneMaterial(materialId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste the Pascal scene payload from the browser clipboard onto the active
|
||||
* level, then carry the clones under the cursor until click-to-place. Escape
|
||||
* removes the uncommitted clones and any scene materials imported with them.
|
||||
*/
|
||||
export async function pasteSelectionAndPickUp(targetLevelId?: AnyNodeId): Promise<boolean> {
|
||||
const activeScope = useInteractionScope.getState().scope
|
||||
if (activeScope.kind === 'placing' || activeScope.kind === 'moving') {
|
||||
emitter.emit('tool:cancel')
|
||||
}
|
||||
|
||||
const result = await pasteSystemEditorClipboardToLevel(targetLevelId)
|
||||
if (!result || result.pastedIds.length === 0) return false
|
||||
|
||||
const discardPaste = () => {
|
||||
useScene.getState().deleteNodes(result.pastedIds)
|
||||
removeUnusedPasteMaterials(result.createdMaterialIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
if (result.pastedIds.length === 1) {
|
||||
const rootId = result.pastedIds[0]!
|
||||
const root = useScene.getState().nodes[rootId]
|
||||
if (root?.type === 'door' || root?.type === 'window') {
|
||||
const metadata =
|
||||
root.metadata && typeof root.metadata === 'object' && !Array.isArray(root.metadata)
|
||||
? (root.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const draft = { ...root, metadata: { ...metadata, isNew: true } }
|
||||
useScene.getState().updateNode(rootId, { metadata: draft.metadata })
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
const unsubscribe = useInteractionScope.subscribe((state, previous) => {
|
||||
const previousOwnsDraft =
|
||||
(previous.scope.kind === 'placing' || previous.scope.kind === 'moving') &&
|
||||
previous.scope.nodeId === rootId
|
||||
const currentOwnsDraft =
|
||||
(state.scope.kind === 'placing' || state.scope.kind === 'moving') &&
|
||||
state.scope.nodeId === rootId
|
||||
if (!previousOwnsDraft || currentOwnsDraft) return
|
||||
unsubscribe()
|
||||
removeUnusedPasteMaterials(result.createdMaterialIds)
|
||||
})
|
||||
useEditor.getState().setMovingNode(draft)
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const started = startGroupPickUp({
|
||||
positionAtCursor: true,
|
||||
scopeToSelection: true,
|
||||
onCancel: discardPaste,
|
||||
})
|
||||
if (!started) sfxEmitter.emit('sfx:item-place')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut uses the same cross-tab clipboard payload as Copy, then removes exactly
|
||||
* the copied roots. Promoted subtree selections (such as all modules in one
|
||||
* cabinet run) therefore remove the same root that Paste will recreate.
|
||||
*/
|
||||
export function cutSelectionToEditorClipboard(): boolean {
|
||||
if (!copySelectedNodesToEditorClipboard()) return false
|
||||
const payload = getEditorClipboardSnapshot()
|
||||
if (!payload || payload.rootIds.length === 0) return false
|
||||
|
||||
if (payload.rootIds.length === 1) {
|
||||
emitDeleteSFX(useScene.getState().nodes[payload.rootIds[0]!]?.type)
|
||||
} else {
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
}
|
||||
useScene.getState().deleteNodes(payload.rootIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every selected node — same semantics as the keyboard Delete arm,
|
||||
* including the accidental-bulk-delete confirm.
|
||||
@@ -444,14 +563,25 @@ export function duplicateSelectionAndPickUp(): boolean {
|
||||
export function deleteSelection(): boolean {
|
||||
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedIds.length === 0) return false
|
||||
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
||||
)
|
||||
if (!confirmed) return false
|
||||
}
|
||||
|
||||
const commitDelete = () => {
|
||||
if (selectedIds.length === 1) {
|
||||
emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
|
||||
} else {
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
}
|
||||
useScene.getState().deleteNodes(selectedIds)
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
|
||||
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
||||
useDeleteConfirmation.getState().requestConfirmation({
|
||||
count: selectedIds.length,
|
||||
onConfirm: commitDelete,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
commitDelete()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
|
||||
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
|
||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||
import { ExportManager } from './export-manager'
|
||||
import { FenceTangentLines3D } from './fence-tangent-lines-3d'
|
||||
@@ -1037,6 +1038,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
2d / 3d / split alike) can anchor to this container's bottom-left. */}
|
||||
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
||||
<QuickMeasurementHud />
|
||||
<DeleteConfirmationDialog />
|
||||
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||
<div
|
||||
className="relative h-full flex-shrink-0"
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from '../../lib/paint-scope'
|
||||
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
||||
import {
|
||||
emitCanvasNodeSelection,
|
||||
resolveCanvasSelectionNode,
|
||||
resolveNodeSelectionTarget,
|
||||
resolveSelectedIdsForNodeClick,
|
||||
@@ -1365,7 +1366,7 @@ export const SelectionManager = () => {
|
||||
useEffect(() => {
|
||||
if (mode !== 'select') return
|
||||
let owns = false
|
||||
let prevKey = ' | ||||