diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/content_editor/services | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) | |
download | gitlab-ce-b76ae638462ab0f673e5915986070518dd3f9ad3.tar.gz |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
8 files changed, 400 insertions, 106 deletions
diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js deleted file mode 100644 index 75e2b0f9eba..00000000000 --- a/app/assets/javascripts/content_editor/services/build_serializer_config.js +++ /dev/null @@ -1,22 +0,0 @@ -const buildSerializerConfig = (extensions = []) => - extensions - .filter(({ serializer }) => serializer) - .reduce( - (serializers, { serializer, tiptapExtension: { name, type } }) => { - const collection = `${type}s`; - - return { - ...serializers, - [collection]: { - ...serializers[collection], - [name]: serializer, - }, - }; - }, - { - nodes: {}, - marks: {}, - }, - ); - -export default buildSerializerConfig; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 29553f4c2ca..a387322bff7 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,8 +1,11 @@ +import eventHubFactory from '~/helpers/event_hub_factory'; +import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants'; /* eslint-disable no-underscore-dangle */ export class ContentEditor { constructor({ tiptapEditor, serializer }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; + this._eventHub = eventHubFactory(); } get tiptapEditor() { @@ -16,12 +19,45 @@ export class ContentEditor { return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0); } + dispose() { + this.tiptapEditor.destroy(); + } + + once(type, handler) { + this._eventHub.$once(type, handler); + } + + on(type, handler) { + this._eventHub.$on(type, handler); + } + + emit(type, params = {}) { + this._eventHub.$emit(type, params); + } + + off(type, handler) { + this._eventHub.$off(type, handler); + } + + disposeAllEvents() { + this._eventHub.dispose(); + } + async setSerializedContent(serializedContent) { const { _tiptapEditor: editor, _serializer: serializer } = this; - editor.commands.setContent( - await serializer.deserialize({ schema: editor.schema, content: serializedContent }), - ); + try { + this._eventHub.$emit(LOADING_CONTENT_EVENT); + const document = await serializer.deserialize({ + schema: editor.schema, + content: serializedContent, + }); + editor.commands.setContent(document); + this._eventHub.$emit(LOADING_SUCCESS_EVENT); + } catch (e) { + this._eventHub.$emit(LOADING_ERROR_EVENT, e); + throw e; + } } getSerializedContent() { diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 9251fdbbdc5..8997960203a 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -1,38 +1,43 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; -import * as Blockquote from '../extensions/blockquote'; -import * as Bold from '../extensions/bold'; -import * as BulletList from '../extensions/bullet_list'; -import * as Code from '../extensions/code'; -import * as CodeBlockHighlight from '../extensions/code_block_highlight'; -import * as Document from '../extensions/document'; -import * as Dropcursor from '../extensions/dropcursor'; -import * as Gapcursor from '../extensions/gapcursor'; -import * as HardBreak from '../extensions/hard_break'; -import * as Heading from '../extensions/heading'; -import * as History from '../extensions/history'; -import * as HorizontalRule from '../extensions/horizontal_rule'; -import * as Image from '../extensions/image'; -import * as Italic from '../extensions/italic'; -import * as Link from '../extensions/link'; -import * as ListItem from '../extensions/list_item'; -import * as OrderedList from '../extensions/ordered_list'; -import * as Paragraph from '../extensions/paragraph'; -import * as Strike from '../extensions/strike'; -import * as Table from '../extensions/table'; -import * as TableCell from '../extensions/table_cell'; -import * as TableHeader from '../extensions/table_header'; -import * as TableRow from '../extensions/table_row'; -import * as Text from '../extensions/text'; -import buildSerializerConfig from './build_serializer_config'; +import Attachment from '../extensions/attachment'; +import Blockquote from '../extensions/blockquote'; +import Bold from '../extensions/bold'; +import BulletList from '../extensions/bullet_list'; +import Code from '../extensions/code'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Document from '../extensions/document'; +import Dropcursor from '../extensions/dropcursor'; +import Emoji from '../extensions/emoji'; +import Gapcursor from '../extensions/gapcursor'; +import HardBreak from '../extensions/hard_break'; +import Heading from '../extensions/heading'; +import History from '../extensions/history'; +import HorizontalRule from '../extensions/horizontal_rule'; +import Image from '../extensions/image'; +import InlineDiff from '../extensions/inline_diff'; +import Italic from '../extensions/italic'; +import Link from '../extensions/link'; +import ListItem from '../extensions/list_item'; +import Loading from '../extensions/loading'; +import OrderedList from '../extensions/ordered_list'; +import Paragraph from '../extensions/paragraph'; +import Reference from '../extensions/reference'; +import Strike from '../extensions/strike'; +import Subscript from '../extensions/subscript'; +import Superscript from '../extensions/superscript'; +import Table from '../extensions/table'; +import TableCell from '../extensions/table_cell'; +import TableHeader from '../extensions/table_header'; +import TableRow from '../extensions/table_row'; +import TaskItem from '../extensions/task_item'; +import TaskList from '../extensions/task_list'; +import Text from '../extensions/text'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; -const collectTiptapExtensions = (extensions = []) => - extensions.map(({ tiptapExtension }) => tiptapExtension); - const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ extensions: [...extensions], @@ -48,6 +53,7 @@ export const createContentEditor = ({ renderMarkdown, uploadsPath, extensions = [], + serializerConfig = { marks: {}, nodes: {} }, tiptapOptions, } = {}) => { if (!isFunction(renderMarkdown)) { @@ -55,6 +61,7 @@ export const createContentEditor = ({ } const builtInContentEditorExtensions = [ + Attachment.configure({ uploadsPath, renderMarkdown }), Blockquote, Bold, BulletList, @@ -62,29 +69,36 @@ export const createContentEditor = ({ CodeBlockHighlight, Document, Dropcursor, + Emoji, Gapcursor, HardBreak, Heading, History, HorizontalRule, - Image.configure({ uploadsPath, renderMarkdown }), + Image, + InlineDiff, Italic, Link, ListItem, + Loading, OrderedList, Paragraph, + Reference, Strike, + Subscript, + Superscript, TableCell, TableHeader, TableRow, Table, + TaskItem, + TaskList, Text, ]; const allExtensions = [...builtInContentEditorExtensions, ...extensions]; - const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts); - const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions }); - const serializerConfig = buildSerializerConfig(allExtensions); + const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); + const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig }); return new ContentEditor({ tiptapEditor, serializer }); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index f121cc9affd..df4d31c3d7f 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -1,5 +1,165 @@ -import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; +import { + MarkdownSerializer as ProseMirrorMarkdownSerializer, + defaultMarkdownSerializer, +} from 'prosemirror-markdown/src/to_markdown'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import Blockquote from '../extensions/blockquote'; +import Bold from '../extensions/bold'; +import BulletList from '../extensions/bullet_list'; +import Code from '../extensions/code'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Emoji from '../extensions/emoji'; +import HardBreak from '../extensions/hard_break'; +import Heading from '../extensions/heading'; +import HorizontalRule from '../extensions/horizontal_rule'; +import Image from '../extensions/image'; +import InlineDiff from '../extensions/inline_diff'; +import Italic from '../extensions/italic'; +import Link from '../extensions/link'; +import ListItem from '../extensions/list_item'; +import OrderedList from '../extensions/ordered_list'; +import Paragraph from '../extensions/paragraph'; +import Reference from '../extensions/reference'; +import Strike from '../extensions/strike'; +import Subscript from '../extensions/subscript'; +import Superscript from '../extensions/superscript'; +import Table from '../extensions/table'; +import TableCell from '../extensions/table_cell'; +import TableHeader from '../extensions/table_header'; +import TableRow from '../extensions/table_row'; +import TaskItem from '../extensions/task_item'; +import TaskList from '../extensions/task_list'; +import Text from '../extensions/text'; + +const defaultSerializerConfig = { + marks: { + [Bold.name]: defaultMarkdownSerializer.marks.strong, + [Code.name]: defaultMarkdownSerializer.marks.code, + [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, + [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true }, + [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true }, + [InlineDiff.name]: { + mixable: true, + open(state, mark) { + return mark.attrs.type === 'addition' ? '{+' : '{-'; + }, + close(state, mark) { + return mark.attrs.type === 'addition' ? '+}' : '-}'; + }, + }, + [Link.name]: { + open() { + return '['; + }, + close(state, mark) { + const href = mark.attrs.canonicalSrc || mark.attrs.href; + return `](${state.esc(href)}${ + mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : '' + })`; + }, + }, + [Strike.name]: { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }, + }, + nodes: { + [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote, + [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, + [CodeBlockHighlight.name]: (state, node) => { + state.write(`\`\`\`${node.attrs.language || ''}\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write('```'); + state.closeBlock(node); + }, + [Emoji.name]: (state, node) => { + const { name } = node.attrs; + + state.write(`:${name}:`); + }, + [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break, + [Heading.name]: defaultMarkdownSerializer.nodes.heading, + [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, + [Image.name]: (state, node) => { + const { alt, canonicalSrc, src, title } = node.attrs; + const quotedTitle = title ? ` ${state.quote(title)}` : ''; + + state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); + }, + [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, + [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list, + [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, + [Reference.name]: (state, node) => { + state.write(node.attrs.originalText || node.attrs.text); + }, + [Table.name]: (state, node) => { + state.renderContent(node); + }, + [TableCell.name]: (state, node) => { + state.renderInline(node); + }, + [TableHeader.name]: (state, node) => { + state.renderInline(node); + }, + [TableRow.name]: (state, node) => { + const isHeaderRow = node.child(0).type.name === 'tableHeader'; + + const renderRow = () => { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + return cellWidths; + }; + + const renderHeaderRow = (cellWidths) => { + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === 'center' ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); + }; + + if (isHeaderRow) { + renderHeaderRow(renderRow()); + } else { + renderRow(); + } + }, + [TaskItem.name]: (state, node) => { + state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + state.renderContent(node); + }, + [TaskList.name]: (state, node) => { + if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node); + else defaultMarkdownSerializer.nodes.ordered_list(state, node); + }, + [Text.name]: defaultMarkdownSerializer.nodes.text, + }, +}; const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; @@ -50,8 +210,16 @@ export default ({ render = () => null, serializerConfig }) => ({ */ serialize: ({ schema, content }) => { const proseMirrorDocument = schema.nodeFromJSON(content); - const { nodes, marks } = serializerConfig; - const serializer = new ProseMirrorMarkdownSerializer(nodes, marks); + const serializer = new ProseMirrorMarkdownSerializer( + { + ...defaultSerializerConfig.nodes, + ...serializerConfig.nodes, + }, + { + ...defaultSerializerConfig.marks, + ...serializerConfig.marks, + }, + ); return serializer.serialize(proseMirrorDocument, { tightLists: true, diff --git a/app/assets/javascripts/content_editor/services/track_ui_control.js b/app/assets/javascripts/content_editor/services/track_ui_control.js new file mode 100644 index 00000000000..61f130ea861 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/track_ui_control.js @@ -0,0 +1,9 @@ +import Tracking from '~/tracking'; +import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants'; + +export default ({ action = TOOLBAR_CONTROL_TRACKING_ACTION, property, value } = {}) => + Tracking.event(undefined, action, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property, + value, + }); diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js deleted file mode 100644 index 613c53144a1..00000000000 --- a/app/assets/javascripts/content_editor/services/upload_file.js +++ /dev/null @@ -1,44 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -const extractAttachmentLinkUrl = (html) => { - const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); - const link = body.querySelector('a'); - const src = link.getAttribute('href'); - const { canonicalSrc } = link.dataset; - - return { src, canonicalSrc }; -}; - -/** - * Uploads a file with a post request to the URL indicated - * in the uploadsPath parameter. The expected response of the - * uploads service is a JSON object that contains, at least, a - * link property. The link property should contain markdown link - * definition (i.e. [GitLab](https://gitlab.com)). - * - * This Markdown will be rendered to extract its canonical and full - * URLs using GitLab Flavored Markdown renderer in the backend. - * - * @param {Object} params - * @param {String} params.uploadsPath An absolute URL that points to a service - * that allows sending a file for uploading via POST request. - * @param {String} params.renderMarkdown A function that accepts a markdown string - * and returns a rendered version in HTML format. - * @param {File} params.file The file to upload - * - * @returns Returns an object with two properties: - * - * canonicalSrc: The URL as defined in the Markdown - * src: The absolute URL that points to the resource in the server - */ -export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { - const formData = new FormData(); - formData.append('file', file, file.name); - - const { data } = await axios.post(uploadsPath, formData); - const { markdown } = data.link; - const rendered = await renderMarkdown(markdown); - - return extractAttachmentLinkUrl(rendered); -}; diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js new file mode 100644 index 00000000000..8ac3f719309 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -0,0 +1,123 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { extractFilename, readFileAsDataURL } from './utils'; + +export const acceptedMimes = { + image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], +}; + +const extractAttachmentLinkUrl = (html) => { + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + const link = body.querySelector('a'); + const src = link.getAttribute('href'); + const { canonicalSrc } = link.dataset; + + return { src, canonicalSrc }; +}; + +/** + * Uploads a file with a post request to the URL indicated + * in the uploadsPath parameter. The expected response of the + * uploads service is a JSON object that contains, at least, a + * link property. The link property should contain markdown link + * definition (i.e. [GitLab](https://gitlab.com)). + * + * This Markdown will be rendered to extract its canonical and full + * URLs using GitLab Flavored Markdown renderer in the backend. + * + * @param {Object} params + * @param {String} params.uploadsPath An absolute URL that points to a service + * that allows sending a file for uploading via POST request. + * @param {String} params.renderMarkdown A function that accepts a markdown string + * and returns a rendered version in HTML format. + * @param {File} params.file The file to upload + * + * @returns Returns an object with two properties: + * + * canonicalSrc: The URL as defined in the Markdown + * src: The absolute URL that points to the resource in the server + */ +export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => { + const formData = new FormData(); + formData.append('file', file, file.name); + + const { data } = await axios.post(uploadsPath, formData); + const { markdown } = data.link; + const rendered = await renderMarkdown(markdown); + + return extractAttachmentLinkUrl(rendered); +}; + +const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { + const encodedSrc = await readFileAsDataURL(file); + const { view } = editor; + + editor.commands.setImage({ uploading: true, src: encodedSrc }); + + const { state } = view; + const position = state.selection.from - 1; + const { tr } = state; + + try { + const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); + + view.dispatch( + tr.setNodeMarkup(position, undefined, { + uploading: false, + src: encodedSrc, + alt: extractFilename(src), + canonicalSrc, + }), + ); + } catch (e) { + editor.commands.deleteRange({ from: position, to: position + 1 }); + editor.emit('error', { + error: __('An error occurred while uploading the image. Please try again.'), + }); + } +}; + +const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => { + await Promise.resolve(); + + const { view } = editor; + + const text = extractFilename(file.name); + + const { state } = view; + const { from } = state.selection; + + editor.commands.insertContent({ + type: 'loading', + attrs: { label: text }, + }); + + try { + const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); + + editor.commands.insertContentAt( + { from, to: from + 1 }, + { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] }, + ); + } catch (e) { + editor.commands.deleteRange({ from, to: from + 1 }); + editor.emit('error', { + error: __('An error occurred while uploading the file. Please try again.'), + }); + } +}; + +export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { + if (!file) return false; + + if (acceptedMimes.image.includes(file?.type)) { + uploadImage({ editor, file, uploadsPath, renderMarkdown }); + + return true; + } + + uploadAttachment({ editor, file, uploadsPath, renderMarkdown }); + + return true; +}; diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index 2a2c7f617da..b3856b0dd74 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -4,8 +4,18 @@ export const hasSelection = (tiptapEditor) => { return from < to; }; -export const getImageAlt = (src) => { - return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' '); +/** + * Extracts filename from a URL + * + * @example + * > extractFilename('https://gitlab.com/images/logo-full.png') + * < 'logo-full' + * + * @param {string} src The URL to extract filename from + * @returns {string} + */ +export const extractFilename = (src) => { + return src.replace(/^.*\/|\..+?$/g, ''); }; export const readFileAsDataURL = (file) => { |