diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-05 18:08:47 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-05-05 18:08:47 +0300 |
commit | dad16033c2b7cfd54ffe20ca5cc1d844e9e41be6 (patch) | |
tree | 8010601f9b7066e07166d997624b723ea4c3f816 /app/assets/javascripts/content_editor | |
parent | 3c86701bc89302550abb9bbaa060132fdcd52480 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/content_editor')
12 files changed, 358 insertions, 84 deletions
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue index 46c15de6b2c..e35fbf14de5 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue @@ -3,6 +3,9 @@ import { GlButtonGroup } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants'; import trackUIControl from '../../services/track_ui_control'; +import Image from '../../extensions/image'; +import Audio from '../../extensions/audio'; +import Video from '../../extensions/video'; import Code from '../../extensions/code'; import CodeBlockHighlight from '../../extensions/code_block_highlight'; import Diagram from '../../extensions/diagram'; @@ -24,7 +27,15 @@ export default { shouldShow: ({ editor, from, to }) => { if (from === to) return false; - const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + const exclude = [ + Code.name, + CodeBlockHighlight.name, + Diagram.name, + Frontmatter.name, + Image.name, + Audio.name, + Video.name, + ]; return !exclude.some((type) => editor.isActive(type)); }, diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link.vue index 2f446832516..abd225c0b1a 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link.vue @@ -26,7 +26,7 @@ export default { directives: { GlTooltip, }, - inject: ['tiptapEditor'], + inject: ['tiptapEditor', 'contentEditor'], data() { return { linkHref: undefined, @@ -57,9 +57,11 @@ export default { this.isEditing = true; }, - endEditingLink() { + async endEditingLink() { this.isEditing = false; + this.linkHref = await this.contentEditor.resolveLink(this.linkCanonicalSrc); + if (!this.linkCanonicalSrc && !this.linkHref) { this.removeLink(); } @@ -70,7 +72,7 @@ export default { this.updateLinkToState(); }, - saveEditedLink() { + async saveEditedLink() { if (!this.linkCanonicalSrc) { this.removeLink(); } else { @@ -166,12 +168,12 @@ export default { @click="removeLink" /> </gl-button-group> - <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit="saveEditedLink"> - <gl-form-group data-testid="link-href-group" :label="__('URL')" label-for="link-href"> - <gl-form-input id="link-href" v-model="linkCanonicalSrc" /> + <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedLink"> + <gl-form-group :label="__('URL')" label-for="link-href"> + <gl-form-input id="link-href" v-model="linkCanonicalSrc" data-testid="link-href" /> </gl-form-group> - <gl-form-group data-testid="link-title-group" :label="__('Title')" label-for="link-title"> - <gl-form-input id="link-title" v-model="linkTitle" /> + <gl-form-group :label="__('Title')" label-for="link-title"> + <gl-form-input id="link-title" v-model="linkTitle" data-testid="link-title" /> </gl-form-group> <div class="gl-display-flex gl-justify-content-end"> <gl-button class="gl-mr-3" data-testid="cancel-link" @click="cancelEditingLink"> diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media.vue new file mode 100644 index 00000000000..d1bc5c83948 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media.vue @@ -0,0 +1,288 @@ +<script> +import { + GlLink, + GlForm, + GlFormGroup, + GlFormInput, + GlLoadingIcon, + GlButton, + GlButtonGroup, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import { __ } from '~/locale'; +import Audio from '../../extensions/audio'; +import Image from '../../extensions/image'; +import Video from '../../extensions/video'; +import EditorStateObserver from '../editor_state_observer.vue'; +import { acceptedMimes } from '../../services/upload_helpers'; + +const MEDIA_TYPES = [Audio.name, Image.name, Video.name]; + +export default { + i18n: { + copySourceLabels: { + [Audio.name]: __('Copy audio URL'), + [Image.name]: __('Copy image URL'), + [Video.name]: __('Copy video URL'), + }, + editLabels: { + [Audio.name]: __('Edit audio description'), + [Image.name]: __('Edit image description'), + [Video.name]: __('Edit video description'), + }, + replaceLabels: { + [Audio.name]: __('Replace audio'), + [Image.name]: __('Replace image'), + [Video.name]: __('Replace video'), + }, + deleteLabels: { + [Audio.name]: __('Delete audio'), + [Image.name]: __('Delete image'), + [Video.name]: __('Delete video'), + }, + }, + components: { + BubbleMenu, + GlForm, + GlFormGroup, + GlFormInput, + GlLink, + GlLoadingIcon, + GlButton, + GlButtonGroup, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor', 'contentEditor'], + data() { + return { + mediaType: undefined, + mediaSrc: undefined, + mediaCanonicalSrc: undefined, + mediaAlt: undefined, + mediaTitle: undefined, + + isEditing: false, + isUpdating: false, + isUploading: false, + }; + }, + computed: { + copySourceLabel() { + return this.$options.i18n.copySourceLabels[this.mediaType]; + }, + editLabel() { + return this.$options.i18n.editLabels[this.mediaType]; + }, + replaceLabel() { + return this.$options.i18n.replaceLabels[this.mediaType]; + }, + deleteLabel() { + return this.$options.i18n.deleteLabels[this.mediaType]; + }, + showProgressIndicator() { + return this.isUploading || this.isUpdating; + }, + }, + methods: { + shouldShow() { + const shouldShow = MEDIA_TYPES.some((type) => this.tiptapEditor.isActive(type)); + + if (!shouldShow) this.isEditing = false; + + return shouldShow; + }, + + startEditingMedia() { + this.isEditing = true; + }, + + endEditingMedia() { + this.isEditing = false; + + this.updateMediaInfoToState(); + }, + + cancelEditingMedia() { + this.endEditingMedia(); + this.updateMediaInfoToState(); + }, + + async saveEditedMedia() { + this.isUpdating = true; + + this.mediaSrc = await this.contentEditor.resolveLink(this.mediaCanonicalSrc); + + const position = this.tiptapEditor.state.selection.from; + + this.tiptapEditor + .chain() + .focus() + .updateAttributes(this.mediaType, { + src: this.mediaSrc, + alt: this.mediaAlt, + canonicalSrc: this.mediaCanonicalSrc, + title: this.mediaTitle, + }) + .run(); + + this.tiptapEditor.commands.setNodeSelection(position); + + this.endEditingMedia(); + + this.isUpdating = false; + }, + + async updateMediaInfoToState() { + this.mediaType = MEDIA_TYPES.find((type) => this.tiptapEditor.isActive(type)); + + if (!this.mediaType) return; + + this.isUpdating = true; + + const { src, title, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes( + this.mediaType, + ); + + this.mediaTitle = title; + this.mediaAlt = alt; + this.mediaCanonicalSrc = canonicalSrc || src; + this.isUploading = uploading; + this.mediaSrc = await this.contentEditor.resolveLink(this.mediaCanonicalSrc); + + this.isUpdating = false; + }, + + replaceMedia() { + this.$refs.fileSelector.click(); + }, + + onFileSelect(e) { + this.tiptapEditor + .chain() + .focus() + .deleteSelection() + .uploadAttachment({ + file: e.target.files[0], + }) + .run(); + + this.$refs.fileSelector.value = ''; + }, + + copyMediaSrc() { + navigator.clipboard.writeText(this.mediaCanonicalSrc); + }, + + deleteMedia() { + this.tiptapEditor.chain().focus().deleteSelection().run(); + }, + }, + + acceptedMimes, +}; +</script> +<template> + <bubble-menu + data-testid="media-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + :editor="tiptapEditor" + plugin-key="bubbleMenuMedia" + :should-show="() => shouldShow()" + > + <editor-state-observer @transaction="updateMediaInfoToState"> + <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> + <gl-loading-icon v-if="showProgressIndicator" class="gl-pl-4 gl-pr-3" /> + <input + ref="fileSelector" + type="file" + name="content_editor_image" + :accept="$options.acceptedMimes[mediaType]" + class="gl-display-none" + data-qa-selector="file_upload_field" + @change="onFileSelect" + /> + + <gl-link + v-if="!showProgressIndicator" + v-gl-tooltip + :href="mediaSrc" + :aria-label="mediaCanonicalSrc" + :title="mediaCanonicalSrc" + target="_blank" + class="gl-px-3 gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis" + > + {{ mediaCanonicalSrc }} + </gl-link> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="copy-media-src" + :aria-label="copySourceLabel" + :title="copySourceLabel" + icon="copy-to-clipboard" + @click="copyMediaSrc" + /> + <gl-button + v-if="!showProgressIndicator" + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="edit-media" + :aria-label="editLabel" + :title="editLabel" + icon="pencil" + @click="startEditingMedia" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="replace-media" + :aria-label="replaceLabel" + :title="replaceLabel" + icon="upload" + @click="replaceMedia" + /> + <gl-button + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + data-testid="delete-media" + :aria-label="deleteLabel" + :title="deleteLabel" + icon="remove" + @click="deleteMedia" + /> + </gl-button-group> + <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedMedia"> + <gl-form-group :label="__('URL')" label-for="media-src"> + <gl-form-input id="media-src" v-model="mediaCanonicalSrc" data-testid="media-src" /> + </gl-form-group> + <gl-form-group :label="__('Description (alt text)')" label-for="media-alt"> + <gl-form-input id="media-alt" v-model="mediaAlt" data-testid="media-alt" /> + </gl-form-group> + <gl-form-group :label="__('Title')" label-for="media-title"> + <gl-form-input id="media-title" v-model="mediaTitle" data-testid="media-title" /> + </gl-form-group> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + class="gl-mr-3" + data-testid="cancel-editing-media" + @click="cancelEditingMedia" + >{{ __('Cancel') }}</gl-button + > + <gl-button variant="confirm" type="submit">{{ __('Apply') }}</gl-button> + </div> + </gl-form> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index a3247298b19..74ae37b6d06 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -7,6 +7,7 @@ import EditorStateObserver from './editor_state_observer.vue'; import FormattingBubbleMenu from './bubble_menus/formatting.vue'; import CodeBlockBubbleMenu from './bubble_menus/code_block.vue'; import LinkBubbleMenu from './bubble_menus/link.vue'; +import MediaBubbleMenu from './bubble_menus/media.vue'; import TopToolbar from './top_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -20,6 +21,7 @@ export default { FormattingBubbleMenu, CodeBlockBubbleMenu, LinkBubbleMenu, + MediaBubbleMenu, EditorStateObserver, }, props: { @@ -95,6 +97,7 @@ export default { <formatting-bubble-menu /> <code-block-bubble-menu /> <link-bubble-menu /> + <media-bubble-menu /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <loading-indicator /> </div> diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue deleted file mode 100644 index 37119bdd066..00000000000 --- a/app/assets/javascripts/content_editor/components/wrappers/media.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; - -const tagNameMap = { - image: 'img', - video: 'video', - audio: 'audio', -}; - -export default { - name: 'MediaWrapper', - components: { - NodeViewWrapper, - GlLoadingIcon, - }, - props: { - node: { - type: Object, - required: true, - }, - }, - computed: { - tagName() { - return tagNameMap[this.node.type.name] || 'img'; - }, - }, -}; -</script> -<template> - <node-view-wrapper class="gl-display-inline-block"> - <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }"> - <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> - <component - :is="tagName" - data-testid="media" - :class="{ - 'gl-max-w-full gl-h-auto': tagName !== 'audio', - 'gl-opacity-5': node.attrs.uploading, - }" - :title="node.attrs.title || node.attrs.alt" - :alt="node.attrs.alt" - :src="node.attrs.src" - controls="true" - /> - <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent> - {{ node.attrs.title || node.attrs.alt }} - </a> - </span> - </node-view-wrapper> -</template> diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 311db8151cb..25f976f524f 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,6 +1,4 @@ import { Image } from '@tiptap/extension-image'; -import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import MediaWrapper from '../components/wrappers/media.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; const resolveImageEl = (element) => @@ -77,7 +75,4 @@ export default Image.extend({ }, ]; }, - addNodeView() { - return VueNodeViewRenderer(MediaWrapper); - }, }); diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index 2c5269377c5..ed343d8acf8 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -1,8 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ import { Node } from '@tiptap/core'; -import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import MediaWrapper from '../components/wrappers/media.vue'; const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); @@ -68,8 +66,4 @@ export default Node.create({ ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''], ]; }, - - addNodeView() { - return VueNodeViewRenderer(MediaWrapper); - }, }); diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js new file mode 100644 index 00000000000..942457b9664 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -0,0 +1,13 @@ +import { memoize } from 'lodash'; + +export default ({ renderMarkdown }) => ({ + resolveUrl: memoize(async (canonicalSrc) => { + const html = await renderMarkdown(`[link](${canonicalSrc})`); + if (!html) return canonicalSrc; + + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + + return body.querySelector('a').getAttribute('href'); + }), +}); diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 21843c482a8..b993851a92f 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -3,12 +3,13 @@ import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } fro /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, eventHub, languageLoader }) { + constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, languageLoader }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; this._eventHub = eventHub; this._languageLoader = languageLoader; + this._assetResolver = assetResolver; } get tiptapEditor() { @@ -34,22 +35,27 @@ export class ContentEditor { this._eventHub.dispose(); } + deserialize(serializedContent) { + const { _tiptapEditor: editor, _deserializer: deserializer } = this; + + return deserializer.deserialize({ + schema: editor.schema, + content: serializedContent, + }); + } + + resolveAssetUrl(canonicalSrc) { + return this._assetResolver.resolveUrl(canonicalSrc); + } + async setSerializedContent(serializedContent) { - const { - _tiptapEditor: editor, - _deserializer: deserializer, - _eventHub: eventHub, - _languageLoader: languageLoader, - } = this; + const { _tiptapEditor: editor, _eventHub: eventHub, _languageLoader: languageLoader } = this; const { doc, tr } = editor.state; const selection = TextSelection.create(doc, 0, doc.content.size); try { eventHub.$emit(LOADING_CONTENT_EVENT); - const result = await deserializer.deserialize({ - schema: editor.schema, - content: serializedContent, - }); + const result = await this.deserialize(serializedContent); if (Object.keys(result).length !== 0) { const { document, languages } = result; 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 28041504e3c..adb1398b2c4 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -60,6 +60,7 @@ import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import createGlApiMarkdownDeserializer from './gl_api_markdown_deserializer'; import createRemarkMarkdownDeserializer from './remark_markdown_deserializer'; +import createAssetResolver from './asset_resolver'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; import languageLoader from './code_block_language_loader'; @@ -152,6 +153,14 @@ export const createContentEditor = ({ : createGlApiMarkdownDeserializer({ render: renderMarkdown, }); + const assetResolver = createAssetResolver({ renderMarkdown }); - return new ContentEditor({ tiptapEditor, serializer, eventHub, deserializer, languageLoader }); + return new ContentEditor({ + tiptapEditor, + serializer, + eventHub, + deserializer, + languageLoader, + assetResolver, + }); }; diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index ed2c4b39131..09f0738b51b 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -70,6 +70,8 @@ const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, const position = state.selection.from - 1; const { tr } = state; + editor.commands.setNodeSelection(position); + try { const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); @@ -81,6 +83,8 @@ const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, canonicalSrc, }), ); + + editor.commands.setNodeSelection(position); } catch (e) { editor.commands.deleteRange({ from: position, to: position + 1 }); eventHub.$emit('alert', { diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index b3856b0dd74..e352fa8a9db 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -15,7 +15,7 @@ export const hasSelection = (tiptapEditor) => { * @returns {string} */ export const extractFilename = (src) => { - return src.replace(/^.*\/|\..+?$/g, ''); + return src.replace(/^.*\/|\.[^.]+?$/g, ''); }; export const readFileAsDataURL = (file) => { |