Extendable by design
Sumaq's core stays lean. When you need more, you build it, and the extension system makes that straightforward.
What you can build
- Custom formatting buttons for your brand's content model
- CMS media pickers that open your asset library
- Content validation that enforces editorial rules
- Brand-specific toolbars tailored to different content types
Here's how.
Writing an Extension
An extension is a plain object with a name, optional buttons, and lifecycle hooks.
Minimal example: a button that wraps selected text in <span class="highlight">:
function highlightExtension() {
return {
name: 'highlight',
buttons: {
highlight: {
label: 'HL',
title: 'Text hervorheben'
}
},
protectedClasses: ['highlight'],
init(editor) {
// editor.wrapper, editor.content, editor.shadowRoot are available
// Create modals or panels here if needed
},
onCommand(editor, buttonName) {
const existing = editor._getClosestElement('SPAN');
if (existing?.classList.contains('highlight')) {
// Remove: unwrap the span
const parent = existing.parentNode;
while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
parent.removeChild(existing);
parent.normalize();
} else {
// Apply: wrap selection in span.highlight
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) {
// Check if cursor is inside a span.highlight
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() {
// Clean up event listeners, modals, etc.
}
};
}
Registration:
const editor = new Sumaq('#editor', {
toolbar: ['bold', 'italic', '|', 'highlight'],
extensions: [highlightExtension()]
});
Extension Contract
| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
yes | Unique identifier. Used internally for command routing. |
buttons |
object |
no | Map of button names to { label, title, icon? }. Each key must appear in the toolbar config to be rendered. |
protectedClasses |
string[] |
no | CSS classes this extension manages. Survive sanitization and getHTML() output. |
init(editor) |
function |
no | Called once after editor DOM is created. Use for creating modals, caching elements. |
onCommand(editor, buttonName) |
function |
no | Called when an extension button is clicked. buttonName matches the key in buttons. |
onSelectionChange(editor, context) |
function |
no | Called on every selection change. Return { [buttonName]: boolean } to update aria-pressed. |
destroy() |
function |
no | Called when the editor is destroyed. Clean up listeners and DOM. |
Context Object (passed to onSelectionChange)
| Property | Type | Description |
|---|---|---|
node | Node | The selection's anchor node |
blockTag | string|null | Current block element tag ('p', 'h2', 'li', etc.) |
blockElement | Element|null | The actual block DOM element |
inlineTags | Set | Active inline formatting ('bold', 'italic', etc.) |
inLink | boolean | Cursor is inside an <a href> |
inAnchor | boolean | Cursor is inside an <a id> (anchor without href) |
inTable | boolean | Cursor is inside a <table> |
inBlockquote | boolean | Cursor is inside a <blockquote> |
textAlign | string | 'left', 'center', or 'right' |
Available Editor Properties
Inside init, onCommand, and onSelectionChange, the editor parameter exposes:
| Property | Use for |
|---|---|
editor.content | The contenteditable element |
editor.wrapper | The editor wrapper (append modals here) |
editor.shadowRoot | The shadow root |
editor._instanceId | Unique ID: use for generating element IDs (e.g., sumaq-${editor._instanceId}-yourext-close) |
editor._getSelection() | Returns the current selection. Uses shadowRoot.getSelection() where available (Chrome/Edge) for correct nodes inside Shadow DOM, falls back to window.getSelection(). |
editor._getClosestElement(tagName) | Walk up from the cursor position to find the nearest ancestor matching tagName. Returns the element or null. |
editor._saveSelection() | Captures the current selection range. Call before opening a dialog. The dialog steals focus and the selection is lost otherwise. |
editor._restoreSelection() | Restores the saved selection and focuses the content area. Call after closing a dialog, before inserting content. |
editor._debounceChange() | Triggers the debounced 'change' event and calls config.onChange if set. Call this after any DOM modification in your extension. |
Close Button
If your extension uses a dialog, import closePath from icons.js for the close button SVG:
import { closePath } from '../icons.js';
Then in your 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>
Use sumaq-${editor._instanceId}-yourext-close as the closeId to keep IDs unique across instances.
Rules
- Button names must be unique across core and all extensions
- Toolbar config controls visibility: a button in
buttonsthat isn't intoolbarwon't render protectedClassesis the whitelist: any class not listed gets stripped by sanitization. The core editor pre-registerstext-centerandtext-right. Don't reuse these names.- Extensions own their DOM: create modals in
init, clean them up indestroy - Don't modify
editor.content.innerHTMLdirectly: use DOM methods and calleditor._debounceChange(). The editor runs a mutation observer that normalizes content (semantic tag conversion, inline style cleanup) on every change.