chore: improve slash menu behavior inside table cells (#15305)
This commit is contained in:
@@ -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**'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user