Formatierter Text mit Werkzeugleiste und HTML-Ansicht (Schema v12)

Textblöcke dürfen ab v12 Auszeichnung tragen: fett, kursiv,
unterstrichen, durchgestrichen, Code, hervorgehoben, hoch- und
tiefgestellt, Absätze, Listen und Links.

Die Entscheidung, was erlaubt ist, fällt auf dem Server beim
Speichern - nicht im Editor und nicht beim Rendern. Was in der
Datenbank landet, ist damit schon sicher, also kann kein Renderer das
Filtern vergessen, und eine manipulierte Anfrage speichert nichts,
was der Editor nicht anbietet.

Nicht erlaubte Elemente werden ausgepackt, nicht gelöscht: der Text
bleibt, nur die Auszeichnung geht. script, style, iframe und
Verwandte verschwinden samt Inhalt, weil Auspacken ihren Code als
Text sichtbar machen würde. Attribute fallen weg außer href an a,
und auch das nur nach Adressprüfung.

Ausgegeben wird formatierter Text nur ab v12, dann in einem div statt
einem p, weil die Positivliste Absätze und Listen enthält. Ältere
Dokumente behalten Klartextsemantik und werden weiter maskiert.

Eine Inkonsistenz nebenbei behoben: ein Text mit einem < behielt
echte Zeilenumbrüche, während alles andere <br> benutzte.

Geprüft: 182 PHP-Tests / 685 Assertions unter PHP 8.2.12 und 8.4.22,
darunter dreizehn Fälle durch den Filter mit acht Angriffen, dazu
Editortest mit axe und öffentliche Browserprüfung. Die Werkzeugleiste
selbst ist nicht bedient und nicht mit einem Screenreader geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 16:54:35 +02:00
1 parent d0938eec69
commit 790f42213c
16 files changed
+557 -30

No files matched your search

+25 -4
View File
@@ -1,6 +1,7 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.js';
import { createRichText } from './rich-text.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
@@ -175,7 +176,23 @@ function renderProperties() {
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
return;
}
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
if (block.type === 'text') {
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
const rich = createRichText(block.props.text, markup => {
history.change(doc => {
upgradeDocument(doc, 12);
const current = locate(doc.blocks, block.id)?.block;
if (current) current.props.text = markup;
}, `rich-${block.id}`);
controls(); schedulePreview();
}, { label: 'Text' });
const label = document.createElement('label'); label.textContent = 'Text';
const help = document.createElement('p'); help.className = 'field-help';
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
panel.append(label, rich.element, help);
}
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
if (INLINE_TEXT[block.type]) {
const inline = document.createElement('button');
inline.className = 'secondary full-width';
@@ -369,7 +386,9 @@ function startInlineEdit(blockId) {
const target = inlineTarget(blockId);
if (!target || busy) return;
const { rule, element } = target;
element.setAttribute('contenteditable', 'plaintext-only');
// A v12 text block keeps its markup while editing in place; everything else stays plain.
const formatted = 'text' === target.block.type && (history.document.schemaVersion ?? 1) >= 12;
element.setAttribute('contenteditable', formatted ? 'true' : 'plaintext-only');
element.spellcheck = true;
element.classList.add('editor-inline');
element.focus();
@@ -382,10 +401,12 @@ function startInlineEdit(blockId) {
selection.addRange(range);
const onInput = () => {
const value = element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
const value = formatted
? element.innerHTML
: element.innerText.replace(/\u00a0/g, ' ').replace(/\n+$/, '');
history.change(doc => {
const current = locate(doc.blocks, blockId)?.block;
if (current) current.props[rule.prop] = rule.multiline ? value : value.replace(/\s*\n+\s*/g, ' ');
if (current) current.props[rule.prop] = (rule.multiline || formatted) ? value : value.replace(/\s*\n+\s*/g, ' ');
}, `inline-${blockId}-${rule.prop}`);
controls();
};