chore: improve slash menu behavior inside table cells (#15305)

This commit is contained in:
Sivin Varghese
2026-08-04 12:18:34 +05:30
committed by GitHub
parent e3ce2772fe
commit 80c9357387
4 changed files with 250 additions and 64 deletions

View File

@@ -102,6 +102,12 @@ const markNames = () => {
return [...names];
};
const nodeNames = () => {
const names = new Set();
view.state.doc.descendants(node => names.add(node.type.name));
return [...names];
};
const menuLabels = () => wrapper.findAll('button').map(button => button.text());
const highlightedLabel = () =>
@@ -128,6 +134,13 @@ const clickToolbar = title =>
new MouseEvent('mousedown', { bubbles: true })
);
const swallowedByEditor = (key, modifiers = {}) =>
Boolean(
view.someProp('handleKeyDown', handler =>
handler(view, new KeyboardEvent('keydown', { key, ...modifiers }))
)
);
const pressInEditor = (key, modifiers = {}) =>
view.dom.dispatchEvent(
new KeyboardEvent('keydown', {
@@ -140,12 +153,6 @@ const pressInEditor = (key, modifiers = {}) =>
const pressEscape = () => pressInEditor('Escape');
// The slash menu binds its arrow keys on document through tinykeys.
const pressInMenu = key =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key, code: key, bubbles: true })
);
const fileOfSize = sizeInMb => {
const file = new File(['x'], 'photo.png', { type: 'image/png' });
Object.defineProperty(file, 'size', { value: sizeInMb * 1024 * 1024 });
@@ -267,6 +274,28 @@ describe('FullEditor', () => {
expect(menuLabels()).toEqual([]);
});
it('lists every enabled command', async () => {
mountEditor();
type('/');
await wrapper.vm.$nextTick();
expect(menuLabels()).toEqual([
'Heading 1',
'Heading 2',
'Heading 3',
'Bold',
'Italic',
'Table',
'Image',
'Video',
'Divider',
'Strikethrough',
'Code',
'Bullet List',
'Ordered List',
]);
});
it('lists only the enabled options', async () => {
mountEditor({ enabledMenuOptions: ['h1', 'horizontalRule'] });
type('/');
@@ -305,49 +334,6 @@ describe('FullEditor', () => {
expect(lastEmittedValue()).toContain('---');
});
it('moves the highlight with the arrow keys', async () => {
mountEditor({ enabledMenuOptions: ['h1', 'h2', 'horizontalRule'] });
type('/');
await wrapper.vm.$nextTick();
await flushPromises();
expect(highlightedLabel()).toBe('Heading 1');
pressInMenu('ArrowDown');
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 2');
pressInMenu('ArrowUp');
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 1');
});
it('runs the highlighted command on enter', async () => {
mountEditor({ enabledMenuOptions: ['h1', 'horizontalRule'] });
type('/');
await wrapper.vm.$nextTick();
await flushPromises();
pressInMenu('ArrowDown');
await wrapper.vm.$nextTick();
pressInMenu('Enter');
await wrapper.vm.$nextTick();
expect(lastEmittedValue()).toContain('---');
expect(wrapper.vm.showSlashMenu).toBe(false);
});
it('keeps enter from reaching the document while it is open', async () => {
mountEditor();
type('/');
await wrapper.vm.$nextTick();
const swallowed = view.someProp('handleKeyDown', handler =>
handler(view, new KeyboardEvent('keydown', { keyCode: 13 }))
);
expect(swallowed).toBe(true);
});
it('anchors to the right edge in a right-to-left article', async () => {
const computedStyle = window.getComputedStyle;
vi.spyOn(window, 'getComputedStyle').mockImplementation(element => ({
@@ -362,6 +348,163 @@ describe('FullEditor', () => {
});
});
describe('slash menu keyboard', () => {
it.each([
['arrow keys', 'ArrowDown', 'ArrowUp', {}],
['control n and p', 'n', 'p', { ctrlKey: true }],
])(
'moves the highlight with the %s',
async (_name, forward, backward, modifiers) => {
mountEditor({ enabledMenuOptions: ['h1', 'h2', 'horizontalRule'] });
type('/');
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 1');
pressInEditor(forward, modifiers);
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 2');
pressInEditor(backward, modifiers);
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 1');
}
);
it('wraps the highlight around both ends', async () => {
mountEditor({ enabledMenuOptions: ['h1', 'h2'] });
type('/');
await wrapper.vm.$nextTick();
pressInEditor('ArrowUp');
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 2');
pressInEditor('ArrowDown');
await wrapper.vm.$nextTick();
expect(highlightedLabel()).toBe('Heading 1');
});
it('runs the highlighted command on enter', async () => {
mountEditor({ enabledMenuOptions: ['h1', 'horizontalRule'] });
type('/');
await wrapper.vm.$nextTick();
pressInEditor('ArrowDown');
await wrapper.vm.$nextTick();
pressInEditor('Enter');
await wrapper.vm.$nextTick();
expect(lastEmittedValue()).toContain('---');
expect(wrapper.vm.showSlashMenu).toBe(false);
});
it.each([
['Enter', {}],
['ArrowUp', {}],
['ArrowDown', {}],
['n', { ctrlKey: true }],
['p', { ctrlKey: true }],
])('keeps %s from the editor while it is open', async (key, modifiers) => {
mountEditor();
type('/');
await wrapper.vm.$nextTick();
expect(swallowedByEditor(key, modifiers)).toBe(true);
});
it.each([
['ArrowDown', { shiftKey: true }],
['ArrowDown', { metaKey: true }],
['ArrowUp', { altKey: true }],
['Enter', { altKey: true }],
])('leaves modified %s to the editor', async (key, modifiers) => {
mountEditor();
type('/');
await wrapper.vm.$nextTick();
expect(swallowedByEditor(key, modifiers)).toBe(false);
});
it('still breaks the line on shift enter while it is open', async () => {
mountEditor();
type('/');
await wrapper.vm.$nextTick();
pressInEditor('Enter', { shiftKey: true });
await wrapper.vm.$nextTick();
expect(nodeNames()).toContain('hard_break');
});
it.each([
[
'nothing matches the term',
async () => {
type('/zzz');
},
],
[
'it is closed',
async () => {
type('/');
await wrapper.vm.$nextTick();
pressEscape();
},
],
])('leaves the arrow keys to the editor when %s', async (_name, open) => {
mountEditor();
await open();
await wrapper.vm.$nextTick();
expect(swallowedByEditor('ArrowDown')).toBe(false);
});
});
describe('slash menu inside a table', () => {
const openInFirstCell = async () => {
wrapper.vm.executeSlashCommand('insertTable');
await wrapper.vm.$nextTick();
placeCursor(4);
type('/');
await wrapper.vm.$nextTick();
};
it('omits the commands a cell cannot store', async () => {
mountEditor();
await openInFirstCell();
expect(menuLabels()).toEqual(['Bold', 'Italic', 'Strikethrough', 'Code']);
});
it('still filters the remaining commands by the typed term', async () => {
mountEditor();
await openInFirstCell();
type('i');
await wrapper.vm.$nextTick();
expect(menuLabels()).toEqual(['Italic', 'Strikethrough']);
});
it('closes the menu when nothing is left to offer', async () => {
mountEditor({ enabledMenuOptions: ['insertTable', 'horizontalRule'] });
await openInFirstCell();
expect(menuLabels()).toEqual([]);
});
it('offers the hidden commands again outside the table', async () => {
mountEditor();
await openInFirstCell();
pressEscape();
await wrapper.vm.$nextTick();
placeCursor(view.state.doc.content.size);
type('/div');
await wrapper.vm.$nextTick();
expect(menuLabels()).toEqual(['Divider']);
});
});
describe('slash commands', () => {
it.each([
['strong', '**Hello**'],

View File

@@ -90,6 +90,7 @@ export default {
slashSearchTerm: '',
slashRange: null,
slashMenuPosition: null,
isSlashMenuInTable: false,
showVideoInput: false,
videoInputPosition: null,
};
@@ -137,6 +138,7 @@ export default {
this.showSlashMenu = true;
this.slashRange = args.range;
this.slashSearchTerm = args.text || '';
this.isSlashMenuInTable = this.isSelectionInsideTable();
this.updateSlashMenuPosition(args.range.from);
return false;
},
@@ -151,15 +153,18 @@ export default {
this.slashMenuPosition = null;
return false;
},
onKeyDown: ({ event }) => {
return (
event.keyCode === 13 &&
this.showSlashMenu &&
this.$refs.slashMenu?.hasItems
);
},
onKeyDown: ({ event }) =>
this.$refs.slashMenu?.handleKeyDown(event) ?? false,
});
},
isSelectionInsideTable() {
const { $from } = editorView.state.selection;
const { table } = editorView.state.schema.nodes;
for (let depth = $from.depth; depth > 0; depth -= 1) {
if ($from.node(depth).type === table) return true;
}
return false;
},
updateSlashMenuPosition(pos) {
if (!editorView) return;
const coords = editorView.coordsAtPos(pos);
@@ -514,6 +519,7 @@ export default {
:search-key="slashSearchTerm"
:enabled-menu-options="enabledMenuOptions"
:position="slashMenuPosition"
:is-in-table="isSlashMenuInTable"
@select-action="executeSlashCommand"
/>
<VideoEmbedInput

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { MENU_OPTIONS_UNAVAILABLE_IN_TABLE } from 'dashboard/constants/editor';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
@@ -17,6 +17,10 @@ const props = defineProps({
type: Object,
default: null,
},
isInTable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['selectAction']);
@@ -109,8 +113,10 @@ const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey.toLowerCase();
const unavailable = props.isInTable ? MENU_OPTIONS_UNAVAILABLE_IN_TABLE : [];
return EDITOR_ACTIONS.filter(action => {
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
if (unavailable.includes(action.menuKey)) return false;
if (!search) return true;
return t(action.labelKey).toLowerCase().includes(search);
});
@@ -145,12 +151,30 @@ const onSelect = () => {
if (item) emit('selectAction', item.value);
};
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const move = step => {
const count = items.value.length;
selectedIndex.value = (selectedIndex.value + step + count) % count;
adjustScroll();
};
const KEY_ACTIONS = {
ArrowDown: () => move(1),
ArrowUp: () => move(-1),
'Ctrl-n': () => move(1),
'Ctrl-p': () => move(-1),
Enter: onSelect,
};
const handleKeyDown = event => {
if (!hasItems.value || event.shiftKey || event.altKey || event.metaKey) {
return false;
}
const action = KEY_ACTIONS[event.ctrlKey ? `Ctrl-${event.key}` : event.key];
if (!action) return false;
action();
return true;
};
// Reset selection when filtered items change
watch(items, () => {
@@ -166,7 +190,7 @@ const onItemClick = index => {
onSelect();
};
defineExpose({ hasItems });
defineExpose({ handleKeyDown });
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->