import PasteMarkdown from '~/content_editor/extensions/paste_markdown'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import Diagram from '~/content_editor/extensions/diagram'; import Frontmatter from '~/content_editor/extensions/frontmatter'; import Heading from '~/content_editor/extensions/heading'; import Bold from '~/content_editor/extensions/bold'; import Italic from '~/content_editor/extensions/italic'; import { VARIANT_DANGER } from '~/alert'; import eventHubFactory from '~/helpers/event_hub_factory'; import { ALERT_EVENT } from '~/content_editor/constants'; import waitForPromises from 'helpers/wait_for_promises'; import MarkdownSerializer from '~/content_editor/services/markdown_serializer'; import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils'; const CODE_BLOCK_HTML = '
var a = 2;
'; const DIAGRAM_HTML = ''; const FRONTMATTER_HTML = '
key: value
'; const PARAGRAPH_HTML = '

Some text with bold and italic text.

'; describe('content_editor/extensions/paste_markdown', () => { let tiptapEditor; let doc; let p; let bold; let italic; let heading; let codeBlock; let renderMarkdown; let eventHub; const defaultData = { 'text/plain': '**bold text**' }; beforeEach(() => { renderMarkdown = jest.fn(); eventHub = eventHubFactory(); jest.spyOn(eventHub, '$emit'); tiptapEditor = createTestEditor({ extensions: [ Bold, Italic, CodeBlockHighlight, Diagram, Frontmatter, Heading, PasteMarkdown.configure({ renderMarkdown, eventHub, serializer: new MarkdownSerializer() }), ], }); ({ builders: { doc, p, bold, italic, heading, codeBlock }, } = createDocBuilder({ tiptapEditor, names: { bold: { markType: Bold.name }, italic: { markType: Italic.name }, heading: { nodeType: Heading.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, }, })); }); const buildClipboardEvent = ({ eventName = 'paste', data = {}, types = ['text/plain'] } = {}) => { return Object.assign(new Event(eventName), { clipboardData: { types, getData: jest.fn((type) => data[type] || defaultData[type]), setData: jest.fn(), clearData: jest.fn(), }, }); }; const triggerPasteEventHandler = (event) => { return new Promise((resolve) => { tiptapEditor.view.someProp('handlePaste', (eventHandler) => { resolve(eventHandler(tiptapEditor.view, event)); }); }); }; const triggerPasteEventHandlerAndWaitForTransaction = (event) => { return waitUntilNextDocTransaction({ tiptapEditor, action: () => { tiptapEditor.view.someProp('handlePaste', (eventHandler) => { return eventHandler(tiptapEditor.view, event); }); }, }); }; it.each` types | data | formatDesc ${['text/plain']} | ${{}} | ${'plain text'} ${['text/plain', 'text/html']} | ${{}} | ${'html format'} ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${'vscode markdown'} ${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${'vscode snippet'} `('handles $formatDesc', async ({ types, data }) => { expect(await triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(true); }); it.each` nodeType | html | handled | desc ${'codeBlock'} | ${CODE_BLOCK_HTML} | ${false} | ${'does not handle'} ${'diagram'} | ${DIAGRAM_HTML} | ${false} | ${'does not handle'} ${'frontmatter'} | ${FRONTMATTER_HTML} | ${false} | ${'does not handle'} ${'paragraph'} | ${PARAGRAPH_HTML} | ${true} | ${'handles'} `('$desc paste if currently a `$nodeType` is in focus', async ({ html, handled }) => { tiptapEditor.commands.insertContent(html); expect(await triggerPasteEventHandler(buildClipboardEvent())).toBe(handled); }); describe.each` eventName | expectedDoc ${'cut'} | ${() => doc(p())} ${'copy'} | ${() => doc(p('Some text with ', bold('bold'), ' and ', italic('italic'), ' text.'))} `('when $eventName event is triggered', ({ eventName, expectedDoc }) => { let event; beforeEach(() => { event = buildClipboardEvent({ eventName }); jest.spyOn(event, 'preventDefault'); jest.spyOn(event, 'stopPropagation'); tiptapEditor.commands.insertContent(PARAGRAPH_HTML); tiptapEditor.commands.selectAll(); tiptapEditor.view.dispatchEvent(event); }); it('prevents default', () => { expect(event.preventDefault).toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalled(); }); it('sets the clipboard data', () => { expect(event.clipboardData.setData).toHaveBeenCalledWith( 'text/plain', 'Some text with bold and italic text.', ); expect(event.clipboardData.setData).toHaveBeenCalledWith('text/html', PARAGRAPH_HTML); expect(event.clipboardData.setData).toHaveBeenCalledWith( 'text/x-gfm', 'Some text with **bold** and _italic_ text.', ); }); it('modifies the document', () => { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc().toJSON()); }); }); describe('when pasting raw markdown source', () => { describe('when rendering markdown succeeds', () => { beforeEach(() => { renderMarkdown.mockResolvedValueOnce('bold text'); }); it('transforms pasted text into a prosemirror node', async () => { const expectedDoc = doc(p(bold('bold text'))); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); describe('when pasting inline content in an existing paragraph', () => { it('inserts the inline content next to the existing paragraph content', async () => { const expectedDoc = doc(p('Initial text and', bold('bold text'))); tiptapEditor.commands.setContent('Initial text and '); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); describe('when pasting inline content and there is text selected', () => { it('inserts the block content after the existing paragraph', async () => { const expectedDoc = doc(p('Initial text', bold('bold text'))); tiptapEditor.commands.setContent('Initial text and '); tiptapEditor.commands.setTextSelection({ from: 13, to: 17 }); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); describe('when pasting block content in an existing paragraph', () => { beforeEach(() => { renderMarkdown.mockReset(); renderMarkdown.mockResolvedValueOnce('

Heading

bold text

'); }); it('inserts the block content after the existing paragraph', async () => { const expectedDoc = doc( p('Initial text and'), heading({ level: 1 }, 'Heading'), p(bold('bold text')), ); tiptapEditor.commands.setContent('Initial text and '); await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent()); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); }); describe('when pasting html content', () => { it('strips out any stray div, pre, span tags', async () => { renderMarkdown.mockResolvedValueOnce( '
bold text
some code
', ); const expectedDoc = doc(p(bold('bold text')), p('some code')); await triggerPasteEventHandlerAndWaitForTransaction( buildClipboardEvent({ types: ['text/html'], data: { 'text/html': '
bold text
some code
', }, }), ); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); describe('when pasting text/x-gfm', () => { it('processes the content as markdown, even if html content exists', async () => { renderMarkdown.mockResolvedValueOnce('bold text'); const expectedDoc = doc(p(bold('bold text'))); await triggerPasteEventHandlerAndWaitForTransaction( buildClipboardEvent({ types: ['text/x-gfm'], data: { 'text/x-gfm': '**bold text**', 'text/plain': 'irrelevant text', 'text/html': '
some random irrelevant html
', }, }), ); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); describe('when pasting vscode-editor-data', () => { it('pastes the content as a code block', async () => { renderMarkdown.mockResolvedValueOnce( '
puts "Hello World"
', ); const expectedDoc = doc( codeBlock( { language: 'ruby', class: 'code highlight js-syntax-highlight language-ruby' }, 'puts "Hello World"', ), ); await triggerPasteEventHandlerAndWaitForTransaction( buildClipboardEvent({ types: ['vscode-editor-data', 'text/plain', 'text/html'], data: { 'vscode-editor-data': '{ "version": 1, "mode": "ruby" }', 'text/plain': 'puts "Hello World"', 'text/html': '
puts "Hello world"
', }, }), ); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); it('pastes as regular markdown if language is markdown', async () => { renderMarkdown.mockResolvedValueOnce('

bold text

'); const expectedDoc = doc(p(bold('bold text'))); await triggerPasteEventHandlerAndWaitForTransaction( buildClipboardEvent({ types: ['vscode-editor-data', 'text/plain', 'text/html'], data: { 'vscode-editor-data': '{ "version": 1, "mode": "markdown" }', 'text/plain': '**bold text**', 'text/html': '

bold text

', }, }), ); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); }); describe('when rendering markdown fails', () => { beforeEach(() => { renderMarkdown.mockRejectedValueOnce(); }); it(`triggers ${ALERT_EVENT} event`, async () => { await triggerPasteEventHandler(buildClipboardEvent()); await waitForPromises(); expect(eventHub.$emit).toHaveBeenCalledWith(ALERT_EVENT, { message: expect.any(String), variant: VARIANT_DANGER, }); }); }); }); });