summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue8
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue96
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue75
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue24
-rw-r--r--app/assets/javascripts/content_editor/constants.js34
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js20
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js45
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js33
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js7
-rw-r--r--app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js11
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js5
11 files changed, 345 insertions, 13 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 7896268acf0..c6ab2e189ef 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -17,8 +17,12 @@ export default {
};
</script>
<template>
- <div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }">
+ <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 :editor="contentEditor.tiptapEditor" />
+ <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
</div>
</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
new file mode 100644
index 00000000000..f706080eaa1
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
@@ -0,0 +1,96 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownForm,
+ GlButton,
+ GlFormInputGroup,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
+import { Editor as TiptapEditor } from '@tiptap/vue-2';
+import { hasSelection } from '../services/utils';
+
+export const linkContentType = 'link';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownForm,
+ GlFormInputGroup,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ tiptapEditor: {
+ type: TiptapEditor,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ linkHref: '',
+ };
+ },
+ computed: {
+ isActive() {
+ return this.tiptapEditor.isActive(linkContentType);
+ },
+ },
+ mounted() {
+ this.tiptapEditor.on('selectionUpdate', ({ editor }) => {
+ const { href } = editor.getAttributes(linkContentType);
+
+ this.linkHref = href;
+ });
+ },
+ methods: {
+ updateLink() {
+ this.tiptapEditor.chain().focus().unsetLink().setLink({ href: this.linkHref }).run();
+
+ this.$emit('execute', { contentType: linkContentType });
+ },
+ selectLink() {
+ const { tiptapEditor } = this;
+
+ // a selection has already been made by the user, so do nothing
+ if (!hasSelection(tiptapEditor)) {
+ tiptapEditor.chain().focus().extendMarkRange(linkContentType).run();
+ }
+ },
+ removeLink() {
+ this.tiptapEditor.chain().focus().unsetLink().run();
+
+ this.$emit('execute', { contentType: linkContentType });
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ v-gl-tooltip
+ :aria-label="__('Insert link')"
+ :title="__('Insert link')"
+ :toggle-class="{ active: isActive }"
+ size="small"
+ category="tertiary"
+ icon="link"
+ @show="selectLink()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="updateLink()">{{ __('Apply') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider v-if="isActive" />
+ <gl-dropdown-item v-if="isActive" @click="removeLink()">
+ {{ __('Remove link') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
new file mode 100644
index 00000000000..473fc472c1b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { Editor as TiptapEditor } from '@tiptap/vue-2';
+import { __ } from '~/locale';
+import { TEXT_STYLE_DROPDOWN_ITEMS } from '../constants';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ tiptapEditor: {
+ type: TiptapEditor,
+ required: true,
+ },
+ },
+ computed: {
+ activeItem() {
+ return TEXT_STYLE_DROPDOWN_ITEMS.find((item) =>
+ this.tiptapEditor.isActive(item.contentType, item.commandParams),
+ );
+ },
+ activeItemLabel() {
+ const { activeItem } = this;
+
+ return activeItem ? activeItem.label : this.$options.i18n.placeholder;
+ },
+ },
+ methods: {
+ execute(item) {
+ const { editorCommand, contentType, commandParams } = item;
+ const value = commandParams?.level;
+
+ if (editorCommand) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ [editorCommand](commandParams || {})
+ .run();
+ }
+
+ this.$emit('execute', { contentType, value });
+ },
+ isActive(item) {
+ return this.tiptapEditor.isActive(item.contentType, item.commandParams);
+ },
+ },
+ items: TEXT_STYLE_DROPDOWN_ITEMS,
+ i18n: {
+ placeholder: __('Text style'),
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ v-gl-tooltip="$options.i18n.placeholder"
+ size="small"
+ :disabled="!activeItem"
+ :text="activeItemLabel"
+ >
+ <gl-dropdown-item
+ v-for="(item, index) in $options.items"
+ :key="index"
+ is-check-item
+ :is-checked="isActive(item)"
+ @click="execute(item)"
+ >
+ {{ item.label }}
+ </gl-dropdown-item>
+ </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 b18649d4e57..07fdd3147e2 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -4,6 +4,8 @@ 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 ToolbarLinkButton from './toolbar_link_button.vue';
+import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
const trackingMixin = Tracking.mixin({
label: CONTENT_EDITOR_TRACKING_LABEL,
@@ -12,6 +14,8 @@ const trackingMixin = Tracking.mixin({
export default {
components: {
ToolbarButton,
+ ToolbarTextStyleDropdown,
+ ToolbarLinkButton,
Divider,
},
mixins: [trackingMixin],
@@ -35,6 +39,12 @@ export default {
<div
class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
>
+ <toolbar-text-style-dropdown
+ data-testid="text-styles"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <divider />
<toolbar-button
data-testid="bold"
content-type="bold"
@@ -62,6 +72,11 @@ export default {
:tiptap-editor="contentEditor.tiptapEditor"
@execute="trackToolbarControlExecution"
/>
+ <toolbar-link-button
+ data-testid="link"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
<divider />
<toolbar-button
data-testid="blockquote"
@@ -73,6 +88,15 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="code-block"
+ content-type="codeBlock"
+ icon-name="doc-code"
+ editor-command="toggleCodeBlock"
+ :label="__('Insert a code block')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="bullet-list"
content-type="bulletList"
icon-name="list-bulleted"
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index 45ebd87dac9..7a5f1d3ed1f 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
'ContentEditor|You have to provide a renderMarkdown function or a custom serializer',
@@ -8,3 +8,35 @@ export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor';
export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
+
+export const TEXT_STYLE_DROPDOWN_ITEMS = [
+ {
+ contentType: 'heading',
+ commandParams: { level: 1 },
+ editorCommand: 'setHeading',
+ label: __('Heading 1'),
+ },
+ {
+ contentType: 'heading',
+ editorCommand: 'setHeading',
+ commandParams: { level: 2 },
+ label: __('Heading 2'),
+ },
+ {
+ contentType: 'heading',
+ editorCommand: 'setHeading',
+ commandParams: { level: 3 },
+ label: __('Heading 3'),
+ },
+ {
+ contentType: 'heading',
+ editorCommand: 'setHeading',
+ commandParams: { level: 4 },
+ label: __('Heading 4'),
+ },
+ {
+ contentType: 'paragraph',
+ editorCommand: 'setParagraph',
+ label: __('Normal text'),
+ },
+];
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 ce8bd57c7e3..50d72f4089a 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,12 +1,20 @@
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
+import * as lowlight from 'lowlight';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang');
+const extractLanguage = (element) => element.getAttribute('lang');
const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
addAttributes() {
return {
- ...this.parent(),
+ language: {
+ default: null,
+ parseHTML: (element) => {
+ return {
+ language: extractLanguage(element),
+ };
+ },
+ },
/* `params` is the name of the attribute that
prosemirror-markdown uses to extract the language
of a codeblock.
@@ -19,8 +27,16 @@ const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
};
},
},
+ class: {
+ default: 'code highlight js-syntax-highlight',
+ },
};
},
+ renderHTML({ HTMLAttributes }) {
+ return ['pre', HTMLAttributes, ['code', {}, 0]];
+ },
+}).configure({
+ lowlight,
});
export const tiptapExtension = ExtendedCodeBlockLowlight;
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 4f0109fd751..287216e68d5 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -2,8 +2,49 @@ import { Image } from '@tiptap/extension-image';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
const ExtendedImage = Image.extend({
- defaultOptions: { inline: true },
-});
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ src: {
+ default: null,
+ /*
+ * GitLab Flavored Markdown provides lazy loading for rendering images. As
+ * as result, the src attribute of the image may contain an embedded resource
+ * instead of the actual image URL. The image URL is moved to the data-src
+ * attribute.
+ */
+ parseHTML: (element) => {
+ const img = element.querySelector('img');
+
+ return {
+ src: img.dataset.src || img.getAttribute('src'),
+ };
+ },
+ },
+ alt: {
+ default: null,
+ parseHTML: (element) => {
+ const img = element.querySelector('img');
+
+ return {
+ alt: img.getAttribute('alt'),
+ };
+ },
+ },
+ };
+ },
+ parseHTML() {
+ return [
+ {
+ priority: 100,
+ tag: 'a.no-attachment-icon',
+ },
+ {
+ tag: 'img[src]',
+ },
+ ];
+ },
+}).configure({ inline: true });
export const tiptapExtension = ExtendedImage;
export const serializer = defaultMarkdownSerializer.nodes.image;
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 9a2fa7a5c98..6f5f81cbf93 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -1,5 +1,36 @@
+import { markInputRule } from '@tiptap/core';
import { Link } from '@tiptap/extension-link';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-export const tiptapExtension = Link;
+export const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm;
+
+export const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim;
+
+const extractHrefFromMatch = (match) => {
+ return { href: match.groups.href };
+};
+
+export const extractHrefFromMarkdownLink = (match) => {
+ /**
+ * Removes the last capture group from the match to satisfy
+ * tiptap markInputRule expectation of having the content as
+ * the last capture group in the match.
+ *
+ * https://github.com/ueberdosis/tiptap/blob/%40tiptap/core%402.0.0-beta.75/packages/core/src/inputRules/markInputRule.ts#L11
+ */
+ match.pop();
+ return extractHrefFromMatch(match);
+};
+
+export const tiptapExtension = Link.extend({
+ addInputRules() {
+ return [
+ markInputRule(markdownLinkSyntaxInputRuleRegExp, this.type, extractHrefFromMarkdownLink),
+ markInputRule(urlSyntaxRegExp, this.type, extractHrefFromMatch),
+ ];
+ },
+}).configure({
+ openOnClick: false,
+});
+
export const serializer = defaultMarkdownSerializer.marks.link;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index e2188f5aa69..29553f4c2ca 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -9,6 +9,13 @@ export class ContentEditor {
return this._tiptapEditor;
}
+ get empty() {
+ const doc = this.tiptapEditor?.state.doc;
+
+ // Makes sure the document has more than one empty paragraph
+ return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
+ }
+
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this;
diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
index 860e5372bc2..d26f32a7e7a 100644
--- a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
+++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
@@ -1,4 +1,4 @@
-import { mapValues, omit } from 'lodash';
+import { mapValues } from 'lodash';
import { InputRule } from 'prosemirror-inputrules';
import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
import Tracking from '~/tracking';
@@ -36,15 +36,16 @@ const trackInputRulesAndShortcuts = (tiptapExtension) => {
addKeyboardShortcuts() {
const shortcuts = this.parent?.() || {};
const { name } = this;
-
/**
* We don’t want to track keyboard shortcuts
* that are not deliberately executed to create
* new types of content
*/
- const withoutEnterShortcut = omit(shortcuts, [ENTER_KEY, BACKSPACE_KEY]);
- const decorated = mapValues(withoutEnterShortcut, (commandFn, shortcut) =>
- trackKeyboardShortcut(name, commandFn, shortcut),
+ const dotNotTrackKeys = [ENTER_KEY, BACKSPACE_KEY];
+ const decorated = mapValues(shortcuts, (commandFn, shortcut) =>
+ dotNotTrackKeys.includes(shortcut)
+ ? commandFn
+ : trackKeyboardShortcut(name, commandFn, shortcut),
);
return decorated;
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
new file mode 100644
index 00000000000..cf5234bbff8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -0,0 +1,5 @@
+export const hasSelection = (tiptapEditor) => {
+ const { from, to } = tiptapEditor.state.selection;
+
+ return from < to;
+};