From a90bfc6d13884b990255283d0552f3b9d0d69d7b Mon Sep 17 00:00:00 2001 From: wolf10drc Date: Mon, 3 Aug 2026 03:42:40 +0400 Subject: [PATCH 1/4] editor: session multi-select groups (Ctrl+G) Add editor-only session selection groups so multi-select furniture/items can be regrouped with Ctrl/Cmd+G and reselected by plain click. Group/Ungroup icons sit on the multi-select floating pill and side panel. Not scene-graph; not saved with the project. --- .../editor-2d/floorplan-group-action-menu.tsx | 42 ++-- .../renderers/floorplan-registry-layer.tsx | 20 +- .../src/components/editor/group-actions.ts | 2 + .../editor/group-floating-action-menu.tsx | 48 ++-- .../components/editor/node-action-menu.tsx | 30 ++- .../components/editor/selection-manager.tsx | 2 + .../ui/panels/multi-selection-panel.tsx | 67 ++++-- .../keyboard-shortcuts-dialog.tsx | 11 + .../sidebar/panels/site-panel/tree-node.tsx | 9 +- packages/editor/src/hooks/use-keyboard.ts | 37 ++- .../editor/src/lib/contextual-help.test.ts | 8 + packages/editor/src/lib/contextual-help.ts | 8 + .../editor/src/lib/selection-routing.test.ts | 25 +- packages/editor/src/lib/selection-routing.ts | 16 +- .../editor/src/lib/session-groups.test.ts | 58 +++++ packages/editor/src/lib/session-groups.ts | 217 ++++++++++++++++++ .../editor/src/store/use-session-groups.ts | 124 ++++++++++ wiki/architecture/README.md | 1 + wiki/architecture/selection-groups.md | 36 +++ wiki/architecture/selection-managers.md | 7 + 20 files changed, 710 insertions(+), 58 deletions(-) create mode 100644 packages/editor/src/lib/session-groups.test.ts create mode 100644 packages/editor/src/lib/session-groups.ts create mode 100644 packages/editor/src/store/use-session-groups.ts create mode 100644 wiki/architecture/selection-groups.md diff --git a/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx index 47274f1186..5075af1f6c 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-action-menu.tsx @@ -1,11 +1,20 @@ '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, @@ -14,25 +23,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(() => { @@ -43,9 +54,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)) @@ -77,9 +85,11 @@ export function FloorplanGroupActionMenu() { deleteSelection()} onDuplicate={() => duplicateSelectionAndPickUp()} + onGroup={showGroup ? () => groupCurrentSelection() : undefined} onMove={() => startGroupPickUp()} onPointerDown={(event) => event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} + onUngroup={showUngroup ? () => ungroupCurrentSelection() : undefined} /> , document.body, diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 75ad84e24e..65f418aa27 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -84,6 +84,7 @@ import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' import useDrawingView from '../../../store/use-drawing-view' import useEditor, { isAngleSnapActive } from '../../../store/use-editor' +import { expandSessionSelectionForNode } from '../../../store/use-session-groups' import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' import useFloorplanMode from '../../../store/use-floorplan-mode' import useInteractionScope, { @@ -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: AnyNodeId[] + 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]) as AnyNodeId[] + } setSelection({ selectedIds: nextSelectedIds }) if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) { const node = useScene.getState().nodes[id] @@ -618,7 +625,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { (id: AnyNodeId, event: React.PointerEvent) => { 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), + }) }, [applyEntrySelection], ) diff --git a/packages/editor/src/components/editor/group-actions.ts b/packages/editor/src/components/editor/group-actions.ts index 2a413d54fa..694809abaa 100644 --- a/packages/editor/src/components/editor/group-actions.ts +++ b/packages/editor/src/components/editor/group-actions.ts @@ -34,6 +34,7 @@ import useEditor, { isMagneticSnapActive, } from '../../store/use-editor' import useInteractionScope from '../../store/use-interaction-scope' +import { removeDeletedIdsFromSessionGroups } from '../../store/use-session-groups' import { useFloorplanGroupDrag } from '../editor-2d/floorplan-group-move' import { classifyParticipant, @@ -581,6 +582,7 @@ export function deleteSelection(): boolean { sfxEmitter.emit('sfx:structure-delete') } useScene.getState().deleteNodes(selectedIds) + removeDeletedIdsFromSessionGroups(selectedIds) useViewer.getState().setSelection({ selectedIds: [] }) } diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index 1ad52801e7..66177a6088 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -7,28 +7,30 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' +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, startGroupPickUp } from './group-actions' import { classifyParticipant, computeGroupBox, expandToComponent } from './group-transform-shared' import { NodeActionMenu } from './node-action-menu' import { useMeshSettleEpoch } from './use-mesh-settle-epoch' -// Matches the single-node FloatingActionMenu's zoom-compensation constants. const REF_ORTHO_ZOOM = 50 const REF_CAMERA_DISTANCE = 12 const MIN_MENU_SCALE = 0.6 const MAX_MENU_SCALE = 1.4 -// Clearance above the group's bbox top so the pill doesn't sit on the meshes. const MENU_Y_OFFSET = 0.42 /** - * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 3D - * view — the group sibling of the single-node `FloatingActionMenu` (which is - * sole-selection only). Anchored above the selection's bounding-box center; - * 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. + * Floating pill for MULTI-selection in 3D: Move, Group, Ungroup, Duplicate, Delete. + * Group/Ungroup are session-only (Ctrl/Cmd+G / Ctrl/Cmd+Shift+G). */ export function GroupFloatingActionMenu() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -37,8 +39,7 @@ export function GroupFloatingActionMenu() { const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const movingNode = useMovingNode() const nodes = useScene((s) => s.nodes) - // Hard-hidden during any active interaction (drag, pick-up, reshape) so the - // pill never competes with the live action — same policy as the 1-node menu. + const sessionGroups = useSessionGroups((s) => s.groups) const scope = useInteractionScope((s) => s.scope) const menuStepBack = resolveOverlayPolicy(scope).conflictingControls === 'hidden' @@ -55,9 +56,18 @@ export function GroupFloatingActionMenu() { [selectedIds, levelId, nodes], ) - // World anchor above the group bbox. Depends on the scene (post-commit - // positions), not the camera, and the menu hides during drags — so a - // memo keyed on selection + nodes is enough, no per-frame box traversal. + const liveIds = useMemo(() => new Set(Object.keys(nodes)), [nodes]) + // Always offer Group when multi-select is not already exactly a session group. + const showGroup = useMemo( + () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds), + [sessionGroups, selectedIds, liveIds], + ) + // Ungroup when any selected member is in a session group. + const showUngroup = useMemo( + () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds), + [sessionGroups, selectedIds, liveIds], + ) + const meshEpoch = useMeshSettleEpoch(nodes) const anchor = useMemo(() => { void meshEpoch @@ -73,8 +83,6 @@ export function GroupFloatingActionMenu() { }, [participantIds, nodes, levelId, meshEpoch]) useFrame((state) => { - // Scale the HTML pill with camera zoom / distance so it feels anchored to - // the world — mirrors the single-node menu. if (!(menuScaleRef.current && groupRef.current)) return const raw = state.camera instanceof THREE.OrthographicCamera @@ -92,6 +100,14 @@ export function GroupFloatingActionMenu() { event.stopPropagation() startGroupPickUp() }, []) + const handleGroup = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + groupCurrentSelection() + }, []) + const handleUngroup = useCallback((event: React.MouseEvent) => { + event.stopPropagation() + ungroupCurrentSelection() + }, []) const handleDuplicate = useCallback((event: React.MouseEvent) => { event.stopPropagation() duplicateSelectionAndPickUp() @@ -119,9 +135,11 @@ export function GroupFloatingActionMenu() { diff --git a/packages/editor/src/components/editor/node-action-menu.tsx b/packages/editor/src/components/editor/node-action-menu.tsx index 39f2248504..a503d24ba0 100644 --- a/packages/editor/src/components/editor/node-action-menu.tsx +++ b/packages/editor/src/components/editor/node-action-menu.tsx @@ -1,7 +1,7 @@ 'use client' import { Icon } from '@iconify/react' -import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react' +import { Copy, Group, Move, Search, Spline, Trash2, Ungroup } from 'lucide-react' import type { MouseEventHandler, PointerEventHandler } from 'react' type NodeActionMenuProps = { @@ -11,6 +11,10 @@ type NodeActionMenuProps = { onDuplicate?: MouseEventHandler onMove?: MouseEventHandler onCurve?: MouseEventHandler + /** Session group (Ctrl/Cmd+G) — multi-selection floating pill. */ + onGroup?: MouseEventHandler + /** Dissolve session group (Ctrl/Cmd+Shift+G). */ + onUngroup?: MouseEventHandler onPointerDown?: PointerEventHandler onPointerUp?: PointerEventHandler onPointerEnter?: PointerEventHandler @@ -24,6 +28,8 @@ export function NodeActionMenu({ onDuplicate, onMove, onCurve, + onGroup, + onUngroup, onPointerDown, onPointerUp, onPointerEnter, @@ -59,6 +65,28 @@ export function NodeActionMenu({ )} + {onGroup && ( + + )} + {onUngroup && ( + + )} {onCurve && (