Jul 28 - Update text-editor to allow image resize

This commit is contained in:
2026-07-28 14:55:12 -04:00
parent ea9458395d
commit 4bb345da39
7 changed files with 622 additions and 26 deletions
+261
View File
@@ -0,0 +1,261 @@
/* Image resizing for the topic body editor.
*
* Quill 2 ships no image-resize UI and the third-party modules for it target
* Quill 1, so this is our own: click an image in the editor to get a selection
* frame with corner handles, drag to scale, or use the size presets.
*
* The size is written as the img `width` ATTRIBUTE (never an inline style):
* `width` is on the sanitizer's img allowlist while `style` is not, so the size
* survives sanitize_html() on save. Height is deliberately left off — the
* public stylesheet has `.prose img{max-width:100%;height:auto}`, so the image
* keeps its aspect ratio and still shrinks on a phone.
*
* Commits go through quill.formatText() so a resize lands in the delta and is
* covered by undo/redo. Loading this file is optional: topic_form.html guards
* on window.JQCImageResize, so a failed load just means no resize UI.
*/
(function () {
'use strict';
var MIN_WIDTH = 40;
var PRESETS = [
{ label: 'S', ratio: 0.25 },
{ label: 'M', ratio: 0.5 },
{ label: 'L', ratio: 0.75 },
{ label: 'Full', ratio: 1 }
];
var CORNERS = ['nw', 'ne', 'sw', 'se'];
function initImageResize(quill) {
if (!quill || !quill.root) return null;
var root = quill.root;
var img = null; // currently selected image
var frame = null; // overlay element (built lazily)
var bar = null; // preset buttons inside the overlay
var label = null; // live "320 px" readout
var drag = null; // in-flight drag state
// ---------------------------------------------------------------- overlay
function build() {
frame = document.createElement('div');
frame.className = 'jqc-imgres';
frame.setAttribute('aria-hidden', 'true');
CORNERS.forEach(function (corner) {
var handle = document.createElement('span');
handle.className = 'jqc-imgres__handle jqc-imgres__handle--' + corner;
handle.addEventListener('pointerdown', function (ev) {
startDrag(ev, corner, handle);
});
frame.appendChild(handle);
});
bar = document.createElement('div');
bar.className = 'jqc-imgres__bar';
PRESETS.forEach(function (preset) {
var btn = document.createElement('button');
btn.type = 'button'; // never submit the topic form
btn.className = 'jqc-imgres__btn';
btn.textContent = preset.label;
btn.title = 'Scale to ' + Math.round(preset.ratio * 100) + '% of the text width';
btn.addEventListener('click', function () {
if (!img) return;
apply(Math.round(available() * preset.ratio));
});
bar.appendChild(btn);
});
var reset = document.createElement('button');
reset.type = 'button';
reset.className = 'jqc-imgres__btn';
reset.textContent = 'Reset';
reset.title = "Back to the image's original size";
reset.addEventListener('click', function () {
if (!img) return;
apply(null);
});
bar.appendChild(reset);
label = document.createElement('span');
label.className = 'jqc-imgres__size';
bar.appendChild(label);
// Pressing a preset must not steal the caret from the editor, or Quill's
// own shortcuts (Ctrl+Z after a resize) would stop working.
bar.addEventListener('mousedown', function (ev) { ev.preventDefault(); });
frame.appendChild(bar);
document.body.appendChild(frame);
}
// Width of the block the image sits in — a paragraph, or a table cell.
function available() {
var parent = img && img.parentElement;
var width = parent ? parent.clientWidth : 0;
return Math.max(MIN_WIDTH, width || root.clientWidth || 600);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/* The bar normally hangs under the image, but the frame is fixed-positioned
* — the page can't be scrolled to reach it. So keep it inside the viewport:
* flip above when there's no room below, and pin it over the image when it
* is taller than the screen. Offsets are relative to the frame (its parent).
*/
function placeBar(box) {
var gap = 8;
var height = bar.offsetHeight || 32;
var width = bar.offsetWidth || 200;
var top = box.bottom + gap;
if (top + height > window.innerHeight - gap) {
top = box.top - height - gap; // flip above
}
top = clamp(top, gap, Math.max(gap, window.innerHeight - height - gap));
var left = clamp(box.left, gap, Math.max(gap, window.innerWidth - width - gap));
bar.style.top = (top - box.top) + 'px';
bar.style.left = (left - box.left) + 'px';
}
function position() {
if (!img || !frame) return;
var box = img.getBoundingClientRect();
// Fixed positioning: no page-scroll maths, and it tracks the editor's own
// scroll container too (the scroll listener below is capturing).
frame.style.top = box.top + 'px';
frame.style.left = box.left + 'px';
frame.style.width = box.width + 'px';
frame.style.height = box.height + 'px';
if (label) label.textContent = Math.round(box.width) + ' px';
placeBar(box);
// Keep the frame out of sight when the image scrolls out of the editor.
var editor = root.getBoundingClientRect();
var hidden = box.bottom < editor.top - 4 || box.top > editor.bottom + 4;
frame.classList.toggle('is-clipped', hidden);
}
// ---------------------------------------------------------------- select
function select(target) {
if (!frame) build();
img = target;
frame.classList.add('is-active');
position();
}
function deselect() {
img = null;
if (frame) frame.classList.remove('is-active');
}
// ---------------------------------------------------------------- commit
/* Write `width` to the image. null clears it (back to natural size). */
function apply(width) {
if (!img) return;
var value = width === null ? null : String(Math.max(MIN_WIDTH, Math.round(width)));
// A stale height attribute would fight the new width, so drop it.
img.removeAttribute('height');
var committed = false;
try {
var blot = Quill.find(img);
if (blot) {
var index = quill.getIndex(blot);
// Pass a formats OBJECT, not (name, value): Quill's argument overload
// reads a null `value` as the `source` argument, so the (name, value)
// form silently does nothing when clearing the width.
quill.formatText(index, 1, { width: value }, 'user');
committed = true;
}
} catch (e) { /* fall through to the DOM path below */ }
if (!committed) {
// No blot (or an unexpected Quill build): set it directly and let
// Quill's mutation observer fold the change into the document.
if (value === null) img.removeAttribute('width');
else img.setAttribute('width', value);
quill.update('user');
}
position();
}
// ---------------------------------------------------------------- drag
function startDrag(ev, corner, handle) {
if (!img) return;
ev.preventDefault();
ev.stopPropagation();
var box = img.getBoundingClientRect();
drag = {
startX: ev.clientX,
width: box.width,
// Dragging a left-side handle grows the image when moving left.
sign: corner === 'ne' || corner === 'se' ? 1 : -1,
max: available()
};
try { handle.setPointerCapture(ev.pointerId); } catch (e) {}
handle.addEventListener('pointermove', onDrag);
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
document.body.classList.add('jqc-imgres-dragging');
}
function onDrag(ev) {
if (!drag || !img) return;
var next = drag.width + (ev.clientX - drag.startX) * drag.sign;
next = Math.max(MIN_WIDTH, Math.min(drag.max, next));
// Live preview only — the committed change happens once, on release, so
// a drag is a single undo step instead of dozens.
img.setAttribute('width', String(Math.round(next)));
img.removeAttribute('height');
position();
}
function endDrag(ev) {
var handle = ev.currentTarget;
handle.removeEventListener('pointermove', onDrag);
handle.removeEventListener('pointerup', endDrag);
handle.removeEventListener('pointercancel', endDrag);
document.body.classList.remove('jqc-imgres-dragging');
if (!drag || !img) { drag = null; return; }
drag = null;
apply(parseInt(img.getAttribute('width'), 10) || available());
}
// ---------------------------------------------------------------- events
root.addEventListener('click', function (ev) {
if (ev.target && ev.target.tagName === 'IMG') select(ev.target);
else deselect();
});
// Clicking anywhere outside the editor (toolbar, another field) drops the
// selection — but not a click on the overlay's own buttons.
document.addEventListener('pointerdown', function (ev) {
if (!img) return;
if (root.contains(ev.target)) return;
if (frame && frame.contains(ev.target)) return;
deselect();
}, true);
document.addEventListener('keydown', function (ev) {
if (img && ev.key === 'Escape') deselect();
});
// Typing/pasting can reflow or remove the image.
quill.on('text-change', function () {
if (!img) return;
if (!root.contains(img)) deselect();
else position();
});
// Capturing: catches the window scroll AND any scrolling ancestor.
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
return { select: select, deselect: deselect, reposition: position };
}
window.JQCImageResize = { init: initImageResize };
})();
+196
View File
@@ -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 };
})();