From 879d35941ba61ed8364214bd6e267043b33af7d9 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 14:45:10 -0300 Subject: [PATCH 1/2] fix(context-menu): fix slash menu dismissal state (SD-2747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slash command menu had three independent state bugs that combined to break the dismiss-and-retype flow: 1. Backspace and Delete were not handled anywhere — neither the PM plugin's handleKeyDown nor the Vue component's document keydown listener caught them, so pressing Backspace after opening the menu left it open. 2. A 5-second slashCooldown locked out subsequent `/` presses immediately after dismissal. The user typed `/`, dismissed the menu, typed `/` again to retry — and got a literal `/` inserted instead of the menu reopening. 3. Escape closed the menu but did not insert the slash the user originally typed (it had been preventDefault'd on open). Per the requirements that match Google Docs, dismissing with Escape should leave the slash visible while dismissing with Backspace should remove it. Plugin handleKeyDown now handles Backspace / Delete (close, no insert) and Escape / ArrowLeft (close, insert `/` at the original anchor). The 5-second cooldown is gone — subsequent `/` reopens the menu immediately. Focus shifts to the Vue search input when the menu opens, so the PM plugin can't see keys typed there. The Vue handleGlobalKeyDown handler gets the same three branches (Backspace/Delete close without insert, Escape closes and inserts the slash) so the dismissal works whichever element holds focus. Removed the three unit tests that codified the cooldown behavior; added six new tests covering the corrected dismissal contract. --- .../components/context-menu/ContextMenu.vue | 28 ++- .../extensions/context-menu/context-menu.js | 61 +++--- .../context-menu/context-menu.test.js | 194 ++++++++---------- 3 files changed, 134 insertions(+), 149 deletions(-) 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..21670c9215 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; } 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); }); }); From 69c880e9c2281966d64ad4893303c1b2fa07a3d4 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 14:58:59 -0300 Subject: [PATCH 2/2] feat(context-menu): show search header + empty state for slash filter (SD-2747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the menu is open, focus is on a hidden search input that captures keystrokes for filtering. The user saw no feedback — they typed `intex`, the filter eliminated all items, and the menu collapsed to a zero-height invisible box. Visually it looked like the menu had silently vanished. Two additions, scoped to the same menu: - A "Searching: /" header appears at the top of the menu whenever the user has typed any filter characters. The header uses a monospaced font for the slash + query so it reads as "this is what you're literally typing," matching command-palette conventions. - A "No matching commands" empty state renders inside the items list when the filter has eliminated every item, so the menu always has visible content as long as it's open. Existing items, divider rendering, and selection state are unchanged. --- .../components/context-menu/ContextMenu.vue | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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 21670c9215..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 @@ -614,6 +614,16 @@ onBeforeUnmount(() => { @keydown.stop /> + +
+ Searching: + /{{ searchQuery }} +
+
+ + +
No matching commands
@@ -672,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);