diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index f40b0bdc17b..bd11c8383c3 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -11,6 +11,7 @@ import {initTextExpander} from './TextExpander.js'; import {showErrorToast} from '../../modules/toast.js'; import {POST} from '../../modules/fetch.js'; import {initTextareaMarkdown} from './EditorMarkdown.js'; +import {initDropzone} from '../dropzone.js'; let elementIdCounter = 0; @@ -47,7 +48,7 @@ class ComboMarkdownEditor { this.prepareEasyMDEToolbarActions(); this.setupContainer(); this.setupTab(); - this.setupDropzone(); + await this.setupDropzone(); // textarea depends on dropzone this.setupTextarea(); await this.switchToUserPreference(); @@ -114,13 +115,30 @@ class ComboMarkdownEditor { } } - setupDropzone() { + async setupDropzone() { const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); if (dropzoneParentContainer) { this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); + if (this.dropzone) this.attachedDropzoneInst = await initDropzone(this.dropzone); } } + dropzoneGetFiles() { + if (!this.dropzone) return null; + return Array.from(this.dropzone.querySelectorAll('.files [name=files]'), (el) => el.value); + } + + dropzoneReloadFiles() { + if (!this.dropzone) return; + this.attachedDropzoneInst.emit('reload'); + } + + dropzoneSubmitReload() { + if (!this.dropzone) return; + this.attachedDropzoneInst.emit('submit'); + this.attachedDropzoneInst.emit('reload'); + } + setupTab() { const tabs = this.container.querySelectorAll('.tabular.menu > .item'); diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index b3acaf5e6f0..8d70fc774b1 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -1,14 +1,14 @@ -import $ from 'jquery'; import {svg} from '../svg.js'; import {htmlEscape} from 'escape-goat'; import {clippie} from 'clippie'; import {showTemporaryTooltip} from '../modules/tippy.js'; -import {POST} from '../modules/fetch.js'; +import {GET, POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; +import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js'; const {csrfToken, i18n} = window.config; -export async function createDropzone(el, opts) { +async function createDropzone(el, opts) { const [{Dropzone}] = await Promise.all([ import(/* webpackChunkName: "dropzone" */'dropzone'), import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'), @@ -16,65 +16,119 @@ export async function createDropzone(el, opts) { return new Dropzone(el, opts); } -export function initGlobalDropzone() { - for (const el of document.querySelectorAll('.dropzone')) { - initDropzone(el); - } +function addCopyLink(file) { + // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard + // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone + const copyLinkEl = createElementFromHTML(` +
+ ${svg('octicon-copy', 14)} Copy link +
`); + copyLinkEl.addEventListener('click', async (e) => { + e.preventDefault(); + let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; + if (file.type?.startsWith('image/')) { + fileMarkdown = `!${fileMarkdown}`; + } else if (file.type?.startsWith('video/')) { + fileMarkdown = ``; + } + const success = await clippie(fileMarkdown); + showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); + }); + file.previewTemplate.append(copyLinkEl); } -export function initDropzone(el) { - const $dropzone = $(el); - const _promise = createDropzone(el, { - url: $dropzone.data('upload-url'), +/** + * @param {HTMLElement} dropzoneEl + */ +export async function initDropzone(dropzoneEl) { + const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url'); + const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url'); + const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url'); + + let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event + let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone + const opts = { + url: dropzoneEl.getAttribute('data-upload-url'), headers: {'X-Csrf-Token': csrfToken}, - maxFiles: $dropzone.data('max-file'), - maxFilesize: $dropzone.data('max-size'), - acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), + acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')) ? null : dropzoneEl.getAttribute('data-accepts'), addRemoveLinks: true, - dictDefaultMessage: $dropzone.data('default-message'), - dictInvalidFileType: $dropzone.data('invalid-input-type'), - dictFileTooBig: $dropzone.data('file-too-big'), - dictRemoveFile: $dropzone.data('remove-file'), + dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'), + dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'), + dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'), + dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'), timeout: 0, thumbnailMethod: 'contain', thumbnailWidth: 480, thumbnailHeight: 480, - init() { - this.on('success', (file, data) => { - file.uuid = data.uuid; - const $input = $(``).val(data.uuid); - $dropzone.find('.files').append($input); - // Create a "Copy Link" element, to conveniently copy the image - // or file link as Markdown to the clipboard - const copyLinkElement = document.createElement('div'); - copyLinkElement.className = 'tw-text-center'; - // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone - copyLinkElement.innerHTML = `${svg('octicon-copy', 14, 'copy link')} Copy link`; - copyLinkElement.addEventListener('click', async (e) => { - e.preventDefault(); - let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; - if (file.type.startsWith('image/')) { - fileMarkdown = `!${fileMarkdown}`; - } else if (file.type.startsWith('video/')) { - fileMarkdown = ``; - } - const success = await clippie(fileMarkdown); - showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); - }); - file.previewTemplate.append(copyLinkElement); - }); - this.on('removedfile', (file) => { - $(`#${file.uuid}`).remove(); - if ($dropzone.data('remove-url')) { - POST($dropzone.data('remove-url'), { - data: new URLSearchParams({file: file.uuid}), - }); - } - }); - this.on('error', function (file, message) { - showErrorToast(message); - this.removeFile(file); - }); - }, + }; + if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file')); + if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size')); + + // there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like: + // "http://localhost:3000/owner/repo/issues/[object%20Event]" + // the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '' + const dzInst = await createDropzone(dropzoneEl, opts); + dzInst.on('success', (file, data) => { + file.uuid = data.uuid; + fileUuidDict[file.uuid] = {submitted: false}; + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${data.uuid}`, value: data.uuid}); + dropzoneEl.querySelector('.files').append(input); + addCopyLink(file); + }); + + dzInst.on('removedfile', async (file) => { + if (disableRemovedfileEvent) return; + document.querySelector(`#dropzone-file-${file.uuid}`)?.remove(); + // when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server + if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) { + await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})}); + } + }); + + dzInst.on('submit', () => { + for (const fileUuid of Object.keys(fileUuidDict)) { + fileUuidDict[fileUuid].submitted = true; + } }); + + dzInst.on('reload', async () => { + try { + const resp = await GET(listAttachmentsUrl); + const respData = await resp.json(); + // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server + disableRemovedfileEvent = true; + dzInst.removeAllFiles(true); + disableRemovedfileEvent = false; + + dropzoneEl.querySelector('.files').innerHTML = ''; + for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove(); + fileUuidDict = {}; + for (const attachment of respData) { + const imgSrc = `${attachmentBaseLinkUrl}/${attachment.uuid}`; + dzInst.emit('addedfile', attachment); + dzInst.emit('thumbnail', attachment, imgSrc); + dzInst.emit('complete', attachment); + addCopyLink(attachment); + fileUuidDict[attachment.uuid] = {submitted: true}; + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${attachment.uuid}`, value: attachment.uuid}); + dropzoneEl.querySelector('.files').append(input); + } + if (!dropzoneEl.querySelector('.dz-preview')) { + dropzoneEl.classList.remove('dz-started'); + } + } catch (error) { + // TODO: if listing the existing attachments failed, it should stop from operating the content or attachments, + // otherwise the attachments might be lost. + showErrorToast(`Failed to load attachments: ${error}`); + console.error(error); + } + }); + + dzInst.on('error', (file, message) => { + showErrorToast(`Dropzone upload error: ${message}`); + dzInst.removeFile(file); + }); + + if (listAttachmentsUrl) dzInst.emit('reload'); + return dzInst; } diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js index aa9ca657b09..f25da911df1 100644 --- a/web_src/js/features/repo-editor.js +++ b/web_src/js/features/repo-editor.js @@ -5,6 +5,7 @@ import {hideElem, queryElems, showElem} from '../utils/dom.js'; import {initMarkupContent} from '../markup/content.js'; import {attachRefIssueContextPopup} from './contextpopup.js'; import {POST} from '../modules/fetch.js'; +import {initDropzone} from './dropzone.js'; function initEditPreviewTab($form) { const $tabMenu = $form.find('.repo-editor-menu'); @@ -41,8 +42,11 @@ function initEditPreviewTab($form) { } export function initRepoEditor() { - const $editArea = $('.repository.editor textarea#edit_area'); - if (!$editArea.length) return; + const dropzoneUpload = document.querySelector('.page-content.repository.editor.upload .dropzone'); + if (dropzoneUpload) initDropzone(dropzoneUpload); + + const editArea = document.querySelector('.page-content.repository.editor textarea#edit_area'); + if (!editArea) return; for (const el of queryElems('.js-quick-pull-choice-option')) { el.addEventListener('input', () => { @@ -108,7 +112,7 @@ export function initRepoEditor() { initEditPreviewTab($form); (async () => { - const editor = await createCodeEditor($editArea[0], filenameInput); + const editor = await createCodeEditor(editArea, filenameInput); // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage // to enable or disable the commit button @@ -142,7 +146,7 @@ export function initRepoEditor() { commitButton?.addEventListener('click', (e) => { // A modal which asks if an empty file should be committed - if (!$editArea.val()) { + if (!editArea.value) { $('#edit-empty-content-modal').modal({ onApprove() { $('.edit.form').trigger('submit'); diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js index 5fafdcf17c1..8bc0c02bcb4 100644 --- a/web_src/js/features/repo-issue-edit.js +++ b/web_src/js/features/repo-issue-edit.js @@ -1,15 +1,12 @@ import $ from 'jquery'; import {handleReply} from './repo-issue.js'; import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; -import {createDropzone} from './dropzone.js'; -import {GET, POST} from '../modules/fetch.js'; +import {POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; import {hideElem, showElem} from '../utils/dom.js'; import {attachRefIssueContextPopup} from './contextpopup.js'; import {initCommentContent, initMarkupContent} from '../markup/content.js'; -const {csrfToken} = window.config; - async function onEditContent(event) { event.preventDefault(); @@ -20,114 +17,27 @@ async function onEditContent(event) { let comboMarkdownEditor; - /** - * @param {HTMLElement} dropzone - */ - const setupDropzone = async (dropzone) => { - if (!dropzone) return null; - - let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event - let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone - const dz = await createDropzone(dropzone, { - url: dropzone.getAttribute('data-upload-url'), - headers: {'X-Csrf-Token': csrfToken}, - maxFiles: dropzone.getAttribute('data-max-file'), - maxFilesize: dropzone.getAttribute('data-max-size'), - acceptedFiles: ['*/*', ''].includes(dropzone.getAttribute('data-accepts')) ? null : dropzone.getAttribute('data-accepts'), - addRemoveLinks: true, - dictDefaultMessage: dropzone.getAttribute('data-default-message'), - dictInvalidFileType: dropzone.getAttribute('data-invalid-input-type'), - dictFileTooBig: dropzone.getAttribute('data-file-too-big'), - dictRemoveFile: dropzone.getAttribute('data-remove-file'), - timeout: 0, - thumbnailMethod: 'contain', - thumbnailWidth: 480, - thumbnailHeight: 480, - init() { - this.on('success', (file, data) => { - file.uuid = data.uuid; - fileUuidDict[file.uuid] = {submitted: false}; - const input = document.createElement('input'); - input.id = data.uuid; - input.name = 'files'; - input.type = 'hidden'; - input.value = data.uuid; - dropzone.querySelector('.files').append(input); - }); - this.on('removedfile', async (file) => { - document.querySelector(`#${file.uuid}`)?.remove(); - if (disableRemovedfileEvent) return; - if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) { - try { - await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})}); - } catch (error) { - console.error(error); - } - } - }); - this.on('submit', () => { - for (const fileUuid of Object.keys(fileUuidDict)) { - fileUuidDict[fileUuid].submitted = true; - } - }); - this.on('reload', async () => { - try { - const response = await GET(editContentZone.getAttribute('data-attachment-url')); - const data = await response.json(); - // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server - disableRemovedfileEvent = true; - dz.removeAllFiles(true); - dropzone.querySelector('.files').innerHTML = ''; - for (const el of dropzone.querySelectorAll('.dz-preview')) el.remove(); - fileUuidDict = {}; - disableRemovedfileEvent = false; - - for (const attachment of data) { - const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`; - dz.emit('addedfile', attachment); - dz.emit('thumbnail', attachment, imgSrc); - dz.emit('complete', attachment); - fileUuidDict[attachment.uuid] = {submitted: true}; - dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; - const input = document.createElement('input'); - input.id = attachment.uuid; - input.name = 'files'; - input.type = 'hidden'; - input.value = attachment.uuid; - dropzone.querySelector('.files').append(input); - } - if (!dropzone.querySelector('.dz-preview')) { - dropzone.classList.remove('dz-started'); - } - } catch (error) { - console.error(error); - } - }); - }, - }); - dz.emit('reload'); - return dz; - }; - const cancelAndReset = (e) => { e.preventDefault(); showElem(renderContent); hideElem(editContentZone); - comboMarkdownEditor.attachedDropzoneInst?.emit('reload'); + comboMarkdownEditor.dropzoneReloadFiles(); }; const saveAndRefresh = async (e) => { e.preventDefault(); + renderContent.classList.add('is-loading'); showElem(renderContent); hideElem(editContentZone); - const dropzoneInst = comboMarkdownEditor.attachedDropzoneInst; try { const params = new URLSearchParams({ content: comboMarkdownEditor.value(), context: editContentZone.getAttribute('data-context'), content_version: editContentZone.getAttribute('data-content-version'), }); - for (const fileInput of dropzoneInst?.element.querySelectorAll('.files [name=files]')) params.append('files[]', fileInput.value); + for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) { + params.append('files[]', file); + } const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); const data = await response.json(); @@ -155,12 +65,14 @@ async function onEditContent(event) { } else { content.querySelector('.dropzone-attachments').outerHTML = data.attachments; } - dropzoneInst?.emit('submit'); - dropzoneInst?.emit('reload'); + comboMarkdownEditor.dropzoneSubmitReload(); initMarkupContent(); initCommentContent(); } catch (error) { + showErrorToast(`Failed to save the content: ${error}`); console.error(error); + } finally { + renderContent.classList.remove('is-loading'); } }; @@ -168,7 +80,6 @@ async function onEditContent(event) { if (!comboMarkdownEditor) { editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML; comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); - comboMarkdownEditor.attachedDropzoneInst = await setupDropzone(editContentZone.querySelector('.dropzone')); editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset); editContentZone.querySelector('.ui.primary.button').addEventListener('click', saveAndRefresh); } @@ -176,6 +87,7 @@ async function onEditContent(event) { // Show write/preview tab and copy raw content as needed showElem(editContentZone); hideElem(renderContent); + // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data if (!comboMarkdownEditor.value()) { comboMarkdownEditor.value(rawContent.textContent); } @@ -196,8 +108,8 @@ export function initRepoIssueCommentEdit() { let editor; if (this.classList.contains('quote-reply-diff')) { - const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply'); - editor = await handleReply($replyBtn); + const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); + editor = await handleReply(replyBtn); } else { // for normal issue/comment page editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index a754e2ae9a5..57c4f19163b 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -5,7 +5,6 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js'; import {setFileFolding} from './file-fold.js'; import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; import {toAbsoluteUrl} from '../utils.js'; -import {initDropzone} from './dropzone.js'; import {GET, POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; @@ -410,21 +409,13 @@ export function initRepoIssueComments() { }); } -export async function handleReply($el) { - hideElem($el); - const $form = $el.closest('.comment-code-cloud').find('.comment-form'); - showElem($form); - - const $textarea = $form.find('textarea'); - let editor = getComboMarkdownEditor($textarea); - if (!editor) { - // FIXME: the initialization of the dropzone is not consistent. - // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized. - // When the form is submitted and partially reload, none of them is initialized. - const dropzone = $form.find('.dropzone')[0]; - if (!dropzone.dropzone) initDropzone(dropzone); - editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor')); - } +export async function handleReply(el) { + const form = el.closest('.comment-code-cloud').querySelector('.comment-form'); + const textarea = form.querySelector('textarea'); + + hideElem(el); + showElem(form); + const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor')); editor.focus(); return editor; } @@ -486,7 +477,7 @@ export function initRepoPullRequestReview() { $(document).on('click', 'button.comment-form-reply', async function (e) { e.preventDefault(); - await handleReply($(this)); + await handleReply(this); }); const $reviewBox = $('.review-box-panel'); @@ -554,8 +545,6 @@ export function initRepoPullRequestReview() { $td.find("input[name='line']").val(idx); $td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed'); $td.find("input[name='path']").val(path); - - initDropzone($td.find('.dropzone')[0]); const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor')); editor.focus(); } catch (error) { diff --git a/web_src/js/index.js b/web_src/js/index.js index 35d69706355..8aff052664b 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -91,7 +91,6 @@ import { initGlobalDeleteButton, initGlobalShowModal, } from './features/common-button.js'; -import {initGlobalDropzone} from './features/dropzone.js'; import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.js'; initGiteaFomantic(); @@ -135,7 +134,6 @@ onDomReady(() => { initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalCopyToClipboardListener, - initGlobalDropzone, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm, initGlobalDeleteButton, diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 7289f19cbfe..57c7c8796ac 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -304,3 +304,17 @@ export function createElementFromHTML(htmlString) { div.innerHTML = htmlString.trim(); return div.firstChild; } + +export function createElementFromAttrs(tagName, attrs) { + const el = document.createElement(tagName); + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === null) continue; + if (value === true) { + el.toggleAttribute(key, value); + } else { + el.setAttribute(key, String(value)); + } + // TODO: in the future we could make it also support "textContent" and "innerHTML" properties if needed + } + return el; +} diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.js index fd7d97cad5e..b9212ec284a 100644 --- a/web_src/js/utils/dom.test.js +++ b/web_src/js/utils/dom.test.js @@ -1,5 +1,16 @@ -import {createElementFromHTML} from './dom.js'; +import {createElementFromAttrs, createElementFromHTML} from './dom.js'; test('createElementFromHTML', () => { expect(createElementFromHTML('foobar').outerHTML).toEqual('foobar'); }); + +test('createElementFromAttrs', () => { + const el = createElementFromAttrs('button', { + id: 'the-id', + class: 'cls-1 cls-2', + 'data-foo': 'the-data', + disabled: true, + required: null, + }); + expect(el.outerHTML).toEqual(''); +});