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>
163 lines
7.5 KiB
JavaScript
163 lines
7.5 KiB
JavaScript
/**
|
||
* 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() };
|
||
}
|