Erweiterbar von Grund auf
Der Kern von Sumaq bleibt schlank. Wenn ihr mehr braucht, baut ihr es, und das Extension-System macht das unkompliziert.
Was ihr bauen könnt
- Eigene Formatierungs-Buttons für das Content-Modell eurer Marke
- CMS-Medienpicker, die eure Asset-Bibliothek öffnen
- Content-Validierung, die redaktionelle Regeln durchsetzt
- Markenspezifische Toolbars für verschiedene Inhaltstypen
So geht's.
Eine Extension schreiben
Eine Extension ist ein einfaches Objekt mit einem name, optionalen buttons und Lifecycle-Hooks.
Minimales Beispiel: ein Button, der markierten Text in <span class="highlight"> einwickelt:
function highlightExtension() {
return {
name: 'highlight',
buttons: {
highlight: {
label: 'HL',
title: 'Text hervorheben'
}
},
protectedClasses: ['highlight'],
init(editor) {
// editor.wrapper, editor.content, editor.shadowRoot sind verfügbar
// Modale oder Panels hier erstellen, wenn nötig
},
onCommand(editor, buttonName) {
const existing = editor._getClosestElement('SPAN');
if (existing?.classList.contains('highlight')) {
// Entfernen: Span auspacken
const parent = existing.parentNode;
while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
parent.removeChild(existing);
parent.normalize();
} else {
// Anwenden: Selektion in span.highlight einwickeln
const sel = editor._getSelection();
if (!sel.toString()) return;
const range = sel.getRangeAt(0);
const span = document.createElement('span');
span.className = 'highlight';
range.surroundContents(span);
}
editor._debounceChange();
},
onSelectionChange(editor, context) {
// Prüfen ob Cursor in einem span.highlight ist
let node = context.node;
let current = node?.nodeType === Node.TEXT_NODE ? node.parentNode : node;
let active = false;
while (current && current !== editor.content) {
if (current.tagName === 'SPAN' && current.classList.contains('highlight')) {
active = true;
break;
}
current = current.parentNode;
}
return { highlight: active };
},
destroy() {
// Event-Listener, Modale etc. aufräumen
}
};
}
Registrierung:
const editor = new Sumaq('#editor', {
toolbar: ['bold', 'italic', '|', 'highlight'],
extensions: [highlightExtension()]
});
Extension-Vertrag
| Eigenschaft | Typ | Pflicht | Beschreibung |
|---|---|---|---|
name |
string |
ja | Eindeutiger Bezeichner. Wird intern für Command-Routing verwendet. |
buttons |
object |
nein | Map von Button-Namen zu { label, title, icon? }. Jeder Schlüssel muss in der toolbar-Konfiguration stehen, um gerendert zu werden. |
protectedClasses |
string[] |
nein | CSS-Klassen, die diese Extension verwaltet. Überleben Bereinigung und getHTML()-Ausgabe. |
init(editor) |
function |
nein | Wird einmal aufgerufen, nachdem das Editor-DOM erstellt wurde. Für Modale, Element-Caching nutzen. |
onCommand(editor, buttonName) |
function |
nein | Wird aufgerufen, wenn ein Extension-Button geklickt wird. buttonName entspricht dem Schlüssel in buttons. |
onSelectionChange(editor, context) |
function |
nein | Wird bei jeder Selektionsänderung aufgerufen. { [buttonName]: boolean } zurückgeben, um aria-pressed zu aktualisieren. |
destroy() |
function |
nein | Wird aufgerufen, wenn der Editor zerstört wird. Listener und DOM aufräumen. |
Context-Objekt (übergeben an onSelectionChange)
| Eigenschaft | Typ | Beschreibung |
|---|---|---|
node | Node | Der Ankerknoten der Selektion |
blockTag | string|null | Aktuelles Block-Element-Tag ('p', 'h2', 'li', etc.) |
blockElement | Element|null | Das tatsächliche Block-DOM-Element |
inlineTags | Set | Aktive Inline-Formatierung ('bold', 'italic', etc.) |
inLink | boolean | Cursor ist innerhalb eines <a href> |
inAnchor | boolean | Cursor ist innerhalb eines <a id> (Anker ohne href) |
inTable | boolean | Cursor ist innerhalb einer <table> |
inBlockquote | boolean | Cursor ist innerhalb eines <blockquote> |
textAlign | string | 'left', 'center' oder 'right' |
Verfügbare Editor-Eigenschaften
Innerhalb von init, onCommand und onSelectionChange stellt der editor-Parameter bereit:
| Eigenschaft | Verwendung |
|---|---|
editor.content | Das contenteditable-Element |
editor.wrapper | Der Editor-Wrapper (Modale hier anhängen) |
editor.shadowRoot | Der Shadow-Root |
editor._instanceId | Eindeutige ID: verwenden für Element-IDs (z.B. sumaq-${editor._instanceId}-yourext-close) |
editor._getSelection() | Gibt die aktuelle Selektion zurück. Nutzt shadowRoot.getSelection() wo verfügbar (Chrome/Edge) für korrekte Knoten im Shadow DOM, fällt auf window.getSelection() zurück. |
editor._getClosestElement(tagName) | Sucht vom Cursor nach oben zum nächsten Vorfahren mit passendem tagName. Gibt das Element oder null zurück. |
editor._saveSelection() | Speichert den aktuellen Selektionsbereich. Vor dem Öffnen eines Dialogs aufrufen. Der Dialog übernimmt den Fokus und die Selektion geht sonst verloren. |
editor._restoreSelection() | Stellt die gespeicherte Selektion wieder her und fokussiert den Inhaltsbereich. Nach dem Schließen eines Dialogs aufrufen, bevor Inhalte eingefügt werden. |
editor._debounceChange() | Löst das entprellte 'change'-Event aus und ruft config.onChange auf, falls gesetzt. Nach jeder DOM-Modifikation in der Extension aufrufen. |
Schließen-Button
Wenn eure Extension einen Dialog verwendet, importiert closePath aus icons.js für das Schließen-Button-SVG:
import { closePath } from '../icons.js';
Dann in eurem Dialog-HTML:
<button type="button" class="sumaq-dialog__close" aria-labelledby="${closeId}">
<svg class="icon" role="img" viewBox="0 0 448 512">
<title id="${closeId}">Schließen</title>
<path d="${closePath}"/>
</svg>
</button>
Verwendet sumaq-${editor._instanceId}-yourext-close als closeId, damit IDs über Instanzen hinweg eindeutig bleiben.
Regeln
- Button-Namen müssen eindeutig sein über Kern und alle Extensions hinweg
- Toolbar-Konfiguration steuert Sichtbarkeit: ein Button in
buttons, der nicht intoolbarsteht, wird nicht gerendert protectedClassesist die Whitelist: jede nicht gelistete Klasse wird durch Bereinigung entfernt. Der Kern-Editor registrierttext-centerundtext-rightvor. Diese Namen nicht wiederverwenden.- Extensions besitzen ihr DOM: Modale in
initerstellen, indestroyaufräumen editor.content.innerHTMLnicht direkt modifizieren: DOM-Methoden verwenden undeditor._debounceChange()aufrufen. Der Editor betreibt einen Mutation-Observer, der Inhalte bei jeder Änderung normalisiert (semantische Tag-Konvertierung, Inline-Style-Bereinigung).