Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-11-04 12:09:38 +00:00
parent 88741f7df9
commit f4c0eed6e5
8 changed files with 203 additions and 75 deletions

View File

@ -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 });

View File

@ -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,
);

View File

@ -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 }) {

View File

@ -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];
},
});

View File

@ -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`

View File

@ -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: {

View File

@ -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');
});
});

View File

@ -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']