Copy drag/drop from blog edit to project (#6) #77

Merged
bvisness merged 2 commits from mark.dev/hmn:master into master 2022-08-02 02:01:56 +00:00
4 changed files with 290 additions and 218 deletions

View File

@ -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 <input type="file">
eUploadBar usually looks like
<div class="upload_bar flex-grow-1">
<div class="instructions">
Upload files by dragging & dropping, pasting, or <label class="pointer link" for="file_input">selecting</label> them.
</div>
<div class="progress flex">
<div class="progress_text mr3"></div>
<div class="progress_bar flex-grow-1 flex-shrink-1 pa1"><div class=""></div></div>
</div>
</div>
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;
}
}
}
}

View File

@ -4,6 +4,7 @@
{{ template "markdown_previews.html" . }}
<script src="{{ static "js/base64.js" }}"></script>
<script src="{{ static "js/markdown_upload.js" }}"></script>
<style>
#editor {
@ -121,222 +122,14 @@
/*
/ Asset upload
*/
const submitButton = document.querySelector("#form input[type=submit]");
const submitText = submitButton.value;
const fileInput = document.querySelector('#file_input');
const uploadBar = document.querySelector('.upload_bar');
const uploadProgress = document.querySelector('.upload_bar .progress');
const uploadProgressText = document.querySelector('.upload_bar .progress_text');
const uploadProgressBar = document.querySelector('.upload_bar .progress_bar');
const uploadProgressBarFill = document.querySelector('.upload_bar .progress_bar > div');
let fileCounter = 0;
let enterCounter = 0;
let uploadQueue = [];
let currentUpload = null;
let currentXhr = null;
let currentBatchSize = 0;
let currentBatchDone = 0;
fileInput.addEventListener("change", function(ev) {
if (fileInput.files.length > 0) {
importUserFiles(fileInput.files);
}
});
textField.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();
});
textField.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) {
textField.classList.add("drop");
}
});
textField.addEventListener("dragleave", function(ev) {
enterCounter--;
if (enterCounter == 0) {
textField.classList.remove("drop");
}
});
function makeUploadString(uploadNumber, filename) {
return `Uploading file #${uploadNumber}: \`${filename}\`...`;
}
textField.addEventListener("drop", function(ev) {
enterCounter = 0;
textField.classList.remove("drop");
if (ev.dataTransfer && ev.dataTransfer.files) {
importUserFiles(ev.dataTransfer.files)
}
ev.preventDefault();
});
textField.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 = textField.selectionStart;
let cursorEnd = textField.selectionEnd;
let toInsert = "";
let linesToCursor = textField.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`;
}
}
textField.value = textField.value.substring(0, cursorStart) + toInsert + textField.value.substring(cursorEnd, textField.value.length);
doMarkdown();
uploadNext();
}
function replaceUploadString(upload, newString) {
let cursorStart = textField.selectionStart;
let cursorEnd = textField.selectionEnd;
let uploadString = makeUploadString(upload.uploadNumber, upload.file.name);
let insertIndex = textField.value.indexOf(uploadString)
textField.value = textField.value.replace(uploadString, newString);
if (cursorStart <= insertIndex + uploadString.length) {
textField.selectionStart = cursorStart;
} else {
textField.selectionStart = cursorStart - uploadString.length + newString.length;
}
if (cursorEnd <= insertIndex + uploadString.length) {
textField.selectionEnd = cursorEnd;
} else {
textField.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}`;
uploadBar.classList.add("uploading");
uploadProgressBarFill.style.width = "0%";
submitButton.disabled = true;
submitButton.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 {
submitButton.disabled = false;
submitButton.value = submitText;
uploadBar.classList.remove("uploading");
currentBatchSize = 0;
currentBatchDone = 0;
}
}
}
setupMarkdownUpload(
document.querySelector("#form input[type=submit]"),
document.querySelector('#file_input'),
document.querySelector('.upload_bar'),
textField,
doMarkdown,
maxFileSize,
uploadUrl
);
</script>
{{ end }}

View File

@ -5,6 +5,8 @@
<script src="{{ static "js/tabs.js" }}"></script>
<script src="{{ static "js/image_selector.js" }}"></script>
<script src="{{ static "js/templates.js" }}"></script>
<script src="{{ static "js/base64.js" }}"></script>
<script src="{{ static "js/markdown_upload.js" }}"></script>
<style>
#desc-preview:empty::after {
@ -12,6 +14,10 @@
color: var(--dimmer-color);
font-style: italic;
}
#description.drop {
box-shadow: inset 0px 0px 5px yellow;
}
</style>
{{ end }}
@ -156,6 +162,17 @@
<textarea id="description" class="w-100 h5 minh-5 mono lh-copy" name="description">
{{- .ProjectSettings.Description -}}
</textarea>
<div class="flex justify-end items-center mt2">
<div class="upload_bar flex-grow-1">
<div class="instructions">
Upload files by dragging & dropping, pasting, or <label class="pointer link" for="file_input">selecting</label> them.
</div>
<div class="progress flex">
<div class="progress_text mr3"></div>
<div class="progress_bar flex-grow-1 flex-shrink-1 pa1"><div class=""></div></div>
</div>
</div>
</div>
<div class="b mt3 mb2">Preview:</div>
<div id="desc-preview" class="w-100"></div>
</div>
@ -196,6 +213,7 @@
</div>
</div>
</form>
<input type="file" multiple name="file_input" id="file_input" class="dn" />{{/* NOTE(mark): copied NOTE(asaf): Placing this outside the form to avoid submitting it to the server by accident */}}
</div>
<script>
let csrf = JSON.parse({{ csrftokenjs .Session }});
@ -366,6 +384,19 @@
logoMaxFileSize,
document.querySelector(".dark_logo")
);
//////////////////
// Asset upload //
//////////////////
setupMarkdownUpload(
document.querySelector("#project_form [data-name=Description] input[type=submit]"),
document.querySelector('#file_input'),
document.querySelector('.upload_bar'),
description,
doMarkdown,
{{ .MaxFileSize }},
{{ .UploadUrl }}
);
</script>
{{ end }}

View File

@ -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
}