// Text widget dialog: a small rich-text editor (bold/italic/underline, // text/highlight color), font size/alignment/background settings, and // the rendered preview. Not a page-load script -- frame_layout.js // fetches this widget's dialog HTML fragment, injects it into the // shared , points window.FRAME_API at this specific widget // (/api/frames/{id}/widgets/{widget_id}), then calls initTextDialog(). // // The editor is never seeded via innerHTML string interpolation -- // #text-editor's data-content attribute (server-rendered from // app/text_content.py's already-sanitized run structure, not raw HTML) // is JSON-parsed and rebuilt with createElement/textContent below. The // same
shape this produces is exactly what // app/text_content.py's parser expects back on save, so round-tripping // (load -> edit -> save -> reload) is stable. function loadTextPreview() { document.getElementById('text-preview').src = `${window.FRAME_API}/preview/text?_=${Date.now()}`; } function buildTextEditorContent(editor, paragraphs) { editor.textContent = ''; if (!paragraphs || !paragraphs.length) return; paragraphs.forEach((para) => { const div = document.createElement('div'); if (!para.length) { div.appendChild(document.createElement('br')); } else { para.forEach((run) => { const span = document.createElement('span'); span.textContent = run.text; if (run.bold) span.style.fontWeight = 'bold'; if (run.italic) span.style.fontStyle = 'italic'; if (run.underline) span.style.textDecoration = 'underline'; if (run.color) span.style.color = run.color; if (run.bg) span.style.backgroundColor = run.bg; div.appendChild(span); }); } editor.appendChild(div); }); } let _textSavedRange = null; let _textSelectionHandler = null; function initTextDialog() { const editor = document.getElementById('text-editor'); let initialContent = null; try { initialContent = JSON.parse(editor.dataset.content || 'null'); } catch (e) { /* leave empty */ } buildTextEditorContent(editor, initialContent); // Native steals focus (and with it, the // editor's text selection) the moment it's interacted with -- track // the most recent in-editor selection continuously so a color pick // can be reapplied to the text the user actually had selected, // instead of applying to nothing. _textSelectionHandler = () => { const sel = window.getSelection(); if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) { _textSavedRange = sel.getRangeAt(0).cloneRange(); } }; document.addEventListener('selectionchange', _textSelectionHandler); function restoreSelection() { if (!_textSavedRange) return; const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(_textSavedRange); } ['text-bold', 'text-italic', 'text-underline'].forEach((id) => { const btn = document.getElementById(id); // preventDefault on mousedown keeps focus (and the selection) in // the editor, so the click's execCommand has something to act on. btn.addEventListener('mousedown', (e) => e.preventDefault()); btn.addEventListener('click', () => { editor.focus(); document.execCommand(btn.dataset.cmd, false, null); }); }); document.getElementById('text-color').addEventListener('input', (e) => { editor.focus(); restoreSelection(); document.execCommand('foreColor', false, e.target.value); }); document.getElementById('text-highlight').addEventListener('input', (e) => { editor.focus(); restoreSelection(); document.execCommand('hiliteColor', false, e.target.value); }); document.getElementById('text-highlight-clear').addEventListener('mousedown', (e) => e.preventDefault()); document.getElementById('text-highlight-clear').addEventListener('click', () => { editor.focus(); restoreSelection(); document.execCommand('hiliteColor', false, 'transparent'); }); document.getElementById('text_font_size').addEventListener('input', (e) => { document.getElementById('text_font_size_value').textContent = `${e.target.value}px`; }); document.getElementById('text-config-form').addEventListener('submit', async (e) => { e.preventDefault(); const body = new URLSearchParams({ text_html: editor.innerHTML, text_font_family: document.getElementById('text_font_family').value, text_font_size: document.getElementById('text_font_size').value, text_align: document.getElementById('text_align').value, text_background_color: document.getElementById('text_background_color').value, }); try { const resp = await fetch(`${window.FRAME_API}/config`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!resp.ok) throw new Error(await apiError(resp)); showStatus(true, 'Saved.'); loadTextPreview(); } catch (e2) { showStatus(false, e2.message); } }); document.getElementById('text-preview-refresh').addEventListener('click', loadTextPreview); loadTextPreview(); initBorderFields(); initButtonActionFields(); } function closeTextDialog() { if (_textSelectionHandler) { document.removeEventListener('selectionchange', _textSelectionHandler); _textSelectionHandler = null; } _textSavedRange = null; }