fix(ui): Add pasted images to dropzone

This adds pasted images to the dropzone to provide the same experience
as when using the dropzone. This gives the possibility to preview and
delete the image. Additionally it provides a copy button to copy the
markdown code for inserting the image.

Removed the old implementation in `repo-legacy.js` and generalized
everything in `common-global.js` as common implementation.

Replaced old jquery code with plain JS.

Fixes #4588
This commit is contained in:
Beowulf 2025-05-02 00:57:43 +02:00
parent ad1adabcbb
commit 6e66380408
No known key found for this signature in database
GPG key ID: 44225F5F2792841D
10 changed files with 303 additions and 158 deletions

View file

@ -208,59 +208,128 @@ export function initGlobalDropzone() {
}
}
export function initDropzone(el) {
const $dropzone = $(el);
const _promise = createDropzone(el, {
url: $dropzone.data('upload-url'),
export async function initDropzone(dropzoneEl, zone = undefined) {
if (!dropzoneEl) return;
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 initFilePreview = (file, data, isReload = false) => {
file.uuid = data.uuid;
fileUuidDict[file.uuid] = {submitted: isReload};
const input = document.createElement('input');
input.id = data.uuid;
input.name = 'files';
input.type = 'hidden';
input.value = data.uuid;
dropzoneEl.querySelector('.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();
const name = file.name.slice(0, file.name.lastIndexOf('.'));
let fileMarkdown = `[${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(name)}" controls></video>`;
}
const success = await clippie(fileMarkdown);
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
});
file.previewTemplate.append(copyLinkElement);
};
const updateDropzoneState = () => {
if (dropzoneEl.querySelector('.dz-preview')) {
dropzoneEl.classList.add('dz-started');
} else {
dropzoneEl.classList.remove('dz-started');
}
};
const dz = await createDropzone(dropzoneEl, {
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'),
maxFiles: dropzoneEl.getAttribute('data-max-file'),
maxFilesize: dropzoneEl.getAttribute('data-max-size'),
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>`;
this.on('success', initFilePreview);
this.on('removedfile', async (file) => {
document.getElementById(file.uuid)?.remove();
if (disableRemovedfileEvent) return;
if (dropzoneEl.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) {
try {
await POST(dropzoneEl.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})});
} catch (error) {
console.error(error);
}
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}),
});
}
updateDropzoneState();
});
this.on('error', function (file, message) {
showErrorToast(message);
this.removeFile(file);
});
this.on('reload', async () => {
if (!zone || !dz.removeAllFiles) return;
try {
const response = await GET(zone.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);
dropzoneEl.querySelector('.files').innerHTML = '';
for (const element of dropzoneEl.querySelectorAll('.dz-preview')) element.remove();
fileUuidDict = {};
disableRemovedfileEvent = false;
for (const attachment of data) {
attachment.type = attachment.mime_type;
dz.emit('addedfile', attachment);
dz.emit('complete', attachment);
if (attachment.type.startsWith('image/')) {
const imgSrc = `${dropzoneEl.getAttribute('data-link-url')}/${attachment.uuid}`;
dz.emit('thumbnail', attachment, imgSrc);
}
initFilePreview(attachment, {uuid: attachment.uuid}, true);
fileUuidDict[attachment.uuid] = {submitted: true};
}
} catch (error) {
console.error(error);
}
updateDropzoneState();
});
this.on('create-thumbnail', (attachment, file) => {
if (attachment.type && /image.*/.test(attachment.type)) {
// When a new issue is created, a thumbnail cannot be fetch, so we need to create it locally.
// The implementation is took from the dropzone library (`dropzone.js` > `_processThumbnailQueue()`)
dz.createThumbnail(
file,
dz.options.thumbnailWidth,
dz.options.thumbnailHeight,
dz.options.thumbnailMethod,
true,
(dataUrl) => {
dz.emit('thumbnail', attachment, dataUrl);
},
);
}
});
},
});
}