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
+4 -1
View File
@@ -36,7 +36,10 @@
"Bash(python -m py_compile app.py)",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/src/utils/index.ts -o utils.ts -w \"utils %{http_code}\\\\n\")",
"Bash(grep -nA15 \"function getAlign|getAlign =|export const getAlign\" utils.ts)",
"Bash(grep -nA20 \"getDiffProperties\" tpf.ts)"
"Bash(grep -nA20 \"getDiffProperties\" tpf.ts)",
"PowerShell(cd \"C:\\\\Users\\\\IT\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-IT-Desktop-Da-Nguyen-Projects-JQC-features\\\\ab68f536-bde2-45aa-8789-5dcb8bd61ef9\\\\scratchpad\"; \\(Get-Content debug_reset.py\\) -replace 'logging.getLogger\\\\\\(\"werkzeug\"\\\\\\).setLevel\\\\\\(logging.ERROR\\\\\\)','' | Set-Content debug_reset2.py -Encoding utf8; python debug_reset2.py 2>&1 | Select-String \"POST|url after|JS ERROR|CONSOLE\")",
"PowerShell(node --check \"c:\\\\Users\\\\IT\\\\Desktop\\\\Da Nguyen\\\\Projects\\\\JQC_features\\\\static\\\\js\\\\image-upload.js\")",
"PowerShell(if \\($?\\) { \"JS SYNTAX OK\" })"
]
}
}
+41
View File
@@ -119,11 +119,52 @@ shows only `is_published` topics (sections with no published topics are hidden).
shows the editor). If Quill fails to load, the textarea stays usable and a save
never wipes the body. The submit handler keeps the body when it contains an
`img`/`table` even though `getText()` is empty for embed-only content.
- **Paste / drop upload:** `static/js/image-upload.js` (ours). Quill 2's built-in
`uploader` module base64s a pasted/dropped file into the body — which
`sanitize_html()` then strips on save (bleach blocks `data:` on img src), so
the image looked fine in the editor and vanished on save. We override
`modules.uploader` (`{mimetypes, handler}`) so paste AND drag-drop both POST to
`/admin/upload` and embed the returned `/static/uploads/...` path. The toolbar
image button now goes through the same `insertFiles()`, so all three routes
behave identically.
- Quill's uploader only sees pasted *files*. HTML pasted from Word/Docs carries
base64 `<img>` markup through the clipboard instead, so `watch(quill)` sweeps
`img[src^="data:image/"]` after a user edit and rehosts each one (delete +
re-insert the embed, preserving any `width`). Re-entrancy is guarded by a
`busy` flag — our own edits fire `text-change` too. A failed rehost tags the
node `data-upload-failed` so it isn't retried forever, and says plainly that
the image won't be saved (we don't silently delete admin content).
- Feedback is a non-blocking `.jqc-toast` (an `alert()` mid-paste interrupts
typing). Client-side checks mirror the server: MIME allowlist + 8 MB cap.
- Remote `http(s)` images pasted from a web page are NOT rehosted — bleach
allows those URLs, so they render, but they hotlink the original server.
- **Images:** the custom `#editor-toolbar` has a `ql-image` button. Images upload
via `POST /admin/upload` (`login_required`, CSRF via `X-CSRFToken` header):
extension allowlist + magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap,
saved as a random `uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`;
the handler `insertEmbed`s it (no base64 → DB stays small).
- **Image resize:** `static/js/image-resize.js` — ours, not vendored (Quill 2 has
no resize UI and the third-party modules target Quill 1). Click an image in the
editor → fixed-positioned frame with 4 corner handles, S/M/L/Full presets
(25/50/75/100 % of the containing block, so it also works inside a table cell)
and a Reset. Loaded after Quill in `topic_form.html` and initialised behind a
`window.JQCImageResize` guard, so a failed load just means no resize UI.
- **Size is the `width` ATTRIBUTE, never inline `style`** — `style` is not on
the sanitizer's img allowlist, `width` is, so the size survives the save.
`height` is removed on every change; the public CSS (`.prose img{max-width:
100%;height:auto}`) keeps the aspect ratio and still shrinks on a phone.
- **Commit via `quill.formatText(i, 1, {width: v}, 'user')` — 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
no-ops when clearing the width (this is exactly what broke Reset). The width
round-trips through `clipboard.convert` on load because Quill's Image blot
lists `width` in its `formats()`.
- A drag writes the attribute directly for live preview and commits once on
release → one undo step per drag, not dozens. The preset bar calls
`preventDefault()` on mousedown so it never steals the caret (otherwise
Ctrl+Z after a resize stops reaching Quill), and it flips above the image /
clamps to the viewport — the frame is `position:fixed`, so a bar left below
the fold could not be scrolled to.
- **Tables:** `quill-table-better` 1.2.3 vendored (`static/vendor/quill-table-better.js`
+ `.css`, UMD → reads global `Quill`, exposes `QuillTableBetter`; self-contained,
no CDN/CSP issues). Registered as `modules/table-better`; Quill 2's basic
+20
View File
@@ -187,6 +187,26 @@ Then visit `https://your-domain/admin`, sign in, and manage content.
change, newest first, filterable by action and type, paginated 50/page. Times
are UTC.
**Adding a photo:** three ways, all equivalent — the toolbar's image button,
**pasting** an image (a screenshot, or a copied picture), or **dragging** an
image file into the editor. In every case the file is uploaded to the server and
referenced by URL. Pasting from Word or Google Docs works too: those arrive as
embedded base64 data, which is uploaded and swapped for a real file
automatically, with the surrounding text preserved. A short notice appears while
an upload runs, or if one is refused (max 8 MB; PNG, JPEG, GIF and WebP only).
One exception: an image copied straight from another website keeps pointing at
that site (it isn't copied to our server), so save the file and paste it in if
you need it to last.
**Resizing a photo:** click any image in the body editor. A frame appears with
corner handles — drag one to scale it — plus quick sizes **S / M / L / Full**
(25 %, 50 %, 75 %, 100 % of the text width) and **Reset** for the original size.
The live readout shows the current width in pixels. An image can't be dragged
wider than the text column, and inside a table it scales to the cell. Ctrl+Z
undoes a resize. The size is stored as the image's `width` attribute, so it
survives the save and is what the public page renders; height stays automatic,
so the photo keeps its proportions and still shrinks to fit a phone screen.
The editor (Quill 2 + quill-table-better) and drag library (SortableJS) are
**vendored locally** under `static/vendor/` — no CDN dependency, so they work on a
locked-down server and survive a strict CSP. The body toolbar supports **inline
+59
View File
@@ -279,3 +279,62 @@ body.quill-on #body_src{display:none} /* hide raw textarea on
/* Images fit the editor width; table styling is owned by quill-table-better. */
#editor .ql-editor img{max-width:100%;height:auto;border-radius:6px}
#editor .ql-editor img:hover{outline:2px solid rgba(23,176,166,.35);outline-offset:2px;cursor:pointer}
/* Image resize overlay (static/js/image-resize.js). Fixed-positioned over the
selected image, so it never affects the editor's own layout or content. */
.jqc-imgres{
position:fixed;display:none;z-index:60;pointer-events:none;
border:1.5px solid var(--aqua);border-radius:4px;
}
.jqc-imgres.is-active{display:block}
.jqc-imgres.is-clipped{display:none} /* image scrolled out of the editor */
.jqc-imgres__handle{
position:absolute;width:11px;height:11px;background:var(--aqua);
border:2px solid #fff;border-radius:50%;pointer-events:auto;
box-shadow:0 1px 3px rgba(0,0,0,.25);
}
.jqc-imgres__handle--nw{top:-6px;left:-6px;cursor:nwse-resize}
.jqc-imgres__handle--ne{top:-6px;right:-6px;cursor:nesw-resize}
.jqc-imgres__handle--sw{bottom:-6px;left:-6px;cursor:nesw-resize}
.jqc-imgres__handle--se{bottom:-6px;right:-6px;cursor:nwse-resize}
/* top/left are set by the script (kept inside the viewport); these are just the
pre-measurement defaults. */
.jqc-imgres__bar{
position:absolute;top:calc(100% + 8px);left:0;display:flex;align-items:center;gap:4px;
padding:4px 6px;background:var(--ink);border-radius:8px;pointer-events:auto;
box-shadow:0 6px 18px -8px rgba(0,0,0,.55);white-space:nowrap;
}
.jqc-imgres__btn{
font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.04em;
color:var(--paper);background:transparent;border:1px solid rgba(247,245,240,.25);
border-radius:6px;padding:3px 9px;cursor:pointer;
}
.jqc-imgres__btn:hover{background:var(--aqua);border-color:var(--aqua);color:#04201E}
.jqc-imgres__size{
font-family:"IBM Plex Mono",monospace;font-size:.7rem;color:rgba(247,245,240,.6);
padding-left:4px;
}
/* Keep the cursor consistent and stop text selection mid-drag. */
body.jqc-imgres-dragging{cursor:ew-resize;user-select:none}
/* Upload feedback for pasted/dropped images (static/js/image-upload.js).
Non-blocking on purpose: an alert() mid-paste would interrupt typing. */
.jqc-toast{
position:fixed;right:20px;bottom:20px;z-index:70;max-width:min(420px,calc(100vw - 40px));
padding:11px 16px;border-radius:10px;background:var(--ink);color:var(--paper);
font-size:.86rem;line-height:1.4;box-shadow:0 10px 30px -12px rgba(0,0,0,.6);
opacity:0;transform:translateY(8px);pointer-events:none;
transition:opacity .2s ease,transform .2s ease;
}
.jqc-toast.is-visible{opacity:1;transform:none}
.jqc-toast--error{background:var(--danger)}
.jqc-toast--busy::before{
content:"";display:inline-block;width:9px;height:9px;margin-right:8px;border-radius:50%;
background:var(--aqua);animation:jqc-toast-pulse 1s ease-in-out infinite;
}
@keyframes jqc-toast-pulse{0%,100%{opacity:.35}50%{opacity:1}}
@media (prefers-reduced-motion:reduce){
.jqc-toast{transition:none}
.jqc-toast--busy::before{animation:none}
}
+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 };
})();
+32 -16
View File
@@ -142,6 +142,8 @@
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/quill.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/quill-table-better.js') }}"></script>
<script src="{{ asset_url('js/image-upload.js') }}"></script>
<script src="{{ asset_url('js/image-resize.js') }}"></script>
<script>
(function () {
if (typeof Quill === 'undefined') return; // textarea stays usable
@@ -156,7 +158,19 @@
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
}
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Paste/drop image uploading. Quill's default uploader base64s the file
// into the body, which the sanitizer strips on save — this replaces it with
// a real upload to /admin/upload.
var uploads = window.JQCImageUpload
? window.JQCImageUpload.create({ url: '{{ url_for("admin.upload") }}', csrf: csrf })
: null;
var modules = { toolbar: toolbarEl };
if (uploads) {
modules.uploader = { mimetypes: uploads.mimetypes, handler: uploads.handler };
}
if (hasTables) {
modules.table = false; // disable Quill's basic table
modules['table-better'] = {
@@ -182,36 +196,38 @@
}
}
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// --- Image button: upload the file, then embed the returned URL. This
// keeps the DB small (no base64) and the image is served as a static file.
// --- Image button: pick a file and upload it through the same path as a
// paste/drop, so the DB stays small (no base64) and the image is served as
// a static file.
if (uploads) {
quill.getModule('toolbar').addHandler('image', function () {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
input.accept = uploads.mimetypes.join(',');
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
var fd = new FormData();
fd.append('file', file);
fetch('{{ url_for("admin.upload") }}', {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, body: j }; });
}).then(function (res) {
if (!res.ok) { alert(res.body.error || 'Upload failed.'); return; }
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', res.body.url, 'user');
quill.setSelection(range.index + 1, 'silent');
}).catch(function () { alert('Upload failed.'); });
uploads.insertFiles(quill, range ? range.index : quill.getLength(), [file]);
};
input.click();
});
// Pasted HTML can carry base64 images (Word, Google Docs); rehost those
// as they arrive, since the uploader module never sees them.
uploads.watch(quill);
}
// Table resize + cell alignment are handled by quill-table-better's own
// drag handles and floating cell menu — no extra toolbar wiring needed.
// --- Click an image to resize it (corner handles + S/M/L/Full presets).
// Guarded: if the script didn't load, the editor still works, images just
// stay at their natural size.
if (window.JQCImageResize) {
window.JQCImageResize.init(quill);
}
// Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on');