Feedback reply: admin-only WYSIWYG editor (server stays the adult)
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex greenlit for this narrow, admin-only surface). * markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text. Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong, i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles (content kept as escaped text) and discards script/style content entirely. * API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html, derives plain text → stored message + the email text/plain part. Unchanged: multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422. * Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List), execCommand with styleWithCSS=false for semantic tags, font size wraps the selection in a fixed-px span, paste intercepted as plain text. No links yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -136,52 +136,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
// In-site reply (Markdown-ish: textarea + toolbar; server renders sanitized HTML).
|
||||
// In-site reply: small admin-only WYSIWYG (contenteditable + execCommand).
|
||||
// The editor's HTML is sanitized SERVER-SIDE; the client just collects it.
|
||||
let replyingId = $state(null);
|
||||
let replyText = $state('');
|
||||
let replyBusy = $state(false);
|
||||
let replyErr = $state('');
|
||||
let replyTextarea = $state(null);
|
||||
function openReply(f) { replyingId = f.id; replyText = ''; replyErr = ''; }
|
||||
function cancelReply() { replyingId = null; replyText = ''; replyErr = ''; }
|
||||
let replyEmpty = $state(true);
|
||||
let replyEditor = $state(null);
|
||||
const SIZE_PX = { small: 13, normal: 15, large: 18, xlarge: 22 };
|
||||
|
||||
// Wrap the current selection (e.g. **bold**), restoring focus + selection.
|
||||
function mdWrap(token) {
|
||||
const el = replyTextarea;
|
||||
if (!el) return;
|
||||
const s = el.selectionStart, e = el.selectionEnd;
|
||||
const sel = replyText.slice(s, e) || 'text';
|
||||
replyText = replyText.slice(0, s) + token + sel + token + replyText.slice(e);
|
||||
queueMicrotask(() => {
|
||||
el.focus();
|
||||
el.selectionStart = s + token.length;
|
||||
el.selectionEnd = s + token.length + sel.length;
|
||||
});
|
||||
function openReply(f) { replyingId = f.id; replyErr = ''; replyEmpty = true; }
|
||||
function cancelReply() { replyingId = null; replyErr = ''; }
|
||||
function focusEditor(node) { queueMicrotask(() => node.focus()); }
|
||||
function onEditorInput() { replyEmpty = !(replyEditor?.textContent || '').trim(); }
|
||||
// Paste as plain text — never let rich/pasted HTML into the editor.
|
||||
function onPaste(e) {
|
||||
e.preventDefault();
|
||||
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
// Prefix each selected line (e.g. "- " or "## ").
|
||||
function mdPrefix(prefix) {
|
||||
const el = replyTextarea;
|
||||
if (!el) return;
|
||||
const s = el.selectionStart, e = el.selectionEnd;
|
||||
const start = replyText.lastIndexOf('\n', s - 1) + 1;
|
||||
let end = replyText.indexOf('\n', e);
|
||||
if (end === -1) end = replyText.length;
|
||||
const block = replyText.slice(start, end) || 'item';
|
||||
const prefixed = block.split('\n').map((ln) => prefix + ln).join('\n');
|
||||
replyText = replyText.slice(0, start) + prefixed + replyText.slice(end);
|
||||
queueMicrotask(() => el.focus());
|
||||
// Bold/italic/lists via execCommand; styleWithCSS=false → semantic <b>/<i>/<ul>.
|
||||
function cmd(c) {
|
||||
replyEditor?.focus();
|
||||
try { document.execCommand('styleWithCSS', false, false); } catch { /* not all browsers */ }
|
||||
document.execCommand(c, false, null);
|
||||
onEditorInput();
|
||||
}
|
||||
// Font size: wrap the selection in a span with a fixed, whitelisted px.
|
||||
function setSize(key) {
|
||||
const px = SIZE_PX[key];
|
||||
if (!px) return;
|
||||
replyEditor?.focus();
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (range.collapsed) return; // need a selection to size
|
||||
const span = document.createElement('span');
|
||||
span.style.fontSize = px + 'px';
|
||||
try { span.appendChild(range.extractContents()); range.insertNode(span); } catch { /* ignore */ }
|
||||
sel.removeAllRanges();
|
||||
onEditorInput();
|
||||
}
|
||||
async function sendReply(f) {
|
||||
const msg = replyText.trim();
|
||||
if (!msg || replyBusy) return;
|
||||
if (replyEmpty || replyBusy) return;
|
||||
const html = replyEditor?.innerHTML || '';
|
||||
replyBusy = true; replyErr = '';
|
||||
try {
|
||||
const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { message: msg });
|
||||
const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { html });
|
||||
f.replies = [...(f.replies || []), res.reply];
|
||||
f.read_at = f.read_at || res.reply.sent_at; // server marked it read
|
||||
replyingId = null; replyText = '';
|
||||
replyingId = null;
|
||||
} catch (e) {
|
||||
replyErr = e?.message || 'Could not send — the draft is kept.';
|
||||
replyErr = e?.message || 'Could not send — your draft is kept.';
|
||||
} finally {
|
||||
replyBusy = false;
|
||||
}
|
||||
@@ -475,15 +481,33 @@
|
||||
|
||||
{#if replyingId === f.id}
|
||||
<div class="composer">
|
||||
<div class="mdbar">
|
||||
<button type="button" class="mdbtn" title="Bold (**…**)" onclick={() => mdWrap('**')}><b>B</b></button>
|
||||
<button type="button" class="mdbtn" title="Bullet list" onclick={() => mdPrefix('- ')}>• List</button>
|
||||
<button type="button" class="mdbtn" title="Heading" onclick={() => mdPrefix('## ')}>H</button>
|
||||
<div class="rtbar">
|
||||
<button type="button" class="rtbtn" title="Bold" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('bold')}><b>B</b></button>
|
||||
<button type="button" class="rtbtn" title="Italic" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('italic')}><i>I</i></button>
|
||||
<select class="szsel" title="Text size" onmousedown={(e) => e.stopPropagation()} onchange={(e) => { setSize(e.currentTarget.value); e.currentTarget.value = ''; }}>
|
||||
<option value="">Size</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="large">Large</option>
|
||||
<option value="xlarge">X-Large</option>
|
||||
</select>
|
||||
<button type="button" class="rtbtn" title="Bullet list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertUnorderedList')}>• List</button>
|
||||
<button type="button" class="rtbtn" title="Numbered list" onmousedown={(e) => e.preventDefault()} onclick={() => cmd('insertOrderedList')}>1. List</button>
|
||||
</div>
|
||||
<textarea bind:this={replyTextarea} bind:value={replyText} rows="4" placeholder="Write a reply… **bold**, - bullets, ## heading"></textarea>
|
||||
<div
|
||||
class="rtedit"
|
||||
contenteditable="true"
|
||||
bind:this={replyEditor}
|
||||
use:focusEditor
|
||||
oninput={onEditorInput}
|
||||
onpaste={onPaste}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Write a reply"
|
||||
></div>
|
||||
{#if replyErr}<p class="cerr">{replyErr}</p>{/if}
|
||||
<div class="cbtns">
|
||||
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || !replyText.trim()}>
|
||||
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || replyEmpty}>
|
||||
{replyBusy ? 'Sending…' : 'Send reply'}
|
||||
</button>
|
||||
<button class="act" onclick={cancelReply}>Cancel</button>
|
||||
@@ -669,18 +693,24 @@
|
||||
.rep .repmsg :global(h4),
|
||||
.rep .repmsg :global(h5) { margin: 6px 0 4px; font-size: 0.95rem; }
|
||||
.composer { margin: 10px 0 0; }
|
||||
.mdbar { display: flex; gap: 6px; margin-bottom: 6px; }
|
||||
.mdbtn {
|
||||
font: inherit; font-size: 0.78rem; background: var(--surface); border: 1px solid var(--line);
|
||||
color: var(--ink); border-radius: 7px; padding: 3px 9px; cursor: pointer; line-height: 1.4;
|
||||
.rtbar { display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.rtbtn {
|
||||
font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line);
|
||||
color: var(--ink); border-radius: 7px; padding: 3px 10px; cursor: pointer; line-height: 1.4; min-width: 30px;
|
||||
}
|
||||
.mdbtn:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||
.composer textarea {
|
||||
width: 100%; box-sizing: border-box; font: inherit; font-size: 0.9rem;
|
||||
.rtbtn:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||
.szsel {
|
||||
font: inherit; font-size: 0.8rem; background: var(--surface); border: 1px solid var(--line);
|
||||
color: var(--ink); border-radius: 7px; padding: 3px 6px; cursor: pointer;
|
||||
}
|
||||
.rtedit {
|
||||
min-height: 90px; box-sizing: border-box; font-size: 0.92rem; line-height: 1.5;
|
||||
padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: var(--bg); color: var(--ink); resize: vertical;
|
||||
background: var(--bg); color: var(--ink);
|
||||
}
|
||||
.composer textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.rtedit:focus { outline: none; border-color: var(--accent); }
|
||||
.rtedit :global(ul), .rtedit :global(ol) { margin: 4px 0; padding-left: 22px; }
|
||||
.rtedit :global(p) { margin: 0 0 6px; }
|
||||
.cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; }
|
||||
.cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
|
||||
.csend {
|
||||
|
||||
Reference in New Issue
Block a user