summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/content_editor/services
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-08-19 09:08:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-08-19 09:08:42 +0000
commitb76ae638462ab0f673e5915986070518dd3f9ad3 (patch)
treebdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/content_editor/services
parent434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff)
downloadgitlab-ce-b76ae638462ab0f673e5915986070518dd3f9ad3.tar.gz
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor/services')
-rw-r--r--app/assets/javascripts/content_editor/services/build_serializer_config.js22
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js42
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js78
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js174
-rw-r--r--app/assets/javascripts/content_editor/services/track_ui_control.js9
-rw-r--r--app/assets/javascripts/content_editor/services/upload_file.js44
-rw-r--r--app/assets/javascripts/content_editor/services/upload_helpers.js123
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js14
8 files changed, 400 insertions, 106 deletions
diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js
deleted file mode 100644
index 75e2b0f9eba..00000000000
--- a/app/assets/javascripts/content_editor/services/build_serializer_config.js
+++ /dev/null
@@ -1,22 +0,0 @@
-const buildSerializerConfig = (extensions = []) =>
- extensions
- .filter(({ serializer }) => serializer)
- .reduce(
- (serializers, { serializer, tiptapExtension: { name, type } }) => {
- const collection = `${type}s`;
-
- return {
- ...serializers,
- [collection]: {
- ...serializers[collection],
- [name]: serializer,
- },
- };
- },
- {
- nodes: {},
- marks: {},
- },
- );
-
-export default buildSerializerConfig;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 29553f4c2ca..a387322bff7 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,8 +1,11 @@
+import eventHubFactory from '~/helpers/event_hub_factory';
+import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
+ this._eventHub = eventHubFactory();
}
get tiptapEditor() {
@@ -16,12 +19,45 @@ export class ContentEditor {
return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
}
+ dispose() {
+ this.tiptapEditor.destroy();
+ }
+
+ once(type, handler) {
+ this._eventHub.$once(type, handler);
+ }
+
+ on(type, handler) {
+ this._eventHub.$on(type, handler);
+ }
+
+ emit(type, params = {}) {
+ this._eventHub.$emit(type, params);
+ }
+
+ off(type, handler) {
+ this._eventHub.$off(type, handler);
+ }
+
+ disposeAllEvents() {
+ this._eventHub.dispose();
+ }
+
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this;
- editor.commands.setContent(
- await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
- );
+ try {
+ this._eventHub.$emit(LOADING_CONTENT_EVENT);
+ const document = await serializer.deserialize({
+ schema: editor.schema,
+ content: serializedContent,
+ });
+ editor.commands.setContent(document);
+ this._eventHub.$emit(LOADING_SUCCESS_EVENT);
+ } catch (e) {
+ this._eventHub.$emit(LOADING_ERROR_EVENT, e);
+ throw e;
+ }
}
getSerializedContent() {
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 9251fdbbdc5..8997960203a 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,38 +1,43 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
-import * as Blockquote from '../extensions/blockquote';
-import * as Bold from '../extensions/bold';
-import * as BulletList from '../extensions/bullet_list';
-import * as Code from '../extensions/code';
-import * as CodeBlockHighlight from '../extensions/code_block_highlight';
-import * as Document from '../extensions/document';
-import * as Dropcursor from '../extensions/dropcursor';
-import * as Gapcursor from '../extensions/gapcursor';
-import * as HardBreak from '../extensions/hard_break';
-import * as Heading from '../extensions/heading';
-import * as History from '../extensions/history';
-import * as HorizontalRule from '../extensions/horizontal_rule';
-import * as Image from '../extensions/image';
-import * as Italic from '../extensions/italic';
-import * as Link from '../extensions/link';
-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 Attachment from '../extensions/attachment';
+import Blockquote from '../extensions/blockquote';
+import Bold from '../extensions/bold';
+import BulletList from '../extensions/bullet_list';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Document from '../extensions/document';
+import Dropcursor from '../extensions/dropcursor';
+import Emoji from '../extensions/emoji';
+import Gapcursor from '../extensions/gapcursor';
+import HardBreak from '../extensions/hard_break';
+import Heading from '../extensions/heading';
+import History from '../extensions/history';
+import HorizontalRule from '../extensions/horizontal_rule';
+import Image from '../extensions/image';
+import InlineDiff from '../extensions/inline_diff';
+import Italic from '../extensions/italic';
+import Link from '../extensions/link';
+import ListItem from '../extensions/list_item';
+import Loading from '../extensions/loading';
+import OrderedList from '../extensions/ordered_list';
+import Paragraph from '../extensions/paragraph';
+import Reference from '../extensions/reference';
+import Strike from '../extensions/strike';
+import Subscript from '../extensions/subscript';
+import Superscript from '../extensions/superscript';
+import Table from '../extensions/table';
+import TableCell from '../extensions/table_cell';
+import TableHeader from '../extensions/table_header';
+import TableRow from '../extensions/table_row';
+import TaskItem from '../extensions/task_item';
+import TaskList from '../extensions/task_list';
+import Text from '../extensions/text';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
-const collectTiptapExtensions = (extensions = []) =>
- extensions.map(({ tiptapExtension }) => tiptapExtension);
-
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
extensions: [...extensions],
@@ -48,6 +53,7 @@ export const createContentEditor = ({
renderMarkdown,
uploadsPath,
extensions = [],
+ serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
} = {}) => {
if (!isFunction(renderMarkdown)) {
@@ -55,6 +61,7 @@ export const createContentEditor = ({
}
const builtInContentEditorExtensions = [
+ Attachment.configure({ uploadsPath, renderMarkdown }),
Blockquote,
Bold,
BulletList,
@@ -62,29 +69,36 @@ export const createContentEditor = ({
CodeBlockHighlight,
Document,
Dropcursor,
+ Emoji,
Gapcursor,
HardBreak,
Heading,
History,
HorizontalRule,
- Image.configure({ uploadsPath, renderMarkdown }),
+ Image,
+ InlineDiff,
Italic,
Link,
ListItem,
+ Loading,
OrderedList,
Paragraph,
+ Reference,
Strike,
+ Subscript,
+ Superscript,
TableCell,
TableHeader,
TableRow,
Table,
+ TaskItem,
+ TaskList,
Text,
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
- const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts);
- const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
- const serializerConfig = buildSerializerConfig(allExtensions);
+ const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
+ const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
return new ContentEditor({ tiptapEditor, serializer });
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index f121cc9affd..df4d31c3d7f 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,5 +1,165 @@
-import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+import {
+ MarkdownSerializer as ProseMirrorMarkdownSerializer,
+ defaultMarkdownSerializer,
+} from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import Blockquote from '../extensions/blockquote';
+import Bold from '../extensions/bold';
+import BulletList from '../extensions/bullet_list';
+import Code from '../extensions/code';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import Emoji from '../extensions/emoji';
+import HardBreak from '../extensions/hard_break';
+import Heading from '../extensions/heading';
+import HorizontalRule from '../extensions/horizontal_rule';
+import Image from '../extensions/image';
+import InlineDiff from '../extensions/inline_diff';
+import Italic from '../extensions/italic';
+import Link from '../extensions/link';
+import ListItem from '../extensions/list_item';
+import OrderedList from '../extensions/ordered_list';
+import Paragraph from '../extensions/paragraph';
+import Reference from '../extensions/reference';
+import Strike from '../extensions/strike';
+import Subscript from '../extensions/subscript';
+import Superscript from '../extensions/superscript';
+import Table from '../extensions/table';
+import TableCell from '../extensions/table_cell';
+import TableHeader from '../extensions/table_header';
+import TableRow from '../extensions/table_row';
+import TaskItem from '../extensions/task_item';
+import TaskList from '../extensions/task_list';
+import Text from '../extensions/text';
+
+const defaultSerializerConfig = {
+ marks: {
+ [Bold.name]: defaultMarkdownSerializer.marks.strong,
+ [Code.name]: defaultMarkdownSerializer.marks.code,
+ [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
+ [Subscript.name]: { open: '<sub>', close: '</sub>', mixable: true },
+ [Superscript.name]: { open: '<sup>', close: '</sup>', mixable: true },
+ [InlineDiff.name]: {
+ mixable: true,
+ open(state, mark) {
+ return mark.attrs.type === 'addition' ? '{+' : '{-';
+ },
+ close(state, mark) {
+ return mark.attrs.type === 'addition' ? '+}' : '-}';
+ },
+ },
+ [Link.name]: {
+ 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)}` : ''
+ })`;
+ },
+ },
+ [Strike.name]: {
+ open: '~~',
+ close: '~~',
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ },
+ },
+ nodes: {
+ [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
+ [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
+ [CodeBlockHighlight.name]: (state, node) => {
+ state.write(`\`\`\`${node.attrs.language || ''}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write('```');
+ state.closeBlock(node);
+ },
+ [Emoji.name]: (state, node) => {
+ const { name } = node.attrs;
+
+ state.write(`:${name}:`);
+ },
+ [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
+ [Heading.name]: defaultMarkdownSerializer.nodes.heading,
+ [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
+ [Image.name]: (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})`);
+ },
+ [ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
+ [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
+ [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
+ [Reference.name]: (state, node) => {
+ state.write(node.attrs.originalText || node.attrs.text);
+ },
+ [Table.name]: (state, node) => {
+ state.renderContent(node);
+ },
+ [TableCell.name]: (state, node) => {
+ state.renderInline(node);
+ },
+ [TableHeader.name]: (state, node) => {
+ state.renderInline(node);
+ },
+ [TableRow.name]: (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();
+ }
+ },
+ [TaskItem.name]: (state, node) => {
+ state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
+ state.renderContent(node);
+ },
+ [TaskList.name]: (state, node) => {
+ if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node);
+ else defaultMarkdownSerializer.nodes.ordered_list(state, node);
+ },
+ [Text.name]: defaultMarkdownSerializer.nodes.text,
+ },
+};
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
@@ -50,8 +210,16 @@ export default ({ render = () => null, serializerConfig }) => ({
*/
serialize: ({ schema, content }) => {
const proseMirrorDocument = schema.nodeFromJSON(content);
- const { nodes, marks } = serializerConfig;
- const serializer = new ProseMirrorMarkdownSerializer(nodes, marks);
+ const serializer = new ProseMirrorMarkdownSerializer(
+ {
+ ...defaultSerializerConfig.nodes,
+ ...serializerConfig.nodes,
+ },
+ {
+ ...defaultSerializerConfig.marks,
+ ...serializerConfig.marks,
+ },
+ );
return serializer.serialize(proseMirrorDocument, {
tightLists: true,
diff --git a/app/assets/javascripts/content_editor/services/track_ui_control.js b/app/assets/javascripts/content_editor/services/track_ui_control.js
new file mode 100644
index 00000000000..61f130ea861
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/track_ui_control.js
@@ -0,0 +1,9 @@
+import Tracking from '~/tracking';
+import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
+
+export default ({ action = TOOLBAR_CONTROL_TRACKING_ACTION, property, value } = {}) =>
+ Tracking.event(undefined, action, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property,
+ value,
+ });
diff --git a/app/assets/javascripts/content_editor/services/upload_file.js b/app/assets/javascripts/content_editor/services/upload_file.js
deleted file mode 100644
index 613c53144a1..00000000000
--- a/app/assets/javascripts/content_editor/services/upload_file.js
+++ /dev/null
@@ -1,44 +0,0 @@
-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/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js
new file mode 100644
index 00000000000..8ac3f719309
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/upload_helpers.js
@@ -0,0 +1,123 @@
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { extractFilename, readFileAsDataURL } from './utils';
+
+export const acceptedMimes = {
+ image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'],
+};
+
+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);
+};
+
+const uploadImage = 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: extractFilename(src),
+ canonicalSrc,
+ }),
+ );
+ } catch (e) {
+ editor.commands.deleteRange({ from: position, to: position + 1 });
+ editor.emit('error', {
+ error: __('An error occurred while uploading the image. Please try again.'),
+ });
+ }
+};
+
+const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
+ await Promise.resolve();
+
+ const { view } = editor;
+
+ const text = extractFilename(file.name);
+
+ const { state } = view;
+ const { from } = state.selection;
+
+ editor.commands.insertContent({
+ type: 'loading',
+ attrs: { label: text },
+ });
+
+ try {
+ const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown });
+
+ editor.commands.insertContentAt(
+ { from, to: from + 1 },
+ { type: 'text', text, marks: [{ type: 'link', attrs: { href: src, canonicalSrc } }] },
+ );
+ } catch (e) {
+ editor.commands.deleteRange({ from, to: from + 1 });
+ editor.emit('error', {
+ error: __('An error occurred while uploading the file. Please try again.'),
+ });
+ }
+};
+
+export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
+ if (!file) return false;
+
+ if (acceptedMimes.image.includes(file?.type)) {
+ uploadImage({ editor, file, uploadsPath, renderMarkdown });
+
+ return true;
+ }
+
+ uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
+
+ return true;
+};
diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js
index 2a2c7f617da..b3856b0dd74 100644
--- a/app/assets/javascripts/content_editor/services/utils.js
+++ b/app/assets/javascripts/content_editor/services/utils.js
@@ -4,8 +4,18 @@ export const hasSelection = (tiptapEditor) => {
return from < to;
};
-export const getImageAlt = (src) => {
- return src.replace(/^.*\/|\..*$/g, '').replace(/\W+/g, ' ');
+/**
+ * Extracts filename from a URL
+ *
+ * @example
+ * > extractFilename('https://gitlab.com/images/logo-full.png')
+ * < 'logo-full'
+ *
+ * @param {string} src The URL to extract filename from
+ * @returns {string}
+ */
+export const extractFilename = (src) => {
+ return src.replace(/^.*\/|\..+?$/g, '');
};
export const readFileAsDataURL = (file) => {