fix(editor): context menu selection parity
This commit is contained in:
parent
a2c44b9f1b
commit
2cab730bf9
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
|
import type { WidgetNode } from '@astralview/sdk';
|
||||||
|
|
||||||
import { selectionKeyOf } from './selection';
|
import { selectionKeyOf } from './selection';
|
||||||
|
|
||||||
@ -26,13 +27,8 @@ export type ContextMenuState =
|
|||||||
|
|
||||||
export function ContextMenu(props: {
|
export function ContextMenu(props: {
|
||||||
state: ContextMenuState | null;
|
state: ContextMenuState | null;
|
||||||
|
nodes: WidgetNode[];
|
||||||
selectionIds: string[];
|
selectionIds: string[];
|
||||||
selectionAllLocked: boolean;
|
|
||||||
selectionSomeLocked: boolean;
|
|
||||||
selectionHasUnlocked: boolean;
|
|
||||||
selectionAllHidden: boolean;
|
|
||||||
selectionSomeHidden: boolean;
|
|
||||||
hasAnyNodes: boolean;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddTextAt: (x: number, y: number) => void;
|
onAddTextAt: (x: number, y: number) => void;
|
||||||
onSelectSingle?: (id?: string) => void;
|
onSelectSingle?: (id?: string) => void;
|
||||||
@ -110,8 +106,29 @@ export function ContextMenu(props: {
|
|||||||
// This prevents transient "disabled" menu items right after right-click selecting.
|
// This prevents transient "disabled" menu items right after right-click selecting.
|
||||||
const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds;
|
const selectionIds = selectionInSync ? props.selectionIds : ctx.selectionIds;
|
||||||
|
|
||||||
|
const nodesById = new Map(props.nodes.map((n) => [n.id, n] as const));
|
||||||
|
const hasAnyNodes = props.nodes.length > 0;
|
||||||
|
|
||||||
|
const selectedNodes = selectionIds
|
||||||
|
.map((id) => nodesById.get(id))
|
||||||
|
.filter((n): n is WidgetNode => !!n);
|
||||||
|
|
||||||
const hasSelection = selectionIds.length > 0;
|
const hasSelection = selectionIds.length > 0;
|
||||||
const canModifySelection = hasSelection && props.selectionHasUnlocked;
|
const hasAnyResolved = selectedNodes.length > 0;
|
||||||
|
|
||||||
|
// If selection contains unknown ids (shouldn't happen, but can during transient updates),
|
||||||
|
// treat them as "locked" so we don't enable destructive actions accidentally.
|
||||||
|
const selectionAllLocked =
|
||||||
|
hasSelection && (hasAnyResolved ? selectedNodes.every((n) => !!n.locked) : true);
|
||||||
|
const selectionSomeLocked = hasAnyResolved ? selectedNodes.some((n) => !!n.locked) : hasSelection;
|
||||||
|
const selectionHasUnlocked = hasAnyResolved ? selectedNodes.some((n) => !n.locked) : false;
|
||||||
|
|
||||||
|
const selectionAllHidden =
|
||||||
|
hasSelection && (hasAnyResolved ? selectedNodes.every((n) => !!n.hidden) : false);
|
||||||
|
const selectionSomeHidden = hasAnyResolved ? selectedNodes.some((n) => !!n.hidden) : false;
|
||||||
|
|
||||||
|
const canModifySelection = hasSelection && selectionHasUnlocked;
|
||||||
|
|
||||||
const hasTarget = ctx.kind === 'node';
|
const hasTarget = ctx.kind === 'node';
|
||||||
const targetId = hasTarget ? ctx.targetId : undefined;
|
const targetId = hasTarget ? ctx.targetId : undefined;
|
||||||
const targetInSelection = !!targetId && selectionIds.includes(targetId);
|
const targetInSelection = !!targetId && selectionIds.includes(targetId);
|
||||||
@ -165,7 +182,7 @@ export function ContextMenu(props: {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!hasTarget && props.hasAnyNodes && props.onSelectAll ? (
|
{!hasTarget && hasAnyNodes && props.onSelectAll ? (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label="Select All"
|
label="Select All"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -188,9 +205,9 @@ export function ContextMenu(props: {
|
|||||||
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label={
|
label={
|
||||||
props.selectionAllLocked
|
selectionAllLocked
|
||||||
? 'Unlock'
|
? 'Unlock'
|
||||||
: props.selectionSomeLocked
|
: selectionSomeLocked
|
||||||
? 'Toggle Lock'
|
? 'Toggle Lock'
|
||||||
: 'Lock'
|
: 'Lock'
|
||||||
}
|
}
|
||||||
@ -202,9 +219,9 @@ export function ContextMenu(props: {
|
|||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
label={
|
label={
|
||||||
props.selectionAllHidden
|
selectionAllHidden
|
||||||
? 'Show'
|
? 'Show'
|
||||||
: props.selectionSomeHidden
|
: selectionSomeHidden
|
||||||
? 'Toggle Visibility'
|
? 'Toggle Visibility'
|
||||||
: 'Hide'
|
: 'Hide'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,19 +58,7 @@ export function EditorApp() {
|
|||||||
// Use the captured selectionIds from the context menu state to avoid a one-frame mismatch.
|
// Use the captured selectionIds from the context menu state to avoid a one-frame mismatch.
|
||||||
const selectionIdsForMenu = ctxMenu?.selectionIds ?? state.selection.ids;
|
const selectionIdsForMenu = ctxMenu?.selectionIds ?? state.selection.ids;
|
||||||
|
|
||||||
const selectionForMenu = useMemo(() => {
|
// (Context menu selection flags are computed inside <ContextMenu /> using the captured selection.)
|
||||||
const ids = new Set(selectionIdsForMenu);
|
|
||||||
return state.doc.screen.nodes.filter((n) => ids.has(n.id));
|
|
||||||
}, [selectionIdsForMenu, state.doc.screen.nodes]);
|
|
||||||
|
|
||||||
const menuSelectionAllLocked = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.locked);
|
|
||||||
const menuSelectionSomeLocked =
|
|
||||||
selectionForMenu.length > 0 && selectionForMenu.some((n) => n.locked) && !menuSelectionAllLocked;
|
|
||||||
const menuSelectionHasUnlocked = selectionForMenu.length > 0 && selectionForMenu.some((n) => !n.locked);
|
|
||||||
|
|
||||||
const menuSelectionAllHidden = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.hidden);
|
|
||||||
const menuSelectionSomeHidden =
|
|
||||||
selectionForMenu.length > 0 && selectionForMenu.some((n) => n.hidden) && !menuSelectionAllHidden;
|
|
||||||
|
|
||||||
const bounds = useMemo(
|
const bounds = useMemo(
|
||||||
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
|
() => ({ w: state.doc.screen.width, h: state.doc.screen.height }),
|
||||||
@ -648,13 +636,8 @@ export function EditorApp() {
|
|||||||
|
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
state={ctxMenu}
|
state={ctxMenu}
|
||||||
|
nodes={state.doc.screen.nodes}
|
||||||
selectionIds={selectionIdsForMenu}
|
selectionIds={selectionIdsForMenu}
|
||||||
selectionAllLocked={menuSelectionAllLocked}
|
|
||||||
selectionSomeLocked={menuSelectionSomeLocked}
|
|
||||||
selectionHasUnlocked={menuSelectionHasUnlocked}
|
|
||||||
selectionAllHidden={menuSelectionAllHidden}
|
|
||||||
selectionSomeHidden={menuSelectionSomeHidden}
|
|
||||||
hasAnyNodes={state.doc.screen.nodes.length > 0}
|
|
||||||
onClose={closeContextMenu}
|
onClose={closeContextMenu}
|
||||||
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}
|
||||||
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
onSelectSingle={(id) => dispatch({ type: 'selectSingle', id })}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user