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(mkdir -p static/uploads)",
"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
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.
- **Images:** the custom `#editor-toolbar` has a `ql-image` button. Images upload
via `POST /admin/upload` (`login_required`, CSRF via `X-CSRFToken` header):
extension allowlist + magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap,
saved as a random `uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`;
the handler `insertEmbed`s it (no base64 → DB stays small).
- **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
`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`
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS` (now includes `img` + table
tags + `data-row`), `strip=True`; bleach restricts URL protocols on both `href`
and `img src` to http/https/mailto, blocking `javascript:` and `data:` (uploads
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.
write — `ALLOWED_TAGS`/`ALLOWED_ATTRS` cover `img` + full table tags with
`colspan/rowspan/data-*/style`; a `CSSSanitizer` (bleach[css] + `tinycss2`)
filters inline `style` to `ALLOWED_CSS_PROPS` (width/height/padding/text-align/
vertical-align/background-color/border*) so the table editor's sizing & alignment
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
live). Public route filters to `Section.published_topics` and drops sections
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_publish.sql # topic.is_published (existing DBs)
# .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 systemctl restart jqc-features # Quill 2 assets are static — no other step
# 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
are UTC.
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
survive a strict CSP. The body toolbar supports **inline images** and **tables**
(Quill 2's built-in table module: insert, add/remove rows & columns). Images 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
locked-down server and survive a strict CSP. The body toolbar supports **inline
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/`
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.
sanitized on save (`bleach` + `tinycss2` CSS sanitizer): a tag allowlist covers
images and tables, and inline `style` is filtered to a small CSS-property allowlist
(width/alignment/border/background) so a paste can't inject markup, `javascript:`,
`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:
`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
import bleach
from bleach.css_sanitizer import CSSSanitizer
from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
@@ -93,30 +94,46 @@ ALLOWED_TAGS = [
"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)
# tables (quill-table-better: resizable cols, aligned/styled cells)
"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 = {
"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"],
"table": ["class", "style", "align", "width", "height", "data-class"],
"colgroup": ["style"],
"col": ["width", "span", "style"],
"tr": ["data-row", "style"],
"td": _TABLE_CELL_ATTRS,
"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):
"""Clean editor HTML against the allowlist. Returns None for empty content
so blank bodies stay NULL. bleach also restricts URL protocols to
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:
return None
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()
# Quill leaves an empty paragraph for a blank editor.
if cleaned in ("", "<p></p>", "<p><br></p>"):
+2 -1
View File
@@ -1,6 +1,7 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1
bleach==6.1.0
bleach[css]==6.1.0
tinycss2==1.3.0
PyMySQL==1.1.1
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.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. */
/* 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 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 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}
/* Tables are authored with quill-table-better: column widths ride in a
<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 .table-wrap{overflow-x:auto}
.topic__link{
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 %}
<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 %}
{% macro val(field, default='') -%}
@@ -77,13 +78,8 @@
<button class="ql-image" title="Insert image"></button>
<button class="ql-clean"></button>
</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 class="ql-formats">
<button class="ql-table-better" title="Insert table"></button>
</span>
</div>
<div id="editor"></div>
@@ -145,21 +141,36 @@
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/quill.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/quill-table-better.js') }}"></script>
<script>
(function () {
if (typeof Quill === 'undefined') return; // textarea stays usable
var ta = document.getElementById('body_src');
var toolbarEl = document.getElementById('editor-toolbar');
var editorEl = document.getElementById('editor');
var quill = new Quill(editorEl, {
theme: 'snow',
// `table: true` enables Quill 2's built-in table module.
modules: { toolbar: toolbarEl, table: true }
});
// quill-table-better: resizable columns/rows + a cell menu for alignment,
// borders and background. Registered only if the vendor script loaded.
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);
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.
@@ -186,28 +197,8 @@
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.');
}
});
});
// Table resize + cell alignment are handled by quill-table-better's own
// drag handles and floating cell menu — no extra toolbar wiring needed.
// Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on');
@@ -217,7 +208,13 @@
// 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 : '';
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>