Sign in

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
nodeNodeThe selection's anchor node
blockTagstring|nullCurrent block element tag ('p', 'h2', 'li', etc.)
blockElementElement|nullThe actual block DOM element
inlineTagsSetActive inline formatting ('bold', 'italic', etc.)
inLinkbooleanCursor is inside an <a href>
inAnchorbooleanCursor is inside an <a id> (anchor without href)
inTablebooleanCursor is inside a <table>
inBlockquotebooleanCursor is inside a <blockquote>
textAlignstring'left', 'center', or 'right'

Available Editor Properties

Inside init, onCommand, and onSelectionChange, the editor parameter exposes:

Property Use for
editor.contentThe contenteditable element
editor.wrapperThe editor wrapper (append modals here)
editor.shadowRootThe shadow root
editor._instanceIdUnique 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

  1. Button names must be unique across core and all extensions
  2. Toolbar config controls visibility: a button in buttons that isn't in toolbar won't render
  3. protectedClasses is the whitelist: any class not listed gets stripped by sanitization. The core editor pre-registers text-center and text-right. Don't reuse these names.
  4. Extensions own their DOM: create modals in init, clean them up in destroy
  5. Don't modify editor.content.innerHTML directly: use DOM methods and call editor._debounceChange(). The editor runs a mutation observer that normalizes content (semantic tag conversion, inline style cleanup) on every change.