Jul 28 - Update text-editor to allow image resize
This commit is contained in:
@@ -36,7 +36,10 @@
|
|||||||
"Bash(python -m py_compile app.py)",
|
"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(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 -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\" })"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
`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
|
- **Images:** the custom `#editor-toolbar` has a `ql-image` button. Images upload
|
||||||
via `POST /admin/upload` (`login_required`, CSRF via `X-CSRFToken` header):
|
via `POST /admin/upload` (`login_required`, CSRF via `X-CSRFToken` header):
|
||||||
extension allowlist + magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap,
|
extension allowlist + magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap,
|
||||||
saved as a random `uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`;
|
saved as a random `uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`;
|
||||||
the handler `insertEmbed`s it (no base64 → DB stays small).
|
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`
|
- **Tables:** `quill-table-better` 1.2.3 vendored (`static/vendor/quill-table-better.js`
|
||||||
+ `.css`, UMD → reads global `Quill`, exposes `QuillTableBetter`; self-contained,
|
+ `.css`, UMD → reads global `Quill`, exposes `QuillTableBetter`; self-contained,
|
||||||
no CDN/CSP issues). Registered as `modules/table-better`; Quill 2's basic
|
no CDN/CSP issues). Registered as `modules/table-better`; Quill 2's basic
|
||||||
|
|||||||
@@ -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
|
change, newest first, filterable by action and type, paginated 50/page. Times
|
||||||
are UTC.
|
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
|
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
|
**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
|
locked-down server and survive a strict CSP. The body toolbar supports **inline
|
||||||
|
|||||||
@@ -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. */
|
/* 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{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}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -142,6 +142,8 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='vendor/quill.min.js') }}"></script>
|
<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="{{ 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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
if (typeof Quill === 'undefined') return; // textarea stays usable
|
if (typeof Quill === 'undefined') return; // textarea stays usable
|
||||||
@@ -156,7 +158,19 @@
|
|||||||
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
|
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 };
|
var modules = { toolbar: toolbarEl };
|
||||||
|
if (uploads) {
|
||||||
|
modules.uploader = { mimetypes: uploads.mimetypes, handler: uploads.handler };
|
||||||
|
}
|
||||||
if (hasTables) {
|
if (hasTables) {
|
||||||
modules.table = false; // disable Quill's basic table
|
modules.table = false; // disable Quill's basic table
|
||||||
modules['table-better'] = {
|
modules['table-better'] = {
|
||||||
@@ -182,36 +196,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var csrf = document.querySelector('meta[name="csrf-token"]').content;
|
// --- 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
|
||||||
// --- Image button: upload the file, then embed the returned URL. This
|
// a static file.
|
||||||
// keeps the DB small (no base64) and the image is served as a static file.
|
if (uploads) {
|
||||||
quill.getModule('toolbar').addHandler('image', function () {
|
quill.getModule('toolbar').addHandler('image', function () {
|
||||||
var input = document.createElement('input');
|
var input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
input.accept = uploads.mimetypes.join(',');
|
||||||
input.onchange = function () {
|
input.onchange = function () {
|
||||||
var file = input.files && input.files[0];
|
var file = input.files && input.files[0];
|
||||||
if (!file) return;
|
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);
|
var range = quill.getSelection(true);
|
||||||
quill.insertEmbed(range.index, 'image', res.body.url, 'user');
|
uploads.insertFiles(quill, range ? range.index : quill.getLength(), [file]);
|
||||||
quill.setSelection(range.index + 1, 'silent');
|
};
|
||||||
}).catch(function () { alert('Upload failed.'); });
|
input.click();
|
||||||
};
|
});
|
||||||
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
|
// Table resize + cell alignment are handled by quill-table-better's own
|
||||||
// drag handles and floating cell menu — no extra toolbar wiring needed.
|
// 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.
|
// Switch the UI from textarea to Quill only once it's ready.
|
||||||
document.body.classList.add('quill-on');
|
document.body.classList.add('quill-on');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user