From 4bb345da39540ab199437c0f73e9529e8641ebf5 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 28 Jul 2026 14:55:12 -0400 Subject: [PATCH] Jul 28 - Update text-editor to allow image resize --- .claude/settings.json | 5 +- CLAUDE.md | 41 +++++ README.md | 20 +++ static/css/admin.css | 59 ++++++++ static/js/image-resize.js | 261 ++++++++++++++++++++++++++++++++ static/js/image-upload.js | 196 ++++++++++++++++++++++++ templates/admin/topic_form.html | 66 +++++--- 7 files changed, 622 insertions(+), 26 deletions(-) create mode 100644 static/js/image-resize.js create mode 100644 static/js/image-upload.js diff --git a/.claude/settings.json b/.claude/settings.json index f4f5e67..e6c683d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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\" })" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index a34a8cc..0b8c528 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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.` 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 diff --git a/README.md b/README.md index a3ed190..a225f29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/static/css/admin.css b/static/css/admin.css index 5297005..08b21b4 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -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} +} diff --git a/static/js/image-resize.js b/static/js/image-resize.js new file mode 100644 index 0000000..700aa0e --- /dev/null +++ b/static/js/image-resize.js @@ -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 }; +})(); diff --git a/static/js/image-upload.js b/static/js/image-upload.js new file mode 100644 index 0000000..41bb68f --- /dev/null +++ b/static/js/image-upload.js @@ -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 }; +})(); diff --git a/templates/admin/topic_form.html b/templates/admin/topic_form.html index 08fefbf..936ae01 100644 --- a/templates/admin/topic_form.html +++ b/templates/admin/topic_form.html @@ -142,6 +142,8 @@ {% block scripts %} + +