diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-10 15:09:12 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-10 15:09:12 +0300 |
commit | 0e0df204c1a0d859ccbbe1be83a5e09a53381f17 (patch) | |
tree | e7bf6fed5fa2b74caf31957c468b0cbc303f4c45 /app/assets | |
parent | a2344dbf1942dc3919c55b0684d2566368e03852 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets')
25 files changed, 336 insertions, 179 deletions
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index 6aa5bb715b2..d2db61e096a 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -117,7 +117,7 @@ export default { {{ __('Summary comment (optional)') }} </template> <div class="common-note-form gfm-form"> - <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white"> + <div class="comment-warning-wrapper-large gl-border-0 gl-bg-white gl-overflow-hidden"> <markdown-field :is-submitting="isSubmitting" :add-spacing-classes="false" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue index fd696670ddf..7c5cc1ea6ee 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue @@ -1,11 +1,13 @@ <script> import { GlLink, + GlSprintf, GlForm, GlFormGroup, GlFormInput, GlButton, GlButtonGroup, + GlLoadingIcon, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; import { getMarkType, getMarkRange } from '@tiptap/core'; @@ -16,12 +18,14 @@ import BubbleMenu from './bubble_menu.vue'; export default { components: { BubbleMenu, + GlSprintf, GlForm, GlFormGroup, GlFormInput, GlLink, GlButton, GlButtonGroup, + GlLoadingIcon, EditorStateObserver, }, directives: { @@ -35,6 +39,9 @@ export default { linkText: undefined, isEditing: false, + + uploading: false, + uploadProgress: 0, }; }, methods: { @@ -129,9 +136,11 @@ export default { updateLinkToState() { const editor = this.tiptapEditor; - const { href, canonicalSrc } = editor.getAttributes(Link.name); + const { href, canonicalSrc, uploading } = editor.getAttributes(Link.name); const text = this.linkTextInDoc(); + this.uploading = uploading; + if ( canonicalSrc === this.linkCanonicalSrc && href === this.linkHref && @@ -150,6 +159,11 @@ export default { if (transaction.getMeta('creatingLink')) { this.isEditing = true; } + + const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {}; + if (this.uploading === filename) { + this.uploadProgress = Math.round(progress * 100); + } }, copyLinkHref() { @@ -203,7 +217,14 @@ export default { @hidden="resetBubbleMenuState" > <gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center"> + <gl-loading-icon v-if="uploading" class="gl-pl-4 gl-pr-3" /> + <span v-if="uploading" class="gl-text-secondary gl-pr-3"> + <gl-sprintf :message="__('Uploading: %{progress}')"> + <template #progress>{{ uploadProgress }}%</template> + </gl-sprintf> + </span> <gl-link + v-else v-gl-tooltip :href="linkHref" :aria-label="linkCanonicalSrc" diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue index 1bfa635c03b..6bb6bdc4e65 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue @@ -1,6 +1,7 @@ <script> import { GlLink, + GlSprintf, GlForm, GlFormGroup, GlFormInput, @@ -48,6 +49,7 @@ export default { }, components: { BubbleMenu, + GlSprintf, GlForm, GlFormGroup, GlFormInput, @@ -71,7 +73,10 @@ export default { isEditing: false, isUpdating: false, - isUploading: false, + + uploading: false, + + uploadProgress: 0, }; }, computed: { @@ -88,7 +93,7 @@ export default { return this.$options.i18n.deleteLabels[this.mediaType]; }, showProgressIndicator() { - return this.isUploading || this.isUpdating; + return this.uploading || this.isUpdating; }, isDrawioDiagram() { return this.mediaType === DrawioDiagram.name; @@ -157,17 +162,27 @@ export default { this.mediaTitle = title; this.mediaAlt = alt; this.mediaCanonicalSrc = canonicalSrc || src; - this.isUploading = uploading; + this.uploading = uploading; + this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); this.isUpdating = false; }, + onTransaction({ transaction }) { + const { filename = '', progress = 0 } = transaction.getMeta('uploadProgress') || {}; + if (this.uploading === filename) { + this.uploadProgress = Math.round(progress * 100); + } + }, + resetMediaInfo() { this.mediaTitle = null; this.mediaAlt = null; this.mediaCanonicalSrc = null; - this.isUploading = false; + this.uploading = false; + + this.uploadProgress = 0; }, replaceMedia() { @@ -204,17 +219,26 @@ export default { }; </script> <template> - <bubble-menu - data-testid="media-bubble-menu" - class="gl-shadow gl-rounded-base gl-bg-white" - plugin-key="bubbleMenuMedia" - :should-show="shouldShow" - @show="updateMediaInfoToState" - @hidden="resetMediaInfo" + <editor-state-observer + :debounce="0" + @selectionUpdate="updateMediaInfoToState" + @transaction="onTransaction" > - <editor-state-observer :debounce="0" @transaction="updateMediaInfoToState"> + <bubble-menu + data-testid="media-bubble-menu" + class="gl-shadow gl-rounded-base gl-bg-white" + plugin-key="bubbleMenuMedia" + :should-show="shouldShow" + @show="updateMediaInfoToState" + @hidden="resetMediaInfo" + > <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" /> + <span v-if="uploading" class="gl-text-secondary gl-pr-3"> + <gl-sprintf :message="__('Uploading: %{progress}')"> + <template #progress>{{ uploadProgress }}%</template> + </gl-sprintf> + </span> <input ref="fileSelector" type="file" @@ -280,7 +304,7 @@ export default { data-testid="replace-media" :aria-label="replaceLabel" :title="replaceLabel" - icon="upload" + icon="retry" @click="replaceMedia" /> <gl-button @@ -315,6 +339,6 @@ export default { <gl-button variant="confirm" type="submit">{{ __('Apply') }}</gl-button> </div> </gl-form> - </editor-state-observer> - </bubble-menu> + </bubble-menu> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 2b2c4a5ac1c..7fee798c65a 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -220,7 +220,7 @@ export default { <div data-testid="content-editor" data-qa-selector="content_editor_container" - class="md-area" + class="md-area gl-border-none! gl-shadow-none!" :class="{ 'is-focused': focused }" > <formatting-toolbar ref="toolbar" @enableMarkdownEditor="$emit('enableMarkdownEditor')" /> diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue index 87eff2451ec..59d71169dd3 100644 --- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue +++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue @@ -34,7 +34,6 @@ export default { <editor-state-observer @alert="displayAlert"> <gl-alert v-if="message" - class="gl-mb-6" :variant="variant" :primary-button-text="actionLabel" @dismiss="dismissAlert" diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue index 1f18090e7d7..5d98fdeef02 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -23,13 +23,9 @@ export default { this.$refs.fileSelector.click(); }, onFileSelect(e) { - this.tiptapEditor - .chain() - .focus() - .uploadAttachment({ - file: e.target.files[0], - }) - .run(); + for (const file of e.target.files) { + this.tiptapEditor.chain().focus().uploadAttachment({ file }).run(); + } // Reset the file input so that the same file can be uploaded again this.$refs.fileSelector.value = ''; @@ -53,6 +49,7 @@ export default { <input ref="fileSelector" type="file" + multiple name="content_editor_image" class="gl-display-none" data-qa-selector="file_upload_field" diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js index 0d5b8e56a6c..e7a6af30266 100644 --- a/app/assets/javascripts/content_editor/extensions/attachment.js +++ b/app/assets/javascripts/content_editor/extensions/attachment.js @@ -2,6 +2,20 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { handleFileEvent } from '../services/upload_helpers'; +const processFiles = ({ files, uploadsPath, renderMarkdown, eventHub, editor }) => { + if (!files.length) { + return false; + } + + let handled = true; + + for (const file of files) { + handled = handled && handleFileEvent({ editor, file, uploadsPath, renderMarkdown, eventHub }); + } + + return handled; +}; + export default Extension.create({ name: 'attachment', @@ -36,25 +50,17 @@ export default Extension.create({ key: new PluginKey('attachment'), props: { handlePaste: (_, event) => { - const { uploadsPath, renderMarkdown, eventHub } = this.options; - - return handleFileEvent({ + return processFiles({ + files: event.clipboardData.files, editor, - file: event.clipboardData.files[0], - uploadsPath, - renderMarkdown, - eventHub, + ...this.options, }); }, handleDrop: (_, event) => { - const { uploadsPath, renderMarkdown, eventHub } = this.options; - - return handleFileEvent({ + return processFiles({ + files: event.dataTransfer.files, editor, - file: event.dataTransfer.files[0], - uploadsPath, - renderMarkdown, - eventHub, + ...this.options, }); }, }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 314d5230b01..b83814103d1 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -29,7 +29,6 @@ export default Link.extend({ addInputRules() { const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?<href>.+?)\)$/gm; - const urlSyntaxRegExp = /(?:^|\s)(?<href>(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; return [ markInputRule({ @@ -37,16 +36,15 @@ export default Link.extend({ type: this.type, getAttributes: extractHrefFromMarkdownLink, }), - markInputRule({ - find: urlSyntaxRegExp, - type: this.type, - getAttributes: extractHrefFromMatch, - }), ]; }, addAttributes() { return { ...this.parent?.(), + uploading: { + default: false, + renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}), + }, href: { default: null, parseHTML: (element) => element.getAttribute('href'), diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js deleted file mode 100644 index 2324e9b132d..00000000000 --- a/app/assets/javascripts/content_editor/extensions/loading.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Node } from '@tiptap/core'; - -export default Node.create({ - name: 'loading', - inline: true, - group: 'inline', - - addAttributes() { - return { - label: { - default: null, - }, - }; - }, - - renderHTML({ node }) { - return [ - 'span', - { class: 'gl-display-inline-flex gl-align-items-center' }, - ['span', { class: 'gl-spinner gl-mx-2' }], - ['span', { class: 'gl-link' }, node.attrs.label], - ]; - }, -}); diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js index 01ffc217894..47766c966a1 100644 --- a/app/assets/javascripts/content_editor/extensions/playable.js +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -61,7 +61,11 @@ export default Node.create({ ...this.extraElementAttrs, }, ], - ['a', { href: node.attrs.src }, node.attrs.title || node.attrs.alt || ''], + [ + 'a', + { href: node.attrs.src, class: 'with-attachment-icon' }, + node.attrs.title || node.attrs.alt || '', + ], ]; }, }); 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 1b366136bfa..3958f77745a 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -40,7 +40,6 @@ 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 MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; @@ -136,7 +135,6 @@ export const createContentEditor = ({ ExternalKeydownHandler.configure({ eventHub }), Link, ListItem, - Loading, MathInline, OrderedList, Paragraph, diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 9ff50b45088..3b77064e903 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -32,7 +32,6 @@ 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 MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; @@ -195,7 +194,6 @@ const defaultSerializerConfig = { inline: true, }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), - [Loading.name]: () => {}, [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: renderReference, diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 48367ac42f5..478b87372d7 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -309,15 +309,13 @@ export function renderHardBreak(state, node, parent, index) { export function renderImage(state, node) { const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; - let realSrc = canonicalSrc || src || ''; + const realSrc = canonicalSrc || src || ''; // eslint-disable-next-line @gitlab/require-i18n-strings - if (realSrc.startsWith('data:')) realSrc = ''; + if (realSrc.startsWith('data:') || realSrc.startsWith('blob:')) return; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; - const sourceExpression = isReference - ? `[${canonicalSrc}]` - : `(${state.esc(realSrc)}${quotedTitle})`; + const sourceExpression = isReference ? `[${canonicalSrc}]` : `(${realSrc}${quotedTitle})`; const sizeAttributes = []; if (width) { diff --git a/app/assets/javascripts/content_editor/services/upload_helpers.js b/app/assets/javascripts/content_editor/services/upload_helpers.js index 548f5cdf19c..f5785397bf0 100644 --- a/app/assets/javascripts/content_editor/services/upload_helpers.js +++ b/app/assets/javascripts/content_editor/services/upload_helpers.js @@ -1,7 +1,33 @@ import { VARIANT_DANGER } from '~/alert'; import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import { extractFilename, readFileAsDataURL } from './utils'; +import { __, sprintf } from '~/locale'; +import { bytesToMiB } from '~/lib/utils/number_utils'; +import TappablePromise from '~/lib/utils/tappable_promise'; +import { ALERT_EVENT } from '../constants'; + +const chain = (editor) => editor.chain().setMeta('preventAutolink', true); + +const findUploadedFilePosition = (editor, filename) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.attrs.uploading === filename) { + position = pos; + return false; + } + + for (const mark of descendant.marks) { + if (mark.type.name === 'link' && mark.attrs.uploading === filename) { + position = pos + 1; + return false; + } + } + + return true; + }); + + return position; +}; export const acceptedMimes = { drawioDiagram: { @@ -47,6 +73,18 @@ const extractAttachmentLinkUrl = (html) => { return { src, canonicalSrc }; }; +class UploadError extends Error {} + +const notifyUploadError = (eventHub, error) => { + eventHub.$emit(ALERT_EVENT, { + message: + error instanceof UploadError + ? error.message + : __('An error occurred while uploading the file. Please try again.'), + variant: VARIANT_DANGER, + }); +}; + /** * Uploads a file with a post request to the URL indicated * in the uploadsPath parameter. The expected response of the @@ -64,85 +102,147 @@ const extractAttachmentLinkUrl = (html) => { * and returns a rendered version in HTML format. * @param {File} params.file The file to upload * - * @returns Returns an object with two properties: + * @returns {TappablePromise} 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); +export const uploadFile = ({ uploadsPath, renderMarkdown, file }) => { + return new TappablePromise(async (tap) => { + const maxFileSize = (gon.max_file_size || 10).toFixed(0); + const fileSize = bytesToMiB(file.size); + if (fileSize > maxFileSize) { + throw new UploadError( + sprintf(__('File is too big (%{fileSize}MiB). Max filesize: %{maxFileSize}MiB.'), { + fileSize: fileSize.toFixed(2), + maxFileSize, + }), + ); + } - const { data } = await axios.post(uploadsPath, formData); - const { markdown } = data.link; - const rendered = await renderMarkdown(markdown); + const formData = new FormData(); + formData.append('file', file, file.name); - return extractAttachmentLinkUrl(rendered); + const { data } = await axios.post(uploadsPath, formData, { + onUploadProgress: (e) => tap(e.loaded / e.total), + }); + const { markdown } = data.link; + const rendered = await renderMarkdown(markdown); + + return extractAttachmentLinkUrl(rendered); + }); }; -const uploadContent = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => { - const encodedSrc = await readFileAsDataURL(file); - const { view } = editor; +const uploadMedia = async ({ type, editor, file, uploadsPath, renderMarkdown, eventHub }) => { + // needed to avoid mismatched transaction error + await Promise.resolve(); + + const objectUrl = URL.createObjectURL(file); + const { selection } = editor.view.state; + const currentNode = selection.$to.node(); + + let position = selection.to; + let content = { + type, + attrs: { uploading: file.name, src: objectUrl, alt: file.name }, + }; + let selectionIncrement = 0; + + // if the current node is not empty, we need to wrap the content in a new paragraph + if (currentNode.content.size > 0 || currentNode.type.name === 'doc') { + content = { + type: 'paragraph', + content: [content], + }; + selectionIncrement = 1; + } - editor.commands.insertContent({ type, attrs: { uploading: true, src: encodedSrc } }); + chain(editor) + .insertContentAt(position, content) + .setNodeSelection(position + selectionIncrement) + .run(); - const { state } = view; - const position = state.selection.from - 1; - const { tr } = state; + uploadFile({ file, uploadsPath, renderMarkdown }) + .tap((progress) => { + chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run(); + }) + .then(({ canonicalSrc }) => { + // the position might have changed while uploading, so we need to find it again + position = findUploadedFilePosition(editor, file.name); - editor.commands.setNodeSelection(position); + editor.view.dispatch( + editor.state.tr.setMeta('preventAutolink', true).setNodeMarkup(position, undefined, { + uploading: false, + src: objectUrl, + alt: file.name, + canonicalSrc, + }), + ); - try { - const { src, canonicalSrc } = await uploadFile({ file, uploadsPath, renderMarkdown }); + chain(editor).setNodeSelection(position).run(); + }) + .catch((e) => { + position = findUploadedFilePosition(editor, file.name); - view.dispatch( - tr.setNodeMarkup(position, undefined, { - uploading: false, - src: encodedSrc, - alt: extractFilename(src), - canonicalSrc, - }), - ); + chain(editor) + .deleteRange({ from: position, to: position + 1 }) + .run(); - editor.commands.setNodeSelection(position); - } catch (e) { - editor.commands.deleteRange({ from: position, to: position + 1 }); - eventHub.$emit('alert', { - message: __('An error occurred while uploading the file. Please try again.'), - variant: VARIANT_DANGER, + notifyUploadError(eventHub, e); }); - } }; const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { + // needed to avoid mismatched transaction error await Promise.resolve(); - const { view } = editor; + const objectUrl = URL.createObjectURL(file); + const { selection } = editor.view.state; + const currentNode = selection.$to.node(); + + let position = selection.to; + let content = { + type: 'text', + text: file.name, + marks: [{ type: 'link', attrs: { href: objectUrl, uploading: file.name } }], + }; - const text = extractFilename(file.name); + // if the current node is not empty, we need to wrap the content in a new paragraph + if (currentNode.content.size > 0 || currentNode.type.name === 'doc') { + content = { + type: 'paragraph', + content: [content], + }; + } - const { state } = view; - const { from } = state.selection; + chain(editor).insertContentAt(position, content).extendMarkRange('link').run(); - editor.commands.insertContent({ - type: 'loading', - attrs: { label: text }, - }); + uploadFile({ file, uploadsPath, renderMarkdown }) + .tap((progress) => { + chain(editor).setMeta('uploadProgress', { filename: file.name, progress }).run(); + }) + .then(({ src, canonicalSrc }) => { + // the position might have changed while uploading, so we need to find it again + position = findUploadedFilePosition(editor, file.name); - 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 }); - eventHub.$emit('alert', { - message: __('An error occurred while uploading the file. Please try again.'), - variant: VARIANT_DANGER, + chain(editor) + .setTextSelection(position) + .extendMarkRange('link') + .updateAttributes('link', { href: src, canonicalSrc, uploading: false }) + .run(); + }) + .catch((e) => { + position = findUploadedFilePosition(editor, file.name); + + chain(editor) + .setTextSelection(position) + .extendMarkRange('link') + .unsetLink() + .deleteSelection() + .run(); + + notifyUploadError(eventHub, e); }); - } }; export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => { @@ -150,7 +250,7 @@ export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eve for (const [type, { mimes, ext }] of Object.entries(acceptedMimes)) { if (mimes.includes(file?.type) && (!ext || file?.name.endsWith(ext))) { - uploadContent({ type, editor, file, uploadsPath, renderMarkdown, eventHub }); + uploadMedia({ type, editor, file, uploadsPath, renderMarkdown, eventHub }); return true; } diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index e352fa8a9db..1c128b4aa19 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -4,26 +4,4 @@ export const hasSelection = (tiptapEditor) => { return from < to; }; -/** - * 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) => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.addEventListener('load', (e) => resolve(e.target.result), { once: true }); - reader.readAsDataURL(file); - }); -}; - export const clamp = (n, min, max) => Math.max(Math.min(n, max), min); diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index bcd9fa01278..e2fb24f7b57 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -39,12 +39,12 @@ function collapsed(file) { } function identifier(file) { - const { userOrGroup, project, id } = getDerivedMergeRequestInformation({ + const { namespace, project, id } = getDerivedMergeRequestInformation({ endpoint: file.load_collapsed_diff_url, }); return uuids({ - seeds: [userOrGroup, project, id, file.file_identifier_hash, file.blob?.id], + seeds: [namespace, project, id, file.file_identifier_hash, file.blob?.id], })[0]; } diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js index 6847b8900d2..bc81c0b0a05 100644 --- a/app/assets/javascripts/diffs/utils/merge_request.js +++ b/app/assets/javascripts/diffs/utils/merge_request.js @@ -1,6 +1,6 @@ import { ZERO_CHANGES_ALT_DISPLAY } from '../constants'; -const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i; +const endpointRE = /^(\/?(.+\/)+(.+)\/-\/merge_requests\/(\d+)).*$/i; function getVersionInfo({ endpoint } = {}) { const dummyRoot = 'https://gitlab.com'; @@ -28,7 +28,7 @@ export function updateChangesTabCount({ export function getDerivedMergeRequestInformation({ endpoint } = {}) { let mrPath; - let userOrGroup; + let namespace; let project; let id; let diffId; @@ -36,13 +36,15 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) { const matches = endpointRE.exec(endpoint); if (matches) { - [, mrPath, userOrGroup, project, id] = matches; + [, mrPath, namespace, project, id] = matches; ({ diffId, startSha } = getVersionInfo({ endpoint })); + + namespace = namespace.replace(/\/$/, ''); } return { mrPath, - userOrGroup, + namespace, project, id, diffId, diff --git a/app/assets/javascripts/lib/utils/tappable_promise.js b/app/assets/javascripts/lib/utils/tappable_promise.js new file mode 100644 index 00000000000..8d327dabe1b --- /dev/null +++ b/app/assets/javascripts/lib/utils/tappable_promise.js @@ -0,0 +1,49 @@ +/** + * A promise that is also tappable, i.e. something you can subscribe + * to to get progress of a promise until it resolves. + * + * @example Usage + * const tp = new TappablePromise((resolve, reject, tap) => { + * for (let i = 0; i < 10; i++) { + * tap(i/10); + * } + * resolve(); + * }); + * + * tp.tap((progress) => { + * console.log(progress); + * }).then(() => { + * console.log('done'); + * }); + * + * // Output: + * // 0 + * // 0.1 + * // 0.2 + * // ... + * // 0.9 + * // done + * + * + * @param {(resolve: Function, reject: Function, tap: Function) => void} callback + * @returns {Promise & { tap: Function }}} + */ +export default function TappablePromise(callback) { + let progressCallback; + + const promise = new Promise((resolve, reject) => { + try { + const tap = (progress) => progressCallback?.(progress); + resolve(callback(tap, resolve, reject)); + } catch (e) { + reject(e); + } + }); + + promise.tap = function tap(_progressCallback) { + progressCallback = _progressCallback; + return this; + }; + + return promise; +} diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index cfe4baaa1f9..bde7d219e9f 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -67,7 +67,7 @@ export default { </script> <template> <div - class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white" + class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-lg gl-border-gray-100 gl-bg-white gl-overflow-hidden" > <div v-if="withAlertContainer" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 3c4070105d1..518b28afd9b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -350,8 +350,7 @@ export default { <template> <div ref="gl-form" - :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }" - class="js-vue-markdown-field md-area position-relative gfm-form" + class="js-vue-markdown-field md-area position-relative gfm-form gl-border-none! gl-shadow-none!" :data-uploads-path="uploadsPath" > <markdown-header diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 06d1779b180..af78cc7b5ca 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -218,7 +218,7 @@ export default { }; </script> <template> - <div> + <div class="md-area gl-px-0! gl-overflow-hidden"> <local-storage-sync :value="editingMode" as-string diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index f9f4bf260a1..e10a82b5197 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -142,10 +142,13 @@ export default { return this.isNewDiscussion ? __('Comment') : __('Reply'); }, timelineEntryClass() { - return this.isNewDiscussion - ? 'timeline-entry note-form' - : // eslint-disable-next-line @gitlab/require-i18n-strings - 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix gl-bg-white! gl-pt-0!'; + return { + 'timeline-entry note-form': this.isNewDiscussion, + // eslint-disable-next-line @gitlab/require-i18n-strings + 'note note-wrapper note-comment discussion-reply-holder gl-border-t-0! clearfix': !this + .isNewDiscussion, + 'gl-bg-white! gl-pt-0!': this.isEditing, + }; }, }, watch: { diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index f9f24366725..cea28b30d42 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -208,7 +208,7 @@ export default { :form-field-props="formFieldProps" :add-spacing-classes="false" data-testid="work-item-add-comment" - class="gl-mb-3" + class="gl-mb-5" use-bottom-toolbar supports-quick-actions :autofocus="autofocus" diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index f3c94732aae..a4cbc430b84 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -69,9 +69,6 @@ export default { update(data) { return data.workspace.workItems.nodes[0]; }, - skip() { - return !this.workItemIid; - }, result() { if (this.isEditing) { this.checkForConflicts(); diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss index a0cbf4fcd43..4e3fb819f4c 100644 --- a/app/assets/stylesheets/components/content_editor.scss +++ b/app/assets/stylesheets/components/content_editor.scss @@ -24,11 +24,21 @@ } } - img.ProseMirror-selectednode { - outline: 3px solid rgba($blue-400, 0.48); + img.ProseMirror-selectednode, + .ProseMirror-selectednode audio, + .ProseMirror-selectednode video { + outline: 3px solid $blue-200; outline-offset: -3px; } + video { + max-width: 400px; + } + + img { + max-width: 100%; + } + ul[data-type='taskList'] { list-style: none; padding: 0; |