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:
1 parent
d0938eec69
commit
790f42213c
16 files changed
+557
-30
No files matched your search
@@ -72,3 +72,18 @@
|
||||
#preset-dialog .field-error{margin:10px 0 0;font-size:12px;color:#8b2f22;font-weight:600}
|
||||
|
||||
.page-card.depth-1{margin-left:28px}.page-card.depth-2{margin-left:56px}.page-card.depth-3{margin-left:84px}
|
||||
|
||||
/* Formatted text: toolbar, editing surface and HTML view. */
|
||||
.rich-text{border:1px solid var(--line);border-radius:8px;background:#fff;overflow:hidden}
|
||||
.rt-bar{display:flex;flex-wrap:wrap;gap:2px;padding:5px;border-bottom:1px solid var(--line);background:#fafbf8}
|
||||
.rt-button{min-width:30px;min-height:30px;padding:2px 7px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--ink);font-size:12px;line-height:1}
|
||||
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
|
||||
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
|
||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
||||
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||
.rt-link input{font-size:11px;min-height:30px}
|
||||
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
||||
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||
.rt-surface a{color:#a4510f}
|
||||
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
|
||||
@@ -102,7 +102,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function effectiveDesign(block, device, key) {
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
|
||||
+25
-4
@@ -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();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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' } = {}) {
|
||||
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);
|
||||
surface.innerHTML = value ?? '';
|
||||
|
||||
const source = document.createElement('textarea');
|
||||
source.className = 'rt-source';
|
||||
source.hidden = true;
|
||||
source.setAttribute('aria-label', `${label} als HTML`);
|
||||
source.value = 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');
|
||||
let savedRange = 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) document.execCommand('createLink', false, target);
|
||||
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, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
linkRow.hidden = false;
|
||||
linkInput.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() };
|
||||
}
|
||||
Reference in new issue
Block a user