diff --git a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue index 7609633ce7..24e90fe1e8 100644 --- a/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue +++ b/packages/super-editor/src/editors/v1/components/context-menu/ContextMenu.vue @@ -223,11 +223,35 @@ const cleanupCustomItems = () => { }; const handleGlobalKeyDown = (event) => { - // ESCAPE: always close popover or menu + // SD-2747: ESCAPE dismisses the menu and inserts a literal `/` at the original anchor — + // the slash was preventDefault'd when the menu opened, so we re-insert it here so the + // user's typed character is preserved when they decline to pick a command. Matches Google + // Docs' trigger-menu behavior. if (event.key === 'Escape' && isOpen.value) { event.preventDefault(); event.stopPropagation(); - closeMenu(); + const pluginState = ContextMenuPluginKey.getState(props.editor?.state); + const anchorPos = pluginState?.anchorPos; + closeMenu({ restoreCursor: false }); + + if (props.editor && anchorPos !== null && anchorPos !== undefined) { + const tr = props.editor.state.tr.insertText('/', anchorPos); + const insertedAt = anchorPos + 1; + tr.setSelection(props.editor.state.selection.constructor.near(tr.doc.resolve(insertedAt))); + props.editor.dispatch(tr); + } + props.editor?.focus?.(); + return; + } + + // SD-2747: BACKSPACE / DELETE dismisses the menu without inserting the slash. Focus is on + // the hidden search input while the menu is open, so the PM plugin's handleKeyDown does + // not see these keys — we have to handle them here. Empty search means an explicit + // dismissal; with a typed filter we let the input handle the deletion normally. + if ((event.key === 'Backspace' || event.key === 'Delete') && isOpen.value && !searchQuery.value) { + event.preventDefault(); + event.stopPropagation(); + closeMenu({ restoreCursor: true }); props.editor?.focus?.(); return; } @@ -590,6 +614,16 @@ onBeforeUnmount(() => { @keydown.stop /> + +
+ + + + @@ -648,6 +686,39 @@ onBeforeUnmount(() => { overflow-y: auto; } +.context-menu-search-header { + display: flex; + align-items: baseline; + gap: 4px; + padding: 6px 10px; + border-bottom: 1px solid var(--sd-ui-menu-border, #eee); + background: var(--sd-ui-menu-header-bg, #fafafa); + font-size: 11px; + color: var(--sd-ui-menu-text-muted, #888); +} + +.context-menu-search-header-label { + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 10px; +} + +.context-menu-search-header-value { + font-family: var(--sd-ui-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + color: var(--sd-ui-menu-text, #47484a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.context-menu-empty { + padding: 10px 10px; + color: var(--sd-ui-menu-text-muted, #888); + font-style: italic; + text-align: center; +} + .context-menu-search { padding: 0.5rem; border-bottom: 1px solid var(--sd-ui-menu-border, #eee); diff --git a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js index 77c1f214c9..b0c808f8fd 100644 --- a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js +++ b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.js @@ -83,7 +83,6 @@ export function findContainingBlockAncestor(element) { * Configuration options for ContextMenu * @typedef {Object} ContextMenuOptions * @property {boolean} [disabled] - Disable the context menu entirely (inherited from editor.options.disableContextMenu) - * @property {number} [cooldownMs=5000] - Cooldown duration in milliseconds to prevent rapid re-opening * @category Options */ @@ -119,7 +118,6 @@ const MENU_OFFSET_X = 0; // Horizontal offset for slash trigger (aligned with cu const MENU_OFFSET_Y = 28; // Vertical offset for slash trigger const CONTEXT_MENU_OFFSET_X = 10; // Small offset for right-click const CONTEXT_MENU_OFFSET_Y = 10; // Small offset for right-click -const SLASH_COOLDOWN_MS = 5000; // Cooldown period to prevent rapid re-opening /** * @module ContextMenu @@ -146,10 +144,6 @@ export const ContextMenu = Extension.create({ return []; } - // Cooldown flag and timeout for slash trigger - let slashCooldown = false; - let slashCooldownTimeout = null; - /** * Check if the context menu is disabled via editor options * @returns {boolean} True if menu is disabled @@ -365,11 +359,6 @@ export const ContextMenu = Extension.create({ destroy() { window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); - // Clear cooldown timeout if exists - if (slashCooldownTimeout) { - clearTimeout(slashCooldownTimeout); - slashCooldownTimeout = null; - } }, }; }, @@ -390,11 +379,6 @@ export const ContextMenu = Extension.create({ } const pluginState = this.getState(view.state); - // If cooldown is active and slash is pressed, allow default behavior - if (event.key === '/' && slashCooldown) { - return false; // Let browser handle it - } - if (event.key === '/' && !pluginState.open) { const { $cursor } = view.state.selection; if (!$cursor) return false; @@ -408,14 +392,6 @@ export const ContextMenu = Extension.create({ event.preventDefault(); - // Set cooldown - slashCooldown = true; - if (slashCooldownTimeout) clearTimeout(slashCooldownTimeout); - slashCooldownTimeout = setTimeout(() => { - slashCooldown = false; - slashCooldownTimeout = null; - }, SLASH_COOLDOWN_MS); - // Only dispatch state update - event will be emitted in apply() view.dispatch( view.state.tr.setMeta(ContextMenuPluginKey, { @@ -426,23 +402,32 @@ export const ContextMenu = Extension.create({ return true; } - if (pluginState.open && (event.key === 'Escape' || event.key === 'ArrowLeft')) { - // Store current state before closing - const { anchorPos } = pluginState; + if (!pluginState.open) { + return false; + } - // Close menu - view.dispatch( - view.state.tr.setMeta(ContextMenuPluginKey, { - type: 'close', - }), - ); + // SD-2747: Backspace / Delete dismisses the menu without inserting any character. + // The user pressed `/` to open it; that `/` was preventDefault'd above and never + // entered the document, so there is nothing to remove on the doc side — just close. + if (event.key === 'Backspace' || event.key === 'Delete') { + event.preventDefault(); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); + return true; + } + + // SD-2747: Escape (or ArrowLeft) closes the menu and inserts a literal `/` at the + // anchor position — matches Google Docs, where the slash stays visible when the + // user dismisses the menu without picking an item. + if (event.key === 'Escape' || event.key === 'ArrowLeft') { + const { anchorPos } = pluginState; + event.preventDefault(); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); - // Restore cursor position and focus if (anchorPos !== null) { - const tr = view.state.tr.setSelection( - view.state.selection.constructor.near(view.state.doc.resolve(anchorPos)), - ); - view.dispatch(tr); + const insertTr = view.state.tr.insertText('/', anchorPos); + const insertedAt = anchorPos + 1; + insertTr.setSelection(view.state.selection.constructor.near(insertTr.doc.resolve(insertedAt))); + view.dispatch(insertTr); view.focus(); } return true; diff --git a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js index 659ecd0833..7e57fabdee 100644 --- a/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js +++ b/packages/super-editor/src/editors/v1/extensions/context-menu/context-menu.test.js @@ -180,147 +180,123 @@ describe('ContextMenu extension', () => { expect(editor.emit).toHaveBeenCalledWith('contextMenu:close'); }); - describe('cooldown mechanism', () => { - it('prevents reopening menu during cooldown period', () => { - const baseDoc = doc(p()); - const initialSelection = TextSelection.create(baseDoc, 1); - let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); - - const editor = { - options: {}, - emit: vi.fn(), - view: null, - }; - - const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); - state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); - + describe('dismissal behavior (SD-2747)', () => { + const makeView = (initial) => { + let state = initial; const view = { - state, + get state() { + return state; + }, dispatch: vi.fn((tr) => { state = state.apply(tr); - view.state = state; }), focus: vi.fn(), - dom: { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - }, + dom: { getBoundingClientRect: () => ({ left: 0, top: 0 }) }, coordsAtPos: () => ({ left: 20, top: 30 }), }; + return view; + }; - editor.view = view; + const openMenu = (plugin, view) => { + plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); + }; - // Open menu first time - const openEvent1 = { key: '/', preventDefault: vi.fn() }; - const opened1 = plugin.props.handleKeyDown.call(plugin, view, openEvent1); - expect(opened1).toBe(true); + it('closes the menu when Backspace is pressed', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); expect(ContextMenuPluginKey.getState(view.state).open).toBe(true); - // Close menu - const closeEvent = { key: 'Escape', preventDefault: vi.fn() }; - plugin.props.handleKeyDown.call(plugin, view, closeEvent); - expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + const event = { key: 'Backspace', preventDefault: vi.fn() }; + const handled = plugin.props.handleKeyDown.call(plugin, view, event); - // Try to open menu again immediately (should be blocked by cooldown) - const openEvent2 = { key: '/', preventDefault: vi.fn() }; - const opened2 = plugin.props.handleKeyDown.call(plugin, view, openEvent2); - expect(opened2).toBe(false); // Should return false during cooldown - expect(openEvent2.preventDefault).not.toHaveBeenCalled(); - expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); // Should remain closed + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); }); - it('allows reopening menu after cooldown expires', async () => { - vi.useFakeTimers(); - + it('closes the menu when Delete is pressed', () => { const baseDoc = doc(p()); - const initialSelection = TextSelection.create(baseDoc, 1); - let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); - - const editor = { - options: {}, - emit: vi.fn(), - view: null, - }; - + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); - state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); - - const view = { - state, - dispatch: vi.fn((tr) => { - state = state.apply(tr); - view.state = state; - }), - focus: vi.fn(), - dom: { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - }, - coordsAtPos: () => ({ left: 20, top: 30 }), - }; - + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); editor.view = view; + openMenu(plugin, view); - // Open and close menu - plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); - plugin.props.handleKeyDown.call(plugin, view, { key: 'Escape', preventDefault: vi.fn() }); - - // Fast forward past cooldown period (5000ms) - vi.advanceTimersByTime(5000); + const event = { key: 'Delete', preventDefault: vi.fn() }; + const handled = plugin.props.handleKeyDown.call(plugin, view, event); - // Should be able to open again after cooldown - const openEvent = { key: '/', preventDefault: vi.fn() }; - const opened = plugin.props.handleKeyDown.call(plugin, view, openEvent); - expect(opened).toBe(true); - expect(openEvent.preventDefault).toHaveBeenCalled(); - expect(ContextMenuPluginKey.getState(view.state).open).toBe(true); - - vi.useRealTimers(); + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); }); - it('clears cooldown timeout on plugin destroy', () => { - vi.useFakeTimers(); - + it('inserts a literal slash at the anchor position when dismissed with Escape', () => { const baseDoc = doc(p()); - const initialSelection = TextSelection.create(baseDoc, 1); - let state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection }); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); + expect(view.state.doc.textContent).toBe(''); - const editor = { - options: {}, - emit: vi.fn(), - view: null, - }; + const event = { key: 'Escape', preventDefault: vi.fn() }; + plugin.props.handleKeyDown.call(plugin, view, event); + + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + expect(view.state.doc.textContent).toBe('/'); + }); + it('does not insert any character when dismissed with Backspace', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); - state = EditorState.create({ schema, doc: baseDoc, selection: initialSelection, plugins: [plugin] }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + openMenu(plugin, view); - const view = { - state, - dispatch: vi.fn((tr) => { - state = state.apply(tr); - view.state = state; - }), - focus: vi.fn(), - dom: { - getBoundingClientRect: () => ({ left: 0, top: 0 }), - }, - coordsAtPos: () => ({ left: 20, top: 30 }), - }; + plugin.props.handleKeyDown.call(plugin, view, { key: 'Backspace', preventDefault: vi.fn() }); + + expect(view.state.doc.textContent).toBe(''); + }); + it('reopens the menu when the slash is pressed again immediately after dismissal', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); editor.view = view; - const viewLifecycle = plugin.spec.view?.(view); + openMenu(plugin, view); + plugin.props.handleKeyDown.call(plugin, view, { key: 'Backspace', preventDefault: vi.fn() }); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); - // Open and close to trigger cooldown - plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); - plugin.props.handleKeyDown.call(plugin, view, { key: 'Escape', preventDefault: vi.fn() }); + const event = { key: '/', preventDefault: vi.fn() }; + const handled = plugin.props.handleKeyDown.call(plugin, view, event); - // Destroy should clear the timeout - viewLifecycle?.destroy?.(); + expect(handled).toBe(true); + expect(event.preventDefault).toHaveBeenCalled(); + expect(ContextMenuPluginKey.getState(view.state).open).toBe(true); + }); - // This test mainly ensures no memory leaks - we can't easily verify the timeout is cleared - // but the destroy() call should not throw - expect(true).toBe(true); + it('ignores Backspace when the menu is not open', () => { + const baseDoc = doc(p()); + const selection = TextSelection.create(baseDoc, 1); + const editor = { options: {}, emit: vi.fn(), view: null }; + const [plugin] = ContextMenu.config.addPmPlugins.call({ editor }); + const view = makeView(EditorState.create({ schema, doc: baseDoc, selection, plugins: [plugin] })); + editor.view = view; + + const handled = plugin.props.handleKeyDown.call(plugin, view, { key: 'Backspace', preventDefault: vi.fn() }); - vi.useRealTimers(); + expect(handled).toBe(false); }); });