Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-07-20 12:55:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-07-20 12:55:51 +0300
commite8d2c2579383897a1dd7f9debd359abe8ae8373d (patch)
treec42be41678c2586d49a75cabce89322082698334 /app/assets/javascripts/content_editor
parentfc845b37ec3a90aaa719975f607740c22ba6a113 (diff)
Add latest changes from gitlab-org/gitlab@14-1-stable-eev14.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue31
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue110
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue91
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue28
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue31
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/horizontal_rule.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js130
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js33
-rw-r--r--app/assets/javascripts/content_editor/extensions/table.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_row.js51
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js61
-rw-r--r--app/assets/javascripts/content_editor/services/upload_file.js44
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js14
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);