Jul 24 - Update Rich-text editor to support uploading images and inserting table
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(curl -sSL -o /tmp/qtest.txt -w \"quill2: %{http_code} size:%{size_download}\\\\n\" https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.min.js)",
|
||||||
|
"Bash(curl -sSL -o quill2.min.js -w \"js: %{http_code} %{size_download}\\\\n\" https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.min.js)",
|
||||||
|
"Bash(curl -sSL -o quill2.snow.css -w \"css: %{http_code} %{size_download}\\\\n\" https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css)",
|
||||||
|
"Bash(mv -f quill2.min.js quill.min.js)",
|
||||||
|
"Bash(mv -f quill2.snow.css quill.snow.css)",
|
||||||
|
"Bash(mkdir -p static/uploads)",
|
||||||
|
"Bash(touch static/uploads/.gitkeep)",
|
||||||
|
"Bash(py --version)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,6 @@ instance/
|
|||||||
*.db
|
*.db
|
||||||
static/img/
|
static/img/
|
||||||
static/vid/
|
static/vid/
|
||||||
|
static/uploads/*
|
||||||
|
!static/uploads/.gitkeep
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -106,16 +106,28 @@ shows only `is_published` topics (sections with no published topics are hidden).
|
|||||||
|
|
||||||
## Content editing (rich text / publish / reorder)
|
## Content editing (rich text / publish / reorder)
|
||||||
|
|
||||||
- **Rich text:** Quill 1.3.7 vendored at `static/vendor/` (no CDN — survives a
|
- **Rich text:** Quill 2.0.3 vendored at `static/vendor/quill.min.js` +
|
||||||
locked-down server or strict CSP). It's a *progressive enhancement over a real
|
`quill.snow.css` (no CDN — survives a locked-down server or strict CSP). It's a
|
||||||
`<textarea name="body_html" id="body_src">`*: `body.quill-on` is added only
|
*progressive enhancement over a real `<textarea name="body_html" id="body_src">`*:
|
||||||
after `new Quill()` succeeds (hides the textarea, shows the editor). If Quill
|
`body.quill-on` is added only after `new Quill()` succeeds (hides the textarea,
|
||||||
fails to load, the textarea stays usable and a save never wipes the body.
|
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.
|
||||||
|
- **Images & tables:** the custom `#editor-toolbar` has a `ql-image` button and a
|
||||||
|
`.ql-table-op` button group. 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). Tables use Quill 2's *built-in*
|
||||||
|
`table: true` module (`getModule('table')` → insertTable/insertRow…/deleteTable) —
|
||||||
|
no third-party plugin. Table buttons `preventDefault` on mousedown to keep the
|
||||||
|
cell selection; cell-dependent ops are wrapped in try/catch.
|
||||||
- **Sanitize on save:** `sanitize_html()` (bleach) runs on every `body_html`
|
- **Sanitize on save:** `sanitize_html()` (bleach) runs on every `body_html`
|
||||||
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS`, `strip=True`, and bleach
|
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS` (now includes `img` + table
|
||||||
restricts link protocols to http/https/mailto (blocks `javascript:`). Empty /
|
tags + `data-row`), `strip=True`; bleach restricts URL protocols on both `href`
|
||||||
`<p><br></p>` editor content is stored as NULL. The public page still renders
|
and `img src` to http/https/mailto, blocking `javascript:` and `data:` (uploads
|
||||||
`body_html` via `Markup`, but the content is now sanitized at the source.
|
are relative `/static` paths). Empty / `<p><br></p>` editor content is stored as
|
||||||
|
NULL. The public page renders `body_html` via `Markup`, sanitized at the source.
|
||||||
- **Draft/publish:** `topic.is_published` (default 1, so existing rows stay
|
- **Draft/publish:** `topic.is_published` (default 1, so existing rows stay
|
||||||
live). Public route filters to `Section.published_topics` and drops sections
|
live). Public route filters to `Section.published_topics` and drops sections
|
||||||
with none. Admin: per-topic `/topic/<id>/toggle` (quick button) + a Published
|
with none. Admin: per-topic `/topic/<id>/toggle` (quick button) + a Published
|
||||||
@@ -139,7 +151,8 @@ sudo mysql < add_admin.sql # audit_log (existing DBs)
|
|||||||
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
|
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
|
||||||
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
|
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
|
||||||
sudo ./venv/bin/pip install -r requirements.txt # includes bleach
|
sudo ./venv/bin/pip install -r requirements.txt # includes bleach
|
||||||
sudo systemctl restart jqc-features
|
sudo -u jqc mkdir -p static/uploads # in-body image uploads (writable)
|
||||||
|
sudo systemctl restart jqc-features # Quill 2 assets are static — no other step
|
||||||
# fail2ban:
|
# fail2ban:
|
||||||
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
|
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
|
||||||
sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/
|
sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/
|
||||||
|
|||||||
@@ -183,13 +183,20 @@ 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.
|
||||||
|
|
||||||
The editor (Quill) and drag library (SortableJS) are **vendored locally** under
|
The editor (Quill 2) and drag library (SortableJS) are **vendored locally** under
|
||||||
`static/vendor/` — no CDN dependency, so they work on a locked-down server and
|
`static/vendor/` — no CDN dependency, so they work on a locked-down server and
|
||||||
survive a strict CSP. Rich-text HTML is sanitized on save (`bleach`) against a
|
survive a strict CSP. The body toolbar supports **inline images** and **tables**
|
||||||
tag allowlist, so a paste can't inject markup or `javascript:` links into the
|
(Quill 2's built-in table module: insert, add/remove rows & columns). Images are
|
||||||
public page. If the editor ever fails to load, the body field degrades to a
|
uploaded via `POST /admin/upload` — the file is stored under `static/uploads/`
|
||||||
|
and referenced by URL, so the database stays small (no base64). Rich-text HTML is
|
||||||
|
sanitized on save (`bleach`) against a tag allowlist that now includes `img` and
|
||||||
|
table tags, so a paste can't inject markup, `javascript:`, or `data:` URLs into
|
||||||
|
the public page. If the editor ever fails to load, the body field degrades to a
|
||||||
plain textarea — a save never wipes content.
|
plain textarea — a save never wipes content.
|
||||||
|
|
||||||
|
`static/uploads/` must be writable by the app user (`jqc`) in production:
|
||||||
|
`sudo -u jqc mkdir -p static/uploads`. Uploaded files are gitignored.
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
|
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
@@ -17,6 +19,26 @@ auth_log = logging.getLogger("jqc.auth")
|
|||||||
|
|
||||||
MEDIA_TYPES = ("none", "image", "video", "embed")
|
MEDIA_TYPES = ("none", "image", "video", "embed")
|
||||||
|
|
||||||
|
# In-body image uploads (rich-text editor). Extension allowlist plus a
|
||||||
|
# magic-byte sniff so a renamed file can't slip a non-image through. SVG is
|
||||||
|
# deliberately excluded (it can carry script). 8 MB cap.
|
||||||
|
ALLOWED_IMAGE_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||||
|
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _sniff_image(head):
|
||||||
|
"""Return a canonical extension if `head` (first bytes of a file) looks like
|
||||||
|
a supported image, else None."""
|
||||||
|
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
return "png"
|
||||||
|
if head[:3] == b"\xff\xd8\xff":
|
||||||
|
return "jpg"
|
||||||
|
if head[:6] in (b"GIF87a", b"GIF89a"):
|
||||||
|
return "gif"
|
||||||
|
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
||||||
|
return "webp"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ auth
|
# ------------------------------------------------------------------ auth
|
||||||
def login_required(view):
|
def login_required(view):
|
||||||
@@ -268,6 +290,40 @@ def reorder():
|
|||||||
return jsonify(ok=True)
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ uploads
|
||||||
|
@admin_bp.route("/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def upload():
|
||||||
|
"""Store an image dropped/picked in the rich-text editor and return its
|
||||||
|
public URL as JSON: {"url": "/static/uploads/<name>"}. CSRF is enforced by
|
||||||
|
the global CSRFProtect via the X-CSRFToken header the editor sends."""
|
||||||
|
file = request.files.get("file")
|
||||||
|
if file is None or not file.filename:
|
||||||
|
return jsonify(error="No file provided."), 400
|
||||||
|
|
||||||
|
if request.content_length and request.content_length > MAX_UPLOAD_BYTES:
|
||||||
|
return jsonify(error="File too large (max 8 MB)."), 413
|
||||||
|
|
||||||
|
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
||||||
|
if ext not in ALLOWED_IMAGE_EXT:
|
||||||
|
return jsonify(error="Unsupported file type."), 400
|
||||||
|
|
||||||
|
# Verify the bytes actually look like an image, not just the name.
|
||||||
|
head = file.stream.read(12)
|
||||||
|
file.stream.seek(0)
|
||||||
|
sniffed = _sniff_image(head)
|
||||||
|
if sniffed is None:
|
||||||
|
return jsonify(error="File is not a valid image."), 400
|
||||||
|
|
||||||
|
upload_dir = os.path.join(current_app.static_folder, "uploads")
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
name = f"{uuid.uuid4().hex}.{sniffed}"
|
||||||
|
file.save(os.path.join(upload_dir, name))
|
||||||
|
|
||||||
|
log_action(session.get("admin"), "create", "upload", None, name)
|
||||||
|
return jsonify(url=url_for("static", filename=f"uploads/{name}"))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ sections
|
# ------------------------------------------------------------------ sections
|
||||||
@admin_bp.route("/section/new", methods=["GET", "POST"])
|
@admin_bp.route("/section/new", methods=["GET", "POST"])
|
||||||
@admin_bp.route("/section/<int:section_id>", methods=["GET", "POST"])
|
@admin_bp.route("/section/<int:section_id>", methods=["GET", "POST"])
|
||||||
|
|||||||
@@ -91,14 +91,28 @@ def log_action(actor, action, entity, entity_id=None, detail=None):
|
|||||||
ALLOWED_TAGS = [
|
ALLOWED_TAGS = [
|
||||||
"p", "br", "strong", "b", "em", "i", "u", "s", "strike",
|
"p", "br", "strong", "b", "em", "i", "u", "s", "strike",
|
||||||
"ul", "ol", "li", "a", "h2", "h3", "blockquote",
|
"ul", "ol", "li", "a", "h2", "h3", "blockquote",
|
||||||
|
# images (inserted via the /admin/upload endpoint or a URL)
|
||||||
|
"img",
|
||||||
|
# tables (Quill 2 built-in table module)
|
||||||
|
"table", "thead", "tbody", "tr", "td", "th", "col", "colgroup",
|
||||||
]
|
]
|
||||||
ALLOWED_ATTRS = {"a": ["href", "title", "target", "rel"]}
|
ALLOWED_ATTRS = {
|
||||||
|
"a": ["href", "title", "target", "rel"],
|
||||||
|
"img": ["src", "alt", "width", "height"],
|
||||||
|
# Quill 2 tags cells/rows with data-row; keep the standard span attrs too.
|
||||||
|
"table": ["class"],
|
||||||
|
"td": ["data-row", "colspan", "rowspan"],
|
||||||
|
"th": ["data-row", "colspan", "rowspan"],
|
||||||
|
"tr": ["data-row"],
|
||||||
|
"col": ["width"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def sanitize_html(raw):
|
def sanitize_html(raw):
|
||||||
"""Clean editor HTML against the allowlist. Returns None for empty content
|
"""Clean editor HTML against the allowlist. Returns None for empty content
|
||||||
so blank bodies stay NULL. bleach also restricts link protocols to
|
so blank bodies stay NULL. bleach also restricts URL protocols to
|
||||||
http/https/mailto, blocking javascript: URLs."""
|
http/https/mailto for both links and images, blocking javascript: and
|
||||||
|
data: URLs (uploaded images are served from a relative /static path)."""
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
cleaned = bleach.clean(
|
cleaned = bleach.clean(
|
||||||
|
|||||||
@@ -263,3 +263,17 @@ body.quill-on #body_src{display:none} /* hide raw textarea on
|
|||||||
#editor-toolbar.ql-toolbar .ql-stroke{stroke:var(--ink-soft)}
|
#editor-toolbar.ql-toolbar .ql-stroke{stroke:var(--ink-soft)}
|
||||||
#editor-toolbar.ql-toolbar button:hover .ql-stroke{stroke:var(--aqua-deep)}
|
#editor-toolbar.ql-toolbar button:hover .ql-stroke{stroke:var(--aqua-deep)}
|
||||||
#editor-toolbar.ql-toolbar button.ql-active .ql-stroke{stroke:var(--aqua-deep)}
|
#editor-toolbar.ql-toolbar button.ql-active .ql-stroke{stroke:var(--aqua-deep)}
|
||||||
|
|
||||||
|
/* Text-labelled table buttons (not SVG icons) need auto width. */
|
||||||
|
#editor-toolbar.ql-toolbar .ql-table-op{
|
||||||
|
width:auto;padding:0 6px;font-size:.72rem;font-weight:600;color:var(--ink-soft);line-height:1.6;
|
||||||
|
}
|
||||||
|
#editor-toolbar.ql-toolbar .ql-table-op[data-op="insert"]{font-size:1rem}
|
||||||
|
#editor-toolbar.ql-toolbar .ql-table-op:hover{color:var(--aqua-deep)}
|
||||||
|
|
||||||
|
/* Images and tables inside the editor mirror how the public page renders them. */
|
||||||
|
#editor .ql-editor img{max-width:100%;height:auto;border-radius:6px}
|
||||||
|
#editor .ql-editor table{border-collapse:collapse;width:100%;margin:.5rem 0}
|
||||||
|
#editor .ql-editor td,#editor .ql-editor th{
|
||||||
|
border:1px solid var(--hair);padding:.4rem .55rem;min-width:2rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ body{
|
|||||||
.prose p{margin:0 0 .8em}
|
.prose p{margin:0 0 .8em}
|
||||||
.prose p:last-child{margin-bottom:0}
|
.prose p:last-child{margin-bottom:0}
|
||||||
.prose strong{color:var(--ink);font-weight:600}
|
.prose strong{color:var(--ink);font-weight:600}
|
||||||
|
.prose img{max-width:100%;height:auto;border-radius:var(--radius);border:1px solid var(--hair);margin:.6em 0}
|
||||||
|
.prose table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.95rem}
|
||||||
|
.prose td,.prose th{border:1px solid var(--hair);padding:.5rem .65rem;text-align:left;vertical-align:top}
|
||||||
|
.prose th{background:rgba(0,0,0,.03);color:var(--ink);font-weight:600}
|
||||||
|
.prose .table-wrap{overflow-x:auto}
|
||||||
|
|
||||||
.topic__link{
|
.topic__link{
|
||||||
align-self:flex-start;display:inline-flex;align-items:center;gap:8px;
|
align-self:flex-start;display:inline-flex;align-items:center;gap:8px;
|
||||||
|
|||||||
Vendored
+8
-7
File diff suppressed because one or more lines are too long
Vendored
+6
-941
File diff suppressed because one or more lines are too long
@@ -74,8 +74,17 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="ql-formats">
|
<span class="ql-formats">
|
||||||
<button class="ql-link"></button>
|
<button class="ql-link"></button>
|
||||||
|
<button class="ql-image" title="Insert image"></button>
|
||||||
<button class="ql-clean"></button>
|
<button class="ql-clean"></button>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="ql-formats ql-table-tools">
|
||||||
|
<button type="button" class="ql-table-op" data-op="insert" title="Insert table">▦</button>
|
||||||
|
<button type="button" class="ql-table-op" data-op="row" title="Add row below">+Row</button>
|
||||||
|
<button type="button" class="ql-table-op" data-op="col" title="Add column right">+Col</button>
|
||||||
|
<button type="button" class="ql-table-op" data-op="delrow" title="Delete row">−Row</button>
|
||||||
|
<button type="button" class="ql-table-op" data-op="delcol" title="Delete column">−Col</button>
|
||||||
|
<button type="button" class="ql-table-op" data-op="deltable" title="Delete table">✕Table</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
<!-- Real field. Quill enhances it; if Quill fails to load, this stays a
|
<!-- Real field. Quill enhances it; if Quill fails to load, this stays a
|
||||||
@@ -140,16 +149,75 @@
|
|||||||
(function () {
|
(function () {
|
||||||
if (typeof Quill === 'undefined') return; // textarea stays usable
|
if (typeof Quill === 'undefined') return; // textarea stays usable
|
||||||
var ta = document.getElementById('body_src');
|
var ta = document.getElementById('body_src');
|
||||||
var toolbar = document.getElementById('editor-toolbar');
|
var toolbarEl = document.getElementById('editor-toolbar');
|
||||||
var editorEl = document.getElementById('editor');
|
var editorEl = document.getElementById('editor');
|
||||||
var quill = new Quill(editorEl, { theme: 'snow', modules: { toolbar: toolbar } });
|
var quill = new Quill(editorEl, {
|
||||||
|
theme: 'snow',
|
||||||
|
// `table: true` enables Quill 2's built-in table module.
|
||||||
|
modules: { toolbar: toolbarEl, table: true }
|
||||||
|
});
|
||||||
if (ta.value.trim()) quill.clipboard.dangerouslyPasteHTML(ta.value);
|
if (ta.value.trim()) quill.clipboard.dangerouslyPasteHTML(ta.value);
|
||||||
|
|
||||||
|
var csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||||||
|
var tableModule = quill.getModule('table');
|
||||||
|
|
||||||
|
// --- 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.
|
||||||
|
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.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.'); });
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Table controls. Row/column ops act on the cell holding the cursor.
|
||||||
|
toolbarEl.querySelectorAll('.ql-table-op').forEach(function (btn) {
|
||||||
|
// Preventing the mousedown default keeps the editor's selection so the
|
||||||
|
// table module knows which cell the cursor is in.
|
||||||
|
btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var op = btn.getAttribute('data-op');
|
||||||
|
try {
|
||||||
|
if (op === 'insert') {
|
||||||
|
if (!quill.getSelection()) quill.setSelection(quill.getLength() - 1, 0);
|
||||||
|
tableModule.insertTable(3, 3);
|
||||||
|
}
|
||||||
|
else if (op === 'row') tableModule.insertRowBelow();
|
||||||
|
else if (op === 'col') tableModule.insertColumnRight();
|
||||||
|
else if (op === 'delrow') tableModule.deleteRow();
|
||||||
|
else if (op === 'delcol') tableModule.deleteColumn();
|
||||||
|
else if (op === 'deltable') tableModule.deleteTable();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Place the cursor inside a table cell first.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 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');
|
||||||
|
|
||||||
ta.form.addEventListener('submit', function () {
|
ta.form.addEventListener('submit', function () {
|
||||||
ta.value = quill.getText().trim().length ? quill.root.innerHTML : '';
|
// getText() is empty for an image/table-only body, so also check for
|
||||||
|
// embeds before treating the editor as blank (which would wipe the save).
|
||||||
|
var hasText = quill.getText().trim().length > 0;
|
||||||
|
var hasEmbed = quill.root.querySelector('img, table');
|
||||||
|
ta.value = (hasText || hasEmbed) ? quill.root.innerHTML : '';
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user