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