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