diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js new file mode 100644 index 000000000..ea2a257e4 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.spec.js @@ -0,0 +1,843 @@ +import { flushPromises, mount } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createStore } from 'vuex'; +import { Selection } from '@chatwoot/prosemirror-schema'; +import { emitter } from 'shared/helpers/mitt'; +import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; +import FullEditor from './FullEditor.vue'; + +let view = null; + +// The component keeps its EditorView in a module local, so subclass it to grab +// the instance and drive the editor through transactions like a user would. +vi.mock('@chatwoot/prosemirror-schema', async importOriginal => { + const actual = await importOriginal(); + class TrackedEditorView extends actual.EditorView { + constructor(...args) { + super(...args); + view = this; + } + } + return { ...actual, EditorView: TrackedEditorView }; +}); + +// jsdom has no layout, and ProseMirror measures the caret through Range rects. +const zeroRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; +Range.prototype.getClientRects = () => [zeroRect]; +Range.prototype.getBoundingClientRect = () => zeroRect; +Element.prototype.scrollIntoView = () => {}; + +const attachImage = vi.fn(); +const uploadExternalImage = vi.fn(); + +const store = createStore({ + actions: { + 'articles/attachImage': (_, payload) => attachImage(payload), + 'articles/uploadExternalImage': (_, payload) => + uploadExternalImage(payload), + }, + getters: { getUISettings: () => ({}) }, +}); + +let wrapper = null; + +const mountEditor = (props = {}) => { + wrapper = mount(FullEditor, { + props: { + modelValue: '', + enabledMenuOptions: ARTICLE_EDITOR_MENU_OPTIONS, + ...props, + }, + global: { + plugins: [store], + mocks: { $route: { params: { portalSlug: 'handbook' } } }, + }, + attachTo: document.body, + }); + return wrapper; +}; + +const type = text => view.dispatch(view.state.tr.insertText(text)); + +// Input rules only run through handleTextInput, which insertText bypasses. +const typeWithRules = text => { + [...text].forEach(character => { + const { from, to } = view.state.selection; + const handled = view.someProp('handleTextInput', rule => + rule(view, from, to, character) + ); + if (!handled) view.dispatch(view.state.tr.insertText(character, from, to)); + }); +}; + +const selectRange = (anchor, head) => + view.dispatch( + view.state.tr.setSelection( + Selection.fromJSON(view.state.doc, { type: 'text', anchor, head }) + ) + ); + +const selectAll = () => selectRange(1, view.state.doc.content.size - 1); + +const placeCursor = pos => + view.dispatch( + view.state.tr.setSelection(Selection.near(view.state.doc.resolve(pos))) + ); + +const lastEmittedValue = () => { + const events = wrapper.emitted('update:modelValue') || []; + return events.length ? events[events.length - 1][0] : null; +}; + +const markNames = () => { + const names = new Set(); + view.state.doc.descendants(node => + node.marks.forEach(mark => names.add(mark.type.name)) + ); + return [...names]; +}; + +const menuLabels = () => wrapper.findAll('button').map(button => button.text()); + +const highlightedLabel = () => + wrapper + .findAll('button') + .find(button => button.classes().includes('bg-n-alpha-1')) + ?.text(); + +// prosemirror-menu binds mousedown on the icon, not on the wrapping item. +const toolbarItems = () => + [...wrapper.vm.$refs.editor.querySelectorAll('.ProseMirror-menuitem')].map( + item => ({ + title: item.firstChild.getAttribute('title'), + disabled: item.firstChild.classList.contains('ProseMirror-menu-disabled'), + hidden: item.style.display === 'none', + icon: item.firstChild, + }) + ); + +const toolbarItem = title => toolbarItems().find(item => item.title === title); + +const clickToolbar = title => + toolbarItem(title).icon.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }) + ); + +const pressInEditor = (key, modifiers = {}) => + view.dom.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...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 }); + return file; +}; + +const selectFile = async file => { + const input = wrapper.find('input[type="file"]'); + Object.defineProperty(input.element, 'files', { value: [file] }); + await input.trigger('change'); + await flushPromises(); +}; + +describe('FullEditor', () => { + let alerts = []; + const collectAlert = ({ message }) => alerts.push(message); + + beforeEach(() => { + alerts = []; + emitter.on('newToastMessage', collectAlert); + attachImage.mockResolvedValue('https://cdn.test/photo.png'); + uploadExternalImage.mockResolvedValue('https://cdn.test/remote.png'); + }); + + afterEach(() => { + emitter.off('newToastMessage', collectAlert); + vi.restoreAllMocks(); + wrapper?.unmount(); + wrapper = null; + view = null; + }); + + describe('content', () => { + it('parses the initial markdown into the document', () => { + mountEditor({ modelValue: '# Title\n\nBody' }); + + expect(view.dom.querySelector('h1').textContent).toBe('Title'); + expect(view.state.doc.textContent).toBe('TitleBody'); + }); + + it('emits the serialized markdown on every edit', async () => { + mountEditor({ modelValue: 'Hello' }); + type(' world'); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue()).toBe('Hello world'); + expect(wrapper.emitted('input').at(-1)).toEqual(['Hello world']); + }); + + it('reloads when the model value changes outside the editor', async () => { + mountEditor({ modelValue: 'Hello' }); + await wrapper.setProps({ modelValue: 'Replaced' }); + + expect(view.state.doc.textContent).toBe('Replaced'); + }); + + it('keeps the caret when the incoming value matches the editor', async () => { + mountEditor({ modelValue: 'Hello' }); + view.dispatch( + view.state.tr.setSelection(Selection.near(view.state.doc.resolve(1))) + ); + await wrapper.setProps({ modelValue: 'Hello' }); + + expect(view.state.selection.from).toBe(1); + }); + + it('reloads when the editor id changes', async () => { + mountEditor({ modelValue: 'Hello', editorId: 'article-1' }); + type(' edited'); + await wrapper.setProps({ editorId: 'article-2' }); + + expect(view.state.doc.textContent).toBe('Hello'); + }); + + it('places the caret at the end when autofocus is on', () => { + mountEditor({ modelValue: 'First\n\nSecond' }); + + expect(view.state.selection.from).toBe(view.state.doc.content.size - 1); + }); + + it('leaves the caret at the start when autofocus is off', () => { + mountEditor({ modelValue: 'First\n\nSecond', autofocus: false }); + + expect(view.state.selection.from).toBe(1); + }); + }); + + describe('slash menu', () => { + it('opens when a slash starts a block', async () => { + mountEditor(); + type('/'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showSlashMenu).toBe(true); + expect(wrapper.vm.slashMenuPosition).toEqual({ top: 4, left: 0 }); + }); + + it('stays closed for a slash inside a word', async () => { + mountEditor({ modelValue: 'and/or' }); + type('/'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showSlashMenu).toBe(false); + }); + + it('filters the list by the typed term', async () => { + mountEditor(); + type('/div'); + await wrapper.vm.$nextTick(); + + expect(menuLabels()).toEqual(['Divider']); + }); + + it('hides on a term that matches nothing', async () => { + mountEditor(); + type('/zzz'); + await wrapper.vm.$nextTick(); + + expect(menuLabels()).toEqual([]); + }); + + it('lists only the enabled options', async () => { + mountEditor({ enabledMenuOptions: ['h1', 'horizontalRule'] }); + type('/'); + await wrapper.vm.$nextTick(); + + expect(menuLabels()).toEqual(['Heading 1', 'Divider']); + }); + + it('closes on escape', async () => { + mountEditor(); + type('/'); + await wrapper.vm.$nextTick(); + pressEscape(); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showSlashMenu).toBe(false); + expect(wrapper.vm.slashMenuPosition).toBeNull(); + }); + + it('closes when the trigger text is deleted', async () => { + mountEditor(); + type('/'); + await wrapper.vm.$nextTick(); + view.dispatch(view.state.tr.delete(1, 2)); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showSlashMenu).toBe(false); + }); + + it('runs the command picked from the menu', async () => { + mountEditor(); + type('/div'); + await wrapper.vm.$nextTick(); + await wrapper.find('button').trigger('click'); + + 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 => ({ + ...computedStyle(element), + direction: 'rtl', + })); + mountEditor(); + type('/'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.slashMenuPosition).toEqual({ top: 4, right: 0 }); + }); + }); + + describe('slash commands', () => { + it.each([ + ['strong', '**Hello**'], + ['em', '*Hello*'], + ['strike', '~~Hello~~'], + ['code', '`Hello`'], + ])('applies the %s mark to the selection', async (action, markdown) => { + mountEditor({ modelValue: 'Hello' }); + selectAll(); + wrapper.vm.executeSlashCommand(action); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue()).toBe(markdown); + }); + + it.each([ + ['h1', '# Hello'], + ['h2', '## Hello'], + ['h3', '### Hello'], + ['bulletList', '* Hello'], + ['orderedList', '1. Hello'], + ])('turns the block into %s', async (action, markdown) => { + mountEditor({ modelValue: 'Hello' }); + wrapper.vm.executeSlashCommand(action); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue().trim()).toBe(markdown); + }); + + it('inserts a table with a header row and a body row', async () => { + mountEditor(); + wrapper.vm.executeSlashCommand('insertTable'); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue()).toContain('| --- | --- | --- |'); + expect(view.dom.querySelectorAll('th')).toHaveLength(3); + expect(view.dom.querySelectorAll('td')).toHaveLength(3); + }); + + it('inserts a divider and keeps typing below it', async () => { + mountEditor({ modelValue: 'Above' }); + wrapper.vm.executeSlashCommand('horizontalRule'); + await wrapper.vm.$nextTick(); + + expect(view.state.selection.node).toBeUndefined(); + + type('Below'); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue().trim()).toBe('Above\n\n---\n\nBelow'); + }); + + it('inserts a divider on its own into an empty article', async () => { + mountEditor(); + wrapper.vm.executeSlashCommand('horizontalRule'); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue().trim()).toBe('---'); + }); + + it('opens the file browser for an image', () => { + mountEditor(); + const input = wrapper.find('input[type="file"]'); + const click = vi.spyOn(input.element, 'click'); + wrapper.vm.executeSlashCommand('imageUpload'); + + expect(click).toHaveBeenCalled(); + }); + + it('opens the embed input for a video instead of editing the document', async () => { + mountEditor(); + type('/'); + await wrapper.vm.$nextTick(); + wrapper.vm.executeSlashCommand('video'); + await wrapper.vm.$nextTick(); + + expect(wrapper.findComponent({ name: 'VideoEmbedInput' }).exists()).toBe( + true + ); + expect(view.state.doc.textContent).toBe(''); + }); + + it('removes the trigger text before running the command', async () => { + mountEditor(); + type('/head'); + await wrapper.vm.$nextTick(); + wrapper.vm.executeSlashCommand('h1'); + await wrapper.vm.$nextTick(); + + expect(view.state.doc.textContent).toBe(''); + expect(view.dom.querySelector('h1')).not.toBeNull(); + }); + + it('ignores an unknown command', async () => { + mountEditor({ modelValue: 'Hello' }); + wrapper.vm.executeSlashCommand('nope'); + await wrapper.vm.$nextTick(); + + expect(view.state.doc.textContent).toBe('Hello'); + expect(wrapper.emitted('update:modelValue')).toBeFalsy(); + }); + + it('ignores a command after the editor is torn down', () => { + const editor = mountEditor({ modelValue: 'Hello' }); + const destroyed = view; + editor.unmount(); + wrapper = null; + + expect(destroyed.isDestroyed).toBe(true); + expect(() => editor.vm.executeSlashCommand('h1')).not.toThrow(); + }); + }); + + describe('video embed', () => { + const openEmbedInput = async (trigger = '/') => { + type(trigger); + await wrapper.vm.$nextTick(); + wrapper.vm.executeSlashCommand('video'); + await wrapper.vm.$nextTick(); + }; + + it('inserts the link and closes the input on submit', async () => { + mountEditor(); + await openEmbedInput(); + wrapper + .findComponent({ name: 'VideoEmbedInput' }) + .vm.$emit('submit', 'https://youtu.be/dQw4w9WgXcQ'); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue().trim()).toBe(''); + expect(wrapper.findComponent({ name: 'VideoEmbedInput' }).exists()).toBe( + false + ); + }); + + it('leaves the document untouched on cancel', async () => { + mountEditor({ modelValue: 'Hello' }); + await openEmbedInput(' /'); + wrapper.findComponent({ name: 'VideoEmbedInput' }).vm.$emit('cancel'); + await wrapper.vm.$nextTick(); + + expect(wrapper.findComponent({ name: 'VideoEmbedInput' }).exists()).toBe( + false + ); + expect(view.state.doc.textContent).toBe('Hello '); + }); + }); + + describe('image upload', () => { + it('uploads a selected file and inserts it', async () => { + mountEditor(); + await selectFile(fileOfSize(1)); + + expect(attachImage).toHaveBeenCalledWith( + expect.objectContaining({ portalSlug: 'handbook' }) + ); + expect(lastEmittedValue()).toContain('![](https://cdn.test/photo.png)'); + }); + + it('rejects a file over the size limit', async () => { + mountEditor(); + await selectFile(fileOfSize(5)); + + expect(attachImage).not.toHaveBeenCalled(); + expect(alerts).toHaveLength(1); + }); + + it('alerts when the upload fails', async () => { + mountEditor(); + attachImage.mockRejectedValue(new Error('nope')); + await selectFile(fileOfSize(1)); + + expect(alerts).toHaveLength(1); + expect(lastEmittedValue()).toBeNull(); + }); + + it('uploads an image pasted as a file', async () => { + mountEditor(); + const paste = new Event('paste', { bubbles: true, cancelable: true }); + paste.clipboardData = { files: [fileOfSize(1)] }; + view.dom.dispatchEvent(paste); + await flushPromises(); + + expect(attachImage).toHaveBeenCalled(); + expect(lastEmittedValue()).toContain('![](https://cdn.test/photo.png)'); + }); + + it('resolves a pasted image url through the upload action', async () => { + mountEditor(); + const url = await wrapper.vm.handleImageUpload('https://web.test/a.png'); + + expect(uploadExternalImage).toHaveBeenCalledWith({ + portalSlug: 'handbook', + url: 'https://web.test/a.png', + }); + expect(url).toBe('https://cdn.test/remote.png'); + }); + + it('alerts and returns an empty url when the paste upload fails', async () => { + mountEditor(); + uploadExternalImage.mockRejectedValue(new Error('nope')); + const url = await wrapper.vm.handleImageUpload('https://web.test/a.png'); + + expect(url).toBe(''); + expect(alerts).toHaveLength(1); + }); + }); + + describe('editor events', () => { + it('forwards focus, blur, keyup and keydown', async () => { + mountEditor({ autofocus: false }); + view.dom.dispatchEvent(new FocusEvent('focus')); + view.dom.dispatchEvent(new FocusEvent('blur')); + view.dom.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })); + view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('focus')).toHaveLength(1); + expect(wrapper.emitted('blur')).toHaveLength(1); + expect(wrapper.emitted('keyup')).toHaveLength(1); + expect(wrapper.emitted('keydown')).toHaveLength(1); + }); + + it('flags a text range and positions the floating menubar', async () => { + mountEditor({ modelValue: 'Hello' }); + selectAll(); + await wrapper.vm.$nextTick(); + + const editor = wrapper.vm.$refs.editor; + expect(editor.classList.contains('has-selection')).toBe(true); + expect(editor.style.getPropertyValue('--selection-top')).toBe('10px'); + expect(editor.style.getPropertyValue('--selection-left')).toBe('0px'); + }); + + it('clears the selection flag on blur', async () => { + mountEditor({ modelValue: 'Hello' }); + selectAll(); + await wrapper.vm.$nextTick(); + view.dom.dispatchEvent(new FocusEvent('blur')); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.$refs.editor.classList.contains('has-selection')).toBe( + false + ); + }); + + it('collapses the selection on escape when the menu is closed', async () => { + mountEditor({ modelValue: 'Hello' }); + selectAll(); + pressEscape(); + await wrapper.vm.$nextTick(); + + expect(view.state.selection.empty).toBe(true); + }); + }); + + describe('floating toolbar', () => { + it('renders a button for every enabled option that has one', () => { + mountEditor(); + + // `video` and `horizontalRule` are slash-only keys; buildMenuOptions drops + // what it does not recognise, so they must not add a toolbar button. + expect(toolbarItems().map(item => item.title)).toEqual([ + 'Toggle strong style', + 'Toggle emphasis', + 'Toggle strikethrough', + 'Add or remove link', + 'Undo last change', + 'Redo last undone change', + 'Wrap in bullet list', + 'Wrap in ordered list', + 'Heading 1', + 'Heading 2', + 'Heading 3', + 'Upload image', + 'Toggle code font', + 'Insert table', + ]); + }); + + it('honours a narrower set of enabled options', () => { + mountEditor({ enabledMenuOptions: ['strong', 'undo'] }); + + expect(toolbarItems().map(item => item.title)).toEqual([ + 'Toggle strong style', + 'Undo last change', + ]); + }); + + it.each([ + ['Toggle strong style', '**Hello**'], + ['Toggle emphasis', '*Hello*'], + ['Toggle strikethrough', '~~Hello~~'], + ['Toggle code font', '`Hello`'], + ])('applies %s to the selection', async (title, markdown) => { + mountEditor({ modelValue: 'Hello' }); + selectAll(); + await wrapper.vm.$nextTick(); + clickToolbar(title); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue()).toBe(markdown); + }); + + it.each([ + ['Wrap in bullet list', '* Hello'], + ['Wrap in ordered list', '1. Hello'], + ['Heading 1', '# Hello'], + ['Heading 2', '## Hello'], + ['Heading 3', '### Hello'], + ])('applies %s to the block', async (title, markdown) => { + mountEditor({ modelValue: 'Hello' }); + clickToolbar(title); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue().trim()).toBe(markdown); + }); + + it('inserts a table', async () => { + mountEditor(); + clickToolbar('Insert table'); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue()).toContain('| --- | --- | --- |'); + }); + + it('opens the file browser', () => { + mountEditor(); + const click = vi.spyOn( + wrapper.find('input[type="file"]').element, + 'click' + ); + clickToolbar('Upload image'); + + expect(click).toHaveBeenCalled(); + }); + + it('disables the link button until text is selected', async () => { + mountEditor({ modelValue: 'Hello' }); + + expect(toolbarItem('Add or remove link').disabled).toBe(true); + + selectAll(); + await wrapper.vm.$nextTick(); + + expect(toolbarItem('Add or remove link').disabled).toBe(false); + }); + + it('disables undo and redo until there is history', async () => { + mountEditor({ modelValue: 'Hello' }); + + expect(toolbarItem('Undo last change').disabled).toBe(true); + expect(toolbarItem('Redo last undone change').disabled).toBe(true); + + type(' edited'); + await wrapper.vm.$nextTick(); + + expect(toolbarItem('Undo last change').disabled).toBe(false); + }); + + it('hides block level buttons while the caret is inside a table', async () => { + mountEditor(); + wrapper.vm.executeSlashCommand('insertTable'); + await wrapper.vm.$nextTick(); + placeCursor(4); + await wrapper.vm.$nextTick(); + + expect( + toolbarItems() + .filter(item => item.hidden) + .map(i => i.title) + ).toEqual([ + 'Toggle strikethrough', + 'Wrap in bullet list', + 'Wrap in ordered list', + 'Heading 1', + 'Heading 2', + 'Heading 3', + 'Upload image', + 'Toggle code font', + 'Insert table', + ]); + expect(toolbarItem('Toggle strong style').hidden).toBe(false); + }); + }); + + describe('markdown shortcuts', () => { + it.each([ + ['# Title', '# Title'], + ['## Title', '## Title'], + ['### Title', '### Title'], + ['- item', '* item'], + ['1. item', '1. item'], + ['> quote', '> quote'], + ['--- ', '---'], + ])('turns %j into a block while typing', async (typed, markdown) => { + mountEditor(); + typeWithRules(typed); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue().trim()).toBe(markdown); + }); + + it('opens a code block on a triple backtick', async () => { + mountEditor(); + typeWithRules('```'); + await wrapper.vm.$nextTick(); + + expect(view.state.doc.firstChild.type.name).toBe('code_block'); + }); + + it.each([ + ['**bold**', 'strong'], + ['*italic*', 'em'], + ['~~struck~~', 'strike'], + ['`code`', 'code'], + ])('turns %j into the %s mark while typing', async (typed, mark) => { + mountEditor(); + typeWithRules(typed); + await wrapper.vm.$nextTick(); + + expect(markNames()).toEqual([mark]); + }); + }); + + describe('keyboard shortcuts', () => { + it.each([ + ['b', 'strong'], + ['i', 'em'], + ])('applies the %s shortcut to the selection', async (key, mark) => { + mountEditor({ modelValue: 'Hello' }); + selectAll(); + pressInEditor(key, { ctrlKey: true }); + await wrapper.vm.$nextTick(); + + expect(markNames()).toEqual([mark]); + }); + + it('undoes and redoes an edit', async () => { + mountEditor({ modelValue: 'Hello' }); + type(' world'); + await wrapper.vm.$nextTick(); + + pressInEditor('z', { ctrlKey: true }); + await wrapper.vm.$nextTick(); + expect(lastEmittedValue()).toBe('Hello'); + + pressInEditor('z', { ctrlKey: true, shiftKey: true }); + await wrapper.vm.$nextTick(); + expect(lastEmittedValue()).toBe('Hello world'); + }); + + it('undoes an inserted divider in a single step', async () => { + mountEditor({ modelValue: 'Above' }); + wrapper.vm.executeSlashCommand('horizontalRule'); + await wrapper.vm.$nextTick(); + expect(lastEmittedValue()).toContain('---'); + + pressInEditor('z', { ctrlKey: true }); + await wrapper.vm.$nextTick(); + + expect(lastEmittedValue()).toBe('Above'); + }); + }); + + describe('markdown round trip', () => { + it.each([ + ['heading', '# Title'], + ['inline marks', '**bold** and *italic* and ~~struck~~ and `code`'], + ['bullet list', '* one\n\n* two'], + ['ordered list', '1. one\n\n2. two'], + ['blockquote', '> quote'], + ['code block', '```\ncode\n```'], + ['divider', 'Above\n\n---\n\nBelow'], + ['image', '![](https://cdn.test/photo.png)'], + ['link', '[label](https://example.com)'], + ['table', '| | |\n| --- | --- |\n| | |'], + ])('serializes %s back to the same markdown', (_label, markdown) => { + mountEditor({ modelValue: markdown }); + + expect(wrapper.vm.contentFromEditor().trim()).toBe(markdown); + }); + }); +}); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 4dc1ee490..5a236d38a 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -244,6 +244,17 @@ export default { const tr = editorView.state.tr.replaceSelectionWith(tableNode); editorView.dispatch(tr.scrollIntoView()); }, + horizontalRule: () => { + editorView.dispatch( + editorView.state.tr + .replaceSelectionWith(schema.nodes.horizontal_rule.create()) + .scrollIntoView() + ); + const { doc, selection, tr } = editorView.state; + editorView.dispatch( + tr.setSelection(Selection.near(doc.resolve(selection.to), 1)) + ); + }, imageUpload: () => this.openFileBrowser(), }; diff --git a/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue b/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue index b13ec91b3..513da379e 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/SlashCommandMenu.vue @@ -72,6 +72,12 @@ const EDITOR_ACTIONS = [ icon: 'i-lucide-video', menuKey: 'video', }, + { + value: 'horizontalRule', + labelKey: 'SLASH_COMMANDS.DIVIDER', + icon: 'i-lucide-minus', + menuKey: 'horizontalRule', + }, { value: 'strike', labelKey: 'SLASH_COMMANDS.STRIKETHROUGH', diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 5dff4a3e6..c82e04793 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -187,6 +187,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'code', 'insertTable', 'video', + 'horizontalRule', ]; // [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer diff --git a/app/javascript/dashboard/i18n/locale/en/components.json b/app/javascript/dashboard/i18n/locale/en/components.json index ad227b939..13e73bca4 100644 --- a/app/javascript/dashboard/i18n/locale/en/components.json +++ b/app/javascript/dashboard/i18n/locale/en/components.json @@ -65,7 +65,8 @@ "ORDERED_LIST": "Ordered List", "TABLE": "Table", "IMAGE": "Image", - "VIDEO": "Video" + "VIDEO": "Video", + "DIVIDER": "Divider" }, "VIDEO_EMBED": { "PLACEHOLDER": "Paste a video link",