Jul 28 - Update text-editor to allow image resize
This commit is contained in:
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user