/* 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 }; })();