diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 12:55:51 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-07-20 12:55:51 +0300 |
commit | e8d2c2579383897a1dd7f9debd359abe8ae8373d (patch) | |
tree | c42be41678c2586d49a75cabce89322082698334 /app/assets/javascripts/content_editor | |
parent | fc845b37ec3a90aaa719975f607740c22ba6a113 (diff) |
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
17 files changed, 637 insertions, 45 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index c6ab2e189ef..9a51def7075 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,10 +1,12 @@ <script> +import { GlAlert } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; import { ContentEditor } from '../services/content_editor'; import TopToolbar from './top_toolbar.vue'; export default { components: { + GlAlert, TiptapEditorContent, TopToolbar, }, @@ -14,15 +16,30 @@ export default { required: true, }, }, + data() { + return { + error: '', + }; + }, + mounted() { + this.contentEditor.tiptapEditor.on('error', (error) => { + this.error = error; + }); + }, }; </script> <template> - <div - data-testid="content-editor" - class="md-area" - :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" - > - <top-toolbar class="gl-mb-4" :content-editor="contentEditor" /> - <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> + <div> + <gl-alert v-if="error" class="gl-mb-6" variant="danger" @dismiss="error = ''"> + {{ error }} + </gl-alert> + <div + data-testid="content-editor" + class="md-area" + :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }" + > + <top-toolbar ref="toolbar" class="gl-mb-4" :content-editor="contentEditor" /> + <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> + </div> </div> </template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue new file mode 100644 index 00000000000..ebeee16dbec --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue @@ -0,0 +1,110 @@ +<script> +import { + GlDropdown, + GlDropdownForm, + GlButton, + GlFormInputGroup, + GlDropdownDivider, + GlDropdownItem, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import { acceptedMimes } from '../extensions/image'; +import { getImageAlt } from '../services/utils'; + +export default { + components: { + GlDropdown, + GlDropdownForm, + GlFormInputGroup, + GlDropdownDivider, + GlDropdownItem, + GlButton, + }, + directives: { + GlTooltip, + }, + props: { + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + }, + data() { + return { + imgSrc: '', + }; + }, + methods: { + resetFields() { + this.imgSrc = ''; + this.$refs.fileSelector.value = ''; + }, + insertImage() { + this.tiptapEditor + .chain() + .focus() + .setImage({ + src: this.imgSrc, + canonicalSrc: this.imgSrc, + alt: getImageAlt(this.imgSrc), + }) + .run(); + + this.resetFields(); + this.emitExecute(); + }, + emitExecute(source = 'url') { + this.$emit('execute', { contentType: 'image', value: source }); + }, + openFileUpload() { + this.$refs.fileSelector.click(); + }, + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .uploadImage({ + file: e.target.files[0], + }) + .run(); + + this.resetFields(); + this.emitExecute('upload'); + }, + }, + acceptedMimes, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip + :aria-label="__('Insert image')" + :title="__('Insert image')" + size="small" + category="tertiary" + icon="media" + @hidden="resetFields()" + > + <gl-dropdown-form class="gl-px-3!"> + <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')"> + <template #append> + <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button> + </template> + </gl-form-input-group> + </gl-dropdown-form> + <gl-dropdown-divider /> + <gl-dropdown-item @click="openFileUpload"> + {{ __('Upload image') }} + </gl-dropdown-item> + + <input + ref="fileSelector" + type="file" + name="content_editor_image" + :accept="$options.acceptedMimes" + class="gl-display-none" + @change="onFileSelect" + /> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue index f706080eaa1..8f57959a73f 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue @@ -43,14 +43,22 @@ export default { }, mounted() { this.tiptapEditor.on('selectionUpdate', ({ editor }) => { - const { href } = editor.getAttributes(linkContentType); + const { canonicalSrc, href } = editor.getAttributes(linkContentType); - this.linkHref = href; + this.linkHref = canonicalSrc || href; }); }, methods: { updateLink() { - this.tiptapEditor.chain().focus().unsetLink().setLink({ href: this.linkHref }).run(); + this.tiptapEditor + .chain() + .focus() + .unsetLink() + .setLink({ + href: this.linkHref, + canonicalSrc: this.linkHref, + }) + .run(); this.$emit('execute', { contentType: linkContentType }); }, diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue new file mode 100644 index 00000000000..49d3006e9bf --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -0,0 +1,91 @@ +<script> +import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; +import { __, sprintf } from '~/locale'; +import { clamp } from '../services/utils'; + +export const tableContentType = 'table'; + +const MIN_ROWS = 3; +const MIN_COLS = 3; +const MAX_ROWS = 8; +const MAX_COLS = 8; + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownForm, + GlButton, + }, + props: { + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + }, + data() { + return { + maxRows: MIN_ROWS, + maxCols: MIN_COLS, + rows: 1, + cols: 1, + }; + }, + methods: { + list(n) { + return new Array(n).fill().map((_, i) => i + 1); + }, + setRowsAndCols(rows, cols) { + this.rows = rows; + this.cols = cols; + this.maxRows = clamp(rows + 1, MIN_ROWS, MAX_ROWS); + this.maxCols = clamp(cols + 1, MIN_COLS, MAX_COLS); + }, + resetState() { + this.rows = 1; + this.cols = 1; + }, + insertTable() { + this.tiptapEditor + .chain() + .focus() + .insertTable({ + rows: this.rows, + cols: this.cols, + withHeaderRow: true, + }) + .run(); + + this.resetState(); + + this.$emit('execute', { contentType: 'table' }); + }, + getButtonLabel(rows, cols) { + return sprintf(__('Insert a %{rows}x%{cols} table.'), { rows, cols }); + }, + }, +}; +</script> +<template> + <gl-dropdown size="small" category="tertiary" icon="table"> + <gl-dropdown-form class="gl-px-3! gl-w-auto!"> + <div class="gl-w-auto!"> + <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex"> + <gl-button + v-for="c of list(maxCols)" + :key="c" + :data-testid="`table-${r}-${c}`" + :class="{ 'gl-bg-blue-50!': r <= rows && c <= cols }" + :aria-label="getButtonLabel(r, c)" + class="gl-display-inline! gl-px-0! gl-w-5! gl-h-5! gl-rounded-0!" + @mouseover="setRowsAndCols(r, c)" + @click="insertTable()" + /> + </div> + <gl-dropdown-divider /> + {{ getButtonLabel(rows, cols) }} + </div> + </gl-dropdown-form> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index d3363ce092b..fafc7a660e7 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -4,7 +4,9 @@ import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from ' import { ContentEditor } from '../services/content_editor'; import Divider from './divider.vue'; import ToolbarButton from './toolbar_button.vue'; +import ToolbarImageButton from './toolbar_image_button.vue'; import ToolbarLinkButton from './toolbar_link_button.vue'; +import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; const trackingMixin = Tracking.mixin({ @@ -16,6 +18,8 @@ export default { ToolbarButton, ToolbarTextStyleDropdown, ToolbarLinkButton, + ToolbarTableButton, + ToolbarImageButton, Divider, }, mixins: [trackingMixin], @@ -87,6 +91,12 @@ export default { @execute="trackToolbarControlExecution" /> <divider /> + <toolbar-image-button + ref="imageButton" + data-testid="image" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> <toolbar-button data-testid="blockquote" content-type="blockquote" @@ -123,5 +133,23 @@ export default { :tiptap-editor="contentEditor.tiptapEditor" @execute="trackToolbarControlExecution" /> + <toolbar-button + data-testid="horizontal-rule" + content-type="horizontalRule" + icon-name="dash" + editor-command="setHorizontalRule" + :label="__('Add a horizontal rule')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-table-button + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> </div> </template> +<style> +.gl-spinner-container { + text-align: left; +} +</style> diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue new file mode 100644 index 00000000000..3762324a431 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue @@ -0,0 +1,31 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; + +export default { + name: 'ImageWrapper', + components: { + NodeViewWrapper, + GlLoadingIcon, + }, + props: { + node: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <span class="gl-relative"> + <img + data-testid="image" + class="gl-max-w-full gl-h-auto" + :class="{ 'gl-opacity-5': node.attrs.uploading }" + :src="node.attrs.src" + /> + <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> + </span> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js index dc1ba431151..756eefa875c 100644 --- a/app/assets/javascripts/content_editor/extensions/hard_break.js +++ b/app/assets/javascripts/content_editor/extensions/hard_break.js @@ -1,5 +1,13 @@ import { HardBreak } from '@tiptap/extension-hard-break'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -export const tiptapExtension = HardBreak; +const ExtendedHardBreak = HardBreak.extend({ + addKeyboardShortcuts() { + return { + 'Shift-Enter': () => this.editor.commands.setHardBreak(), + }; + }, +}); + +export const tiptapExtension = ExtendedHardBreak; export const serializer = defaultMarkdownSerializer.nodes.hard_break; diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js index dcc59476518..c287938af5c 100644 --- a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js +++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js @@ -1,5 +1,12 @@ +import { nodeInputRule } from '@tiptap/core'; import { HorizontalRule } from '@tiptap/extension-horizontal-rule'; import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -export const tiptapExtension = HorizontalRule; +export const hrInputRuleRegExp = /^---$/; + +export const tiptapExtension = HorizontalRule.extend({ + addInputRules() { + return [nodeInputRule(hrInputRuleRegExp, this.type)]; + }, +}); export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 287216e68d5..4dd8a1376ad 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,10 +1,65 @@ import { Image } from '@tiptap/extension-image'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { __ } from '~/locale'; +import ImageWrapper from '../components/wrappers/image.vue'; +import { uploadFile } from '../services/upload_file'; +import { getImageAlt, readFileAsDataURL } from '../services/utils'; + +export const acceptedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/jpg']; + +const resolveImageEl = (element) => + element.nodeName === 'IMG' ? element : element.querySelector('img'); + +const startFileUpload = 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: getImageAlt(src), + canonicalSrc, + }), + ); + } catch (e) { + editor.commands.deleteRange({ from: position, to: position + 1 }); + editor.emit('error', __('An error occurred while uploading the image. Please try again.')); + } +}; + +const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { + if (acceptedMimes.includes(file?.type)) { + startFileUpload({ editor, file, uploadsPath, renderMarkdown }); + + return true; + } + + return false; +}; const ExtendedImage = Image.extend({ + defaultOptions: { + ...Image.options, + uploadsPath: null, + renderMarkdown: null, + }, addAttributes() { return { ...this.parent?.(), + uploading: { + default: false, + }, src: { default: null, /* @@ -14,17 +69,25 @@ const ExtendedImage = Image.extend({ * attribute. */ parseHTML: (element) => { - const img = element.querySelector('img'); + const img = resolveImageEl(element); return { src: img.dataset.src || img.getAttribute('src'), }; }, }, + canonicalSrc: { + default: null, + parseHTML: (element) => { + return { + canonicalSrc: element.dataset.canonicalSrc, + }; + }, + }, alt: { default: null, parseHTML: (element) => { - const img = element.querySelector('img'); + const img = resolveImageEl(element); return { alt: img.getAttribute('alt'), @@ -44,7 +107,62 @@ const ExtendedImage = Image.extend({ }, ]; }, -}).configure({ inline: true }); + addCommands() { + return { + ...this.parent(), + uploadImage: ({ file }) => () => { + const { uploadsPath, renderMarkdown } = this.options; + + handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor }); + }, + }; + }, + addProseMirrorPlugins() { + const { editor } = this; + + return [ + new Plugin({ + key: new PluginKey('handleDropAndPasteImages'), + props: { + handlePaste: (_, event) => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ + editor, + file: event.clipboardData.files[0], + uploadsPath, + renderMarkdown, + }); + }, + handleDrop: (_, event) => { + const { uploadsPath, renderMarkdown } = this.options; + + return handleFileEvent({ + editor, + file: event.dataTransfer.files[0], + uploadsPath, + renderMarkdown, + }); + }, + }, + }), + ]; + }, + addNodeView() { + return VueNodeViewRenderer(ImageWrapper); + }, +}); + +const serializer = (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})`); +}; -export const tiptapExtension = ExtendedImage; -export const serializer = defaultMarkdownSerializer.nodes.image; +export const configure = ({ renderMarkdown, uploadsPath }) => { + return { + tiptapExtension: ExtendedImage.configure({ inline: true, renderMarkdown, uploadsPath }), + serializer, + }; +}; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 6f5f81cbf93..12019ab4636 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -1,9 +1,7 @@ import { markInputRule } from '@tiptap/core'; import { Link } from '@tiptap/extension-link'; -import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; - export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; const extractHrefFromMatch = (match) => { @@ -29,8 +27,37 @@ export const tiptapExtension = Link.extend({ markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch), ]; }, + addAttributes() { + return { + ...this.parent?.(), + href: { + default: null, + parseHTML: (element) => { + return { + href: element.getAttribute('href'), + }; + }, + }, + canonicalSrc: { + default: null, + parseHTML: (element) => { + return { + canonicalSrc: element.dataset.canonicalSrc, + }; + }, + }, + }; + }, }).configure({ openOnClick: false, }); -export const serializer = defaultMarkdownSerializer.marks.link; +export const serializer = { + 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)}` : ''})`; + }, +}; diff --git a/app/assets/javascripts/content_editor/extensions/table.js b/app/assets/javascripts/content_editor/extensions/table.js new file mode 100644 index 00000000000..566f7a21a85 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table.js @@ -0,0 +1,7 @@ +import { Table } from '@tiptap/extension-table'; + +export const tiptapExtension = Table; + +export function serializer(state, node) { + state.renderContent(node); +} diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js new file mode 100644 index 00000000000..6c25b867466 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -0,0 +1,9 @@ +import { TableCell } from '@tiptap/extension-table-cell'; + +export const tiptapExtension = TableCell.extend({ + content: 'inline*', +}); + +export function serializer(state, node) { + state.renderInline(node); +} diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js new file mode 100644 index 00000000000..3475857b9e6 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -0,0 +1,9 @@ +import { TableHeader } from '@tiptap/extension-table-header'; + +export const tiptapExtension = TableHeader.extend({ + content: 'inline*', +}); + +export function serializer(state, node) { + state.renderInline(node); +} diff --git a/app/assets/javascripts/content_editor/extensions/table_row.js b/app/assets/javascripts/content_editor/extensions/table_row.js new file mode 100644 index 00000000000..07d2eb4faa2 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_row.js @@ -0,0 +1,51 @@ +import { TableRow } from '@tiptap/extension-table-row'; + +export const tiptapExtension = TableRow.extend({ + allowGapCursor: false, +}); + +export function serializer(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(); + } +} 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 8a54da6f57d..9251fdbbdc5 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -20,35 +20,16 @@ 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 { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; -const builtInContentEditorExtensions = [ - Blockquote, - Bold, - BulletList, - Code, - CodeBlockHighlight, - Document, - Dropcursor, - Gapcursor, - HardBreak, - Heading, - History, - HorizontalRule, - Image, - Italic, - Link, - ListItem, - OrderedList, - Paragraph, - Strike, - Text, -]; - const collectTiptapExtensions = (extensions = []) => extensions.map(({ tiptapExtension }) => tiptapExtension); @@ -63,11 +44,43 @@ const createTiptapEditor = ({ extensions = [], ...options } = {}) => ...options, }); -export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => { +export const createContentEditor = ({ + renderMarkdown, + uploadsPath, + extensions = [], + tiptapOptions, +} = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); } + const builtInContentEditorExtensions = [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + Document, + Dropcursor, + Gapcursor, + HardBreak, + Heading, + History, + HorizontalRule, + Image.configure({ uploadsPath, renderMarkdown }), + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Strike, + TableCell, + TableHeader, + TableRow, + Table, + Text, + ]; + const allExtensions = [...builtInContentEditorExtensions, ...extensions]; const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts); const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions }); diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js new file mode 100644 index 00000000000..613c53144a1 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/upload_file.js @@ -0,0 +1,44 @@ +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/utils.js b/app/assets/javascripts/content_editor/services/utils.js index cf5234bbff8..2a2c7f617da 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -3,3 +3,17 @@ export const hasSelection = (tiptapEditor) => { return from < to; }; + +export const getImageAlt = (src) => { + return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' '); +}; + +export const readFileAsDataURL = (file) => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener('load', (e) => resolve(e.target.result), { once: true }); + reader.readAsDataURL(file); + }); +}; + +export const clamp = (n, min, max) => Math.max(Math.min(n, max), min); |