fix-editor-sync-context-menu-selection-snapshot

This commit is contained in:
clawdbot 2026-01-28 16:15:58 +08:00
parent 9d95f34bd9
commit ab84000919

View File

@ -9,7 +9,7 @@ import {
Tabs, Tabs,
Typography, Typography,
} from 'antd'; } from 'antd';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { bindEditorHotkeys } from './hotkeys'; import { bindEditorHotkeys } from './hotkeys';
import { Canvas } from './Canvas'; import { Canvas } from './Canvas';
import { ContextMenu, type ContextMenuState } from './ContextMenu'; import { ContextMenu, type ContextMenuState } from './ContextMenu';
@ -48,17 +48,26 @@ export function EditorApp() {
const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]); const selected = state.doc.screen.nodes.find((n) => n.id === state.selection.ids[0]);
const hasSelection = state.selection.ids.length > 0; const hasSelection = state.selection.ids.length > 0;
const selection = useMemo(() => { // Note: selection lock/visibility flags for the context menu are computed from `selectionIdsForMenu` below.
const ids = new Set(state.selection.ids);
// Context-menu parity: right-click selection updates happen via reducer dispatch,
// but the context menu opens immediately.
// Use the captured selectionIds from the context menu state to avoid a one-frame mismatch.
const selectionIdsForMenu = ctxMenu?.selectionIds ?? state.selection.ids;
const selectionForMenu = useMemo(() => {
const ids = new Set(selectionIdsForMenu);
return state.doc.screen.nodes.filter((n) => ids.has(n.id)); return state.doc.screen.nodes.filter((n) => ids.has(n.id));
}, [state.doc.screen.nodes, state.selection.ids]); }, [selectionIdsForMenu, state.doc.screen.nodes]);
const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked); const menuSelectionAllLocked = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.locked);
const selectionSomeLocked = selection.length > 0 && selection.some((n) => n.locked) && !selectionAllLocked; const menuSelectionSomeLocked =
const selectionHasUnlocked = selection.length > 0 && selection.some((n) => !n.locked); selectionForMenu.length > 0 && selectionForMenu.some((n) => n.locked) && !menuSelectionAllLocked;
const menuSelectionHasUnlocked = selectionForMenu.length > 0 && selectionForMenu.some((n) => !n.locked);
const selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden); const menuSelectionAllHidden = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.hidden);
const selectionSomeHidden = selection.length > 0 && selection.some((n) => n.hidden) && !selectionAllHidden; 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 }),
@ -77,10 +86,29 @@ export function EditorApp() {
const closeContextMenu = useCallback(() => setCtxMenu(null), []); const closeContextMenu = useCallback(() => setCtxMenu(null), []);
// Selection parity: if selection changes via hotkeys/toolbar, close any open context menu. const ctxMenuSyncedRef = useRef(false);
useEffect(() => {
// Reset the sync gate whenever a new context menu opens/closes.
ctxMenuSyncedRef.current = false;
}, [ctxMenu?.selectionKey]);
// Selection parity: if selection changes via hotkeys/toolbar *after* the context menu has opened,
// close it. But don't close immediately during the one-frame gap where right-click selection
// has not been reduced into state yet.
useEffect(() => { useEffect(() => {
if (!ctxMenu) return; if (!ctxMenu) return;
const currentKey = state.selection.ids.join('|'); const currentKey = state.selection.ids.join('|');
// Wait until reducer state has caught up with the context menu's intended selection.
if (!ctxMenuSyncedRef.current) {
if (currentKey === ctxMenu.selectionKey) {
ctxMenuSyncedRef.current = true;
}
return;
}
if (currentKey !== ctxMenu.selectionKey) { if (currentKey !== ctxMenu.selectionKey) {
setCtxMenu(null); setCtxMenu(null);
} }
@ -522,12 +550,12 @@ export function EditorApp() {
<ContextMenu <ContextMenu
state={ctxMenu} state={ctxMenu}
selectionIds={state.selection.ids} selectionIds={selectionIdsForMenu}
selectionAllLocked={selectionAllLocked} selectionAllLocked={menuSelectionAllLocked}
selectionSomeLocked={selectionSomeLocked} selectionSomeLocked={menuSelectionSomeLocked}
selectionHasUnlocked={selectionHasUnlocked} selectionHasUnlocked={menuSelectionHasUnlocked}
selectionAllHidden={selectionAllHidden} selectionAllHidden={menuSelectionAllHidden}
selectionSomeHidden={selectionSomeHidden} selectionSomeHidden={menuSelectionSomeHidden}
hasAnyNodes={state.doc.screen.nodes.length > 0} 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 })}