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> <script>
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { createContentEditor } from '../services/create_content_editor'; import { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorAlert from './content_editor_alert.vue';
...@@ -7,10 +6,11 @@ import ContentEditorProvider from './content_editor_provider.vue'; ...@@ -7,10 +6,11 @@ import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue'; import EditorStateObserver from './editor_state_observer.vue';
import FormattingBubbleMenu from './formatting_bubble_menu.vue'; import FormattingBubbleMenu from './formatting_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue'; import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
export default { export default {
components: { components: {
GlLoadingIcon, LoadingIndicator,
ContentEditorAlert, ContentEditorAlert,
ContentEditorProvider, ContentEditorProvider,
TiptapEditorContent, TiptapEditorContent,
...@@ -40,7 +40,6 @@ export default { ...@@ -40,7 +40,6 @@ export default {
}, },
data() { data() {
return { return {
isLoadingContent: false,
focused: false, focused: false,
}; };
}, },
...@@ -62,12 +61,6 @@ export default { ...@@ -62,12 +61,6 @@ export default {
this.contentEditor.dispose(); this.contentEditor.dispose();
}, },
methods: { methods: {
displayLoadingIndicator() {
this.isLoadingContent = true;
},
hideLoadingIndicator() {
this.isLoadingContent = false;
},
focus() { focus() {
this.focused = true; this.focused = true;
}, },
...@@ -85,14 +78,7 @@ export default { ...@@ -85,14 +78,7 @@ export default {
<template> <template>
<content-editor-provider :content-editor="contentEditor"> <content-editor-provider :content-editor="contentEditor">
<div> <div>
<editor-state-observer <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
@loading="displayLoadingIndicator"
@loadingSuccess="hideLoadingIndicator"
@loadingError="hideLoadingIndicator"
@docUpdate="notifyChange"
@focus="focus"
@blur="blur"
/>
<content-editor-alert /> <content-editor-alert />
<div <div
data-testid="content-editor" data-testid="content-editor"
...@@ -101,13 +87,11 @@ export default { ...@@ -101,13 +87,11 @@ export default {
:class="{ 'is-focused': focused }" :class="{ 'is-focused': focused }"
> >
<top-toolbar ref="toolbar" class="gl-mb-4" /> <top-toolbar ref="toolbar" class="gl-mb-4" />
<div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center"> <div class="gl-relative">
<gl-loading-icon size="sm" />
</div>
<template v-else>
<formatting-bubble-menu /> <formatting-bubble-menu />
<tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</template> <loading-indicator />
</div>
</div> </div>
</div> </div>
</content-editor-provider> </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 { Table } from '@tiptap/extension-table';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { VARIANT_WARNING } from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { getMarkdownSource } from '../services/markdown_sourcemap'; import { getMarkdownSource } from '../services/markdown_sourcemap';
import { shouldRenderHTMLTable } from '../services/serialization_helpers'; import { shouldRenderHTMLTable } from '../services/serialization_helpers';
...@@ -14,7 +15,7 @@ const onUpdate = debounce((editor) => { ...@@ -14,7 +15,7 @@ const onUpdate = debounce((editor) => {
message: __( message: __(
'The content editor may change the markdown formatting style of the document, which may not match your original markdown style.', '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; alertShown = true;
......
...@@ -39,6 +39,7 @@ import Loading from '../extensions/loading'; ...@@ -39,6 +39,7 @@ import Loading from '../extensions/loading';
import MathInline from '../extensions/math_inline'; import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list'; import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph'; import Paragraph from '../extensions/paragraph';
import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference'; import Reference from '../extensions/reference';
import Strike from '../extensions/strike'; import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript'; import Subscript from '../extensions/subscript';
...@@ -120,6 +121,7 @@ export const createContentEditor = ({ ...@@ -120,6 +121,7 @@ export const createContentEditor = ({
MathInline, MathInline,
OrderedList, OrderedList,
Paragraph, Paragraph,
PasteMarkdown.configure({ renderMarkdown, eventHub }),
Reference, Reference,
Strike, Strike,
Subscript, Subscript,
......
import { VARIANT_DANGER } from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { extractFilename, readFileAsDataURL } from './utils'; import { extractFilename, readFileAsDataURL } from './utils';
...@@ -74,7 +75,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub ...@@ -74,7 +75,7 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub
editor.commands.deleteRange({ from: position, to: position + 1 }); editor.commands.deleteRange({ from: position, to: position + 1 });
eventHub.$emit('alert', { eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'), 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 ...@@ -105,7 +106,7 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eve
editor.commands.deleteRange({ from, to: from + 1 }); editor.commands.deleteRange({ from, to: from + 1 });
eventHub.$emit('alert', { eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'), message: __('An error occurred while uploading the file. Please try again.'),
variant: 'danger', variant: VARIANT_DANGER,
}); });
} }
}; };
......
...@@ -3999,6 +3999,9 @@ msgstr "" ...@@ -3999,6 +3999,9 @@ msgstr ""
msgid "An error occurred while parsing the file." msgid "An error occurred while parsing the file."
msgstr "" msgstr ""
msgid "An error occurred while pasting text in the editor. Please try again."
msgstr ""
msgid "An error occurred while removing epics." msgid "An error occurred while removing epics."
msgstr "" msgstr ""
......
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent } from '@tiptap/vue-2'; import { EditorContent } from '@tiptap/vue-2';
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditor from '~/content_editor/components/content_editor.vue'; import ContentEditor from '~/content_editor/components/content_editor.vue';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
...@@ -8,11 +6,7 @@ import ContentEditorProvider from '~/content_editor/components/content_editor_pr ...@@ -8,11 +6,7 @@ import ContentEditorProvider from '~/content_editor/components/content_editor_pr
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue'; import FormattingBubbleMenu from '~/content_editor/components/formatting_bubble_menu.vue';
import TopToolbar from '~/content_editor/components/top_toolbar.vue'; import TopToolbar from '~/content_editor/components/top_toolbar.vue';
import { import LoadingIndicator from '~/content_editor/components/loading_indicator.vue';
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import { emitEditorEvent } from '../test_utils'; import { emitEditorEvent } from '../test_utils';
jest.mock('~/emoji'); jest.mock('~/emoji');
...@@ -25,9 +19,6 @@ describe('ContentEditor', () => { ...@@ -25,9 +19,6 @@ describe('ContentEditor', () => {
const findEditorElement = () => wrapper.findByTestId('content-editor'); const findEditorElement = () => wrapper.findByTestId('content-editor');
const findEditorContent = () => wrapper.findComponent(EditorContent); const findEditorContent = () => wrapper.findComponent(EditorContent);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findBubbleMenu = () => wrapper.findComponent(FormattingBubbleMenu);
const createWrapper = (propsData = {}) => { const createWrapper = (propsData = {}) => {
renderMarkdown = jest.fn(); renderMarkdown = jest.fn();
...@@ -117,69 +108,15 @@ describe('ContentEditor', () => { ...@@ -117,69 +108,15 @@ describe('ContentEditor', () => {
expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true); expect(wrapper.findComponent(ContentEditorAlert).exists()).toBe(true);
}); });
describe('when loading content', () => { it('renders loading indicator component', () => {
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 () => {
createWrapper(); createWrapper();
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT); expect(wrapper.findComponent(LoadingIndicator).exists()).toBe(true);
await nextTick();
contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT);
await nextTick();
}); });
it('hides loading indicator', () => { it('renders formatting bubble menu', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('displays EditorContent component', () => {
expect(findEditorContent().exists()).toBe(true);
});
});
describe('when loading content fails', () => {
const error = 'error';
beforeEach(async () => {
createWrapper(); createWrapper();
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT); expect(wrapper.findComponent(FormattingBubbleMenu).exists()).toBe(true);
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);
});
}); });
}); });
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'; ...@@ -4,6 +4,7 @@ import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image'; import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link'; import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading'; import Loading from '~/content_editor/extensions/loading';
import { VARIANT_DANGER } from '~/flash';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import eventHubFactory from '~/helpers/event_hub_factory'; import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, createDocBuilder } from '../test_utils'; import { createTestEditor, createDocBuilder } from '../test_utils';
...@@ -168,7 +169,8 @@ describe('content_editor/extensions/attachment', () => { ...@@ -168,7 +169,8 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => { it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile }); 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.'); expect(message).toBe('An error occurred while uploading the image. Please try again.');
done(); done();
}); });
...@@ -244,7 +246,8 @@ describe('content_editor/extensions/attachment', () => { ...@@ -244,7 +246,8 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => { it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); 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.'); expect(message).toBe('An error occurred while uploading the file. Please try again.');
done(); 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 }) => { ...@@ -142,3 +142,23 @@ export const triggerMarkInputRule = ({ tiptapEditor, inputRuleText }) => {
f(view, selection.from, inputRuleText.length + 1, 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