diff --git a/packages/editor/src/editor/EditorApp.tsx b/packages/editor/src/editor/EditorApp.tsx index da1242b..dfe4226 100644 --- a/packages/editor/src/editor/EditorApp.tsx +++ b/packages/editor/src/editor/EditorApp.tsx @@ -9,7 +9,7 @@ import { Tabs, Typography, } from 'antd'; -import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { bindEditorHotkeys } from './hotkeys'; import { Canvas } from './Canvas'; 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 hasSelection = state.selection.ids.length > 0; - const selection = useMemo(() => { - const ids = new Set(state.selection.ids); + // Note: selection lock/visibility flags for the context menu are computed from `selectionIdsForMenu` below. + + // 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)); - }, [state.doc.screen.nodes, state.selection.ids]); + }, [selectionIdsForMenu, state.doc.screen.nodes]); - const selectionAllLocked = selection.length > 0 && selection.every((n) => n.locked); - const selectionSomeLocked = selection.length > 0 && selection.some((n) => n.locked) && !selectionAllLocked; - const selectionHasUnlocked = selection.length > 0 && selection.some((n) => !n.locked); + 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 selectionAllHidden = selection.length > 0 && selection.every((n) => n.hidden); - const selectionSomeHidden = selection.length > 0 && selection.some((n) => n.hidden) && !selectionAllHidden; + const menuSelectionAllHidden = selectionForMenu.length > 0 && selectionForMenu.every((n) => n.hidden); + const menuSelectionSomeHidden = + selectionForMenu.length > 0 && selectionForMenu.some((n) => n.hidden) && !menuSelectionAllHidden; const bounds = useMemo( () => ({ w: state.doc.screen.width, h: state.doc.screen.height }), @@ -77,10 +86,29 @@ export function EditorApp() { 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(() => { if (!ctxMenu) return; + 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) { setCtxMenu(null); } @@ -522,12 +550,12 @@ export function EditorApp() { 0} onClose={closeContextMenu} onAddTextAt={(x, y) => dispatch({ type: 'addTextAt', x, y })}