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 = {
|
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
|
* "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
|
* selected node; whoever owns the node's catalog/panel (host browser, a
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
computeAffectedSiblingIds,
|
computeAffectedSiblingIds,
|
||||||
floorplanHandleDoubleClickAffordance,
|
floorplanHandleDoubleClickAffordance,
|
||||||
InteractiveGeometry,
|
InteractiveGeometry,
|
||||||
|
isFloorplanOpeningPlacementState,
|
||||||
splitFloorplanOverlay,
|
splitFloorplanOverlay,
|
||||||
subscribeFloorplanAffordanceToolCancel,
|
subscribeFloorplanAffordanceToolCancel,
|
||||||
} from './floorplan-registry-layer'
|
} 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', () => {
|
describe('floorplan vertex double-click routing', () => {
|
||||||
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
isIdle,
|
isIdle,
|
||||||
tangentReshapeScope,
|
tangentReshapeScope,
|
||||||
} from '../../../lib/interaction/scope'
|
} from '../../../lib/interaction/scope'
|
||||||
|
import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||||
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
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 useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
|
||||||
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
|
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
|
getMovingNode,
|
||||||
useEndpointReshape,
|
useEndpointReshape,
|
||||||
useMovingNode,
|
useMovingNode,
|
||||||
} from '../../../store/use-interaction-scope'
|
} 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 MOVE_CURSOR_STYLE = { cursor: 'move' } as const
|
||||||
const NO_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } 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 {
|
function snapshotNode(node: AnyNode): NodeSnapshot {
|
||||||
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
// Shallow-clone every non-id, non-type field. Arrays / vec tuples are
|
||||||
// deep-cloned to detach from the live store reference.
|
// 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
|
// wall's registry entry would otherwise swallow the click via
|
||||||
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
// `handleClickStop` / `handleSelect`, so the placement never fires.
|
||||||
// Pass clicks through in that case.
|
// Pass clicks through in that case.
|
||||||
const editorPhase = useEditor((s) => s.phase)
|
|
||||||
const editorMode = useEditor((s) => s.mode)
|
const editorMode = useEditor((s) => s.mode)
|
||||||
const editorTool = useEditor((s) => s.tool)
|
|
||||||
const structureLayer = useEditor((s) => s.structureLayer)
|
const structureLayer = useEditor((s) => s.structureLayer)
|
||||||
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
const endpointReshape = useEndpointReshape()
|
const endpointReshape = useEndpointReshape()
|
||||||
const isOpeningPlacementActive =
|
|
||||||
(editorPhase === 'structure' &&
|
|
||||||
editorMode === 'build' &&
|
|
||||||
(editorTool === 'door' || editorTool === 'window')) ||
|
|
||||||
(movingNode != null && !!nodeRegistry.get(movingNode.type)?.capabilities?.wallOpeningPlacement)
|
|
||||||
const isMarqueeSelectionActive =
|
const isMarqueeSelectionActive =
|
||||||
editorMode === 'select' &&
|
editorMode === 'select' &&
|
||||||
floorplanSelectionTool === 'marquee' &&
|
floorplanSelectionTool === 'marquee' &&
|
||||||
@@ -557,13 +581,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
const applyEntrySelection = useCallback(
|
const applyEntrySelection = useCallback(
|
||||||
(id: AnyNodeId, shouldToggle: boolean) => {
|
(id: AnyNodeId, shouldToggle: boolean) => {
|
||||||
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
||||||
setSelection({
|
const nextSelectedIds = shouldToggle
|
||||||
selectedIds: shouldToggle
|
|
||||||
? currentSelectedIds.includes(id)
|
? currentSelectedIds.includes(id)
|
||||||
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
? currentSelectedIds.filter((selectedId) => selectedId !== id)
|
||||||
: [...currentSelectedIds, 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
|
// Setting selection re-renders the entry — the overlay pass mounts
|
||||||
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
// (endpoint handles, etc.), reshuffling DOM under the cursor between
|
||||||
// pointerdown and click. If the click target ends up on the SVG
|
// 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>) => {
|
const handleClickStop = useCallback((event: React.MouseEvent<SVGGElement>) => {
|
||||||
|
if (isFloorplanOpeningPlacementActiveNow()) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -829,6 +857,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
|
|
||||||
const handleEntryPointerDown = useCallback(
|
const handleEntryPointerDown = useCallback(
|
||||||
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => {
|
(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 (startDirectMoveDrag(id, event)) return
|
||||||
if (startDirectRotateDrag(id, event)) return
|
if (startDirectRotateDrag(id, event)) return
|
||||||
if (startGroupMoveDrag(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.
|
// still propagate normally inside the registry tree.
|
||||||
<g
|
<g
|
||||||
className="floorplan-registry-layer"
|
className="floorplan-registry-layer"
|
||||||
onClick={isOpeningPlacementActive ? undefined : handleClickStop}
|
onClick={handleClickStop}
|
||||||
opacity={isAmbient ? 0.3 : undefined}
|
opacity={isAmbient ? 0.3 : undefined}
|
||||||
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
style={isAmbient ? NO_POINTER_EVENTS_STYLE : undefined}
|
||||||
>
|
>
|
||||||
@@ -1347,7 +1380,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
||||||
interactiveElevators={interactiveElevators}
|
interactiveElevators={interactiveElevators}
|
||||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||||
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
||||||
key={`base-${entry.id}`}
|
key={`base-${entry.id}`}
|
||||||
levelDataCacheRef={levelDataCacheRef}
|
levelDataCacheRef={levelDataCacheRef}
|
||||||
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
||||||
@@ -1401,7 +1433,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|||||||
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
||||||
interactiveElevators={interactiveElevators}
|
interactiveElevators={interactiveElevators}
|
||||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||||
isOpeningPlacementActive={isOpeningPlacementActive}
|
|
||||||
key={`overlay-${entry.id}`}
|
key={`overlay-${entry.id}`}
|
||||||
levelDataCacheRef={levelDataCacheRef}
|
levelDataCacheRef={levelDataCacheRef}
|
||||||
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
levelNodeIdsByType={floorplanData.levelNodeIdsByType}
|
||||||
@@ -1735,7 +1766,6 @@ type FloorplanRegistryEntryProps = {
|
|||||||
hoveredHandleId: string | null
|
hoveredHandleId: string | null
|
||||||
interactiveElevators: unknown
|
interactiveElevators: unknown
|
||||||
isMarqueeSelectionActive: boolean
|
isMarqueeSelectionActive: boolean
|
||||||
isOpeningPlacementActive: boolean
|
|
||||||
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
levelDataCacheRef: { current: Map<string, LevelDataCacheEntry> }
|
||||||
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
levelNodeIdsByType: ReadonlyMap<string, readonly AnyNodeId[]>
|
||||||
moving: boolean
|
moving: boolean
|
||||||
@@ -1792,7 +1822,6 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|||||||
hoveredHandleId,
|
hoveredHandleId,
|
||||||
interactiveElevators,
|
interactiveElevators,
|
||||||
isMarqueeSelectionActive,
|
isMarqueeSelectionActive,
|
||||||
isOpeningPlacementActive,
|
|
||||||
levelDataCacheRef,
|
levelDataCacheRef,
|
||||||
levelNodeIdsByType,
|
levelNodeIdsByType,
|
||||||
moving,
|
moving,
|
||||||
@@ -1941,9 +1970,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|||||||
: visibleGeometry
|
: visibleGeometry
|
||||||
if (!geometry) return null
|
if (!geometry) return null
|
||||||
|
|
||||||
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
|
const entryClick = isMarqueeSelectionActive ? undefined : onClickStop
|
||||||
const entryPointerDown =
|
const entryPointerDown = isMarqueeSelectionActive ? undefined : handlePointerDown
|
||||||
isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : handlePointerDown
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<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,
|
type AnyNodeId,
|
||||||
bboxCornerAnchors,
|
bboxCornerAnchors,
|
||||||
collectAlignmentAnchors,
|
collectAlignmentAnchors,
|
||||||
|
emitter,
|
||||||
pauseSceneHistory,
|
pauseSceneHistory,
|
||||||
pauseSpaceDetection,
|
pauseSpaceDetection,
|
||||||
resolveAlignment,
|
resolveAlignment,
|
||||||
resumeSceneHistory,
|
resumeSceneHistory,
|
||||||
resumeSpaceDetection,
|
resumeSpaceDetection,
|
||||||
|
type SceneMaterialId,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -16,9 +18,15 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { Plane, Vector2, Vector3 } from 'three'
|
import { Plane, Vector2, Vector3 } from 'three'
|
||||||
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { GROUP_MOVE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||||
import { duplicateNodesToLevel } from '../../lib/scene-clipboard'
|
import {
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
copySelectedNodesToEditorClipboard,
|
||||||
|
duplicateNodesToLevel,
|
||||||
|
getEditorClipboardSnapshot,
|
||||||
|
pasteSystemEditorClipboardToLevel,
|
||||||
|
} from '../../lib/scene-clipboard'
|
||||||
|
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||||
|
import useDeleteConfirmation from '../../store/use-delete-confirmation'
|
||||||
import useEditor, {
|
import useEditor, {
|
||||||
isAlignmentGuideActive,
|
isAlignmentGuideActive,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
@@ -79,7 +87,7 @@ export function canGroupPickUp(): boolean {
|
|||||||
* and drag them along with the copies.
|
* and drag them along with the copies.
|
||||||
*/
|
*/
|
||||||
export function startGroupPickUp(
|
export function startGroupPickUp(
|
||||||
opts: { onCancel?: () => void; scopeToSelection?: boolean } = {},
|
opts: { onCancel?: () => void; positionAtCursor?: boolean; scopeToSelection?: boolean } = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
const { selectedIds, levelId } = useViewer.getState().selection
|
const { selectedIds, levelId } = useViewer.getState().selection
|
||||||
const participantIds = groupParticipantIds()
|
const participantIds = groupParticipantIds()
|
||||||
@@ -202,16 +210,18 @@ export function startGroupPickUp(
|
|||||||
const applyMove = (e: PointerEvent) => {
|
const applyMove = (e: PointerEvent) => {
|
||||||
const plan = resolvePlanPoint(e)
|
const plan = resolvePlanPoint(e)
|
||||||
if (!plan) return
|
if (!plan) return
|
||||||
// Delta-relative to where tracking starts so the group never teleports
|
// Ordinary moves are delta-relative so the group never teleports. A
|
||||||
// to the cursor.
|
// pasted selection instead arrives centered under the cursor.
|
||||||
if (!startPlan) {
|
if (!opts.positionAtCursor && !startPlan) {
|
||||||
startPlan = plan
|
startPlan = plan
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const step = useEditor.getState().gridSnapStep
|
const step = useEditor.getState().gridSnapStep
|
||||||
const snap = isGridSnapActive() && step > 0
|
const snap = isGridSnapActive() && step > 0
|
||||||
let dx = snap ? Math.round((plan[0] - startPlan[0]) / step) * step : plan[0] - startPlan[0]
|
const rawDx = opts.positionAtCursor ? plan[0] - restCenter[0] : plan[0] - startPlan![0]
|
||||||
let dz = snap ? Math.round((plan[1] - startPlan[1]) / step) * step : plan[1] - startPlan[1]
|
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) {
|
if (isAlignmentGuideActive() && candidates.length > 0 && restAnchors.length > 0) {
|
||||||
const result = resolveAlignment({
|
const result = resolveAlignment({
|
||||||
@@ -361,6 +371,7 @@ export function startGroupPickUp(
|
|||||||
// Only a press over a tracked surface commits; a click on side panels or
|
// Only a press over a tracked surface commits; a click on side panels or
|
||||||
// the toolbar keeps the pick-up alive.
|
// the toolbar keeps the pick-up alive.
|
||||||
if (!resolvePlanPoint(e)) return
|
if (!resolvePlanPoint(e)) return
|
||||||
|
if (opts.positionAtCursor && !lastDelta) applyMove(e)
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
commitPointerId = e.pointerId
|
commitPointerId = e.pointerId
|
||||||
@@ -380,6 +391,17 @@ export function startGroupPickUp(
|
|||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
const key = e.key.toLowerCase()
|
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) {
|
if ((key === 'r' || key === 't') && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -437,6 +459,103 @@ export function duplicateSelectionAndPickUp(): boolean {
|
|||||||
return true
|
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,
|
* Delete every selected node — same semantics as the keyboard Delete arm,
|
||||||
* including the accidental-bulk-delete confirm.
|
* including the accidental-bulk-delete confirm.
|
||||||
@@ -444,14 +563,25 @@ export function duplicateSelectionAndPickUp(): boolean {
|
|||||||
export function deleteSelection(): boolean {
|
export function deleteSelection(): boolean {
|
||||||
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
if (selectedIds.length === 0) return false
|
if (selectedIds.length === 0) return false
|
||||||
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
|
||||||
const confirmed = window.confirm(
|
const commitDelete = () => {
|
||||||
`Delete ${selectedIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
|
if (selectedIds.length === 1) {
|
||||||
)
|
emitDeleteSFX(useScene.getState().nodes[selectedIds[0]!]?.type)
|
||||||
if (!confirmed) return false
|
} else {
|
||||||
}
|
|
||||||
sfxEmitter.emit('sfx:structure-delete')
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
useScene.getState().deleteNodes(selectedIds)
|
useScene.getState().deleteNodes(selectedIds)
|
||||||
useViewer.getState().setSelection({ selectedIds: [] })
|
useViewer.getState().setSelection({ selectedIds: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIds.length >= BULK_DELETE_THRESHOLD) {
|
||||||
|
useDeleteConfirmation.getState().requestConfirmation({
|
||||||
|
count: selectedIds.length,
|
||||||
|
onConfirm: commitDelete,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
commitDelete()
|
||||||
return true
|
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 type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||||
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
|
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
|
import { DeleteConfirmationDialog } from './delete-confirmation-dialog'
|
||||||
import { EditorLayoutV2 } from './editor-layout-v2'
|
import { EditorLayoutV2 } from './editor-layout-v2'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
import { FenceTangentLines3D } from './fence-tangent-lines-3d'
|
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. */}
|
2d / 3d / split alike) can anchor to this container's bottom-left. */}
|
||||||
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
||||||
<QuickMeasurementHud />
|
<QuickMeasurementHud />
|
||||||
|
<DeleteConfirmationDialog />
|
||||||
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||||
<div
|
<div
|
||||||
className="relative h-full flex-shrink-0"
|
className="relative h-full flex-shrink-0"
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from '../../lib/paint-scope'
|
} from '../../lib/paint-scope'
|
||||||
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
|
||||||
import {
|
import {
|
||||||
|
emitCanvasNodeSelection,
|
||||||
resolveCanvasSelectionNode,
|
resolveCanvasSelectionNode,
|
||||||
resolveNodeSelectionTarget,
|
resolveNodeSelectionTarget,
|
||||||
resolveSelectedIdsForNodeClick,
|
resolveSelectedIdsForNodeClick,
|
||||||
@@ -1365,7 +1366,7 @@ export const SelectionManager = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
let owns = false
|
let owns = false
|
||||||
let prevKey = ' | |||||||