2026-02-21 18:35:20 -06:00

139 lines
6.1 KiB
Python

from nicegui import ui
def rich_text_editor() -> ui.editor:
"""A rich text editor component with a customizable toolbar and styling."""
toolbar = (
"["
+ "['bold', 'italic', 'strike', 'underline'],"
+ "['quote', 'unordered', 'ordered', 'code'],"
+ "['link', 'remove-formatting'],"
+ "['print', 'fullscreen'],"
+ "['viewsource']"
+ "]"
)
props_string = (
f"toolbar={toolbar} "
+ "content-style='font-size: 16px; line-height: 1.6; color: white;' "
+ "toolbar-bg='gray-800' "
+ "toolbar-text-color='white' "
+ "toolbar-toggle-color='yellow-8'"
)
editor = (
ui.editor(placeholder="Start writing or use Create Template...")
.classes("flex-grow bg-gray-800 text-white")
.props(props_string)
)
def attach_paste_handler():
# This script runs after the editor's Vue component is mounted,
# ensuring the element is available in the DOM.
# It uses a raw f-string (rf"...") to handle backslashes in the regex
# and doubled curly braces ({{...}}) for JavaScript code blocks.
_ = ui.run_javascript(rf"""
const editorElement = document.getElementById('{editor.id}');
if (editorElement) {{
const contentArea = editorElement.querySelector('.q-editor__content');
if (contentArea) {{
// Function to parse a CSS color string into an [r, g, b] array.
const parseColor = (colorStr) => {{
const d = document.createElement("div");
d.style.color = colorStr;
document.body.appendChild(d);
const computedColor = window.getComputedStyle(d).color;
document.body.removeChild(d);
const parts = computedColor.match(/rgba?\((\d+), (\d+), (\d+)\)/);
if (parts) {{
return [parseInt(parts[1]), parseInt(parts[2]), parseInt(parts[3])];
}}
return null;
}};
// Function to calculate the perceived luminance of a color.
const getLuminance = (r, g, b) => {{
return 0.299 * r + 0.587 * g + 0.114 * b;
}};
const paste_handler = (evt) => {{
evt.preventDefault();
const clipboardData = evt.clipboardData;
if (!clipboardData) {{ return; }}
let pastedHtml = clipboardData.getData('text/html');
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {{ return; }}
selection.deleteFromDocument();
const range = selection.getRangeAt(0);
if (pastedHtml) {{
const tempDiv = document.createElement('div');
tempDiv.innerHTML = pastedHtml;
tempDiv.querySelectorAll('*').forEach(el => {{
el.style.backgroundColor = '';
el.style.background = '';
let colorToCheck = el.style.color;
if (el.tagName === 'FONT' && el.getAttribute('color')) {{
colorToCheck = el.getAttribute('color');
}}
if (colorToCheck) {{
const rgb = parseColor(colorToCheck);
if (rgb) {{
const luminance = getLuminance(rgb[0], rgb[1], rgb[2]);
// A luminance threshold of 100 is a good starting point
// for identifying dark colors on a dark background.
if (luminance < 100) {{
el.style.color = ''; // Reset to inherit default color
if (el.tagName === 'FONT') {{
el.removeAttribute('color');
}}
}}
}}
}}
if (el.hasAttribute('style') && !el.getAttribute('style').trim()) {{
el.removeAttribute('style');
}}
}});
const sanitizedHtml = tempDiv.innerHTML;
const fragment = range.createContextualFragment(sanitizedHtml);
const lastNode = fragment.lastChild;
range.insertNode(fragment);
if (lastNode) {{
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}}
}} else {{
const pastedText = clipboardData.getData('text/plain');
if (pastedText) {{
const textNode = document.createTextNode(pastedText);
range.insertNode(textNode);
const newRange = document.createRange();
newRange.setStartAfter(textNode);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}}
}}
}};
contentArea.addEventListener('paste', paste_handler);
}} else {{
console.error('Error: Could not find the .q-editor__content element.');
}}
}} else {{
console.error(`Error: Could not find editor element with ID: {editor.id}`);
}}
""")
_ = editor.on("vue-mounted", attach_paste_handler)
return editor