fix(editor): context menu selection parity

This commit is contained in:
clawdbot 2026-01-29 00:35:01 +08:00
parent a2c44b9f1b
commit 2cab730bf9
2 changed files with 31 additions and 31 deletions

View File

@ -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'
} }

View File

@ -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 })}