Commit 4764398f authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '337145-parse-pasted-markdown' into 'master'

Parse pasted markdown in the Content Editor

See merge request gitlab-org/gitlab!78394
parents 39f74244 42449f07
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue';
......@@ -7,10 +6,11 @@ import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
export default {
components: {
GlLoadingIcon,
LoadingIndicator,
ContentEditorAlert,
ContentEditorProvider,
TiptapEditorContent,
......@@ -40,7 +40,6 @@ export default {
},
data() {
return {
isLoadingContent: false,
focused: false,
};
},
......@@ -62,12 +61,6 @@ export default {
this.contentEditor.dispose();
},
methods: {
displayLoadingIndicator() {
this.isLoadingContent = true;
},
hideLoadingIndicator() {
this.isLoadingContent = false;
},
focus() {
this.focused = true;
},
......@@ -85,14 +78,7 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
<editor-state-observer
@loading="displayLoadingIndicator"
@loadingSuccess="hideLoadingIndicator"
@loadingError="hideLoadingIndicator"
@docUpdate="notifyChange"
@focus="focus"
@blur="blur"
/>
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
<content-editor-alert />
<div
data-testid="content-editor"
......@@ -101,13 +87,11 @@ export default {
:class="{ 'is-focused': focused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" />
<div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
<gl-loading-icon size="sm" />
</div>
<template v-else>
<div class="gl-relative">
<formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</template>
<loading-indicator />
</div>
</div>
</div>
</content-editor-provider>
......
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlLoadingIcon,
EditorStateObserver,
},
data() {
return {
isLoading: false,
};
},
methods: {
displayLoadingIndicator() {
this.isLoading = true;
},
hideLoadingIndicator() {
this.isLoading = false;
},
},
};
</script>
<template>
<editor-state-observer
@loading="displayLoadingIndicator"
@loadingSuccess="hideLoadingIndicator"
@loadingError="hideLoadingIndicator"
>
<div
v-if="isLoading"
class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
<div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
<gl-loading-icon size="md" />
</div>
</editor-state-observer>
</template>
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from 'prosemirror-state';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/flash';
import createMarkdownDeserializer from '../services/markdown_deserializer';
import {
ALERT_EVENT,
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
EXTENSION_PRIORITY_HIGHEST,
} from '../constants';
const TEXT_FORMAT = 'text/plain';
const HTML_FORMAT = 'text/html';
const VS_CODE_FORMAT = 'vscode-editor-data';
export default Extension.create({
name: 'pasteMarkdown',
priority: EXTENSION_PRIORITY_HIGHEST,
addOptions() {
return {
renderMarkdown: null,
};
},
addCommands() {
return {
pasteMarkdown: (markdown) => () => {
const { editor, options } = this;
const { renderMarkdown, eventHub } = options;
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
eventHub.$emit(LOADING_CONTENT_EVENT);
deserializer
.deserialize({ schema: editor.schema, content: markdown })
.then((doc) => {
if (!doc) {
return;
}
const { state, view } = editor;
const { tr, selection } = state;
tr.replaceWith(selection.from - 1, selection.to, doc.content);
view.dispatch(tr);
eventHub.$emit(LOADING_SUCCESS_EVENT);
})
.catch(() => {
eventHub.$emit(ALERT_EVENT, {
message: __('An error occurred while pasting text in the editor. Please try again.'),
variant: VARIANT_DANGER,
});
eventHub.$emit(LOADING_ERROR_EVENT);
});
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
handlePaste: (_, event) => {
const { clipboardData } = event;
const content = clipboardData.getData(TEXT_FORMAT);
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
const language = vsCodeMeta.mode;
if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) {
return false;
}
this.editor.commands.pasteMarkdown(content);
return true;
},
},
}),
];
},
});
import { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash';
import { VARIANT_WARNING } from '~/flash';
import { __ } from '~/locale';
import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serialization_helpers';
......@@ -14,7 +15,7 @@ const onUpdate = debounce((editor) => {
message: __(
'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.',
),
variant: 'warning',
variant: VARIANT_WARNING,
});
alertShown = true;
......
......@@ -39,6 +39,7 @@ import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
......@@ -120,6 +121,7 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
PasteMarkdown.configure({ renderMarkdown, eventHub }),
Reference,
Strike,
Subscript,
......
import { VARIANT_DANGER } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { extractFilename, readFileAsDataURL } from './utils';
......@@ -74,7 +75,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub
editor.commands.deleteRange({ from: position, to: position + 1 });
eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'),
variant: 'danger',
variant: VARIANT_DANGER,
});
}
};
......@@ -105,7 +106,7 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
editor.commands.deleteRange({ from, to: from + 1 });
eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'),
variant: 'danger',
variant: VARIANT_DANGER,
});
}
};
......
......@@ -3999,6 +3999,9 @@ msgstr ""
msgid "An error occurred while parsing the file."
msgstr ""
msgid "An error occurred while pasting text in the editor. Please try again."
msgstr ""
msgid "An error occurred while removing epics."
msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent } from '@tiptap/vue-2';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
......@@ -8,11 +6,7 @@ import ContentEditorProvider from '~/content_editor/components/content_editor_pr
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
import { emitEditorEvent } from '../test_utils';
jest.mock('~/emoji');
......@@ -25,9 +19,6 @@ describe('ContentEditor', () => {
const findEditorElement = () => wrapper.findByTestId('content-editor');
const findEditorContent = () => wrapper.findComponent(EditorContent);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu);
const createWrapper = (propsData = {}) => {
renderMarkdown = jest.fn();
......@@ -117,69 +108,15 @@ describe('ContentEditor', () => {
expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true);
});
describe('when loading content', () => {
beforeEach(async () => {
createWrapper();
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
});
it('displays loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('hides EditorContent component', () => {
expect(findEditorContent().exists()).toBe(false);
});
it('hides formatting bubble menu', () => {
expect(findBubbleMenu().exists()).toBe(false);
});
});
describe('when loading content succeeds', () => {
beforeEach(async () => {
it('renders loading indicator component', () => {
createWrapper();
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT);
await nextTick();
expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true);
});
it('hides loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays EditorContent component', () => {
expect(findEditorContent().exists()).toBe(true);
});
});
describe('when loading content fails', () => {
const error = 'error';
beforeEach(async () => {
it('renders formatting bubble menu', () => {
createWrapper();
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.eventHub.$emit(LOADING_ERROR_EVENT, error);
await nextTick();
});
it('hides loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays EditorContent component', () => {
expect(findEditorContent().exists()).toBe(true);
});
it('displays formatting bubble menu', () => {
expect(findBubbleMenu().exists()).toBe(true);
});
expect(wrapper.findComponent(FormattingBubbleMenu).exists()).toBe(true);
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
describe('content_editor/components/loading_indicator', () => {
let wrapper;
const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const createWrapper = () => {
wrapper = shallowMountExtended(LoadingIndicator);
};
afterEach(() => {
wrapper.destroy();
});
describe('when loading content', () => {
beforeEach(async () => {
createWrapper();
findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
await nextTick();
});
it('displays loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when loading content succeeds', () => {
beforeEach(async () => {
createWrapper();
findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
await nextTick();
findEditorStateObserver().vm.$emit(LOADING_SUCCESS_EVENT);
await nextTick();
});
it('hides loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('when loading content fails', () => {
const error = 'error';
beforeEach(async () => {
createWrapper();
findEditorStateObserver().vm.$emit(LOADING_CONTENT_EVENT);
await nextTick();
findEditorStateObserver().vm.$emit(LOADING_ERROR_EVENT, error);
await nextTick();
});
it('hides loading indicator', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
});
......@@ -4,6 +4,7 @@ import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import { VARIANT_DANGER } from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, createDocBuilder } from '../test_utils';
......@@ -168,7 +169,8 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile });
eventHub.$on('alert', ({ message }) => {
eventHub.$on('alert', ({ message, variant }) => {
expect(variant).toBe(VARIANT_DANGER);
expect(message).toBe('An error occurred while uploading the image. Please try again.');
done();
});
......@@ -244,7 +246,8 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
eventHub.$on('alert', ({ message }) => {
eventHub.$on('alert', ({ message, variant }) => {
expect(variant).toBe(VARIANT_DANGER);
expect(message).toBe('An error occurred while uploading the file. Please try again.');
done();
});
......
import PasteMarkdown from '~/content_editor/extensions/paste_markdown';
import Bold from '~/content_editor/extensions/bold';
import { VARIANT_DANGER } from '~/flash';
import eventHubFactory from '~/helpers/event_hub_factory';
import {
ALERT_EVENT,
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import waitForPromises from 'helpers/wait_for_promises';
import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
describe('content_editor/extensions/paste_markdown', () => {
let tiptapEditor;
let doc;
let p;
let bold;
let renderMarkdown;
let eventHub;
const defaultData = { 'text/plain': '**bold text**' };
beforeEach(() => {
renderMarkdown = jest.fn();
eventHub = eventHubFactory();
jest.spyOn(eventHub, '$emit');
tiptapEditor = createTestEditor({
extensions: [PasteMarkdown.configure({ renderMarkdown, eventHub }), Bold],
});
({
builders: { doc, p, bold },
} = createDocBuilder({
tiptapEditor,
names: {
Bold: { markType: Bold.name },
},
}));
});
const buildClipboardEvent = ({ data = {}, types = ['text/plain'] } = {}) => {
return Object.assign(new Event('paste'), {
clipboardData: { types, getData: jest.fn((type) => data[type] || defaultData[type]) },
});
};
const triggerPasteEventHandler = (event) => {
let handled = false;
tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
handled = eventHandler(tiptapEditor.view, event);
});
return handled;
};
const triggerPasteEventHandlerAndWaitForTransaction = (event) => {
return waitUntilNextDocTransaction({
tiptapEditor,
action: () => {
tiptapEditor.view.someProp('handlePaste', (eventHandler) => {
return eventHandler(tiptapEditor.view, event);
});
},
});
};
it.each`
types | data | handled | desc
${['text/plain']} | ${{}} | ${true} | ${'handles plain text'}
${['text/plain', 'text/html']} | ${{}} | ${false} | ${'doesn’t handle html format'}
${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "markdown" }' }} | ${true} | ${'handles vscode markdown'}
${['text/plain', 'text/html', 'vscode-editor-data']} | ${{ 'vscode-editor-data': '{ "mode": "ruby" }' }} | ${false} | ${'doesn’t vscode code snippet'}
`('$desc', ({ types, handled, data }) => {
expect(triggerPasteEventHandler(buildClipboardEvent({ types, data }))).toBe(handled);
});
describe('when pasting raw markdown source', () => {
describe('when rendering markdown succeeds', () => {
beforeEach(() => {
renderMarkdown.mockResolvedValueOnce('<strong>bold text</strong>');
});
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());
});
it(`triggers ${LOADING_SUCCESS_EVENT}`, async () => {
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_CONTENT_EVENT);
expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_SUCCESS_EVENT);
});
});
describe('when rendering markdown fails', () => {
beforeEach(() => {
renderMarkdown.mockRejectedValueOnce();
});
it(`triggers ${LOADING_ERROR_EVENT} event`, async () => {
triggerPasteEventHandler(buildClipboardEvent());
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith(LOADING_ERROR_EVENT);
});
it(`triggers ${ALERT_EVENT} event`, async () => {
triggerPasteEventHandler(buildClipboardEvent());
await waitForPromises();
expect(eventHub.$emit).toHaveBeenCalledWith(ALERT_EVENT, {
message: expect.any(String),
variant: VARIANT_DANGER,
});
});
});
});
});
......@@ -142,3 +142,23 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => {
f(view, selection.from, inputRuleText.length + 1, inputRuleText),
);
};
/**
* Executes an action that triggers a transaction in the
* tiptap Editor. Returns a promise that resolves
* after the transaction completes
* @param {*} params.tiptapEditor Tiptap editor
* @param {*} params.action A function that triggers a transaction in the tiptap Editor
* @returns A promise that resolves when the transaction completes
*/
export const waitUntilNextDocTransaction = ({ tiptapEditor, action }) => {
return new Promise((resolve) => {
const handleTransaction = () => {
tiptapEditor.off('update', handleTransaction);
resolve();
};
tiptapEditor.on('update', handleTransaction);
action();
});
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment