/** * Formatted text for text blocks. * * Two views on the same value: a formatting view with a toolbar and an HTML view with the markup. * Whatever is typed here is a *proposal* – the server filters it against its allowlist when it is * stored, so this file never decides what is allowed. It only offers what the allowlist accepts. * * execCommand is deprecated but is still the only thing every browser implements for this job, and * the alternative would be a selection engine of our own. The formatting it produces is normalised * on the server anyway, so a browser quirk cannot store something unexpected. */ /** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */ export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a']; const COMMANDS = [ { command: 'bold', label: 'B', title: 'Fett (Strg+B)', style: 'font-weight:700' }, { command: 'italic', label: 'I', title: 'Kursiv (Strg+I)', style: 'font-style:italic' }, { command: 'underline', label: 'U', title: 'Unterstrichen (Strg+U)', style: 'text-decoration:underline' }, { command: 'strikeThrough', label: 'S', title: 'Durchgestrichen', style: 'text-decoration:line-through' }, { command: 'insertUnorderedList', label: '•', title: 'Liste' }, { command: 'insertOrderedList', label: '1.', title: 'Nummerierte Liste' }, { command: 'removeFormat', label: '⌫', title: 'Formatierung entfernen' }, ]; function button(label, title, onClick, className = 'rt-button') { const node = document.createElement('button'); node.type = 'button'; node.className = className; node.textContent = label; node.title = title; node.setAttribute('aria-label', title); node.addEventListener('mousedown', event => event.preventDefault()); // keep the selection node.addEventListener('click', onClick); return node; } /** * Builds the control. `apply` is called with the markup whenever it changes; the caller decides * what to do with it (history entry, preview refresh). */ export function createRichText(value, apply, { label = 'Text', plain = false } = {}) { const wrap = document.createElement('div'); wrap.className = 'rich-text'; const bar = document.createElement('div'); bar.className = 'rt-bar'; bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', `${label} formatieren`); const surface = document.createElement('div'); surface.className = 'rt-surface'; surface.contentEditable = 'true'; surface.spellcheck = true; surface.setAttribute('role', 'textbox'); surface.setAttribute('aria-multiline', 'true'); surface.setAttribute('aria-label', label); // Unter Schema v12 ist der Text Klartext: "Preis 5 Euro" ist dann eine Zeichenfolge, kein // Markup, und darf beim Oeffnen nicht als HTML gelesen werden. if (plain) surface.textContent = value ?? ''; else surface.innerHTML = value ?? ''; const source = document.createElement('textarea'); source.className = 'rt-source'; source.hidden = true; source.setAttribute('aria-label', `${label} als HTML`); source.value = plain ? surface.innerHTML : (value ?? ''); const push = markup => { source.value = markup; apply(markup); }; for (const entry of COMMANDS) { const node = button(entry.label, entry.title, () => { surface.focus(); document.execCommand(entry.command); push(surface.innerHTML); }); if (entry.style) node.style.cssText = entry.style; bar.append(node); } // Link insertion without a browser prompt: a field appears in the bar and takes the target. const linkRow = document.createElement('div'); linkRow.className = 'rt-link'; linkRow.hidden = true; const linkInput = document.createElement('input'); linkInput.type = 'text'; linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…'; linkInput.setAttribute('aria-label', 'Linkziel'); const newTabLabel = document.createElement('label'); newTabLabel.className = 'check-label'; const newTab = document.createElement('input'); newTab.type = 'checkbox'; newTabLabel.append(newTab, document.createTextNode(' In neuem Tab öffnen')); let savedRange = null, currentLink = null; const confirmLink = () => { const target = linkInput.value.trim(); linkRow.hidden = true; surface.focus(); if (savedRange) { const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(savedRange); } if (target) { if (currentLink && surface.contains(currentLink)) currentLink.setAttribute('href', target); else document.execCommand('createLink', false, target); const range = window.getSelection().rangeCount ? window.getSelection().getRangeAt(0) : null; for (const anchor of surface.querySelectorAll('a')) { if (anchor !== currentLink && !(anchor.getAttribute('href') === target && range?.intersectsNode(anchor))) continue; if (newTab.checked) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener'); } else { anchor.removeAttribute('target'); anchor.removeAttribute('rel'); } } } else document.execCommand('unlink'); push(surface.innerHTML); linkInput.value = ''; }; linkInput.addEventListener('keydown', event => { if ('Enter' === event.key) { event.preventDefault(); confirmLink(); } if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); } }); linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button')); bar.append(button('🔗', 'Link setzen oder entfernen', () => { const selection = window.getSelection(); savedRange = selection.rangeCount > 0 && surface.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0).cloneRange() : null; const node = savedRange?.startContainer; currentLink = (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement)?.closest('a'); if (currentLink && (!surface.contains(currentLink) || !currentLink.contains(savedRange.endContainer))) currentLink = null; linkInput.value = currentLink?.getAttribute('href') || ''; newTab.checked = currentLink?.getAttribute('target') === '_blank'; linkRow.hidden = false; linkInput.focus(); })); linkRow.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); linkRow.hidden = true; surface.focus(); } }); const toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => { const toSource = source.hidden; if (toSource) { source.value = surface.innerHTML; } else { surface.innerHTML = source.value; apply(source.value); } source.hidden = !toSource; surface.hidden = toSource; toggle.setAttribute('aria-pressed', String(toSource)); (toSource ? source : surface).focus(); }, 'rt-button rt-toggle'); toggle.setAttribute('aria-pressed', 'false'); bar.append(toggle); surface.addEventListener('input', () => push(surface.innerHTML)); // Paste as plain text: pasted markup would be stripped on the server anyway, and pasting a // whole web page into a text block is never what someone means. surface.addEventListener('paste', event => { event.preventDefault(); const text = event.clipboardData?.getData('text/plain') ?? ''; document.execCommand('insertText', false, text); }); source.addEventListener('change', () => { surface.innerHTML = source.value; apply(source.value); }); wrap.append(bar, linkRow, surface, source); return { element: wrap, focus: () => surface.focus() }; }