Jul 24 - Update Rich-text editor to enhance table editor
This commit is contained in:
+22
-1
@@ -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/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user