From ce5745233c3491e95b51a586e110df2a82771130 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Tue, 16 Apr 2024 11:28:30 +0800 Subject: [PATCH 01/12] Improve attachment upload methods --- web_src/js/features/common-global.js | 10 ++-- .../js/features/comp/ComboMarkdownEditor.js | 11 ++-- web_src/js/features/comp/Paste.js | 59 ++++++++++++------- web_src/js/features/repo-issue-edit.js | 24 +++----- web_src/js/features/repo-issue.js | 4 +- web_src/js/utils/dom.js | 21 +++++-- 6 files changed, 77 insertions(+), 52 deletions(-) diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index e7db9b23364bb..43b0329dba8ea 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -5,12 +5,13 @@ import {createDropzone} from './dropzone.js'; import {showGlobalErrorMessage} from '../bootstrap.js'; import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js'; import {svg} from '../svg.js'; -import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter} from '../utils/dom.js'; +import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter, getComboMarkdownEditor} from '../utils/dom.js'; import {htmlEscape} from 'escape-goat'; import {showTemporaryTooltip} from '../modules/tippy.js'; import {confirmModal} from './comp/ConfirmModal.js'; import {showErrorToast} from '../modules/toast.js'; import {request, POST, GET} from '../modules/fetch.js'; +import {removeLinksInTextarea} from './comp/ComboMarkdownEditor.js'; import '../htmx.js'; const {appUrl, appSubUrl, csrfToken, i18n} = window.config; @@ -249,12 +250,13 @@ export function initDropzone(el) { }); file.previewTemplate.append(copyLinkElement); }); - this.on('removedfile', (file) => { - $(`#${file.uuid}`).remove(); + this.on('removedfile', async (file) => { + document.getElementById(file.uuid)?.remove(); if ($dropzone.data('remove-url')) { - POST($dropzone.data('remove-url'), { + await POST($dropzone.data('remove-url'), { data: new URLSearchParams({file: file.uuid}), }); + removeLinksInTextarea(getComboMarkdownEditor(el.closest('form').querySelector('.combo-markdown-editor')), file); } }); this.on('error', function (file, message) { diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index d3fab375a9396..b336bf0f78ef6 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -296,11 +296,6 @@ class ComboMarkdownEditor { } } -export function getComboMarkdownEditor(el) { - if (el instanceof $) el = el[0]; - return el?._giteaComboMarkdownEditor; -} - export async function initComboMarkdownEditor(container, options = {}) { if (container instanceof $) { if (container.length !== 1) { @@ -315,3 +310,9 @@ export async function initComboMarkdownEditor(container, options = {}) { await editor.init(); return editor; } + +export function removeLinksInTextarea(editor, file) { + const fileName = file.name.slice(0, file.name.lastIndexOf('.')); + const fileText = `\\[${fileName}\\]\\(/attachments/${file.uuid}\\)`; + editor.value(editor.value().replace(new RegExp(`${fileName}`, 'g'), '').replace(new RegExp(`\\!${fileText}`, 'g'), '').replace(new RegExp(fileText, 'g'), '')); +} diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js index b26296d1fc967..6a5c63af19047 100644 --- a/web_src/js/features/comp/Paste.js +++ b/web_src/js/features/comp/Paste.js @@ -82,35 +82,48 @@ class CodeMirrorEditor { } } -async function handleClipboardImages(editor, dropzone, images, e) { +async function handleClipboardFiles(editor, dropzone, files, e) { const uploadUrl = dropzone.getAttribute('data-upload-url'); const filesContainer = dropzone.querySelector('.files'); - if (!dropzone || !uploadUrl || !filesContainer || !images.length) return; + if (!dropzone || !uploadUrl || !filesContainer || !files.length) return; e.preventDefault(); e.stopPropagation(); - for (const img of images) { - const name = img.name.slice(0, img.name.lastIndexOf('.')); + for (const file of files) { + if (!file) continue; + const name = file.name.slice(0, file.name.lastIndexOf('.')); const placeholder = `![${name}](uploading ...)`; editor.insertPlaceholder(placeholder); - const {uuid} = await uploadFile(img, uploadUrl); - const {width, dppx} = await imageInfo(img); + const {uuid} = await uploadFile(file, uploadUrl); + const {width, dppx} = await imageInfo(file); const url = `/attachments/${uuid}`; let text; - if (width > 0 && dppx > 1) { - // Scale down images from HiDPI monitors. This uses the tag because it's the only - // method to change image size in Markdown that is supported by all implementations. - text = `${htmlEscape(name)}`; + if (file.type?.startsWith('image/')) { + if (width > 0 && dppx > 1) { + // Scale down images from HiDPI monitors. This uses the tag because it's the only + // method to change image size in Markdown that is supported by all implementations. + text = `${htmlEscape(name)}`; + } else { + text = `![${name}](${url})`; + } } else { - text = `![${name}](${url})`; + text = `[${name}](${url})`; } editor.replacePlaceholder(placeholder, text); + file.uuid = uuid; + dropzone.dropzone.emit('addedfile', file); + if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(file.name)) { + const imgSrc = `/attachments/${file.uuid}`; + dropzone.dropzone.emit('thumbnail', file, imgSrc); + dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; + } + dropzone.dropzone.emit('complete', file); const input = document.createElement('input'); input.setAttribute('name', 'files'); input.setAttribute('type', 'hidden'); @@ -134,21 +147,25 @@ function handleClipboardText(textarea, text, e) { } export function initEasyMDEPaste(easyMDE, dropzone) { - easyMDE.codemirror.on('paste', (_, e) => { - const {images} = getPastedContent(e); - if (images.length) { - handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); + const pasteFunc = (e) => { + const {files} = getPastedContent(e); + if (files.length) { + handleClipboardFiles(new CodeMirrorEditor(easyMDE.codemirror), dropzone, files, e); } - }); + }; + easyMDE.codemirror.on('paste', (_, e) => pasteFunc(e)); + easyMDE.codemirror.on('drop', (_, e) => pasteFunc(e)); } export function initTextareaPaste(textarea, dropzone) { - textarea.addEventListener('paste', (e) => { - const {images, text} = getPastedContent(e); - if (images.length) { - handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + const pasteFunc = (e) => { + const {files, text} = getPastedContent(e); + if (files.length) { + handleClipboardFiles(new TextareaEditor(textarea), dropzone, files, e); } else if (text) { handleClipboardText(textarea, text, e); } - }); + }; + textarea.addEventListener('paste', (e) => pasteFunc(e)); + textarea.addEventListener('drop', (e) => pasteFunc(e)); } diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js index 4c03325c7a5f6..6be928ac8634c 100644 --- a/web_src/js/features/repo-issue-edit.js +++ b/web_src/js/features/repo-issue-edit.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import {handleReply} from './repo-issue.js'; -import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; +import {initComboMarkdownEditor, removeLinksInTextarea} from './comp/ComboMarkdownEditor.js'; import {createDropzone} from './dropzone.js'; import {GET, POST} from '../modules/fetch.js'; -import {hideElem, showElem} from '../utils/dom.js'; +import {hideElem, showElem, getComboMarkdownEditor} from '../utils/dom.js'; import {attachRefIssueContextPopup} from './contextpopup.js'; import {initCommentContent, initMarkupContent} from '../markup/content.js'; @@ -26,7 +26,6 @@ async function onEditContent(event) { 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}, @@ -45,7 +44,6 @@ async function onEditContent(event) { 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'; @@ -56,19 +54,15 @@ async function onEditContent(event) { this.on('removedfile', async (file) => { document.getElementById(file.uuid)?.remove(); if (disableRemovedfileEvent) return; - if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) { + if (dropzone.getAttribute('data-remove-url')) { try { await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})}); + removeLinksInTextarea(getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')), file); } 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')); @@ -78,16 +72,16 @@ async function onEditContent(event) { 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); + if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(attachment.name)) { + const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`; + dz.emit('thumbnail', attachment, imgSrc); + dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; + } 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'; diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 2b2eed58bbfb3..a98c1a73fc9df 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import {htmlEscape} from 'escape-goat'; import {showTemporaryTooltip, createTippy} from '../modules/tippy.js'; -import {hideElem, showElem, toggleElem} from '../utils/dom.js'; +import {hideElem, showElem, toggleElem, getComboMarkdownEditor} from '../utils/dom.js'; import {setFileFolding} from './file-fold.js'; -import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; +import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; import {toAbsoluteUrl} from '../utils.js'; import {initDropzone} from './common-global.js'; import {POST, GET} from '../modules/fetch.js'; diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index fb23a71725fbf..d9fe774580289 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -258,16 +258,27 @@ export function isElemVisible(element) { return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } +export function getComboMarkdownEditor(el) { + if (el.jquery) el = el[0]; + return el?._giteaComboMarkdownEditor; +} + // extract text and images from "paste" event export function getPastedContent(e) { - const images = []; - for (const item of e.clipboardData?.items ?? []) { - if (item.type?.startsWith('image/')) { - images.push(item.getAsFile()); + const acceptedFiles = getComboMarkdownEditor(e.currentTarget).dropzone.getAttribute('data-accepts'); + const files = []; + const data = e.clipboardData?.items || e.dataTransfer?.items; + for (const item of data ?? []) { + if (!item.type?.startsWith('text/')) { + const file = item.getAsFile(); + const extName = file.name.slice(file.name.lastIndexOf('.'), file.name.length); + if (acceptedFiles.includes(extName)) { + files.push(file); + } } } const text = e.clipboardData?.getData?.('text') ?? ''; - return {text, images}; + return {text, files}; } // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this From 48cf2a5127a2bfc15e87ec0c082a333f4562715c Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Tue, 16 Apr 2024 12:04:49 +0800 Subject: [PATCH 02/12] Fix js error in edit issue and improve image type check --- web_src/js/features/comp/Paste.js | 2 +- web_src/js/utils/dom.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js index 6a5c63af19047..29ff1b2cf26eb 100644 --- a/web_src/js/features/comp/Paste.js +++ b/web_src/js/features/comp/Paste.js @@ -118,7 +118,7 @@ async function handleClipboardFiles(editor, dropzone, files, e) { file.uuid = uuid; dropzone.dropzone.emit('addedfile', file); - if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(file.name)) { + if (file.type?.startsWith('image/')) { const imgSrc = `/attachments/${file.uuid}`; dropzone.dropzone.emit('thumbnail', file, imgSrc); dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index d9fe774580289..6911a564bb98c 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -259,7 +259,7 @@ export function isElemVisible(element) { } export function getComboMarkdownEditor(el) { - if (el.jquery) el = el[0]; + if (el?.jquery) el = el[0]; return el?._giteaComboMarkdownEditor; } From 5af777b5c905f4fc256dd2408fcfbaecc6348c93 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Tue, 16 Apr 2024 15:42:46 +0800 Subject: [PATCH 03/12] Fix cannot to paste text file --- web_src/js/features/repo-issue-edit.js | 2 +- web_src/js/utils/dom.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js index 6be928ac8634c..7e91ef1551a9f 100644 --- a/web_src/js/features/repo-issue-edit.js +++ b/web_src/js/features/repo-issue-edit.js @@ -76,7 +76,7 @@ async function onEditContent(event) { for (const attachment of data) { dz.emit('addedfile', attachment); - if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(attachment.name)) { + if (/\.(jpg|jpeg|png|gif|bmp|svg)$/i.test(attachment.name)) { const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`; dz.emit('thumbnail', attachment, imgSrc); dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 6911a564bb98c..40e127af37dc9 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -269,7 +269,7 @@ export function getPastedContent(e) { const files = []; const data = e.clipboardData?.items || e.dataTransfer?.items; for (const item of data ?? []) { - if (!item.type?.startsWith('text/')) { + if (item?.kind === 'file') { const file = item.getAsFile(); const extName = file.name.slice(file.name.lastIndexOf('.'), file.name.length); if (acceptedFiles.includes(extName)) { From b7828ad1741696f6d2ba26fb6054edf38893f630 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 18 Apr 2024 09:36:36 +0800 Subject: [PATCH 04/12] Update web_src/js/features/comp/Paste.js Co-authored-by: silverwind --- web_src/js/features/comp/Paste.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js index 29ff1b2cf26eb..4a6ac9c2399e4 100644 --- a/web_src/js/features/comp/Paste.js +++ b/web_src/js/features/comp/Paste.js @@ -121,7 +121,7 @@ async function handleClipboardFiles(editor, dropzone, files, e) { if (file.type?.startsWith('image/')) { const imgSrc = `/attachments/${file.uuid}`; dropzone.dropzone.emit('thumbnail', file, imgSrc); - dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%'; + dropzone.querySelector(`img[src='${CSS.escape(imgSrc)}']`).style.maxWidth = '100%'; } dropzone.dropzone.emit('complete', file); const input = document.createElement('input'); From 64cffdd62e5c7485c5074f9b49d13554b8e1cfd1 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Thu, 18 Apr 2024 09:49:32 +0800 Subject: [PATCH 05/12] Change extName to use utils.js extname --- web_src/js/utils/dom.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 40e127af37dc9..a856e89be1fae 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -1,4 +1,5 @@ import {debounce} from 'throttle-debounce'; +import {extname} from '../utils.js'; function elementsCall(el, func, ...args) { if (typeof el === 'string' || el instanceof String) { @@ -271,8 +272,7 @@ export function getPastedContent(e) { for (const item of data ?? []) { if (item?.kind === 'file') { const file = item.getAsFile(); - const extName = file.name.slice(file.name.lastIndexOf('.'), file.name.length); - if (acceptedFiles.includes(extName)) { + if (acceptedFiles.includes(extname(file.name))) { files.push(file); } } From c552d09a57f8cb436fb693284411c743abc5d099 Mon Sep 17 00:00:00 2001 From: Tyrone Yeh Date: Fri, 3 May 2024 13:14:53 +0800 Subject: [PATCH 06/12] Adjust js code --- web_src/js/features/repo-issue-edit.js | 2 +- web_src/js/features/repo-issue.js | 2 +- web_src/js/utils/dom.js | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js index 7e91ef1551a9f..77424bf60458f 100644 --- a/web_src/js/features/repo-issue-edit.js +++ b/web_src/js/features/repo-issue-edit.js @@ -185,7 +185,7 @@ export function initRepoIssueCommentEdit() { editor = await handleReply($replyBtn); } else { // for normal issue/comment page - editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); + editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor')); } if (editor) { if (editor.value()) { diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index a98c1a73fc9df..d002e0d060139 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -414,7 +414,7 @@ export async function handleReply($el) { showElem($form); const $textarea = $form.find('textarea'); - let editor = getComboMarkdownEditor($textarea); + let editor = getComboMarkdownEditor($textarea[0]); 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. diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index a856e89be1fae..e61bc913520d8 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -260,7 +260,6 @@ export function isElemVisible(element) { } export function getComboMarkdownEditor(el) { - if (el?.jquery) el = el[0]; return el?._giteaComboMarkdownEditor; } From 8d45f2ebfde5109c9e69f21c6b5b22aef381365b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Mon, 24 Jun 2024 10:53:29 +0800 Subject: [PATCH 07/12] fix --- .../js/features/comp/ComboMarkdownEditor.js | 13 +++--- web_src/js/features/comp/EditorMarkdown.js | 4 +- .../comp/{Paste.js => EditorUpload.js} | 42 ++++++++++++++----- web_src/js/features/dropzone.js | 1 + web_src/js/features/repo-issue-edit.js | 1 + web_src/js/utils/dom.js | 21 ---------- 6 files changed, 43 insertions(+), 39 deletions(-) rename web_src/js/features/comp/{Paste.js => EditorUpload.js} (83%) diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index adf7ac1e32bd9..2f8ccb6dc8f54 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -3,7 +3,7 @@ import '@github/text-expander-element'; import $ from 'jquery'; import {attachTribute} from '../tribute.js'; import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js'; -import {initEasyMDEPaste, initTextareaPaste} from './Paste.js'; +import {initEasyMDEPaste, initTextareaPaste} from './EditorUpload.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; @@ -291,6 +291,11 @@ class ComboMarkdownEditor { } } +export function getComboMarkdownEditor(el) { + if (el instanceof $) el = el[0]; + return el?._giteaComboMarkdownEditor; +} + export async function initComboMarkdownEditor(container, options = {}) { if (container instanceof $) { if (container.length !== 1) { @@ -305,9 +310,3 @@ export async function initComboMarkdownEditor(container, options = {}) { await editor.init(); return editor; } - -export function removeLinksInTextarea(editor, file) { - const fileName = file.name.slice(0, file.name.lastIndexOf('.')); - const fileText = `\\[${fileName}\\]\\(/attachments/${file.uuid}\\)`; - editor.value(editor.value().replace(new RegExp(`${fileName}`, 'g'), '').replace(new RegExp(`\\!${fileText}`, 'g'), '').replace(new RegExp(fileText, 'g'), '')); -} diff --git a/web_src/js/features/comp/EditorMarkdown.js b/web_src/js/features/comp/EditorMarkdown.js index cf412e3807a37..9ec71aba74447 100644 --- a/web_src/js/features/comp/EditorMarkdown.js +++ b/web_src/js/features/comp/EditorMarkdown.js @@ -1,4 +1,6 @@ -import {triggerEditorContentChanged} from './Paste.js'; +export function triggerEditorContentChanged(target) { + target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); +} function handleIndentSelection(textarea, e) { const selStart = textarea.selectionStart; diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/EditorUpload.js similarity index 83% rename from web_src/js/features/comp/Paste.js rename to web_src/js/features/comp/EditorUpload.js index c72434c4cc65e..39bc7d8bd5b57 100644 --- a/web_src/js/features/comp/Paste.js +++ b/web_src/js/features/comp/EditorUpload.js @@ -1,8 +1,11 @@ import {htmlEscape} from 'escape-goat'; import {POST} from '../../modules/fetch.js'; import {imageInfo} from '../../utils/image.js'; -import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js'; +import {replaceTextareaSelection} from '../../utils/dom.js'; import {isUrl} from '../../utils/url.js'; +import {extname} from '../../utils.js'; +import {triggerEditorContentChanged} from './EditorMarkdown.js'; +import {getComboMarkdownEditor} from './ComboMarkdownEditor.js'; async function uploadFile(file, uploadUrl) { const formData = new FormData(); @@ -12,10 +15,6 @@ async function uploadFile(file, uploadUrl) { return await res.json(); } -export function triggerEditorContentChanged(target) { - target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true})); -} - class TextareaEditor { constructor(editor) { this.editor = editor; @@ -82,23 +81,21 @@ class CodeMirrorEditor { } } +// FIXME: handle non-image files 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; - e.preventDefault(); e.stopPropagation(); for (const img of images) { const name = img.name.slice(0, img.name.lastIndexOf('.')); - + const {width, dppx} = await imageInfo(img); const placeholder = `![${name}](uploading ...)`; - editor.insertPlaceholder(placeholder); + editor.insertPlaceholder(placeholder); const {uuid} = await uploadFile(img, uploadUrl); - const {width, dppx} = await imageInfo(img); let text; if (width > 0 && dppx > 1) { @@ -139,6 +136,18 @@ function handleClipboardText(textarea, e, {text, isShiftDown}) { // else, let the browser handle it } +// extract text and images from "paste" event +function getPastedContent(e) { + const images = []; + for (const item of e.clipboardData?.items ?? []) { + if (item.type?.startsWith('image/')) { + images.push(item.getAsFile()); + } + } + const text = e.clipboardData?.getData?.('text') ?? ''; + return {text, images}; +} + export function initEasyMDEPaste(easyMDE, dropzone) { easyMDE.codemirror.on('paste', (_, e) => { const {images} = getPastedContent(e); @@ -164,4 +173,17 @@ export function initTextareaPaste(textarea, dropzone) { handleClipboardText(textarea, e, {text, isShiftDown}); } }); + textarea.addEventListener('drop', (e) => { + const acceptedFiles = getComboMarkdownEditor(textarea).dropzone.getAttribute('data-accepts'); + const files = []; + for (const item of e.dataTransfer?.items ?? []) { + if (item?.kind !== 'file') continue; + const file = item.getAsFile(); + if (acceptedFiles.includes(extname(file.name))) { + files.push(file); + } + } + // FIXME: handle upload files + handleClipboardImages(new TextareaEditor(textarea), dropzone, files, e); + }); } diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index b3acaf5e6f09e..cef26d9903c50 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -70,6 +70,7 @@ export function initDropzone(el) { data: new URLSearchParams({file: file.uuid}), }); } + // TODO: remove the link from editor and maybe merge the duplicate code }); this.on('error', function (file, message) { showErrorToast(message); diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js index 5fafdcf17c105..48e62b03f583f 100644 --- a/web_src/js/features/repo-issue-edit.js +++ b/web_src/js/features/repo-issue-edit.js @@ -64,6 +64,7 @@ async function onEditContent(event) { console.error(error); } } + // TODO: remove the link from editor and maybe merge the duplicate code }); this.on('submit', () => { for (const fileUuid of Object.keys(fileUuidDict)) { diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 91383b64ca0cd..9260584f4f6a8 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -263,27 +263,6 @@ export function isElemVisible(element) { return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); } -export function getComboMarkdownEditor(el) { - return el?._giteaComboMarkdownEditor; -} - -// extract text and images from "paste" event -export function getPastedContent(e) { - const acceptedFiles = getComboMarkdownEditor(e.currentTarget).dropzone.getAttribute('data-accepts'); - const files = []; - const data = e.clipboardData?.items || e.dataTransfer?.items; - for (const item of data ?? []) { - if (item?.kind === 'file') { - const file = item.getAsFile(); - if (acceptedFiles.includes(extname(file.name))) { - files.push(file); - } - } - } - const text = e.clipboardData?.getData?.('text') ?? ''; - return {text, files}; -} - // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this export function replaceTextareaSelection(textarea, text) { const before = textarea.value.slice(0, textarea.selectionStart ?? undefined); From 32dc0e1767a8b04c8b7ba5ac8f56510b447c0f3c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 01:03:44 +0800 Subject: [PATCH 08/12] fix --- .../js/features/comp/ComboMarkdownEditor.js | 10 +- web_src/js/features/comp/EditorUpload.js | 126 ++++++++++-------- web_src/js/features/comp/EditorUpload.test.js | 14 ++ web_src/js/features/dropzone.js | 16 ++- web_src/js/utils.js | 6 + web_src/js/utils.test.js | 12 +- web_src/js/utils/dom.js | 1 - 7 files changed, 119 insertions(+), 66 deletions(-) create mode 100644 web_src/js/features/comp/EditorUpload.test.js diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js index 8dcfbb1971d8f..21779e32a8efb 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.js +++ b/web_src/js/features/comp/ComboMarkdownEditor.js @@ -3,7 +3,7 @@ import '@github/text-expander-element'; import $ from 'jquery'; import {attachTribute} from '../tribute.js'; import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js'; -import {initEasyMDEPaste, initTextareaPaste} from './EditorUpload.js'; +import {initEasyMDEPaste, initTextareaUpload} from './EditorUpload.js'; import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js'; import {renderPreviewPanelContent} from '../repo-editor.js'; import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js'; @@ -11,7 +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'; +import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.js'; let elementIdCounter = 0; @@ -111,7 +111,7 @@ class ComboMarkdownEditor { initTextareaMarkdown(this.textarea); if (this.dropzone) { - initTextareaPaste(this.textarea, this.dropzone); + initTextareaUpload(this.textarea, this.dropzone); } } @@ -130,13 +130,13 @@ class ComboMarkdownEditor { dropzoneReloadFiles() { if (!this.dropzone) return; - this.attachedDropzoneInst.emit('reload'); + this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles); } dropzoneSubmitReload() { if (!this.dropzone) return; this.attachedDropzoneInst.emit('submit'); - this.attachedDropzoneInst.emit('reload'); + this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles); } setupTab() { diff --git a/web_src/js/features/comp/EditorUpload.js b/web_src/js/features/comp/EditorUpload.js index 39bc7d8bd5b57..5e32248c6aeaf 100644 --- a/web_src/js/features/comp/EditorUpload.js +++ b/web_src/js/features/comp/EditorUpload.js @@ -1,18 +1,27 @@ import {htmlEscape} from 'escape-goat'; -import {POST} from '../../modules/fetch.js'; import {imageInfo} from '../../utils/image.js'; import {replaceTextareaSelection} from '../../utils/dom.js'; import {isUrl} from '../../utils/url.js'; -import {extname} from '../../utils.js'; +import {isWellKnownImageFilename} from '../../utils.js'; import {triggerEditorContentChanged} from './EditorMarkdown.js'; -import {getComboMarkdownEditor} from './ComboMarkdownEditor.js'; +import {DropzoneCustomEventRemovedFile} from '../dropzone.js'; -async function uploadFile(file, uploadUrl) { - const formData = new FormData(); - formData.append('file', file, file.name); +let uploadIdCounter = 0; - const res = await POST(uploadUrl, {data: formData}); - return await res.json(); +function uploadFile(dropzoneEl, file) { + return new Promise((resolve, _) => { + file._giteaUploadId = uploadIdCounter++; + const dropzoneInst = dropzoneEl.dropzone; + const onSuccess = (successFile, successResp) => { + if (successFile._giteaUploadId === file._giteaUploadId) { + resolve({uuid: successResp.uuid}); + } + dropzoneInst.off('success', onSuccess); + }; + // TODO: handle errors (or maybe not needed at the moment) + dropzoneInst.on('success', onSuccess); + dropzoneInst.handleFiles([file]); + }); } class TextareaEditor { @@ -81,46 +90,51 @@ class CodeMirrorEditor { } } -// FIXME: handle non-image files -async function handleClipboardImages(editor, dropzone, images, e) { - const uploadUrl = dropzone.getAttribute('data-upload-url'); - const filesContainer = dropzone.querySelector('.files'); +function isImageFile(file) { + return file.type?.startsWith('image/') || isWellKnownImageFilename(file.name); +} +async function handleUploadFiles(editor, dropzoneEl, files, e) { e.preventDefault(); - e.stopPropagation(); + for (const file of files) { + const name = file.name.slice(0, file.name.lastIndexOf('.')); + const isImage = isImageFile(file); - for (const img of images) { - const name = img.name.slice(0, img.name.lastIndexOf('.')); - const {width, dppx} = await imageInfo(img); - const placeholder = `![${name}](uploading ...)`; + let placeholder = `[${name}](uploading ...)`; + if (isImage) placeholder = `!${placeholder}`; editor.insertPlaceholder(placeholder); - const {uuid} = await uploadFile(img, uploadUrl); - - let text; - if (width > 0 && dppx > 1) { - // Scale down images from HiDPI monitors. This uses the tag because it's the only - // method to change image size in Markdown that is supported by all implementations. - // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" - const url = `attachments/${uuid}`; - text = `${htmlEscape(name)}`; + const {uuid} = await uploadFile(dropzoneEl, file); + + let fileMarkdownLink; + if (isImage) { + const {width, dppx} = await imageInfo(file); + if (width > 0 && dppx > 1) { + // Scale down images from HiDPI monitors. This uses the tag because it's the only + // method to change image size in Markdown that is supported by all implementations. + // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" + const url = `attachments/${uuid}`; + fileMarkdownLink = `${htmlEscape(name)}`; + } else { + // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" + // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" + const url = `/attachments/${uuid}`; + fileMarkdownLink = `![${name}](${url})`; + } } else { - // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" - // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" const url = `/attachments/${uuid}`; - text = `![${name}](${url})`; + fileMarkdownLink = `[${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); + editor.replacePlaceholder(placeholder, fileMarkdownLink); } } +export function removeAttachmentLinksFromMarkdown(text, fileUuid) { + text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), ''); + text = text.replace(new RegExp(`]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), ''); + return text; +} + function handleClipboardText(textarea, e, {text, isShiftDown}) { // pasting with "shift" means "paste as original content" in most applications if (isShiftDown) return; // let the browser handle it @@ -148,16 +162,25 @@ function getPastedContent(e) { return {text, images}; } -export function initEasyMDEPaste(easyMDE, dropzone) { +export function initEasyMDEPaste(easyMDE, dropzoneEl) { + const editor = new CodeMirrorEditor(easyMDE.codemirror); easyMDE.codemirror.on('paste', (_, e) => { const {images} = getPastedContent(e); - if (images.length) { - handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e); - } + if (!images.length) return; + handleUploadFiles(editor, dropzoneEl, images, e); + }); + easyMDE.codemirror.on('drop', (_, e) => { + if (!e.dataTransfer.files.length) return; + handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e); + }); + dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => { + const oldText = easyMDE.codemirror.getValue(); + const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid); + if (oldText !== newText) easyMDE.codemirror.setValue(newText); }); } -export function initTextareaPaste(textarea, dropzone) { +export function initTextareaUpload(textarea, dropzoneEl) { let isShiftDown = false; textarea.addEventListener('keydown', (e) => { if (e.shiftKey) isShiftDown = true; @@ -168,22 +191,17 @@ export function initTextareaPaste(textarea, dropzone) { textarea.addEventListener('paste', (e) => { const {images, text} = getPastedContent(e); if (images.length) { - handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e); + handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e); } else if (text) { handleClipboardText(textarea, e, {text, isShiftDown}); } }); textarea.addEventListener('drop', (e) => { - const acceptedFiles = getComboMarkdownEditor(textarea).dropzone.getAttribute('data-accepts'); - const files = []; - for (const item of e.dataTransfer?.items ?? []) { - if (item?.kind !== 'file') continue; - const file = item.getAsFile(); - if (acceptedFiles.includes(extname(file.name))) { - files.push(file); - } - } - // FIXME: handle upload files - handleClipboardImages(new TextareaEditor(textarea), dropzone, files, e); + if (!e.dataTransfer.files.length) return; + handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e); + }); + dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => { + const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid); + if (textarea.value !== newText) textarea.value = newText; }); } diff --git a/web_src/js/features/comp/EditorUpload.test.js b/web_src/js/features/comp/EditorUpload.test.js new file mode 100644 index 0000000000000..caecfb91ea2d0 --- /dev/null +++ b/web_src/js/features/comp/EditorUpload.test.js @@ -0,0 +1,14 @@ +import {removeAttachmentLinksFromMarkdown} from './EditorUpload.js'; + +test('removeAttachmentLinksFromMarkdown', () => { + expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b'); + expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a ![x](attachments/foo) b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a ![x](/attachments/foo) b', 'foo')).toBe('a b'); + + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); + expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b'); +}); diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index 8d70fc774b19c..ac72a76f63da2 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -8,6 +8,10 @@ import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js'; const {csrfToken, i18n} = window.config; +// dropzone has its owner event dispatcher (emitter) +export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files'; +export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file'; + async function createDropzone(el, opts) { const [{Dropzone}] = await Promise.all([ import(/* webpackChunkName: "dropzone" */'dropzone'), @@ -68,16 +72,18 @@ export async function initDropzone(dropzoneEl) { // "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; + dzInst.on('success', (file, resp) => { + file.uuid = resp.uuid; fileUuidDict[file.uuid] = {submitted: false}; - const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${data.uuid}`, value: data.uuid}); + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid}); dropzoneEl.querySelector('.files').append(input); addCopyLink(file); }); dzInst.on('removedfile', async (file) => { if (disableRemovedfileEvent) return; + + dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid}); 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) { @@ -91,7 +97,7 @@ export async function initDropzone(dropzoneEl) { } }); - dzInst.on('reload', async () => { + dzInst.on(DropzoneCustomEventReloadFiles, async () => { try { const resp = await GET(listAttachmentsUrl); const respData = await resp.json(); @@ -129,6 +135,6 @@ export async function initDropzone(dropzoneEl) { dzInst.removeFile(file); }); - if (listAttachmentsUrl) dzInst.emit('reload'); + if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles); return dzInst; } diff --git a/web_src/js/utils.js b/web_src/js/utils.js index ce0fb66343b13..4edc8c0f3b2da 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -8,7 +8,9 @@ export function basename(path = '') { // transform /path/to/file.ext to .ext export function extname(path = '') { + const lastSlashIndex = path.lastIndexOf('/'); const lastPointIndex = path.lastIndexOf('.'); + if (lastSlashIndex > lastPointIndex) return ''; return lastPointIndex < 0 ? '' : path.substring(lastPointIndex); } @@ -142,3 +144,7 @@ export function serializeXml(node) { } export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function isWellKnownImageFilename(fn) { + return /\.(jpe?g|png|gif|webp|svg)$/i.test(fn); +} diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 2754e41c433ec..9b23e07ebbceb 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -1,7 +1,7 @@ import { basename, extname, isObject, stripTags, parseIssueHref, parseUrl, translateMonth, translateDay, blobToDataURI, - toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, + toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isWellKnownImageFilename, } from './utils.js'; test('basename', () => { @@ -15,6 +15,7 @@ test('extname', () => { expect(extname('/path/')).toEqual(''); expect(extname('/path')).toEqual(''); expect(extname('file.js')).toEqual('.js'); + expect(extname('/my.path/file')).toEqual(''); }); test('isObject', () => { @@ -112,3 +113,12 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a'))); expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a'))); }); + +test('isWellKnownImageFilename', () => { + for (const filename of ['a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { + expect(isWellKnownImageFilename(filename)).toBeTruthy(); + } + for (const filename of ['', 'a.jpg.x', '/path.png/x', 'webp']) { + expect(isWellKnownImageFilename(filename)).toBeFalsy(); + } +}); diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js index 2ef24e6ef19a0..6a38968899f9c 100644 --- a/web_src/js/utils/dom.js +++ b/web_src/js/utils/dom.js @@ -1,5 +1,4 @@ import {debounce} from 'throttle-debounce'; -import {extname} from '../utils.js'; function elementsCall(el, func, ...args) { if (typeof el === 'string' || el instanceof String) { From 4c3bf58c20e548e83aa37c177f3436ccb2acf87f Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 03:10:57 +0800 Subject: [PATCH 09/12] Update web_src/js/utils.js Co-authored-by: silverwind --- web_src/js/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/utils.js b/web_src/js/utils.js index 4edc8c0f3b2da..d39c8b41c15e7 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -146,5 +146,5 @@ export function serializeXml(node) { export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export function isWellKnownImageFilename(fn) { - return /\.(jpe?g|png|gif|webp|svg)$/i.test(fn); + return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(fn); } From 1e730eaa1457dce6aa16426cb49fcea75d006055 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 08:01:17 +0800 Subject: [PATCH 10/12] fix utils --- web_src/js/utils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/utils.js b/web_src/js/utils.js index d39c8b41c15e7..c09c592568c6e 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -1,13 +1,13 @@ import {encode, decode} from 'uint8-to-base64'; // transform /path/to/file.ext to file.ext -export function basename(path = '') { +export function basename(path) { const lastSlashIndex = path.lastIndexOf('/'); return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1); } // transform /path/to/file.ext to .ext -export function extname(path = '') { +export function extname(path) { const lastSlashIndex = path.lastIndexOf('/'); const lastPointIndex = path.lastIndexOf('.'); if (lastSlashIndex > lastPointIndex) return ''; From c9d088d9fe67048641d48994d82eb601e156e43c Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Thu, 27 Jun 2024 11:35:02 +0800 Subject: [PATCH 11/12] merge duplicate code --- web_src/js/features/comp/EditorUpload.js | 56 +++++++----------------- web_src/js/features/dropzone.js | 46 +++++++++++++------ web_src/js/utils.js | 8 +++- web_src/js/utils.test.js | 18 +++++--- web_src/js/utils/image.js | 7 +-- web_src/js/utils/image.test.js | 1 + 6 files changed, 71 insertions(+), 65 deletions(-) diff --git a/web_src/js/features/comp/EditorUpload.js b/web_src/js/features/comp/EditorUpload.js index 5e32248c6aeaf..5b3cf383ac54b 100644 --- a/web_src/js/features/comp/EditorUpload.js +++ b/web_src/js/features/comp/EditorUpload.js @@ -1,25 +1,27 @@ -import {htmlEscape} from 'escape-goat'; import {imageInfo} from '../../utils/image.js'; import {replaceTextareaSelection} from '../../utils/dom.js'; import {isUrl} from '../../utils/url.js'; -import {isWellKnownImageFilename} from '../../utils.js'; import {triggerEditorContentChanged} from './EditorMarkdown.js'; -import {DropzoneCustomEventRemovedFile} from '../dropzone.js'; +import { + DropzoneCustomEventRemovedFile, + DropzoneCustomEventUploadDone, + generateMarkdownLinkForAttachment, +} from '../dropzone.js'; let uploadIdCounter = 0; function uploadFile(dropzoneEl, file) { return new Promise((resolve, _) => { - file._giteaUploadId = uploadIdCounter++; + const curUploadId = uploadIdCounter++; + file._giteaUploadId = curUploadId; const dropzoneInst = dropzoneEl.dropzone; - const onSuccess = (successFile, successResp) => { - if (successFile._giteaUploadId === file._giteaUploadId) { - resolve({uuid: successResp.uuid}); + const onUploadDone = ({file}) => { + if (file._giteaUploadId === curUploadId) { + dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone); + resolve(); } - dropzoneInst.off('success', onSuccess); }; - // TODO: handle errors (or maybe not needed at the moment) - dropzoneInst.on('success', onSuccess); + dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone); dropzoneInst.handleFiles([file]); }); } @@ -90,42 +92,16 @@ class CodeMirrorEditor { } } -function isImageFile(file) { - return file.type?.startsWith('image/') || isWellKnownImageFilename(file.name); -} - async function handleUploadFiles(editor, dropzoneEl, files, e) { e.preventDefault(); for (const file of files) { const name = file.name.slice(0, file.name.lastIndexOf('.')); - const isImage = isImageFile(file); - - let placeholder = `[${name}](uploading ...)`; - if (isImage) placeholder = `!${placeholder}`; + const {width, dppx} = await imageInfo(file); + const placeholder = `[${name}](uploading ...)`; editor.insertPlaceholder(placeholder); - const {uuid} = await uploadFile(dropzoneEl, file); - - let fileMarkdownLink; - if (isImage) { - const {width, dppx} = await imageInfo(file); - if (width > 0 && dppx > 1) { - // Scale down images from HiDPI monitors. This uses the tag because it's the only - // method to change image size in Markdown that is supported by all implementations. - // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" - const url = `attachments/${uuid}`; - fileMarkdownLink = `${htmlEscape(name)}`; - } else { - // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" - // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" - const url = `/attachments/${uuid}`; - fileMarkdownLink = `![${name}](${url})`; - } - } else { - const url = `/attachments/${uuid}`; - fileMarkdownLink = `[${name}](${url})`; - } - editor.replacePlaceholder(placeholder, fileMarkdownLink); + await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload + editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx})); } } diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js index ac72a76f63da2..f25a6137186fb 100644 --- a/web_src/js/features/dropzone.js +++ b/web_src/js/features/dropzone.js @@ -5,12 +5,14 @@ import {showTemporaryTooltip} from '../modules/tippy.js'; import {GET, POST} from '../modules/fetch.js'; import {showErrorToast} from '../modules/toast.js'; import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js'; +import {isImageFile, isVideoFile} from '../utils.js'; const {csrfToken, i18n} = window.config; // dropzone has its owner event dispatcher (emitter) export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files'; export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file'; +export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done'; async function createDropzone(el, opts) { const [{Dropzone}] = await Promise.all([ @@ -20,6 +22,26 @@ async function createDropzone(el, opts) { return new Dropzone(el, opts); } +export function generateMarkdownLinkForAttachment(file, {width, dppx} = {}) { + let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; + if (isImageFile(file)) { + fileMarkdown = `!${fileMarkdown}`; + if (width > 0 && dppx > 1) { + // Scale down images from HiDPI monitors. This uses the tag because it's the only + // method to change image size in Markdown that is supported by all implementations. + // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" + fileMarkdown = `${htmlEscape(file.name)}`; + } else { + // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" + // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" + fileMarkdown = `![${file.name}](/attachments/${file.uuid})`; + } + } else if (isVideoFile(file)) { + fileMarkdown = ``; + } + return fileMarkdown; +} + 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 @@ -29,13 +51,7 @@ function addCopyLink(file) { `); 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); + const success = await clippie(generateMarkdownLinkForAttachment(file)); showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); }); file.previewTemplate.append(copyLinkEl); @@ -78,6 +94,7 @@ export async function initDropzone(dropzoneEl) { const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid}); dropzoneEl.querySelector('.files').append(input); addCopyLink(file); + dzInst.emit(DropzoneCustomEventUploadDone, {file}); }); dzInst.on('removedfile', async (file) => { @@ -110,13 +127,14 @@ export async function initDropzone(dropzoneEl) { 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}); + const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size}; + const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`; + dzInst.emit('addedfile', file); + dzInst.emit('thumbnail', file, imgSrc); + dzInst.emit('complete', file); + addCopyLink(file); // it is from server response, so no "type" + fileUuidDict[file.uuid] = {submitted: true}; + const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid}); dropzoneEl.querySelector('.files').append(input); } if (!dropzoneEl.querySelector('.dz-preview')) { diff --git a/web_src/js/utils.js b/web_src/js/utils.js index c09c592568c6e..2d40fa20a8c79 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -145,6 +145,10 @@ export function serializeXml(node) { export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -export function isWellKnownImageFilename(fn) { - return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(fn); +export function isImageFile({name, type}) { + return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/'); +} + +export function isVideoFile({name, type}) { + return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/'); } diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 9b23e07ebbceb..1ec3d3630b5b4 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -1,7 +1,7 @@ import { basename, extname, isObject, stripTags, parseIssueHref, parseUrl, translateMonth, translateDay, blobToDataURI, - toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isWellKnownImageFilename, + toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, } from './utils.js'; test('basename', () => { @@ -114,11 +114,17 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => { expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a'))); }); -test('isWellKnownImageFilename', () => { - for (const filename of ['a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { - expect(isWellKnownImageFilename(filename)).toBeTruthy(); +test('file detection', () => { + for (const name of ['a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { + expect(isImageFile({name})).toBeTruthy(); } - for (const filename of ['', 'a.jpg.x', '/path.png/x', 'webp']) { - expect(isWellKnownImageFilename(filename)).toBeFalsy(); + for (const name of ['', 'a.jpg.x', '/path.png/x', 'webp']) { + expect(isImageFile({name})).toBeFalsy(); + } + for (const name of ['a.mpg', '/a.mpeg', '.file.mp4', '.webm', 'file.mkv']) { + expect(isVideoFile({name})).toBeTruthy(); + } + for (const name of ['', 'a.mpg.x', '/path.mp4/x', 'webm']) { + expect(isVideoFile({name})).toBeFalsy(); } }); diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js index ed5d98e35ad0b..c71d71594121b 100644 --- a/web_src/js/utils/image.js +++ b/web_src/js/utils/image.js @@ -19,11 +19,10 @@ export async function pngChunks(blob) { return chunks; } -// decode a image and try to obtain width and dppx. If will never throw but instead +// decode a image and try to obtain width and dppx. It will never throw but instead // return default values. export async function imageInfo(blob) { - let width = 0; // 0 means no width could be determined - let dppx = 1; // 1 dot per pixel for non-HiDPI screens + let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens if (blob.type === 'image/png') { // only png is supported currently try { @@ -41,6 +40,8 @@ export async function imageInfo(blob) { } } } catch {} + } else { + return {}; // no image info for non-image files } return {width, dppx}; diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js index ba4758250c7f6..af56ed2331fb6 100644 --- a/web_src/js/utils/image.test.js +++ b/web_src/js/utils/image.test.js @@ -26,4 +26,5 @@ test('imageInfo', async () => { expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1}); expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2}); expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1}); + expect(await imageInfo(await dataUriToBlob(`data:image/gif;base64,`))).toEqual({}); }); From 0c1e0923280428a03a18af255009f5d265488da2 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 27 Jun 2024 10:41:09 +0200 Subject: [PATCH 12/12] Update web_src/js/features/comp/EditorUpload.js --- web_src/js/features/comp/EditorUpload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/features/comp/EditorUpload.js b/web_src/js/features/comp/EditorUpload.js index 5b3cf383ac54b..8861abfe03a5e 100644 --- a/web_src/js/features/comp/EditorUpload.js +++ b/web_src/js/features/comp/EditorUpload.js @@ -11,7 +11,7 @@ import { let uploadIdCounter = 0; function uploadFile(dropzoneEl, file) { - return new Promise((resolve, _) => { + return new Promise((resolve) => { const curUploadId = uploadIdCounter++; file._giteaUploadId = curUploadId; const dropzoneInst = dropzoneEl.dropzone;