Files
KantSites/public/assets/rich-text.js
T
kantarciandClaude Fable 5.1 7eb462ac7b Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber
sends the other spelling there with 301, cms:site:origin writes the setting, the
system screen shows it. The roles now match the role help (editor: drafts,
publisher: live content, admin: design, settings, users, system). The installer no
longer reopens on an unreachable database, the session cookie follows ADMIN_PATH,
percent-encoded credentials no longer break the container, consent only accepts
same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the
editor, updater, download and public-output findings from the full review
(ADR 099, ADR 100).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:15:20 +02:00

163 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <b>5 Euro</b>" 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() };
}