diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /app/assets/javascripts/content_editor | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) | |
download | gitlab-ce-a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4.tar.gz |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
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; +}; |