diff --git a/public/js/markdown_upload.js b/public/js/markdown_upload.js new file mode 100644 index 00000000..35768e6e --- /dev/null +++ b/public/js/markdown_upload.js @@ -0,0 +1,239 @@ +/* Requires base64.js + +Usage: setupMarkdownUpload(eSubmit, eFileInput, eUploadBar, eText, doMarkdown, maxFileSize, uploadUrl) +eSubmit is the element of the button to submit/save the markdown changes. It + will be disabled and tell users files are uploading while uploading is + happening. +eFileInput is the +eUploadBar usually looks like +
+
+ Upload files by dragging & dropping, pasting, or them. +
+
+
+
+
+
+eText is the text field that can be dropped into and is editing the markdown. +doMarkdown is the function returned by initLiveMarkdown. +maxFileSize +uploadUrl +*/ + +function setupMarkdownUpload(eSubmit, eFileInput, eUploadBar, eText, doMarkdown, maxFileSize, uploadUrl) { + const submitText = eSubmit.value; + const uploadProgress = eUploadBar.querySelector('.progress'); + const uploadProgressText = eUploadBar.querySelector('.progress_text'); + const uploadProgressBar = eUploadBar.querySelector('.progress_bar'); + const uploadProgressBarFill = eUploadBar.querySelector('.progress_bar > div'); + let fileCounter = 0; + let enterCounter = 0; + let uploadQueue = []; + let currentUpload = null; + let currentXhr = null; + let currentBatchSize = 0; + let currentBatchDone = 0; + + eFileInput.addEventListener("change", function(ev) { + if (eFileInput.files.length > 0) { + importUserFiles(eFileInput.files); + } + }); + + eText.addEventListener("dragover", function(ev) { + let effect = "none"; + for (let i = 0; i < ev.dataTransfer.items.length; ++i) { + if (ev.dataTransfer.items[i].kind.toLowerCase() == "file") { + effect = "copy"; + break; + } + } + ev.dataTransfer.dropEffect = effect; + ev.preventDefault(); + }); + + eText.addEventListener("dragenter", function(ev) { + enterCounter++; + let droppable = false; + for (let i = 0; i < ev.dataTransfer.items.length; ++i) { + if (ev.dataTransfer.items[i].kind.toLowerCase() == "file") { + droppable = true; + break; + } + } + if (droppable) { + eText.classList.add("drop"); + } + }); + + eText.addEventListener("dragleave", function(ev) { + enterCounter--; + if (enterCounter == 0) { + eText.classList.remove("drop"); + } + }); + + function makeUploadString(uploadNumber, filename) { + return `Uploading file #${uploadNumber}: \`${filename}\`...`; + } + + eText.addEventListener("drop", function(ev) { + enterCounter = 0; + eText.classList.remove("drop"); + + if (ev.dataTransfer && ev.dataTransfer.files) { + importUserFiles(ev.dataTransfer.files) + } + + ev.preventDefault(); + }); + + eText.addEventListener("paste", function(ev) { + const files = ev.clipboardData?.files ?? []; + if (files.length > 0) { + importUserFiles(files) + } + }); + + function importUserFiles(files) { + let items = []; + for (let i = 0; i < files.length; ++i) { + let f = files[i]; + if (f.size < maxFileSize) { + items.push({ file: f, error: null }); + } else { + items.push({ file: null, error: `\`${f.name}\` is too big! Max size is ${maxFileSize} but the file is ${f.size}.` }); + } + } + + let cursorStart = eText.selectionStart; + let cursorEnd = eText.selectionEnd; + + let toInsert = ""; + let linesToCursor = eText.value.substr(0, cursorStart).split("\n"); + let cursorLine = linesToCursor[linesToCursor.length-1].trim(); + if (cursorLine.length > 0) { + toInsert = "\n\n"; + } + for (let i = 0; i < items.length; ++i) { + if (items[i].file) { + fileCounter++; + toInsert += makeUploadString(fileCounter, items[i].file.name) + "\n\n"; + queueUpload(fileCounter, items[i].file); + } else { + toInsert += `${items[i].error}\n\n`; + } + } + + eText.value = eText.value.substring(0, cursorStart) + toInsert + eText.value.substring(cursorEnd, eText.value.length); + doMarkdown(); + uploadNext(); + } + + function replaceUploadString(upload, newString) { + let cursorStart = eText.selectionStart; + let cursorEnd = eText.selectionEnd; + let uploadString = makeUploadString(upload.uploadNumber, upload.file.name); + let insertIndex = eText.value.indexOf(uploadString) + eText.value = eText.value.replace(uploadString, newString); + if (cursorStart <= insertIndex + uploadString.length) { + eText.selectionStart = cursorStart; + } else { + eText.selectionStart = cursorStart - uploadString.length + newString.length; + } + if (cursorEnd <= insertIndex + uploadString.length) { + eText.selectionEnd = cursorEnd; + } else { + eText.selectionEnd = cursorEnd - uploadString.length + newString.length; + } + doMarkdown(); + } + + function replaceUploadStringError(upload) { + replaceUploadString(upload, `There was a problem uploading your file \`${upload.file.name}\`.`); + } + + function queueUpload(uploadNumber, file) { + uploadQueue.push({ + uploadNumber: uploadNumber, + file: file + }); + + currentBatchSize++; + uploadProgressText.textContent = `Uploading files ${currentBatchDone+1}/${currentBatchSize}`; + } + + function uploadDone(ev) { + try { + if (currentXhr.status == 200 && currentXhr.response) { + if (currentXhr.response.url) { + let url = currentXhr.response.url; + let newString = `[${currentUpload.file.name}](${url})`; + if (currentXhr.response.mime.startsWith("image")) { + newString = "!" + newString; + } + + replaceUploadString(currentUpload, newString); + } else if (currentXhr.response.error) { + replaceUploadString(currentUpload, `Upload failed for \`${currentUpload.file.name}\`: ${currentXhr.response.error}.`); + } else { + replaceUploadStringError(currentUpload); + } + } else { + replaceUploadStringError(currentUpload); + } + } catch (err) { + console.error(err); + replaceUploadStringError(currentUpload); + } + currentUpload = null; + currentXhr = null; + currentBatchDone++; + uploadNext(); + } + + function updateUploadProgress(ev) { + if (ev.lengthComputable) { + let progress = ev.loaded / ev.total; + uploadProgressBarFill.style.width = Math.floor(progress * 100) + "%"; + } + } + + function uploadNext() { + if (currentUpload == null) { + next = uploadQueue.shift(); + if (next) { + uploadProgressText.textContent = `Uploading files ${currentBatchDone+1}/${currentBatchSize}`; + eUploadBar.classList.add("uploading"); + uploadProgressBarFill.style.width = "0%"; + eSubmit.disabled = true; + eSubmit.value = "Uploading files..."; + + try { + let utf8Filename = strToUTF8Arr(next.file.name); + let base64Filename = base64EncArr(utf8Filename); + // NOTE(asaf): We use XHR because fetch can't do upload progress reports. Womp womp. https://youtu.be/Pubd-spHN-0?t=2 + currentXhr = new XMLHttpRequest(); + currentXhr.upload.addEventListener("progress", updateUploadProgress); + currentXhr.open("POST", uploadUrl, true); + currentXhr.setRequestHeader("Hmn-Upload-Filename", base64Filename); + currentXhr.responseType = "json"; + currentXhr.addEventListener("loadend", uploadDone); + currentXhr.send(next.file); + currentUpload = next; + } catch (err) { + replaceUploadStringError(next); + console.error(err); + uploadNext(); + } + } else { + eSubmit.disabled = false; + eSubmit.value = submitText; + eUploadBar.classList.remove("uploading"); + currentBatchSize = 0; + currentBatchDone = 0; + } + } + } +} diff --git a/src/templates/src/editor.html b/src/templates/src/editor.html index cbb5917a..5998187e 100644 --- a/src/templates/src/editor.html +++ b/src/templates/src/editor.html @@ -4,6 +4,7 @@ {{ template "markdown_previews.html" . }} + {{ end }} @@ -156,6 +162,17 @@ +
+
+
+ Upload files by dragging & dropping, pasting, or them. +
+
+
+
+
+
+
Preview:
@@ -196,6 +213,7 @@ + {{/* NOTE(mark): copied NOTE(asaf): Placing this outside the form to avoid submitting it to the server by accident */}} {{ end }} diff --git a/src/website/projects.go b/src/website/projects.go index ed3dfa83..11108dc0 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -402,6 +402,9 @@ type ProjectEditData struct { APICheckUsernameUrl string LogoMaxFileSize int + + MaxFileSize int + UploadUrl string } func ProjectNew(c *RequestContext) ResponseData { @@ -428,6 +431,9 @@ func ProjectNew(c *RequestContext) ResponseData { APICheckUsernameUrl: hmnurl.BuildAPICheckUsername(), LogoMaxFileSize: ProjectLogoMaxFileSize, + + MaxFileSize: AssetMaxSize(c.CurrentUser), + UploadUrl: c.UrlContext.BuildAssetUpload(), }, c.Perf) return res } @@ -552,6 +558,9 @@ func ProjectEdit(c *RequestContext) ResponseData { APICheckUsernameUrl: hmnurl.BuildAPICheckUsername(), LogoMaxFileSize: ProjectLogoMaxFileSize, + + MaxFileSize: AssetMaxSize(c.CurrentUser), + UploadUrl: c.UrlContext.BuildAssetUpload(), }, c.Perf) return res }