mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-07-07 09:55:41 +02:00
merge commit: fix(ui): Add pasted images to dropzone (#7749)
Some checks failed
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing-integration / test-sqlite (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
Integration tests for the release process / release-simulation (push) Has been cancelled
Some checks failed
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing-integration / test-sqlite (push) Waiting to run
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions
testing / security-check (push) Blocked by required conditions
Integration tests for the release process / release-simulation (push) Has been cancelled
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:
commit
6e58d285c7
10 changed files with 303 additions and 158 deletions
|
@ -22,6 +22,12 @@ type Attachment struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebAttachment the generic attachment with mime type
|
||||||
|
type WebAttachment struct {
|
||||||
|
*Attachment
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
}
|
||||||
|
|
||||||
// EditAttachmentOptions options for editing attachments
|
// EditAttachmentOptions options for editing attachments
|
||||||
// swagger:model
|
// swagger:model
|
||||||
type EditAttachmentOptions struct {
|
type EditAttachmentOptions struct {
|
||||||
|
|
|
@ -3593,9 +3593,9 @@ func GetIssueAttachments(ctx *context.Context) {
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
attachments := make([]*api.Attachment, len(issue.Attachments))
|
attachments := make([]*api.WebAttachment, len(issue.Attachments))
|
||||||
for i := 0; i < len(issue.Attachments); i++ {
|
for i := 0; i < len(issue.Attachments); i++ {
|
||||||
attachments[i] = convert.ToAttachment(ctx.Repo.Repository, issue.Attachments[i])
|
attachments[i] = convert.ToWebAttachment(ctx.Repo.Repository, issue.Attachments[i])
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, attachments)
|
ctx.JSON(http.StatusOK, attachments)
|
||||||
}
|
}
|
||||||
|
@ -3628,13 +3628,13 @@ func GetCommentAttachments(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
attachments := make([]*api.Attachment, 0)
|
|
||||||
if err := comment.LoadAttachments(ctx); err != nil {
|
if err := comment.LoadAttachments(ctx); err != nil {
|
||||||
ctx.ServerError("LoadAttachments", err)
|
ctx.ServerError("LoadAttachments", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
attachments := make([]*api.WebAttachment, len(comment.Attachments))
|
||||||
for i := 0; i < len(comment.Attachments); i++ {
|
for i := 0; i < len(comment.Attachments); i++ {
|
||||||
attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i]))
|
attachments[i] = convert.ToWebAttachment(ctx.Repo.Repository, comment.Attachments[i])
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, attachments)
|
ctx.JSON(http.StatusOK, attachments)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
package convert
|
package convert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
repo_model "forgejo.org/models/repo"
|
repo_model "forgejo.org/models/repo"
|
||||||
api "forgejo.org/modules/structs"
|
api "forgejo.org/modules/structs"
|
||||||
)
|
)
|
||||||
|
@ -20,9 +23,13 @@ func APIAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachm
|
||||||
return attach.DownloadURL()
|
return attach.DownloadURL()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToAttachment converts models.Attachment to api.Attachment for API usage
|
// ToWebAttachment converts models.Attachment to api.WebAttachment for API usage
|
||||||
func ToAttachment(repo *repo_model.Repository, a *repo_model.Attachment) *api.Attachment {
|
func ToWebAttachment(repo *repo_model.Repository, a *repo_model.Attachment) *api.WebAttachment {
|
||||||
return toAttachment(repo, a, WebAssetDownloadURL)
|
attachment := toAttachment(repo, a, WebAssetDownloadURL)
|
||||||
|
return &api.WebAttachment{
|
||||||
|
Attachment: attachment,
|
||||||
|
MimeType: mime.TypeByExtension(filepath.Ext(attachment.Name)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToAPIAttachment converts models.Attachment to api.Attachment for API usage
|
// ToAPIAttachment converts models.Attachment to api.Attachment for API usage
|
||||||
|
|
56
services/convert/attachment_test.go
Normal file
56
services/convert/attachment_test.go
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
|
"forgejo.org/models/unittest"
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
api "forgejo.org/modules/structs"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToWebAttachment(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
headRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
attachment := &repo_model.Attachment{
|
||||||
|
ID: 10,
|
||||||
|
UUID: "uuidxxx",
|
||||||
|
RepoID: 1,
|
||||||
|
IssueID: 1,
|
||||||
|
ReleaseID: 0,
|
||||||
|
UploaderID: 0,
|
||||||
|
CommentID: 0,
|
||||||
|
Name: "test.png",
|
||||||
|
DownloadCount: 90,
|
||||||
|
Size: 30,
|
||||||
|
NoAutoTime: false,
|
||||||
|
CreatedUnix: 9342,
|
||||||
|
CustomDownloadURL: "",
|
||||||
|
ExternalURL: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
webAttachment := ToWebAttachment(headRepo, attachment)
|
||||||
|
|
||||||
|
assert.NotNil(t, webAttachment)
|
||||||
|
assert.Equal(t, &api.WebAttachment{
|
||||||
|
Attachment: &api.Attachment{
|
||||||
|
ID: 10,
|
||||||
|
Name: "test.png",
|
||||||
|
Created: time.Unix(9342, 0),
|
||||||
|
DownloadCount: 90,
|
||||||
|
Size: 30,
|
||||||
|
UUID: "uuidxxx",
|
||||||
|
DownloadURL: fmt.Sprintf("%sattachments/uuidxxx", setting.AppURL),
|
||||||
|
Type: "attachment",
|
||||||
|
},
|
||||||
|
MimeType: "image/png",
|
||||||
|
}, webAttachment)
|
||||||
|
}
|
94
tests/e2e/issue-comment-dropzone.test.e2e.ts
Normal file
94
tests/e2e/issue-comment-dropzone.test.e2e.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// @watch start
|
||||||
|
// web_src/js/features/common-global.js
|
||||||
|
// web_src/js/features/comp/Paste.js
|
||||||
|
// web_src/js/features/repo-issue.js
|
||||||
|
// web_src/js/features/repo-legacy.js
|
||||||
|
// @watch end
|
||||||
|
|
||||||
|
import {expect, type Locator, type Page, type TestInfo} from '@playwright/test';
|
||||||
|
import {test, save_visual, dynamic_id} from './utils_e2e.ts';
|
||||||
|
|
||||||
|
test.use({user: 'user2'});
|
||||||
|
|
||||||
|
async function pasteImage(el: Locator) {
|
||||||
|
await el.evaluate(async (el) => {
|
||||||
|
const base64 = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAAHklEQVQoU2MUk1P7z0AGYBzViDvURgMHT4oaQoEDAFuJEu2fuGfhAAAAAElFTkSuQmCC`;
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
const response = await fetch(base64);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
el.focus();
|
||||||
|
|
||||||
|
let pasteEvent = new Event('paste', {bubbles: true, cancelable: true});
|
||||||
|
pasteEvent = Object.assign(pasteEvent, {
|
||||||
|
clipboardData: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
kind: 'file',
|
||||||
|
type: 'image/png',
|
||||||
|
getAsFile() {
|
||||||
|
return new File([blob], 'foo.png', {type: blob.type});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
el.dispatchEvent(pasteEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCopy(page: Page, workerInfo: TestInfo, startWith: string) {
|
||||||
|
const project = workerInfo.project.name;
|
||||||
|
if (project === 'webkit' || project === 'Mobile Safari') return;
|
||||||
|
|
||||||
|
const dropzone = page.locator('.dropzone');
|
||||||
|
const preview = dropzone.locator('.dz-preview');
|
||||||
|
const copyLink = preview.locator('.octicon-copy').locator('..');
|
||||||
|
await copyLink.click();
|
||||||
|
|
||||||
|
const clipboardContent = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(clipboardContent).toContain(startWith);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Paste image in new comment', async ({page}, workerInfo) => {
|
||||||
|
await page.goto('/user2/repo1/issues/new');
|
||||||
|
|
||||||
|
await pasteImage(page.locator('.markdown-text-editor'));
|
||||||
|
|
||||||
|
const dropzone = page.locator('.dropzone');
|
||||||
|
await expect(dropzone.locator('.files')).toHaveCount(1);
|
||||||
|
const preview = dropzone.locator('.dz-preview');
|
||||||
|
await expect(preview).toHaveCount(1);
|
||||||
|
await expect(preview.locator('.dz-filename')).toHaveText('foo.png');
|
||||||
|
await expect(preview.locator('.octicon-copy')).toBeVisible();
|
||||||
|
await assertCopy(page, workerInfo, ';
|
||||||
|
|
||||||
|
await save_visual(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Re-add images to dropzone on edit', async ({page}, workerInfo) => {
|
||||||
|
await page.goto('/user2/repo1/issues/new');
|
||||||
|
|
||||||
|
const issueTitle = dynamic_id();
|
||||||
|
await page.locator('#issue_title').fill(issueTitle);
|
||||||
|
await pasteImage(page.locator('.markdown-text-editor'));
|
||||||
|
await page.getByRole('button', {name: 'Create issue'}).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/user2\/repo1\/issues\/\d+$/);
|
||||||
|
await page.click('.comment-container .context-menu');
|
||||||
|
await page.click('.comment-container .menu > .edit-content');
|
||||||
|
|
||||||
|
const dropzone = page.locator('.dropzone');
|
||||||
|
await expect(dropzone.locator('.files').first()).toHaveCount(1);
|
||||||
|
const preview = dropzone.locator('.dz-preview');
|
||||||
|
await expect(preview).toHaveCount(1);
|
||||||
|
await expect(preview.locator('.dz-filename')).toHaveText('foo.png');
|
||||||
|
await expect(preview.locator('.octicon-copy')).toBeVisible();
|
||||||
|
await assertCopy(page, workerInfo, ';
|
||||||
|
|
||||||
|
await save_visual(page);
|
||||||
|
});
|
|
@ -629,7 +629,12 @@ func TestIssueCommentAttachment(t *testing.T) {
|
||||||
assert.NotEqual(t, 0, id)
|
assert.NotEqual(t, 0, id)
|
||||||
|
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user2", "repo1", id))
|
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user2", "repo1", id))
|
||||||
session.MakeRequest(t, req, http.StatusOK)
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
var attachments []*api.WebAttachment
|
||||||
|
DecodeJSON(t, resp, &attachments)
|
||||||
|
assert.Len(t, attachments, 1)
|
||||||
|
assert.Equal(t, attachments[0].UUID, uuid)
|
||||||
|
assert.Equal(t, "image/png", attachments[0].MimeType)
|
||||||
|
|
||||||
// Using the ID of a comment that does not belong to the repository must fail
|
// Using the ID of a comment that does not belong to the repository must fail
|
||||||
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user5", "repo4", id))
|
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user5", "repo4", id))
|
||||||
|
|
|
@ -208,28 +208,22 @@ export function initGlobalDropzone() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initDropzone(el) {
|
export async function initDropzone(dropzoneEl, zone = undefined) {
|
||||||
const $dropzone = $(el);
|
if (!dropzoneEl) return;
|
||||||
const _promise = createDropzone(el, {
|
|
||||||
url: $dropzone.data('upload-url'),
|
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
|
||||||
headers: {'X-Csrf-Token': csrfToken},
|
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
|
||||||
maxFiles: $dropzone.data('max-file'),
|
|
||||||
maxFilesize: $dropzone.data('max-size'),
|
const initFilePreview = (file, data, isReload = false) => {
|
||||||
acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.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'),
|
|
||||||
timeout: 0,
|
|
||||||
thumbnailMethod: 'contain',
|
|
||||||
thumbnailWidth: 480,
|
|
||||||
thumbnailHeight: 480,
|
|
||||||
init() {
|
|
||||||
this.on('success', (file, data) => {
|
|
||||||
file.uuid = data.uuid;
|
file.uuid = data.uuid;
|
||||||
const $input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
|
fileUuidDict[file.uuid] = {submitted: isReload};
|
||||||
$dropzone.find('.files').append($input);
|
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
|
// Create a "Copy Link" element, to conveniently copy the image
|
||||||
// or file link as Markdown to the clipboard
|
// or file link as Markdown to the clipboard
|
||||||
const copyLinkElement = document.createElement('div');
|
const copyLinkElement = document.createElement('div');
|
||||||
|
@ -238,29 +232,104 @@ export function initDropzone(el) {
|
||||||
copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
|
copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
|
||||||
copyLinkElement.addEventListener('click', async (e) => {
|
copyLinkElement.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
|
const name = file.name.slice(0, file.name.lastIndexOf('.'));
|
||||||
|
let fileMarkdown = `[${name}](/attachments/${file.uuid})`;
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
fileMarkdown = `!${fileMarkdown}`;
|
fileMarkdown = `!${fileMarkdown}`;
|
||||||
} else if (file.type.startsWith('video/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
|
fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(name)}" controls></video>`;
|
||||||
}
|
}
|
||||||
const success = await clippie(fileMarkdown);
|
const success = await clippie(fileMarkdown);
|
||||||
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
|
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
|
||||||
});
|
});
|
||||||
file.previewTemplate.append(copyLinkElement);
|
file.previewTemplate.append(copyLinkElement);
|
||||||
});
|
};
|
||||||
this.on('removedfile', (file) => {
|
const updateDropzoneState = () => {
|
||||||
$(`#${file.uuid}`).remove();
|
if (dropzoneEl.querySelector('.dz-preview')) {
|
||||||
if ($dropzone.data('remove-url')) {
|
dropzoneEl.classList.add('dz-started');
|
||||||
POST($dropzone.data('remove-url'), {
|
} else {
|
||||||
data: new URLSearchParams({file: file.uuid}),
|
dropzoneEl.classList.remove('dz-started');
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dz = await createDropzone(dropzoneEl, {
|
||||||
|
url: dropzoneEl.getAttribute('data-upload-url'),
|
||||||
|
headers: {'X-Csrf-Token': csrfToken},
|
||||||
|
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: 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', 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateDropzoneState();
|
||||||
});
|
});
|
||||||
this.on('error', function (file, message) {
|
this.on('error', function (file, message) {
|
||||||
showErrorToast(message);
|
showErrorToast(message);
|
||||||
this.removeFile(file);
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,9 +82,8 @@ class CodeMirrorEditor {
|
||||||
|
|
||||||
async function handleClipboardImages(editor, dropzone, images, e) {
|
async function handleClipboardImages(editor, dropzone, images, e) {
|
||||||
const uploadUrl = dropzone.getAttribute('data-upload-url');
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
@ -92,7 +91,7 @@ async function handleClipboardImages(editor, dropzone, images, e) {
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
const name = img.name.slice(0, img.name.lastIndexOf('.'));
|
const name = img.name.slice(0, img.name.lastIndexOf('.'));
|
||||||
|
|
||||||
const placeholder = ``;
|
const placeholder = ``;
|
||||||
editor.insertPlaceholder(placeholder);
|
editor.insertPlaceholder(placeholder);
|
||||||
|
|
||||||
const {uuid} = await uploadFile(img, uploadUrl);
|
const {uuid} = await uploadFile(img, uploadUrl);
|
||||||
|
@ -101,12 +100,11 @@ async function handleClipboardImages(editor, dropzone, images, e) {
|
||||||
const text = ``;
|
const text = ``;
|
||||||
editor.replacePlaceholder(placeholder, text);
|
editor.replacePlaceholder(placeholder, text);
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const attachment = {uuid, name: img.name, browser_download_url: url, size: img.size, type: img.type};
|
||||||
input.setAttribute('name', 'files');
|
dropzone.dropzone.emit('addedfile', attachment);
|
||||||
input.setAttribute('type', 'hidden');
|
dropzone.dropzone.emit('create-thumbnail', attachment, img);
|
||||||
input.setAttribute('id', uuid);
|
dropzone.dropzone.emit('complete', attachment);
|
||||||
input.value = uuid;
|
dropzone.dropzone.emit('success', attachment, {uuid});
|
||||||
filesContainer.append(input);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 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.
|
// When the form is submitted and partially reload, none of them is initialized.
|
||||||
const dropzone = $form.find('.dropzone')[0];
|
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 = await initComboMarkdownEditor($form.find('.combo-markdown-editor'));
|
||||||
}
|
}
|
||||||
editor.focus();
|
editor.focus();
|
||||||
|
@ -580,7 +580,7 @@ export function initRepoPullRequestReview() {
|
||||||
$td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
|
$td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
|
||||||
$td.find("input[name='path']").val(path);
|
$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'));
|
const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor'));
|
||||||
editor.focus();
|
editor.focus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
import {initCitationFileCopyContent} from './citation.js';
|
import {initCitationFileCopyContent} from './citation.js';
|
||||||
import {initCompLabelEdit} from './comp/LabelEdit.js';
|
import {initCompLabelEdit} from './comp/LabelEdit.js';
|
||||||
import {initRepoDiffConversationNav} from './repo-diff.js';
|
import {initRepoDiffConversationNav} from './repo-diff.js';
|
||||||
import {createDropzone} from './dropzone.js';
|
|
||||||
import {showErrorToast} from '../modules/toast.js';
|
import {showErrorToast} from '../modules/toast.js';
|
||||||
import {initCommentContent, initMarkupContent} from '../markup/content.js';
|
import {initCommentContent, initMarkupContent} from '../markup/content.js';
|
||||||
import {initCompReactionSelector} from './comp/ReactionSelector.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 {hideElem, showElem} from '../utils/dom.js';
|
||||||
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
|
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
|
||||||
import {attachRefIssueContextPopup} from './contextpopup.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 {MarkdownQuote} from '@github/quote-selection';
|
||||||
import {toAbsoluteUrl} from '../utils.js';
|
import {toAbsoluteUrl} from '../utils.js';
|
||||||
import {initGlobalShowModal} from './common-global.js';
|
import {initDropzone, initGlobalShowModal} from './common-global.js';
|
||||||
|
|
||||||
const {csrfToken} = window.config;
|
|
||||||
|
|
||||||
export function initRepoCommentForm() {
|
export function initRepoCommentForm() {
|
||||||
const $commentForm = $('.comment.form');
|
const $commentForm = $('.comment.form');
|
||||||
|
@ -312,115 +309,27 @@ async function onEditContent(event) {
|
||||||
|
|
||||||
let comboMarkdownEditor;
|
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) => {
|
const cancelAndReset = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showElem(renderContent);
|
showElem(renderContent);
|
||||||
hideElem(editContentZone);
|
hideElem(editContentZone);
|
||||||
comboMarkdownEditor.value(rawContent.textContent);
|
comboMarkdownEditor.value(rawContent.textContent);
|
||||||
comboMarkdownEditor.attachedDropzoneInst?.emit('reload');
|
editContentZone.querySelector('.dropzone')?.dropzone?.emit('reload');
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveAndRefresh = async (e) => {
|
const saveAndRefresh = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showElem(renderContent);
|
showElem(renderContent);
|
||||||
hideElem(editContentZone);
|
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 {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
content: comboMarkdownEditor.value(),
|
content: comboMarkdownEditor.value(),
|
||||||
context: editContentZone.getAttribute('data-context'),
|
context: editContentZone.getAttribute('data-context'),
|
||||||
content_version: editContentZone.getAttribute('data-content-version'),
|
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) {
|
for (const fileInput of files) {
|
||||||
params.append('files[]', fileInput.value);
|
params.append('files[]', fileInput.value);
|
||||||
}
|
}
|
||||||
|
@ -451,8 +360,7 @@ async function onEditContent(event) {
|
||||||
} else {
|
} else {
|
||||||
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
|
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
|
||||||
}
|
}
|
||||||
dropzoneInst?.emit('submit');
|
dropzone?.emit('submit');
|
||||||
dropzoneInst?.emit('reload');
|
|
||||||
initMarkupContent();
|
initMarkupContent();
|
||||||
initCommentContent();
|
initCommentContent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -463,8 +371,10 @@ async function onEditContent(event) {
|
||||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||||
if (!comboMarkdownEditor) {
|
if (!comboMarkdownEditor) {
|
||||||
editContentZone.innerHTML = document.getElementById('issue-comment-editor-template').innerHTML;
|
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 = 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.addEventListener('ce-quick-submit', saveAndRefresh);
|
||||||
editContentZone.querySelector('button[data-button-name="cancel-edit"]').addEventListener('click', cancelAndReset);
|
editContentZone.querySelector('button[data-button-name="cancel-edit"]').addEventListener('click', cancelAndReset);
|
||||||
editContentZone.querySelector('button[data-button-name="save-edit"]').addEventListener('click', saveAndRefresh);
|
editContentZone.querySelector('button[data-button-name="save-edit"]').addEventListener('click', saveAndRefresh);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue