Jul 24 - Update Rich-text editor to enhance table editor

This commit is contained in:
2026-07-24 10:42:22 -04:00
parent 10a8658b67
commit db2d1795b4
10 changed files with 125 additions and 85 deletions
+22 -1
View File
@@ -8,7 +8,28 @@
"Bash(mv -f quill2.snow.css quill.snow.css)", "Bash(mv -f quill2.snow.css quill.snow.css)",
"Bash(mkdir -p static/uploads)", "Bash(mkdir -p static/uploads)",
"Bash(touch static/uploads/.gitkeep)", "Bash(touch static/uploads/.gitkeep)",
"Bash(py --version)" "Bash(py --version)",
"Bash(cd /tmp)",
"Bash(curl -sSL -w \"\\\\npkg: %{http_code}\\\\n\" https://registry.npmjs.org/quill-table-better/latest)",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('version',d.get\\('version'\\)\\); print\\('peer',d.get\\('peerDependencies'\\)\\); print\\('desc',d.get\\('description'\\)\\); print\\('main',d.get\\('main'\\),'module',d.get\\('module'\\)\\); print\\('files dist:',[k for k in \\(d.get\\('files'\\) or []\\)]\\)\")",
"Bash(curl -sSL -w \" %{http_code}\\\\n\" -o /dev/null https://cdn.jsdelivr.net/npm/quill-table-better@latest/dist/quill-table-better.js)",
"Bash(curl -sSL -w \" css %{http_code}\\\\n\" -o /dev/null https://cdn.jsdelivr.net/npm/quill-table-better@latest/dist/quill-table-better.css)",
"Bash(curl -sSL https://registry.npmjs.org/quill-table-better/latest -o qtb.json)",
"Bash(curl -sSL https://cdn.jsdelivr.net/npm/quill-table-better@latest/dist/quill-table-better.js -o qtb.js)",
"Bash(curl -sSL https://cdn.jsdelivr.net/npm/quill-table-better@latest/dist/quill-table-better.css -o qtb.css)",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/README.md -o qtb-readme.md -w \"readme: %{http_code}\\\\n\")",
"Bash(grep -nA3 -i \"register\\\\|modules:\\\\|table-better'\\\\|toolbarTable\\\\|keyboardBindings\\\\|table: false\\\\|menus\" qtb-readme.md)",
"Bash(curl -sSL \"https://api.github.com/repos/attoae/quill-table-better/git/trees/main?recursive=1\" -o tree.json)",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/src/formats/table.ts -o table.ts -w \"table.ts %{http_code}\\\\n\")",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/src/config.ts -o config.ts -w \"config %{http_code}\\\\n\")",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/src/config/index.ts -o cfg.ts -w \"cfg %{http_code}\\\\n\")",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/src/ui/toolbar-table.ts -o tt.ts -w \"toolbar-table %{http_code}\\\\n\")",
"Bash(curl -sSL https://raw.githubusercontent.com/attoae/quill-table-better/main/src/quill-table-better.ts -o main.ts)",
"Bash(cp /tmp/qtb.js static/vendor/quill-table-better.js)",
"Bash(cp /tmp/qtb.css static/vendor/quill-table-better.css)",
"Bash(python -m py_compile app.py admin.py)",
"Bash(cd /tmp *)",
"Read(//tmp/**)"
] ]
} }
} }
+23 -15
View File
@@ -113,21 +113,29 @@ 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.
- **Images & tables:** the custom `#editor-toolbar` has a `ql-image` button and a - **Images:** the custom `#editor-toolbar` has a `ql-image` button. Images upload
`.ql-table-op` button group. Images upload via `POST /admin/upload` via `POST /admin/upload` (`login_required`, CSRF via `X-CSRFToken` header):
(`login_required`, CSRF via `X-CSRFToken` header): extension allowlist + extension allowlist + magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap,
magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap, saved as a random saved as a random `uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`;
`uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`; the handler the handler `insertEmbed`s it (no base64 → DB stays small).
`insertEmbed`s it (no base64 → DB stays small). Tables use Quill 2's *built-in* - **Tables:** `quill-table-better` 1.2.3 vendored (`static/vendor/quill-table-better.js`
`table: true` module (`getModule('table')` → insertTable/insertRow…/deleteTable) — + `.css`, UMD → reads global `Quill`, exposes `QuillTableBetter`; self-contained,
no third-party plugin. Table buttons `preventDefault` on mousedown to keep the no CDN/CSP issues). Registered as `modules/table-better`; Quill 2's basic
cell selection; cell-dependent ops are wrapped in try/catch. `table` module is disabled (`table: false`). The `ql-table-better` toolbar button
opens a size picker to insert; **column/row drag-resize** and a floating **cell
menu (alignment, borders, background, merge/split)** are the module's own UI — no
custom wiring. Init is guarded by `typeof QuillTableBetter` so a failed vendor
load still leaves a working editor + textarea. Registration and keyboard bindings
(`QuillTableBetter.keyboardBindings`) live in `topic_form.html`.
- **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` (now includes `img` + table write — `ALLOWED_TAGS`/`ALLOWED_ATTRS` cover `img` + full table tags with
tags + `data-row`), `strip=True`; bleach restricts URL protocols on both `href` `colspan/rowspan/data-*/style`; a `CSSSanitizer` (bleach[css] + `tinycss2`)
and `img src` to http/https/mailto, blocking `javascript:` and `data:` (uploads filters inline `style` to `ALLOWED_CSS_PROPS` (width/height/padding/text-align/
are relative `/static` paths). Empty / `<p><br></p>` editor content is stored as vertical-align/background-color/border*) so the table editor's sizing & alignment
NULL. The public page renders `body_html` via `Markup`, sanitized at the source. survive but `position`, `behavior`, `url(javascript:)` etc. are stripped. bleach
also restricts URL protocols on `href`/`img src` to http/https/mailto (blocks
`javascript:`/`data:`; uploads are relative `/static` paths). Empty / `<p><br></p>`
is stored NULL. Public 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
@@ -150,7 +158,7 @@ shows only `is_published` topics (sections with no published topics are hidden).
sudo mysql < add_admin.sql # audit_log (existing DBs) 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 # bleach[css] + tinycss2 (CSS sanitize)
sudo -u jqc mkdir -p static/uploads # in-body image uploads (writable) 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 sudo systemctl restart jqc-features # Quill 2 assets are static — no other step
# fail2ban: # fail2ban:
+10 -8
View File
@@ -183,16 +183,18 @@ 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 2) and drag library (SortableJS) are **vendored locally** under The editor (Quill 2 + quill-table-better) and drag library (SortableJS) are
`static/vendor/` — no CDN dependency, so they work on a locked-down server and **vendored locally** under `static/vendor/` — no CDN dependency, so they work on a
survive a strict CSP. The body toolbar supports **inline images** and **tables** locked-down server and survive a strict CSP. The body toolbar supports **inline
(Quill 2's built-in table module: insert, add/remove rows & columns). Images are images** and **tables** with **drag-to-resize columns/rows** and a floating cell
menu for **alignment, borders and background** (merge/split too). Images are
uploaded via `POST /admin/upload` — the file is stored under `static/uploads/` 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 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 sanitized on save (`bleach` + `tinycss2` CSS sanitizer): a tag allowlist covers
table tags, so a paste can't inject markup, `javascript:`, or `data:` URLs into images and tables, and inline `style` is filtered to a small CSS-property allowlist
the public page. If the editor ever fails to load, the body field degrades to a (width/alignment/border/background) so a paste can't inject markup, `javascript:`,
plain textarea — a save never wipes content. `data:`, or dangerous CSS into the public page. If the editor ever fails to load,
the body field degrades to a plain textarea — a save never wipes content.
`static/uploads/` must be writable by the app user (`jqc`) in production: `static/uploads/` must be writable by the app user (`jqc`) in production:
`sudo -u jqc mkdir -p static/uploads`. Uploaded files are gitignored. `sudo -u jqc mkdir -p static/uploads`. Uploaded files are gitignored.
+26 -9
View File
@@ -4,6 +4,7 @@ from datetime import datetime
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
import bleach import bleach
from bleach.css_sanitizer import CSSSanitizer
from flask import Flask, render_template from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
@@ -93,30 +94,46 @@ ALLOWED_TAGS = [
"ul", "ol", "li", "a", "h2", "h3", "blockquote", "ul", "ol", "li", "a", "h2", "h3", "blockquote",
# images (inserted via the /admin/upload endpoint or a URL) # images (inserted via the /admin/upload endpoint or a URL)
"img", "img",
# tables (Quill 2 built-in table module) # tables (quill-table-better: resizable cols, aligned/styled cells)
"table", "thead", "tbody", "tr", "td", "th", "col", "colgroup", "table", "thead", "tbody", "tr", "td", "th", "col", "colgroup",
] ]
# quill-table-better carries column widths, alignment, borders and background
# as inline `style` on the table/cell tags, plus data-* bookkeeping attributes.
_TABLE_CELL_ATTRS = [
"data-row", "data-cell", "data-class", "colspan", "rowspan",
"width", "height", "style",
]
ALLOWED_ATTRS = { ALLOWED_ATTRS = {
"a": ["href", "title", "target", "rel"], "a": ["href", "title", "target", "rel"],
"img": ["src", "alt", "width", "height"], "img": ["src", "alt", "width", "height"],
# Quill 2 tags cells/rows with data-row; keep the standard span attrs too. "table": ["class", "style", "align", "width", "height", "data-class"],
"table": ["class"], "colgroup": ["style"],
"td": ["data-row", "colspan", "rowspan"], "col": ["width", "span", "style"],
"th": ["data-row", "colspan", "rowspan"], "tr": ["data-row", "style"],
"tr": ["data-row"], "td": _TABLE_CELL_ATTRS,
"col": ["width"], "th": _TABLE_CELL_ATTRS,
} }
# Only these CSS properties survive on a `style` attribute — enough for the
# table editor's sizing/alignment/borders, nothing that can smuggle script.
ALLOWED_CSS_PROPS = [
"width", "height", "min-width", "padding", "text-align", "vertical-align",
"background-color", "border", "border-style", "border-color",
"border-width", "border-collapse",
]
_css_sanitizer = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPS)
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 URL protocols to so blank bodies stay NULL. bleach also restricts URL protocols to
http/https/mailto for both links and images, blocking javascript: and http/https/mailto for both links and images, blocking javascript: and
data: URLs (uploaded images are served from a relative /static path).""" data: URLs (uploaded images are served from a relative /static path). Inline
`style` is filtered to the ALLOWED_CSS_PROPS allowlist via CSSSanitizer."""
if not raw: if not raw:
return None return None
cleaned = bleach.clean( cleaned = bleach.clean(
raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True,
css_sanitizer=_css_sanitizer,
).strip() ).strip()
# Quill leaves an empty paragraph for a blank editor. # Quill leaves an empty paragraph for a blank editor.
if cleaned in ("", "<p></p>", "<p><br></p>"): if cleaned in ("", "<p></p>", "<p><br></p>"):
+2 -1
View File
@@ -1,6 +1,7 @@
Flask==3.0.3 Flask==3.0.3
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1 Flask-WTF==1.2.1
bleach==6.1.0 bleach[css]==6.1.0
tinycss2==1.3.0
PyMySQL==1.1.1 PyMySQL==1.1.1
gunicorn==22.0.0 gunicorn==22.0.0
+1 -12
View File
@@ -264,16 +264,5 @@ body.quill-on #body_src{display:none} /* hide raw textarea on
#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. */ /* Images fit the editor width; table styling is owned by quill-table-better. */
#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 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;
}
+6 -3
View File
@@ -152,10 +152,13 @@ body{
.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 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} /* Tables are authored with quill-table-better: column widths ride in a
.prose td,.prose th{border:1px solid var(--hair);padding:.5rem .65rem;text-align:left;vertical-align:top} <colgroup>, and per-cell alignment/border/background as inline styles (which
win over these defaults). table-layout:fixed honours the authored widths;
max-width keeps a wide table from overflowing the column. */
.prose table{border-collapse:collapse;table-layout:fixed;max-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;word-wrap:break-word}
.prose th{background:rgba(0,0,0,.03);color:var(--ink);font-weight:600} .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;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+33 -36
View File
@@ -3,6 +3,7 @@
{% block head_extra %} {% block head_extra %}
<link href="{{ url_for('static', filename='vendor/quill.snow.css') }}" rel="stylesheet"> <link href="{{ url_for('static', filename='vendor/quill.snow.css') }}" rel="stylesheet">
<link href="{{ url_for('static', filename='vendor/quill-table-better.css') }}" rel="stylesheet">
{% endblock %} {% endblock %}
{% macro val(field, default='') -%} {% macro val(field, default='') -%}
@@ -77,13 +78,8 @@
<button class="ql-image" title="Insert image"></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"> <span class="ql-formats">
<button type="button" class="ql-table-op" data-op="insert" title="Insert table"></button> <button class="ql-table-better" 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> </span>
</div> </div>
<div id="editor"></div> <div id="editor"></div>
@@ -145,21 +141,36 @@
{% 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> <script>
(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 toolbarEl = 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', // quill-table-better: resizable columns/rows + a cell menu for alignment,
// `table: true` enables Quill 2's built-in table module. // borders and background. Registered only if the vendor script loaded.
modules: { toolbar: toolbarEl, table: true } var hasTables = typeof QuillTableBetter !== 'undefined';
}); if (hasTables) {
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
}
var modules = { toolbar: toolbarEl };
if (hasTables) {
modules.table = false; // disable Quill's basic table
modules['table-better'] = {
language: 'en_US',
menus: ['column', 'row', 'merge', 'table', 'cell', 'wrap', 'delete'],
toolbarTable: true
};
modules.keyboard = { bindings: QuillTableBetter.keyboardBindings };
}
var quill = new Quill(editorEl, { theme: 'snow', modules: modules });
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 csrf = document.querySelector('meta[name="csrf-token"]').content;
var tableModule = quill.getModule('table');
// --- Image button: upload the file, then embed the returned URL. This // --- 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. // keeps the DB small (no base64) and the image is served as a static file.
@@ -186,28 +197,8 @@
input.click(); input.click();
}); });
// --- Table controls. Row/column ops act on the cell holding the cursor. // Table resize + cell alignment are handled by quill-table-better's own
toolbarEl.querySelectorAll('.ql-table-op').forEach(function (btn) { // drag handles and floating cell menu — no extra toolbar wiring needed.
// 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');
@@ -217,7 +208,13 @@
// embeds before treating the editor as blank (which would wipe the save). // embeds before treating the editor as blank (which would wipe the save).
var hasText = quill.getText().trim().length > 0; var hasText = quill.getText().trim().length > 0;
var hasEmbed = quill.root.querySelector('img, table'); var hasEmbed = quill.root.querySelector('img, table');
ta.value = (hasText || hasEmbed) ? quill.root.innerHTML : ''; if (!hasText && !hasEmbed) { ta.value = ''; return; }
// Drop quill-table-better's transient selection nodes, then serialize
// clean semantic HTML (not raw innerHTML, which can leak UI markup).
if (hasTables) {
try { quill.getModule('table-better').deleteTableTemporary(); } catch (e) {}
}
ta.value = quill.getSemanticHTML();
}); });
})(); })();
</script> </script>