Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +237 to +241
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate slash reinsertion to slash-triggered menu dismissals

handleGlobalKeyDown always reinserts '/' on Escape whenever the menu is open, but the menu is also opened by right-click (handleRightClick dispatches type: 'open' with a normal cursor position). In that common path there was no suppressed slash keystroke to restore, so pressing Escape now mutates the document by inserting an unexpected / at anchorPos. Please condition this reinsertion on a slash-triggered open only (e.g., based on trigger/context), and keep Escape as close-only for context-menu opens.

Useful? React with 👍 / 👎.

}
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;
}
Expand Down Expand Up @@ -590,6 +614,16 @@ onBeforeUnmount(() => {
@keydown.stop
/>

<!-- SD-2747: When the user types after `/`, the hidden input captures keystrokes and the
menu filters silently. Without a visible echo of the search term the user only sees
the menu shrink or vanish, with no signal that their typing is being interpreted as
a filter. This header mirrors what the user is searching for so the interaction is
visible. -->
<div v-if="searchQuery" class="context-menu-search-header">
<span class="context-menu-search-header-label">Searching:</span>
<span class="context-menu-search-header-value">/{{ searchQuery }}</span>
</div>

<div class="context-menu-items">
<template v-for="(section, sectionIndex) in filteredSections" :key="section.id">
<!-- Render divider before section (except for first section) -->
Expand All @@ -613,6 +647,10 @@ onBeforeUnmount(() => {
</div>
</template>
</template>

<!-- SD-2747: Empty state. Without this the menu collapses to an invisible 0-height box
when nothing matches the filter, so the user sees no feedback at all. -->
<div v-if="searchQuery && filteredItems.length === 0" class="context-menu-empty">No matching commands</div>
</div>
</div>
</template>
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
},
};
},
Expand All @@ -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;
Expand All @@ -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, {
Expand All @@ -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;
Expand Down
Loading
Loading