Six more vendored families alongside the existing Noto Sans (Inter, Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/ serif/slab/mono variety), all OFL-licensed with their own per-family license file in app/fonts/ since each has a different copyright holder. Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only families (Inter and Source Sans's current Google Fonts releases, plus Playfair Display/Lora/Merriweather) were skipped in favor of static builds from their own upstream repos, keeping every family's loading code uniform with what was already there. Considered but deliberately left out: Georgia -- a proprietary Microsoft core font, not freely redistributable, unlike everything else vendored here. Also moves the bold/italic/underline/color toolbar below the contenteditable box per request, and reorders the dialog's Settings card to a more natural family-then-size order. Fixes a latent migration bug this surfaced: migration 20 (static image widget) used Base.metadata.create_all, which creates every table declared in Base.metadata that's missing, not just its own new one -- harmless when nothing else pending, but once TextWidgetConfig existed it would silently pre-create text_widget_configs (in whatever shape models.py currently declares) before migration 21 got a turn, so migration 21's own CREATE TABLE (or a later ALTER TABLE adding font_family) would collide with a table create_all had already leaked into existence. Both migrations 20 and 21 now use raw, frozen CREATE TABLE SQL instead, matching migration 17's existing precedent for exactly this reason.
142 lines
5.3 KiB
JavaScript
142 lines
5.3 KiB
JavaScript
// 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 <dialog>, 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 <div><span style="..."> 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 <input type="color"> 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();
|
|
}
|
|
|
|
function closeTextDialog() {
|
|
if (_textSelectionHandler) {
|
|
document.removeEventListener('selectionchange', _textSelectionHandler);
|
|
_textSelectionHandler = null;
|
|
}
|
|
_textSavedRange = null;
|
|
}
|