Files
KantSites/public/assets/rich-text.js
T
kantarciandClaude Opus 5 711af312af Pick images, pages, forms, sections and terms instead of typing ids and addresses
The share image and featured image of a page become image fields with the library dialog,
as do image settings of plugins. The editor offers forms, sections, categories and tags as
lists, a page search next to every link target and in text links, the redirect target has a
page search, and the form editor picks the reply field, the condition and the option images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:35:05 +02:00

170 lines
8.0 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.
*/
import { openPageDialog } from './page-picker.js';
import { openDocuments } from './media.js';
/** 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(); }
});
// Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen.
const fill = value => { linkInput.value = value; linkInput.focus(); };
linkRow.append(linkInput,
button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'),
button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'),
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() };
}