Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor dropzone #31482

Merged
merged 9 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions web_src/js/features/comp/ComboMarkdownEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
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');

Expand Down
164 changes: 109 additions & 55 deletions web_src/js/features/dropzone.js
Original file line number Diff line number Diff line change
@@ -1,80 +1,134 @@
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'),
]);
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 "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
const copyLinkEl = createElementFromHTML(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
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 = `<video src="/attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`;
}
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 = $(`<input id="${data.uuid}" name="files" type="hidden">`).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 = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
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 = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
}
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 '<img src="[object Event]">'
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;
}
12 changes: 8 additions & 4 deletions web_src/js/features/repo-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
Loading