Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
88741f7df9
commit
f4c0eed6e5
|
|
@ -81,4 +81,13 @@ export default CodeBlockLowlight.extend({
|
|||
addNodeView() {
|
||||
return new VueNodeViewRenderer(CodeBlockWrapper);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const parentPlugins = this.parent?.() ?? [];
|
||||
// We don't want TipTap's VSCode paste plugin to be loaded since
|
||||
// it conflicts with our CopyPaste plugin.
|
||||
const i = parentPlugins.findIndex((plugin) => plugin.key.includes('VSCode'));
|
||||
if (i >= 0) parentPlugins.splice(i, 1);
|
||||
return parentPlugins;
|
||||
},
|
||||
}).configure({ lowlight });
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import CodeBlockHighlight from './code_block_highlight';
|
|||
import CodeSuggestion from './code_suggestion';
|
||||
import Diagram from './diagram';
|
||||
import Frontmatter from './frontmatter';
|
||||
import { loadingPlugin, findLoader } from './loading';
|
||||
|
||||
const TEXT_FORMAT = 'text/plain';
|
||||
const GFM_FORMAT = 'text/x-gfm';
|
||||
|
|
@ -31,21 +32,6 @@ function parseHTML(schema, html) {
|
|||
return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) };
|
||||
}
|
||||
|
||||
const findLoader = (editor, loaderId) => {
|
||||
let position;
|
||||
|
||||
editor.view.state.doc.descendants((descendant, pos) => {
|
||||
if (descendant.type.name === 'loading' && descendant.attrs.id === loaderId) {
|
||||
position = pos;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return position;
|
||||
};
|
||||
|
||||
export default Extension.create({
|
||||
name: 'copyPaste',
|
||||
priority: EXTENSION_PRIORITY_HIGHEST,
|
||||
|
|
@ -74,13 +60,20 @@ export default Extension.create({
|
|||
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
editor.commands.insertContent({ type: 'loading', attrs: { id: loaderId } });
|
||||
editor
|
||||
.chain()
|
||||
.deleteSelection()
|
||||
.setMeta(loadingPlugin, {
|
||||
add: { loaderId, pos: editor.state.selection.from },
|
||||
})
|
||||
.run();
|
||||
|
||||
return promise;
|
||||
})
|
||||
.then(async ({ document }) => {
|
||||
if (!document) return;
|
||||
|
||||
const pos = findLoader(editor, loaderId);
|
||||
const pos = findLoader(editor.state, loaderId);
|
||||
if (!pos) return;
|
||||
|
||||
const { firstChild, childCount } = document.content;
|
||||
|
|
@ -91,7 +84,7 @@ export default Extension.create({
|
|||
|
||||
editor
|
||||
.chain()
|
||||
.deleteRange({ from: pos, to: pos + 1 })
|
||||
.setMeta(loadingPlugin, { remove: { loaderId } })
|
||||
.insertContentAt(pos, toPaste.toJSON(), {
|
||||
updateSelection: false,
|
||||
})
|
||||
|
|
@ -114,6 +107,7 @@ export default Extension.create({
|
|||
const handleCutAndCopy = (view, event) => {
|
||||
const slice = view.state.selection.content();
|
||||
const gfmContent = this.options.serializer.serialize({ doc: slice.content });
|
||||
|
||||
const documentFragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment(
|
||||
slice.content,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export default marks.map((name) =>
|
|||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }];
|
||||
const tag = name === 'span' ? `${name}:not([data-escaped-char])` : name;
|
||||
return [{ tag, priority: PARSE_HTML_PRIORITY_LOWEST }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,52 @@
|
|||
import { Node } from '@tiptap/core';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { Plugin } from '@tiptap/pm/state';
|
||||
|
||||
const createDotsLoader = () => {
|
||||
const root = document.createElement('span');
|
||||
root.classList.add('gl-display-inline-flex', 'gl-align-items-center');
|
||||
root.innerHTML = '<span class="gl-dots-loader gl-mx-2"><span></span></span>';
|
||||
return root;
|
||||
};
|
||||
|
||||
export const loadingPlugin = new Plugin({
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
let transformedSet = set.map(tr.mapping, tr.doc);
|
||||
const action = tr.getMeta(this);
|
||||
|
||||
if (action?.add) {
|
||||
const deco = Decoration.widget(action.add.pos, createDotsLoader(), {
|
||||
id: action.add.loaderId,
|
||||
side: -1,
|
||||
});
|
||||
transformedSet = transformedSet.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
transformedSet = transformedSet.remove(
|
||||
transformedSet.find(null, null, (spec) => spec.id === action.remove.loaderId),
|
||||
);
|
||||
}
|
||||
return transformedSet;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const findLoader = (state, loaderId) => {
|
||||
const decos = loadingPlugin.getState(state);
|
||||
const found = decos.find(null, null, (spec) => spec.id === loaderId);
|
||||
|
||||
return found.length ? found[0].from : null;
|
||||
};
|
||||
|
||||
export const findAllLoaders = (state) => loadingPlugin.getState(state).find();
|
||||
|
||||
export default Node.create({
|
||||
name: 'loading',
|
||||
|
|
@ -13,11 +61,7 @@ export default Node.create({
|
|||
};
|
||||
},
|
||||
|
||||
renderHTML() {
|
||||
return [
|
||||
'span',
|
||||
{ class: 'gl-display-inline-flex gl-align-items-center' },
|
||||
['span', { class: 'gl-dots-loader gl-mx-2' }, ['span']],
|
||||
];
|
||||
addProseMirrorPlugins() {
|
||||
return [loadingPlugin];
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ describe('content_editor/extensions/code_block_highlight', () => {
|
|||
|
||||
expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
|
||||
});
|
||||
|
||||
it('includes the lowlight plugin', () => {
|
||||
expect(tiptapEditor.state.plugins).toContainEqual(
|
||||
expect.objectContaining({ key: expect.stringContaining('lowlight') }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not include the VSCode paste plugin', () => {
|
||||
expect(tiptapEditor.state.plugins).not.toContainEqual(
|
||||
expect.objectContaining({ key: expect.stringContaining('VSCode') }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import CopyPaste from '~/content_editor/extensions/copy_paste';
|
||||
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
|
||||
import Loading from '~/content_editor/extensions/loading';
|
||||
import Loading, { findAllLoaders } from '~/content_editor/extensions/loading';
|
||||
import Diagram from '~/content_editor/extensions/diagram';
|
||||
import Frontmatter from '~/content_editor/extensions/frontmatter';
|
||||
import Selection from '~/content_editor/extensions/selection';
|
||||
import Heading from '~/content_editor/extensions/heading';
|
||||
import Bold from '~/content_editor/extensions/bold';
|
||||
import BulletList from '~/content_editor/extensions/bullet_list';
|
||||
|
|
@ -13,12 +14,7 @@ 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,
|
||||
sleep,
|
||||
} from '../test_utils';
|
||||
import { createTestEditor, createDocBuilder, waitUntilNextDocTransaction } from '../test_utils';
|
||||
|
||||
const CODE_BLOCK_HTML = '<pre class="js-syntax-highlight" lang="javascript">var a = 2;</pre>';
|
||||
const CODE_SUGGESTION_HTML =
|
||||
|
|
@ -35,13 +31,11 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
let p;
|
||||
let bold;
|
||||
let italic;
|
||||
let loading;
|
||||
let heading;
|
||||
let codeBlock;
|
||||
let bulletList;
|
||||
let listItem;
|
||||
let renderMarkdown;
|
||||
let resolveRenderMarkdownPromise;
|
||||
let resolveRenderMarkdownPromiseAndWait;
|
||||
|
||||
let eventHub;
|
||||
|
|
@ -52,7 +46,6 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
renderMarkdown = jest.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRenderMarkdownPromise = resolve;
|
||||
resolveRenderMarkdownPromiseAndWait = (data) =>
|
||||
waitUntilNextDocTransaction({ tiptapEditor, action: () => resolve(data) });
|
||||
}),
|
||||
|
|
@ -65,6 +58,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
Bold,
|
||||
Italic,
|
||||
Loading,
|
||||
Selection,
|
||||
CodeBlockHighlight,
|
||||
Diagram,
|
||||
Frontmatter,
|
||||
|
|
@ -76,17 +70,18 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
});
|
||||
|
||||
({
|
||||
builders: { doc, p, bold, italic, heading, loading, codeBlock, bulletList, listItem },
|
||||
builders: { doc, p, bold, italic, heading, codeBlock, bulletList, listItem },
|
||||
} = createDocBuilder({
|
||||
tiptapEditor,
|
||||
names: {
|
||||
bold: { markType: Bold.name },
|
||||
italic: { markType: Italic.name },
|
||||
loading: { nodeType: Loading.name },
|
||||
heading: { nodeType: Heading.name },
|
||||
bulletList: { nodeType: BulletList.name },
|
||||
listItem: { nodeType: ListItem.name },
|
||||
codeBlock: { nodeType: CodeBlockHighlight.name },
|
||||
diagram: { nodeType: Diagram.name },
|
||||
frontmatter: { nodeType: Frontmatter.name },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
@ -110,17 +105,6 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
});
|
||||
};
|
||||
|
||||
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'}
|
||||
|
|
@ -185,36 +169,22 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
|
||||
describe('when pasting raw markdown source', () => {
|
||||
it('shows a loading indicator while markdown is being processed', async () => {
|
||||
const expectedDoc = doc(p(loading({ id: expect.any(String) })));
|
||||
await triggerPasteEventHandler(buildClipboardEvent());
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
expect(findAllLoaders(tiptapEditor.state)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('pastes in the correct position if some content is added before the markdown is processed', async () => {
|
||||
const expectedDoc = doc(p(bold('some markdown'), 'some content'));
|
||||
const resolvedValue = '<strong>some markdown</strong>';
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
await triggerPasteEventHandler(buildClipboardEvent());
|
||||
|
||||
tiptapEditor.commands.insertContent('some content');
|
||||
|
||||
await resolveRenderMarkdownPromiseAndWait(resolvedValue);
|
||||
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
expect(tiptapEditor.state.selection.from).toEqual(26); // end of the document
|
||||
});
|
||||
|
||||
it('does not paste anything if the loading indicator is deleted before the markdown is processed', async () => {
|
||||
const expectedDoc = doc(p());
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
tiptapEditor.chain().selectAll().deleteSelection().run();
|
||||
resolveRenderMarkdownPromise('some markdown');
|
||||
|
||||
// wait some time to be sure no transaction happened
|
||||
await sleep();
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
});
|
||||
|
||||
describe('when rendering markdown succeeds', () => {
|
||||
|
|
@ -227,7 +197,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
it('transforms pasted text into a prosemirror node', async () => {
|
||||
const expectedDoc = doc(p(bold('bold text')));
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
await triggerPasteEventHandler(buildClipboardEvent());
|
||||
await resolveRenderMarkdownPromiseAndWait(resolvedValue);
|
||||
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
|
|
@ -239,7 +209,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
|
||||
tiptapEditor.commands.setContent('Initial text and ');
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
await triggerPasteEventHandler(buildClipboardEvent());
|
||||
await resolveRenderMarkdownPromiseAndWait(resolvedValue);
|
||||
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
|
|
@ -253,7 +223,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
tiptapEditor.commands.setContent('Initial text and ');
|
||||
tiptapEditor.commands.setTextSelection({ from: 13, to: 17 });
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
await triggerPasteEventHandler(buildClipboardEvent());
|
||||
await resolveRenderMarkdownPromiseAndWait(resolvedValue);
|
||||
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
|
|
@ -274,7 +244,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
|
||||
tiptapEditor.commands.setContent('Initial text and ');
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(buildClipboardEvent());
|
||||
await triggerPasteEventHandler(buildClipboardEvent());
|
||||
await resolveRenderMarkdownPromiseAndWait(resolvedValue);
|
||||
|
||||
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
|
||||
|
|
@ -289,7 +259,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
|
||||
const expectedDoc = doc(p(bold('bold text')), p('some code'));
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(
|
||||
await triggerPasteEventHandler(
|
||||
buildClipboardEvent({
|
||||
types: ['text/html'],
|
||||
data: {
|
||||
|
|
@ -309,7 +279,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
const resolvedValue = '<strong>bold text</strong>';
|
||||
const expectedDoc = doc(p(bold('bold text')));
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(
|
||||
await triggerPasteEventHandler(
|
||||
buildClipboardEvent({
|
||||
types: ['text/x-gfm', 'text/plain', 'text/html'],
|
||||
data: {
|
||||
|
|
@ -332,7 +302,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
bulletList(listItem(p('Cat')), listItem(p('Dog')), listItem(p('Turtle'))),
|
||||
);
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(
|
||||
await triggerPasteEventHandler(
|
||||
buildClipboardEvent({
|
||||
types: ['text/plain', 'text/html'],
|
||||
data: {
|
||||
|
|
@ -359,7 +329,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
),
|
||||
);
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(
|
||||
await triggerPasteEventHandler(
|
||||
buildClipboardEvent({
|
||||
types: ['vscode-editor-data', 'text/plain', 'text/html'],
|
||||
data: {
|
||||
|
|
@ -380,7 +350,7 @@ describe('content_editor/extensions/copy_paste', () => {
|
|||
|
||||
const expectedDoc = doc(p(bold('bold text')));
|
||||
|
||||
await triggerPasteEventHandlerAndWaitForTransaction(
|
||||
await triggerPasteEventHandler(
|
||||
buildClipboardEvent({
|
||||
types: ['vscode-editor-data', 'text/plain', 'text/html'],
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import HTMLMarks from '~/content_editor/extensions/html_marks';
|
||||
import { createTestEditor, createDocBuilder } from '../test_utils';
|
||||
|
||||
describe('content_editor/extensions/html_marks', () => {
|
||||
let tiptapEditor;
|
||||
let doc;
|
||||
let ins;
|
||||
let abbr;
|
||||
let bdo;
|
||||
let cite;
|
||||
let dfn;
|
||||
let small;
|
||||
let span;
|
||||
let time;
|
||||
let kbd;
|
||||
let q;
|
||||
let p;
|
||||
let samp;
|
||||
let varMark;
|
||||
let ruby;
|
||||
let rp;
|
||||
let rt;
|
||||
|
||||
beforeEach(() => {
|
||||
tiptapEditor = createTestEditor({ extensions: [...HTMLMarks] });
|
||||
|
||||
({
|
||||
builders: {
|
||||
doc,
|
||||
ins,
|
||||
abbr,
|
||||
bdo,
|
||||
cite,
|
||||
dfn,
|
||||
small,
|
||||
span,
|
||||
time,
|
||||
kbd,
|
||||
q,
|
||||
samp,
|
||||
var: varMark,
|
||||
ruby,
|
||||
rp,
|
||||
rt,
|
||||
p,
|
||||
},
|
||||
} = createDocBuilder({
|
||||
tiptapEditor,
|
||||
names: {
|
||||
...HTMLMarks.reduce(
|
||||
(builders, htmlMark) => ({
|
||||
...builders,
|
||||
[htmlMark.name]: { markType: htmlMark.name },
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it.each`
|
||||
input | expectedContent
|
||||
${'<ins>inserted</ins>'} | ${() => ins('inserted')}
|
||||
${'<abbr title="abbr">abbreviation</abbr>'} | ${() => abbr({ title: 'abbr' }, 'abbreviation')}
|
||||
${'<bdo dir="rtl">bdo</bdo>'} | ${() => bdo({ dir: 'rtl' }, 'bdo')}
|
||||
${'<cite>citation</cite>'} | ${() => cite('citation')}
|
||||
${'<dfn>definition</dfn>'} | ${() => dfn('definition')}
|
||||
${'<small>small text</small>'} | ${() => small('small text')}
|
||||
${'<span dir="rtl">span text</span>'} | ${() => span({ dir: 'rtl' }, 'span text')}
|
||||
${'<time datetime="2023-11-02">November 2, 2023</time>'} | ${() => time({ datetime: '2023-11-02' }, 'November 2, 2023')}
|
||||
${'<kbd>keyboard</kbd>'} | ${() => kbd('keyboard')}
|
||||
${'<q>quote</q>'} | ${() => q('quote')}
|
||||
${'<samp>sample</samp>'} | ${() => samp('sample')}
|
||||
${'<var>variable</var>'} | ${() => varMark('variable')}
|
||||
${'<ruby>base<rp>(</rp><rt>ruby</rt><rp>)</rp></ruby>'} | ${() => ruby('base', rp('('), rt('ruby'), rp(')'))}
|
||||
`('parses and creates marks for $input', ({ input, expectedContent }) => {
|
||||
tiptapEditor.commands.setContent(input);
|
||||
expect(tiptapEditor.getJSON()).toEqual(doc(p(expectedContent())).toJSON());
|
||||
expect(tiptapEditor.getHTML()).toContain(input);
|
||||
});
|
||||
|
||||
it('does not parse an element with a data-escaped-char attribute', () => {
|
||||
const input = '<span data-escaped-char>#</span> not a heading';
|
||||
const expectedDoc = doc(p('# not a heading'));
|
||||
tiptapEditor.commands.setContent(input);
|
||||
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
|
||||
expect(tiptapEditor.getHTML()).not.toContain('<span');
|
||||
});
|
||||
});
|
||||
|
|
@ -466,6 +466,15 @@ RSpec.shared_examples 'edits content using the content editor' do |params = {
|
|||
end
|
||||
end
|
||||
|
||||
it 'does not show a loading indicator after undo paste' do
|
||||
type_in_content_editor [modifier_key, 'v']
|
||||
type_in_content_editor [modifier_key, 'z']
|
||||
|
||||
page.within content_editor_testid do
|
||||
expect(page).not_to have_css('.gl-dots-loader')
|
||||
end
|
||||
end
|
||||
|
||||
it 'pastes raw text without formatting if shift + ctrl + v is pressed' do
|
||||
type_in_content_editor [modifier_key, :shift, 'v']
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue