Skip to content
Merged
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,44 @@ const cleanupCustomItems = () => {
};

const handleGlobalKeyDown = (event) => {
// ESCAPE: always close popover or menu
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();
closeMenu();
const pluginState = ContextMenuPluginKey.getState(props.editor?.state);
const anchorPos = pluginState?.anchorPos;
const trigger = pluginState?.trigger;
closeMenu({ restoreCursor: false });

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)));
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;
}
Expand Down Expand Up @@ -590,6 +623,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 +656,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 +695,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;
}
Comment thread
luccas-harbour marked this conversation as resolved.

.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 All @@ -97,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.
*/

/**
Expand All @@ -119,7 +123,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 +149,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 All @@ -167,6 +166,7 @@ export const ContextMenu = Extension.create({
anchorPos: null,
menuPosition: null,
disabled: isMenuDisabled(),
trigger: null,
...value,
});

Expand Down Expand Up @@ -307,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
Expand All @@ -327,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:
Expand Down Expand Up @@ -365,11 +370,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 +390,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 +403,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 +413,35 @@ 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. 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, trigger } = 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);
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)));
view.dispatch(insertTr);
view.focus();
}
return true;
Expand Down
Loading
Loading