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>2022-05-05 18:08:47 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-05-05 18:08:47 +0300
commitdad16033c2b7cfd54ffe20ca5cc1d844e9e41be6 (patch)
tree8010601f9b7066e07166d997624b723ea4c3f816 /app/assets/javascripts/content_editor
parent3c86701bc89302550abb9bbaa060132fdcd52480 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link.vue18
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media.vue288
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/media.vue51
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js6
-rw-r--r--app/assets/javascripts/content_editor/services/asset_resolver.js13
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js28
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js11
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js4
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js2
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) => {