Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
'use client'

import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { isActive } from '../../lib/interaction/scope'
import { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups'
import useEditor from '../../store/use-editor'
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
import useSessionGroups, {
groupCurrentSelection,
ungroupCurrentSelection,
} from '../../store/use-session-groups'
import {
deleteSelection,
duplicateSelectionAndPickUp,
Expand All @@ -14,25 +20,27 @@ import {
import { NodeActionMenu } from '../editor/node-action-menu'

/**
* Floating Move / Duplicate / Delete pill for a MULTI-selection in the 2D
* floor plan — the group sibling of `FloorplanRegistryActionMenu` (which is
* sole-selection only). Anchored above the dashed group selection box; every
* action targets the whole selection: Move picks the group up (it rides the
* cursor until a click places it), Duplicate clones the selection and picks
* the clones up, Delete removes everything selected.
*
* Gated on floorplan hover so it never coexists with the 3D group menu in
* split view (that one hides while the floor plan is hovered), and hidden
* during any active interaction so it never competes with a live drag.
* Floating multi-select pill on the floor plan: Move, Group, Ungroup, Duplicate, Delete.
*/
export function FloorplanGroupActionMenu() {
const isMultiSelect = useViewer((s) => s.selection.selectedIds.length > 1)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const isMultiSelect = selectedIds.length > 1
const movingNode = useMovingNode()
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const scopeActive = useInteractionScope((s) => isActive(s.scope))
const sessionGroups = useSessionGroups((s) => s.groups)
const sceneNodes = useScene((s) => s.nodes)
const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
const showGroup = useMemo(
() => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
[sessionGroups, selectedIds, liveIds],
)
const showUngroup = useMemo(
() => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
[sessionGroups, selectedIds, liveIds],
)

const [position, setPosition] = useState<{ left: number; top: number } | null>(null)

const isVisible = isMultiSelect && !movingNode && isFloorplanHovered && !scopeActive

useEffect(() => {
Expand All @@ -43,9 +51,6 @@ export function FloorplanGroupActionMenu() {
let raf = 0
const tick = () => {
raf = requestAnimationFrame(tick)
// The dashed group box exists exactly while the multi-selection has
// transformable participants — anchor to its top edge. Only publish
// actual changes so the idle poll doesn't re-render every frame.
const box = document.querySelector('[data-group-selection-box]') as SVGGElement | null
if (!box) {
setPosition((prev) => (prev === null ? prev : null))
Expand Down Expand Up @@ -77,9 +82,11 @@ export function FloorplanGroupActionMenu() {
<NodeActionMenu
onDelete={() => deleteSelection()}
onDuplicate={() => duplicateSelectionAndPickUp()}
onGroup={showGroup ? () => groupCurrentSelection() : undefined}
onMove={() => startGroupPickUp()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onUngroup={showUngroup ? () => ungroupCurrentSelection() : undefined}
/>
</div>,
document.body,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import useInteractionScope, {
useEndpointReshape,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { expandSessionSelectionForNode } from '../../../store/use-session-groups'
import { startGroupPickUp } from '../../editor/group-actions'
import { classifyParticipant } from '../../editor/group-transform-shared'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
Expand Down Expand Up @@ -589,13 +590,19 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}, [bumpAffectedSiblingEpochs])

const applyEntrySelection = useCallback(
(id: AnyNodeId, shouldToggle: boolean) => {
(id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => {
const currentSelectedIds = useViewer.getState().selection.selectedIds
const nextSelectedIds = shouldToggle
? currentSelectedIds.includes(id)
let nextSelectedIds: string[]
if (options.shouldToggle) {
nextSelectedIds = currentSelectedIds.includes(id)
? currentSelectedIds.filter((selectedId) => selectedId !== id)
: [...currentSelectedIds, id]
: [id]
} else if (options.isolateMember) {
nextSelectedIds = [id]
} else {
const expanded = expandSessionSelectionForNode(id)
nextSelectedIds = expanded && expanded.length > 1 ? expanded : [id]
}
setSelection({ selectedIds: nextSelectedIds })
if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
const node = useScene.getState().nodes[id]
Expand All @@ -618,7 +625,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
if (event.button !== 0) return
event.stopPropagation()
applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
applyEntrySelection(id, {
shouldToggle: event.metaKey || event.ctrlKey || event.shiftKey,
isolateMember: event.altKey && !(event.metaKey || event.ctrlKey || event.shiftKey),
})
Comment thread
cursor[bot] marked this conversation as resolved.
},
[applyEntrySelection],
)
Expand Down Expand Up @@ -691,7 +701,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (endEvent.pointerId !== pointerId) return
cleanup()
if (!engaged) {
applyEntrySelection(id, true)
// Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean).
applyEntrySelection(id, {
shouldToggle: true,
isolateMember: false,
})
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const baseArgs = {
currentSelectedIds: ['wall_1'],
getFloorplanHitIdAtPoint: () => 'door_1',
isWallBuildActive: false,
modifierKeys: { meta: false, ctrl: false, shift: false },
modifierKeys: { meta: false, ctrl: false, shift: false, alt: false },
planPoint: [0, 0] as [number, number],
structureLayer: 'elements',
}
Expand All @@ -16,7 +16,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
test('shift-click on a floorplan node toggles into the current selection', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
modifierKeys: { meta: false, ctrl: false, shift: true },
modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
})

expect(result).toEqual({
Expand All @@ -30,7 +30,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
currentSelectedIds: ['wall_1', 'door_1'],
modifierKeys: { meta: false, ctrl: false, shift: true },
modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
})

expect(result).toEqual({
Expand All @@ -44,7 +44,7 @@ describe('resolveFloorplanBackgroundSelection', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
getFloorplanHitIdAtPoint: () => null,
modifierKeys: { meta: false, ctrl: false, shift: true },
modifierKeys: { meta: false, ctrl: false, shift: true, alt: false },
})

expect(result).toEqual({
Expand All @@ -54,6 +54,33 @@ describe('resolveFloorplanBackgroundSelection', () => {
})
})

test('plain click expands a session group', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
expandIdsForNode: (nodeId) => (nodeId === 'door_1' ? ['door_1', 'wall_2'] : null),
})

expect(result).toEqual({
handled: true,
kind: 'select-elements',
selectedIds: ['door_1', 'wall_2'],
})
})

test('alt-click selects one member without expanding', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
expandIdsForNode: () => ['door_1', 'wall_2'],
modifierKeys: { meta: false, ctrl: false, shift: false, alt: true },
})

expect(result).toEqual({
handled: true,
kind: 'select-elements',
selectedIds: ['door_1'],
})
})

test('uses the registry hit result for zone selection', () => {
const result = resolveFloorplanBackgroundSelection({
...baseArgs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,43 @@ type ModifierKeys = {
meta: boolean
ctrl: boolean
shift: boolean
/** Alt alone: select one session-group member without expanding. */
alt: boolean
}

type ResolveFloorplanBackgroundSelectionArgs = {
canSelectElementFloorplanGeometry: boolean
canSelectFloorplanZones: boolean
currentSelectedIds: string[]
/** Session-group expand on plain click (not on modifier/Alt). */
expandIdsForNode?: (nodeId: string) => string[] | null
getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null
isWallBuildActive: boolean
modifierKeys: ModifierKeys
planPoint: WallPlanPoint
structureLayer: string
}

function hasToggleModifier(modifierKeys: ModifierKeys): boolean {
return modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
}

function resolveHitSelection(
hitId: string,
currentSelectedIds: string[],
modifierKeys: ModifierKeys,
expandIdsForNode?: (nodeId: string) => string[] | null,
): string[] {
if (hasToggleModifier(modifierKeys)) {
return currentSelectedIds.includes(hitId)
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
: [...currentSelectedIds, hitId]
}
if (modifierKeys.alt) return [hitId]
const expanded = expandIdsForNode?.(hitId)
return expanded && expanded.length > 1 ? expanded : [hitId]
}

export type FloorplanBackgroundSelectionResult =
| {
handled: true
Expand Down Expand Up @@ -48,6 +72,7 @@ export function resolveFloorplanBackgroundSelection({
canSelectElementFloorplanGeometry,
canSelectFloorplanZones,
currentSelectedIds,
expandIdsForNode,
getFloorplanHitIdAtPoint,
isWallBuildActive,
modifierKeys,
Expand All @@ -71,12 +96,7 @@ export function resolveFloorplanBackgroundSelection({
return {
handled: true,
kind: 'select-elements',
selectedIds:
modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift
? currentSelectedIds.includes(hitId)
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
: [...currentSelectedIds, hitId]
: [hitId],
selectedIds: resolveHitSelection(hitId, currentSelectedIds, modifierKeys, expandIdsForNode),
}
}
}
Expand All @@ -92,7 +112,7 @@ export function resolveFloorplanBackgroundSelection({
return {
handled: true,
kind: 'clear-elements',
preserveSelection: modifierKeys.meta || modifierKeys.ctrl || modifierKeys.shift,
preserveSelection: hasToggleModifier(modifierKeys),
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/editor/src/components/editor/floorplan-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import useInteractionScope, {
useReshapingNode,
} from '../../store/use-interaction-scope'
import usePlacementPreview from '../../store/use-placement-preview'
import { expandSessionSelectionForNode } from '../../store/use-session-groups'
import { useStairBuildPreview } from '../../store/use-stair-build-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
Expand Down Expand Up @@ -843,11 +844,13 @@ function getSelectionModifierKeys(event?: {
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
}) {
return {
meta: Boolean(event?.metaKey),
ctrl: Boolean(event?.ctrlKey),
shift: Boolean(event?.shiftKey),
alt: Boolean(event?.altKey),
}
}

Expand Down Expand Up @@ -9978,6 +9981,7 @@ export function FloorplanPanel({
canSelectElementFloorplanGeometry,
canSelectFloorplanZones,
currentSelectedIds: useViewer.getState().selection.selectedIds,
expandIdsForNode: expandSessionSelectionForNode,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marquee click skips group expand

Medium Severity

When the floor-plan selection tool is marquee, registry entry handlers are disabled and no-drag clicks go through toggleFloorplanSelection, which always commits a single id. That path never calls expandSessionSelectionForNode and never honors Alt isolate, so plain click does not reselect a session group in marquee mode even though click-tool, 3D, and tree paths do.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c804e3f. Configure here.

getFloorplanHitIdAtPoint,
isWallBuildActive,
modifierKeys,
Expand Down
Loading
Loading