From 879d35941ba61ed8364214bd6e267043b33af7d9 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 11 May 2026 14:45:10 -0300 Subject: [PATCH 1/3] 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/3] 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); From 92e7c026879b8f2662c65ceb4c6141a246f6be57 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 14 May 2026 15:17:20 -0300 Subject: [PATCH 3/3] fix(context-menu): gate slash reinsertion to slash-triggered opens (SD-2747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Codex's P1 and Luccas's review on PR #3234. Pressing Escape (or ArrowLeft) on a context menu opened via right-click previously inserted a literal `/` at the click position, mutating the document — the dismissal path assumed the menu was always opened by a suppressed `/` keystroke. Track which gesture opened the menu and gate slash-reinsertion on `trigger === 'slash'` everywhere. Also wires ArrowLeft into the Vue-side `handleGlobalKeyDown`: the menu's hidden search input owns focus while the menu is open, so the PM plugin's matching branch never fires in the live flow and ArrowLeft was simply swallowed instead of dismissing the menu. Plus three undeclared CSS variables flagged by Codex (`--sd-ui-menu-header-bg`, `--sd-ui-menu-text-muted`, `--sd-ui-font-mono`) now have :root defaults so consumers can theme them and the inline fallbacks in `ContextMenu.vue` are no longer the only source. Changes - `context-menu.js`: new `trigger: 'slash'|'rightClick'|null` field on the plugin state, set from the existing `isRightClick` signal in the 'open' meta and cleared on 'close'. The PM-side Escape/ArrowLeft branch only inserts `/` when `trigger === 'slash'`. - `ContextMenu.vue::handleGlobalKeyDown`: same `trigger === 'slash'` gate applied to the Vue-owned dismissal path. ArrowLeft now joins Escape in this branch. - `variables.css`: declare the three undeclared `--sd-ui-menu-*` tokens at :root with the same fallback values currently inlined in ContextMenu.vue. TDD - 5 new failing unit tests before the fix, 5 green after: - records `trigger='slash'` on keyboard open - records `trigger='rightClick'` on clientX/clientY open - clears trigger on close - Escape on right-click open: closes without inserting `/` - ArrowLeft on right-click open: closes without inserting `/` - All 64 context-menu plugin tests pass. - All 44 ContextMenu.vue component tests pass. Verification - Full `super-editor` suite: 12 716 / 12 716 pass. - Browser repro on the dev app: - Right-click → Escape: menu closes, doc unchanged (no stray `/`). - Right-click → ArrowLeft: menu closes, doc unchanged. - Slash → Escape: menu closes, `/` inserted at anchor (regression preserved). - Slash → ArrowLeft: menu closes, `/` inserted at anchor (was previously swallowed because the PM plugin branch never reached when hidden input holds focus). --- .../components/context-menu/ContextMenu.vue | 21 +++- .../extensions/context-menu/context-menu.js | 28 +++-- .../context-menu/context-menu.test.js | 104 ++++++++++++++++++ .../src/assets/styles/helpers/variables.css | 4 + 4 files changed, 144 insertions(+), 13 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 24e90fe1e8..81e1930d64 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,18 +223,27 @@ const cleanupCustomItems = () => { }; const handleGlobalKeyDown = (event) => { - // 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) { + // SD-2747: ESCAPE / ArrowLeft dismiss the menu. When the menu was opened by the + // slash hotkey the original `/` was preventDefault'd, so we reinsert it at the + // anchor — matches Google Docs' trigger-menu behavior. When the menu was + // opened by right-click no keystroke was suppressed and dismissal must NOT + // mutate the document. + // + // AIDEV-NOTE: SD-2747 P2. The gate is `pluginState.trigger === 'slash'`; without + // this, pressing Escape on a right-click context menu inserts an unwanted `/` + // at the click position. ArrowLeft is included here because the hidden search + // input owns focus while the menu is open, so the PM plugin's matching branch + // never fires in practice — every dismissal in the live flow comes through + // this handler. + if ((event.key === 'Escape' || event.key === 'ArrowLeft') && isOpen.value) { event.preventDefault(); event.stopPropagation(); const pluginState = ContextMenuPluginKey.getState(props.editor?.state); const anchorPos = pluginState?.anchorPos; + const trigger = pluginState?.trigger; closeMenu({ restoreCursor: false }); - if (props.editor && anchorPos !== null && anchorPos !== undefined) { + if (trigger === 'slash' && 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))); 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 b0c808f8fd..9a8387030b 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 @@ -96,6 +96,11 @@ export function findContainingBlockAncestor(element) { * @property {string} [menuPosition.left] - Left position in pixels (e.g., "100px") * @property {string} [menuPosition.top] - Top position in pixels (e.g., "28px") * @property {boolean} disabled - Whether the menu functionality is disabled + * @property {'slash'|'rightClick'|null} trigger - SD-2747: which gesture opened the menu. + * The slash-keystroke open path preventDefaults `/`, so dismissal owes the user a + * literal `/` at anchorPos. The right-click path suppresses no keystroke, so + * dismissal must NOT mutate the document. Every dismissal branch (PM + Vue) reads + * this field to decide whether to reinsert. */ /** @@ -161,6 +166,7 @@ export const ContextMenu = Extension.create({ anchorPos: null, menuPosition: null, disabled: isMenuDisabled(), + trigger: null, ...value, }); @@ -301,12 +307,17 @@ export const ContextMenu = Extension.create({ top: `${top + offsetY}px`, }; - // Update state + // Update state. SD-2747 P2: `trigger` distinguishes slash-keystroke + // opens (dismissal reinserts `/`) from right-click opens (dismissal + // is non-mutating). `isRightClick` was computed above from the + // presence of clientX/clientY in the meta payload — the same + // signal the positioning code uses. const newState = { ...value, open: true, anchorPos: meta.pos, menuPosition, + trigger: isRightClick ? 'rightClick' : 'slash', }; // Emit event after state update @@ -321,7 +332,7 @@ export const ContextMenu = Extension.create({ case 'close': { editor.emit('contextMenu:close'); - return ensureStateShape({ ...value, open: false, anchorPos: null }); + return ensureStateShape({ ...value, open: false, anchorPos: null, trigger: null }); } default: @@ -415,15 +426,18 @@ export const ContextMenu = Extension.create({ 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. + // SD-2747: Escape (or ArrowLeft) closes the menu. For slash-triggered opens + // we reinsert a literal `/` at the anchor — matches Google Docs, where the + // slash stays visible when the user dismisses the menu without picking an + // item. For right-click opens no slash was suppressed, so dismissal must + // NOT mutate the document. The `trigger` field on the plugin state + // disambiguates the two paths (SD-2747 P2). if (event.key === 'Escape' || event.key === 'ArrowLeft') { - const { anchorPos } = pluginState; + const { anchorPos, trigger } = pluginState; event.preventDefault(); view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); - if (anchorPos !== null) { + if (trigger === 'slash' && anchorPos !== null) { const insertTr = view.state.tr.insertText('/', anchorPos); const insertedAt = anchorPos + 1; insertTr.setSelection(view.state.selection.constructor.near(insertTr.doc.resolve(insertedAt))); 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 7e57fabdee..58bba62567 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 @@ -300,6 +300,110 @@ describe('ContextMenu extension', () => { }); }); + // SD-2747 P2: the menu can be opened two ways — by typing `/` (the slash + // keystroke is preventDefault'd, so the menu owes the user a literal `/` + // when they dismiss without picking) and by right-click (no slash was + // suppressed, so dismissal must NOT mutate the document). The plugin must + // record which trigger fired and every dismissal path (PM and Vue) must + // gate the slash-reinsert on that flag. + describe('open-trigger source tracking (SD-2747 P2)', () => { + const makeView = (initial) => { + let state = initial; + const view = { + get state() { + return state; + }, + dispatch: vi.fn((tr) => { + state = state.apply(tr); + }), + focus: vi.fn(), + dom: { getBoundingClientRect: () => ({ left: 0, top: 0 }) }, + coordsAtPos: () => ({ left: 20, top: 30 }), + }; + return view; + }; + + it('records trigger="slash" when the menu opens via the keyboard hotkey', () => { + 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; + + plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); + + expect(ContextMenuPluginKey.getState(view.state).trigger).toBe('slash'); + }); + + it('records trigger="rightClick" when the menu opens via clientX/clientY', () => { + 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; + + view.dispatch( + view.state.tr.setMeta(ContextMenuPluginKey, { + type: 'open', + pos: 1, + clientX: 100, + clientY: 50, + }), + ); + + expect(ContextMenuPluginKey.getState(view.state).trigger).toBe('rightClick'); + }); + + it('clears trigger on close', () => { + 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; + plugin.props.handleKeyDown.call(plugin, view, { key: '/', preventDefault: vi.fn() }); + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'close' })); + + expect(ContextMenuPluginKey.getState(view.state).trigger).toBe(null); + }); + + it('Escape on a right-click-opened menu closes the menu without inserting a slash', () => { + // Bug A from Luccas's review: pressing Escape on a context menu that + // was opened via right-click previously inserted `/` at the click + // position, mutating the document. The dismissal must be a no-op + // beyond closing. + 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; + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'open', pos: 1, clientX: 100, clientY: 50 })); + + plugin.props.handleKeyDown.call(plugin, view, { key: 'Escape', preventDefault: vi.fn() }); + + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + expect(view.state.doc.textContent).toBe(''); + }); + + it('ArrowLeft on a right-click-opened menu closes the menu without inserting a slash', () => { + 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; + view.dispatch(view.state.tr.setMeta(ContextMenuPluginKey, { type: 'open', pos: 1, clientX: 100, clientY: 50 })); + + plugin.props.handleKeyDown.call(plugin, view, { key: 'ArrowLeft', preventDefault: vi.fn() }); + + expect(ContextMenuPluginKey.getState(view.state).open).toBe(false); + expect(view.state.doc.textContent).toBe(''); + }); + }); + describe('menu positioning', () => { it('positions menu at clientX/clientY for context menu', () => { const baseDoc = doc(p()); diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index b078a53f7a..d8ec54adb3 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -119,6 +119,10 @@ --sd-ui-menu-item-hover-bg: var(--sd-ui-hover-bg); --sd-ui-menu-item-active-bg: var(--sd-ui-active-bg); --sd-ui-menu-item-active-text: var(--sd-ui-action); + /* SD-2747: context menu's slash-filter search header + empty-state styling */ + --sd-ui-menu-header-bg: #fafafa; + --sd-ui-menu-text-muted: #888; + --sd-ui-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; /* UI: tools */ --sd-ui-tools-gap: 6px;