1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-08-04 17:35:21 +02:00

merge commit: fix(ui): Add pasted images to dropzone (#7749)

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7749
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
0ko 2025-06-30 14:30:25 +02:00
commit 6e58d285c7
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);
},
);
}
});
},
});
}

View file

@ -82,9 +82,8 @@ class CodeMirrorEditor {
async function handleClipboardImages(editor, dropzone, images, e) {
const uploadUrl = dropzone.getAttribute('data-upload-url');
const filesContainer = dropzone.querySelector('.files');
if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
if (!dropzone || !uploadUrl || !images.length) return;
e.preventDefault();
e.stopPropagation();
@ -92,7 +91,7 @@ async function handleClipboardImages(editor, dropzone, images, e) {
for (const img of images) {
const name = img.name.slice(0, img.name.lastIndexOf('.'));
const placeholder = `![${name}](uploading ...)`;
const placeholder = `![${name}](uploading...)`;
editor.insertPlaceholder(placeholder);
const {uuid} = await uploadFile(img, uploadUrl);
@ -101,12 +100,11 @@ async function handleClipboardImages(editor, dropzone, images, e) {
const text = `![${name}](${url})`;
editor.replacePlaceholder(placeholder, text);
const input = document.createElement('input');
input.setAttribute('name', 'files');
input.setAttribute('type', 'hidden');
input.setAttribute('id', uuid);
input.value = uuid;
filesContainer.append(input);
const attachment = {uuid, name: img.name, browser_download_url: url, size: img.size, type: img.type};
dropzone.dropzone.emit('addedfile', attachment);
dropzone.dropzone.emit('create-thumbnail', attachment, img);
dropzone.dropzone.emit('complete', attachment);
dropzone.dropzone.emit('success', attachment, {uuid});
}
}

View file

@ -445,7 +445,7 @@ export async function handleReply($el) {
// 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);
if (!dropzone.dropzone) await initDropzone(dropzone);
editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor'));
}
editor.focus();
@ -580,7 +580,7 @@ export function initRepoPullRequestReview() {
$td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
$td.find("input[name='path']").val(path);
initDropzone($td.find('.dropzone')[0]);
await initDropzone($td.find('.dropzone')[0]);
const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor'));
editor.focus();
} catch (error) {

View file

@ -16,7 +16,6 @@ import {
import {initCitationFileCopyContent} from './citation.js';
import {initCompLabelEdit} from './comp/LabelEdit.js';
import {initRepoDiffConversationNav} from './repo-diff.js';
import {createDropzone} from './dropzone.js';
import {showErrorToast} from '../modules/toast.js';
import {initCommentContent, initMarkupContent} from '../markup/content.js';
import {initCompReactionSelector} from './comp/ReactionSelector.js';
@ -26,12 +25,10 @@ import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.js';
import {hideElem, showElem} from '../utils/dom.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
import {POST, GET} from '../modules/fetch.js';
import {POST} from '../modules/fetch.js';
import {MarkdownQuote} from '@github/quote-selection';
import {toAbsoluteUrl} from '../utils.js';
import {initGlobalShowModal} from './common-global.js';
const {csrfToken} = window.config;
import {initDropzone, initGlobalShowModal} from './common-global.js';
export function initRepoCommentForm() {
const $commentForm = $('.comment.form');
@ -312,115 +309,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.getElementById(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.value(rawContent.textContent);
comboMarkdownEditor.attachedDropzoneInst?.emit('reload');
editContentZone.querySelector('.dropzone')?.dropzone?.emit('reload');
};
const saveAndRefresh = async (e) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
const dropzoneInst = comboMarkdownEditor.attachedDropzoneInst;
const dropzone = editContentZone.querySelector('.dropzone')?.dropzone;
for (const element of dropzone?.element?.querySelectorAll('.dz-preview') ?? []) element.classList.remove('dz-success');
try {
const params = new URLSearchParams({
content: comboMarkdownEditor.value(),
context: editContentZone.getAttribute('data-context'),
content_version: editContentZone.getAttribute('data-content-version'),
});
const files = dropzoneInst?.element?.querySelectorAll('.files [name=files]') ?? [];
const files = dropzone?.element?.querySelectorAll('.files [name=files]') ?? [];
for (const fileInput of files) {
params.append('files[]', fileInput.value);
}
@ -451,8 +360,7 @@ async function onEditContent(event) {
} else {
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
}
dropzoneInst?.emit('submit');
dropzoneInst?.emit('reload');
dropzone?.emit('submit');
initMarkupContent();
initCommentContent();
} catch (error) {
@ -463,8 +371,10 @@ async function onEditContent(event) {
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.getElementById('issue-comment-editor-template').innerHTML;
const dropzone = editContentZone.querySelector('.dropzone');
if (!dropzone.dropzone) await initDropzone(dropzone, editContentZone);
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
comboMarkdownEditor.attachedDropzoneInst = await setupDropzone(editContentZone.querySelector('.dropzone'));
dropzone.dropzone.emit('reload');
editContentZone.addEventListener('ce-quick-submit', saveAndRefresh);
editContentZone.querySelector('button[data-button-name="cancel-edit"]').addEventListener('click', cancelAndReset);
editContentZone.querySelector('button[data-button-name="save-edit"]').addEventListener('click', saveAndRefresh);