Jul 28 - Update text-editor to allow image resize
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/* Paste / drop image uploading for the topic body editor.
|
||||
*
|
||||
* Why this exists: Quill 2's built-in `uploader` module turns a pasted or
|
||||
* dropped image into a base64 `data:` URL. That looks right in the editor but
|
||||
* sanitize_html() drops `data:` sources on save (bleach restricts img src to
|
||||
* http/https/mailto), so the image would silently vanish — and base64 would
|
||||
* bloat the row anyway. So we override the uploader to POST the file to
|
||||
* /admin/upload and embed the returned /static/uploads/... path instead.
|
||||
*
|
||||
* Two entry points are needed because Quill routes pasted content two ways:
|
||||
* - uploader handler -> paste/drop of an image FILE (a screenshot, a file
|
||||
* dragged from the desktop)
|
||||
* - the data: sweep -> paste of HTML that embeds base64 images (Word,
|
||||
* Google Docs, another web page). Those never reach
|
||||
* the uploader; they arrive through the clipboard as
|
||||
* markup, so we rehost them after the fact.
|
||||
*
|
||||
* Everything degrades safely: topic_form.html guards on window.JQCImageUpload,
|
||||
* and a failed upload leaves a visible notice rather than a silent loss.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
var MAX_BYTES = 8 * 1024 * 1024; // matches MAX_UPLOAD_BYTES in admin.py
|
||||
var SWEEP_DELAY = 250;
|
||||
|
||||
/* ------------------------------------------------------------- notices */
|
||||
var toast = null;
|
||||
var toastTimer = null;
|
||||
|
||||
function notify(message, kind) {
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.className = 'jqc-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.className = 'jqc-toast is-visible' + (kind ? ' jqc-toast--' + kind : '');
|
||||
clearTimeout(toastTimer);
|
||||
if (kind !== 'busy') {
|
||||
toastTimer = setTimeout(function () {
|
||||
toast.className = 'jqc-toast';
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
if (toast) toast.className = 'jqc-toast';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- upload */
|
||||
function makeUploader(opts) {
|
||||
var url = opts.url;
|
||||
var csrf = opts.csrf;
|
||||
|
||||
/* POST one file, resolve with its public URL. */
|
||||
function upload(file) {
|
||||
if (MIME.indexOf(file.type) === -1) {
|
||||
return Promise.reject(new Error('Only PNG, JPEG, GIF and WebP images can be uploaded.'));
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return Promise.reject(new Error('That image is larger than the 8 MB limit.'));
|
||||
}
|
||||
var fd = new FormData();
|
||||
// The server re-checks the extension, so give the blob a plausible name:
|
||||
// a pasted screenshot arrives as a nameless Blob.
|
||||
fd.append('file', file, file.name || ('pasted.' + file.type.split('/')[1]));
|
||||
return fetch(url, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (body) {
|
||||
if (!res.ok || !body.url) {
|
||||
throw new Error(body.error || 'Upload failed.');
|
||||
}
|
||||
return body.url;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* Upload files one at a time and embed them at `index`, in order. */
|
||||
function insertFiles(quill, index, files) {
|
||||
var images = files.filter(function (f) { return MIME.indexOf(f.type) !== -1; });
|
||||
if (!images.length) return Promise.resolve(0);
|
||||
|
||||
notify(images.length > 1 ? 'Uploading ' + images.length + ' images…'
|
||||
: 'Uploading image…', 'busy');
|
||||
|
||||
var at = index;
|
||||
var done = 0;
|
||||
return images.reduce(function (chain, file) {
|
||||
return chain.then(function () {
|
||||
return upload(file).then(function (link) {
|
||||
quill.insertEmbed(at, 'image', link, 'user');
|
||||
at += 1;
|
||||
done += 1;
|
||||
});
|
||||
});
|
||||
}, Promise.resolve()).then(function () {
|
||||
quill.setSelection(at, 'silent');
|
||||
clearNotice();
|
||||
return done;
|
||||
}).catch(function (err) {
|
||||
notify(err.message || 'Upload failed.', 'error');
|
||||
return done;
|
||||
});
|
||||
}
|
||||
|
||||
/* Quill calls this as the uploader module (`this` is the module, so
|
||||
`this.quill` is the editor) for both paste and drag-and-drop. */
|
||||
function handler(range, files) {
|
||||
var quill = this.quill;
|
||||
var index = range && typeof range.index === 'number'
|
||||
? range.index
|
||||
: quill.getLength();
|
||||
insertFiles(quill, index, Array.prototype.slice.call(files));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- base64 -> uploads */
|
||||
function dataUrlToFile(dataUrl) {
|
||||
// fetch() parses data: URLs for us — no manual atob/Uint8Array juggling.
|
||||
return fetch(dataUrl).then(function (r) { return r.blob(); }).then(function (blob) {
|
||||
return new File([blob], 'pasted.' + (blob.type.split('/')[1] || 'png'),
|
||||
{ type: blob.type });
|
||||
});
|
||||
}
|
||||
|
||||
/* Replace an inline base64 image with an uploaded copy. The embed is
|
||||
swapped (delete + insert) rather than having its src poked directly, so
|
||||
the change goes through the document instead of behind Quill's back. */
|
||||
function rehost(quill, img) {
|
||||
var width = img.getAttribute('width');
|
||||
return dataUrlToFile(img.src).then(upload).then(function (link) {
|
||||
var blot = Quill.find(img);
|
||||
if (!blot) return;
|
||||
var index = quill.getIndex(blot);
|
||||
quill.deleteText(index, 1, 'user');
|
||||
quill.insertEmbed(index, 'image', link, 'user');
|
||||
if (width) quill.formatText(index, 1, { width: width }, 'user');
|
||||
});
|
||||
}
|
||||
|
||||
/* Watch for base64 images arriving via a pasted-HTML fragment and rehost
|
||||
them one by one. Re-entrancy is guarded: our own edits fire text-change. */
|
||||
function watch(quill) {
|
||||
var timer = null;
|
||||
var busy = false;
|
||||
|
||||
function sweep() {
|
||||
if (busy) return;
|
||||
var img = quill.root.querySelector('img[src^="data:image/"]');
|
||||
if (!img) { clearNotice(); return; }
|
||||
busy = true;
|
||||
notify('Uploading pasted image…', 'busy');
|
||||
rehost(quill, img).catch(function (err) {
|
||||
// Leave the image in place — deleting the admin's content would be
|
||||
// worse — but say plainly that it will not survive the save.
|
||||
notify((err.message || 'Upload failed.') +
|
||||
" The pasted image can't be saved; upload it with the image button.",
|
||||
'error');
|
||||
img.setAttribute('data-upload-failed', '1'); // don't retry forever
|
||||
}).then(function () {
|
||||
busy = false;
|
||||
schedule();
|
||||
});
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
// Skip images already known to be unuploadable (e.g. SVG).
|
||||
var pending = quill.root.querySelector(
|
||||
'img[src^="data:image/"]:not([data-upload-failed])');
|
||||
if (pending) sweep();
|
||||
}, SWEEP_DELAY);
|
||||
}
|
||||
|
||||
quill.on('text-change', function (delta, old, source) {
|
||||
if (busy || source !== 'user') return;
|
||||
schedule();
|
||||
});
|
||||
schedule(); // in case content was pasted before this ran
|
||||
}
|
||||
|
||||
return {
|
||||
mimetypes: MIME,
|
||||
upload: upload,
|
||||
insertFiles: insertFiles,
|
||||
handler: handler,
|
||||
watch: watch,
|
||||
notify: notify
|
||||
};
|
||||
}
|
||||
|
||||
window.JQCImageUpload = { create: makeUploader };
|
||||
})();
|
||||
Reference in New Issue
Block a user