diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js index 295e499b6..9a37d5eaf 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js @@ -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**'], diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 5a236d38a..2c64f498f 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -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" /> 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 }); diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index c82e04793..54a9cee7b 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -190,6 +190,19 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ '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 // escapes; the re-parse renders them literally, unescaping would crash it. const flattenLink = (_match, text, url) => {