diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-04 15:09:38 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-04 15:09:38 +0300 |
commit | f4c0eed6e59f393302ccc38468063cb5c4698f79 (patch) | |
tree | c7b97ac78c073ccc052d611abdd294b6c879a996 | |
parent | 88741f7df96173e80dea5e67ea9946e766935849 (diff) |
Add latest changes from gitlab-org/gitlab@master
8 files changed, 203 insertions, 75 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 8917417e55e..da5ac7eb158 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -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 }); diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index ab9e5619600..3ae5ef0025d 100644 --- a/app/assets/javascripts/content_editor/extensions/copy_paste.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -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, ); diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js index 79fc0eea2c7..58fa2655e25 100644 --- a/app/assets/javascripts/content_editor/extensions/html_marks.js +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -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 }) { diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js index 0115fb10d5d..942ac650925 100644 --- a/app/assets/javascripts/content_editor/extensions/loading.js +++ b/app/assets/javascripts/content_editor/extensions/loading.js @@ -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]; }, }); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index fc8460c7f84..6f0c0ee6ed5 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -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` diff --git a/spec/frontend/content_editor/extensions/copy_paste_spec.js b/spec/frontend/content_editor/extensions/copy_paste_spec.js index 4729b1c1223..49839db44cf 100644 --- a/spec/frontend/content_editor/extensions/copy_paste_spec.js +++ b/spec/frontend/content_editor/extensions/copy_paste_spec.js @@ -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,35 +169,21 @@ 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'); + await resolveRenderMarkdownPromiseAndWait(resolvedValue); - // wait some time to be sure no transaction happened - await sleep(); expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); }); @@ -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: { diff --git a/spec/frontend/content_editor/extensions/html_marks_spec.js b/spec/frontend/content_editor/extensions/html_marks_spec.js new file mode 100644 index 00000000000..3757962ce52 --- /dev/null +++ b/spec/frontend/content_editor/extensions/html_marks_spec.js @@ -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'); + }); +}); diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 5271b5bd45c..6bfb60c3f34 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -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'] |