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]; 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 menuLabels = () => wrapper.findAll('button').map(button => button.text());
const highlightedLabel = () => const highlightedLabel = () =>
@@ -128,6 +134,13 @@ const clickToolbar = title =>
new MouseEvent('mousedown', { bubbles: true }) 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 = {}) => const pressInEditor = (key, modifiers = {}) =>
view.dom.dispatchEvent( view.dom.dispatchEvent(
new KeyboardEvent('keydown', { new KeyboardEvent('keydown', {
@@ -140,12 +153,6 @@ const pressInEditor = (key, modifiers = {}) =>
const pressEscape = () => pressInEditor('Escape'); 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 fileOfSize = sizeInMb => {
const file = new File(['x'], 'photo.png', { type: 'image/png' }); const file = new File(['x'], 'photo.png', { type: 'image/png' });
Object.defineProperty(file, 'size', { value: sizeInMb * 1024 * 1024 }); Object.defineProperty(file, 'size', { value: sizeInMb * 1024 * 1024 });
@@ -267,6 +274,28 @@ describe('FullEditor', () => {
expect(menuLabels()).toEqual([]); 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 () => { it('lists only the enabled options', async () => {
mountEditor({ enabledMenuOptions: ['h1', 'horizontalRule'] }); mountEditor({ enabledMenuOptions: ['h1', 'horizontalRule'] });
type('/'); type('/');
@@ -305,49 +334,6 @@ describe('FullEditor', () => {
expect(lastEmittedValue()).toContain('---'); 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 () => { it('anchors to the right edge in a right-to-left article', async () => {
const computedStyle = window.getComputedStyle; const computedStyle = window.getComputedStyle;
vi.spyOn(window, 'getComputedStyle').mockImplementation(element => ({ 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', () => { describe('slash commands', () => {
it.each([ it.each([
['strong', '**Hello**'], ['strong', '**Hello**'],

View File

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

View File

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

View File

@@ -190,6 +190,19 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'horizontalRule', 'horizontalRule',
]; ];
// A markdown table cell holds inline content only; anything else is lost on save.
export const MENU_OPTIONS_UNAVAILABLE_IN_TABLE = [
'h1',
'h2',
'h3',
'bulletList',
'orderedList',
'imageUpload',
'insertTable',
'horizontalRule',
'video',
];
// [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer // [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer
// escapes; the re-parse renders them literally, unescaping would crash it. // escapes; the re-parse renders them literally, unescaping would crash it.
const flattenLink = (_match, text, url) => { const flattenLink = (_match, text, url) => {