Anmelden

Überall integrierbar

Sumaq ist ein einziges ES-Modul, welches ihr importiert und auf einen Container zeigen lasst und das war's. Sumaq funktioniert in jedem Browser und in jedem Kontext: PHP-Backends, React-Komponente, Vue, Svelte, native Web Komponente oder eben als vanilla JS Modul. Keine Abhängigkeiten, kein Vendor-Lock-in und faire Preise.

Vanilla JS

Das Setup ohne viel Aufwand. Ein Container mit einer ID, ein Import und ihr könnt losschreiben.

<div id="editor"></div>

<script type="module">
  import Sumaq from '/path/to/sumaq.js';

  const editor = new Sumaq('#editor', {
    toolbar: ['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']
  });
</script>

Die Instanz gibt euch Zugriff auf Inhalt und Events:

// Inhalt lesen
editor.getHTML();
editor.getText();

// Inhalt setzen
editor.setHTML('<p>Neuer Inhalt</p>');

// Auf Events reagieren
editor.on('input', (html) => {
  console.log('Inhalt geändert:', html);
});

// Theme wechseln
editor.setTheme('elegant');

Alle Methoden und Events findet ihr in der API-Referenz.

Alles in einer Datei

Ihr könnt das alles in einer einzigen HTML-Datei zusammensetzen. Der Editor oben, die Vorschau unten und beim Schreiben wird die Ausgabe live aktualisiert:

<!doctype html>
<html lang="de">
<head>
  <meta charset="utf-8">
  <title>Sumaq Editor</title>
</head>
<body>
  <div id="editor"></div>
  <h2>Vorschau</h2>
  <output id="preview"></output>

  <script type="module">
    import Sumaq from './sumaq.js';

    const editor = new Sumaq('#editor', {
      toolbar: ['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']
    });

    editor.on('input', (html) => {
      document.getElementById('preview').innerHTML = html;
    });
  </script>
</body>
</html>

Native Web Component

Ein Build-Script erzeugt eine eigenständige <sumaq-editor>-Komponente ohne externe Abhängigkeiten. Das Script liegt im Editor-Repository unter adapters/.

./adapters/build-web-component.sh ./path/to/your/project/

Das Ergebnis ist eine einzige Datei. Einbinden, Tag setzen, fertig. Attribute steuern die Konfiguration: toolbar (kommagetrennt), content (initialer HTML-Inhalt).

<script src="sumaq-editor.js"></script>

<sumaq-editor
  toolbar="h2,h3,p,|,bold,italic,|,link"
  content="<p>Hier schreiben</p>">
</sumaq-editor>

Events funktionieren wie bei jedem anderen HTML-Element:

document.querySelector('sumaq-editor')
  .addEventListener('sumaq:change', (e) => {
    console.log(e.detail.html);
  });

Die Methoden .getHTML(), .getText() und .setHTML() stehen direkt auf dem Element zur Verfügung:

const el = document.querySelector('sumaq-editor');

// Inhalt lesen
el.getHTML();
el.getText();

// Inhalt setzen
el.setHTML('<p>Neuer Inhalt</p>');

Über die .editor-Property erreicht ihr die vollständige Sumaq-Instanz mit allen Methoden und Events, die in der API-Referenz dokumentiert sind:

const el = document.querySelector('sumaq-editor');

// Auf Events reagieren
el.editor.on('input', (html) => {
  console.log('Inhalt geändert:', html);
});

// Theme wechseln
el.editor.setTheme('elegant');

PHP-CMS

Euer Server rendert den Container mit bestehendem Inhalt. JavaScript bindet Sumaq beim Laden ein. Beim Absenden holt getHTML() die bereinigte Ausgabe in ein verstecktes Input-Feld.

<form method="post">
  <div id="editor"><?= $content ?></div>
  <input type="hidden" name="content" id="content-field">
  <button type="submit">Speichern</button>
</form>

<script type="module">
  import Sumaq from '/assets/js/sumaq.js';

  const editor = new Sumaq('#editor', {
    toolbar: ['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link', 'image']
  });

  document.querySelector('form').addEventListener('submit', () => {
    document.getElementById('content-field').value = editor.getHTML();
  });
</script>

React

Ein Build-Script erzeugt eine eigenständige SumaqEditor-Komponente für React: der gesamte Editor-Code wird dabei in die Datei eingebettet.

./adapters/build-react.sh ./path/to/your/project/

Das Ergebnis ist eine einzelne JSX-Datei. Importieren, Props setzen, fertig:

import SumaqEditor from './SumaqEditor';

function App() {
  return (
    <SumaqEditor
      toolbar={['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']}
      content="<p>Initialer Inhalt</p>"
      onChange={(html) => console.log(html)}
      onInput={(html) => console.log(html)}
    />
  );
}

Props: toolbar (Array), content (initialer HTML-Inhalt), onChange, onInput, styleOverrides (CSS-String für Shadow DOM), extensions (Array).

Über eine Ref stehen .editor (Sumaq-Instanz), .getHTML(), .getText() und .setHTML() zur Verfügung:

const ref = useRef();

<SumaqEditor ref={ref} toolbar={['h2', 'h3', 'p']} />

// Später:
ref.current.getHTML();
ref.current.setHTML('<p>Neuer Inhalt</p>');

Vue

Ein Build-Script erzeugt eine eigenständige SumaqEditor-Komponente für Vue 3: Composition API, <script setup>.

./adapters/build-vue.sh ./path/to/your/project/

Das Ergebnis ist eine einzelne .vue-Datei:

<script setup>
import SumaqEditor from './SumaqEditor.vue';

function onEditorChange(html) {
  console.log(html);
}
</script>

<template>
  <SumaqEditor
    :toolbar="['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']"
    content="<p>Initialer Inhalt</p>"
    @change="onEditorChange"
    @input="(html) => console.log(html)"
  />
</template>

Props: toolbar (Array), content (String), style-overrides (CSS-String), extensions (Array). Events: change, input.

Über eine Template-Ref stehen .editor, .getHTML(), .getText() und .setHTML() zur Verfügung:

<template>
  <SumaqEditor ref="editorRef" :toolbar="['h2', 'h3', 'p']" />
</template>

<script setup>
import { ref } from 'vue';
const editorRef = ref(null);

// Später:
editorRef.value.getHTML();
editorRef.value.setHTML('<p>Neuer Inhalt</p>');
</script>

Svelte

Ein Build-Script erzeugt eine eigenständige SumaqEditor-Komponente für Svelte.

./adapters/build-svelte.sh ./path/to/your/project/

Das Ergebnis ist eine einzelne .svelte-Datei:

<script>
  import SumaqEditor from './SumaqEditor.svelte';
</script>

<SumaqEditor
  toolbar={['h2', 'h3', 'p', '|', 'bold', 'italic', '|', 'link']}
  content="<p>Initialer Inhalt</p>"
  on:change={(e) => console.log(e.detail)}
  on:input={(e) => console.log(e.detail)}
/>

Props: toolbar (Array), content (String), styleOverrides (CSS-String), extensions (Array). Events: change, input (Payload in e.detail).

Über bind:this stehen .getEditor(), .getHTML(), .getText() und .setHTML() zur Verfügung:

<script>
  import SumaqEditor from './SumaqEditor.svelte';
  let editorComponent;
</script>

<SumaqEditor bind:this={editorComponent} toolbar={['h2', 'h3', 'p']} />

<button on:click={() => console.log(editorComponent.getHTML())}>
  HTML ausgeben
</button>

Performance

Bis 5.000 Absätze (~25.000 DOM-Knoten) bleibt jede Operation innerhalb des 16ms-Frame-Budgets. Bei 10.000 Absätzen bleibt Tippen mit 18ms flüssig. Formatierungsoperationen werden spürbar. Ab 15.000 Absätzen überschreitet die Eingabelatenz die wahrnehmbare Schwelle.

getHTML() bleibt bei jeder Größe schnell: 50.000 Absätze werden in 30ms zurückgelesen.

Ergebnisse eines Stresstests, gemessen in Chromium, mit schrittweise größeren Dokumenten in einer einzelnen Sumaq-Instanz. „Tippen“ ist execCommand('insertText'), „Fett“ ist execCommand('bold') auf eine Selektion, „getHTML()“ liest das gesamte Dokument zurück.

Absätze DOM-Knoten Tippen Fett getHTML()
5003.800<1ms<1ms<1ms
1.0007.800<1ms<1ms1ms
2.0009.4003,5ms10ms2ms
3.00015.0008ms10ms3ms
5.00025.00010ms21ms6ms
10.00050.00018ms58ms9ms
20.000100.00043ms108ms11ms
50.000250.000106ms439ms30ms

Der Engpass bei hohen Knotenzahlen ist execCommand und der DOM-Mutationsaufwand des Browsers selbst, nicht Sumaq. Kein Virtual-DOM-Diffing, kein State-Reconciliation-Overhead, nur der Browser, der tut, was Browser tun.


Warum Shadow DOM

Das Problem

Sumaq ist dafür gebaut, in jede Seite eingebettet zu werden: CMS-Backends, Kunden-Websites, Admin-Panels. Jede Host-Seite bringt eigenes CSS mit: Resets, Framework-Styles, Komponentenbibliotheken, eigene Overrides. Ohne Kapselung dringen diese Styles in den Editor ein und zerstören seine Darstellung.

Beispiele, was ohne Isolation schiefgeht:

  • Ein globales button { background: red; } überschreibt alle Toolbar-Buttons
  • Ein CSS-Reset entfernt Listenpunkte von <ul>/<ol> im Inhaltsbereich
  • Framework-Styles (Bootstrap, Tailwind, etc.) überschreiben Überschriftengrößen, Link-Farben, Tabellenrahmen
  • * { box-sizing: border-box; } oder * { margin: 0; } können je nach Host kollidieren oder nicht, unberechenbar

Der Editor kann nicht kontrollieren oder vorhersagen, welches CSS die Host-Seite lädt.

Die Lösung

Einen Shadow-Root an ein Host-Element anhängen und den gesamten Editor darin rendern. Das gesamte CSS ist als <style>-Element innerhalb des Shadow-Root eingebettet, mit dem Ergebnis:

  • Vollständige CSS-Kapselung: Host-Styles können die Shadow-Grenze nicht überqueren
  • Host-Seiten-Resets, Frameworks und globale Regeln haben keinerlei Auswirkung auf den Editor
  • Die eigenen Styles des Editors haben keinerlei Auswirkung auf die Host-Seite
  • Typografie-Basis für den Inhaltsbereich ist eigenständig und vorhersagbar
  • Keine Spezifitätsspiele, kein !important, kein Kampf gegen die Kaskade

Implementierung

Architektur

container (Light DOM)
  └── shadowHost <div>
        └── #shadow-root (open)
              ├── <style>/* gesamtes Editor-CSS */</style>
              └── .sumaq-editor
                    ├── .sumaq-toolbar
                    ├── .sumaq-content [contentEditable]
                    ├── .sumaq-source <textarea>
                    ├── .sumaq-modal
                    └── .sumaq-modal (link)

Zentrale Entscheidungen

  • Offener Shadow-Root (mode: 'open'): erlaubt externem JS den Zugriff auf das Shadow DOM via shadowRoot bei Bedarf (z.B. für Tests, programmatischen Zugriff auf den Inhaltsbereich)
  • Zwischengeschaltetes Host-Element: ein einfaches <div> sitzt zwischen Container und Shadow-Root, damit destroy() es sauber entfernen und den Original-Container-Inhalt wiederherstellen kann
  • Gesamtes CSS eingebettet: die externe sumaq.css-Datei wird als Dokumentation/Fallback behalten, aber die Shadow-DOM-Version trägt alle Styles intern via static styles
  • Typografie-Basis: da keine Host-Styles den Inhaltsbereich erreichen, muss der Editor seine eigene vollständige typografische Grundlage definieren: Überschriften, Absätze, Listen (einschließlich verschachtelter Marker-Progression), Zitate, Inline-Formatierung, Tabellen, Codeblöcke und Links

Was weiterhin durch Shadow DOM funktioniert

  • document.execCommand(): operiert auf dem fokussierten contentEditable unabhängig von der Shadow-Grenze
  • window.getSelection(): gibt Selektionen innerhalb offener Shadow-Roots in allen modernen Browsern zurück
  • aria-pressed, role="separator", SVG <title>: der Barrierefreiheitsbaum ist vollständig durch Shadow DOM exponiert
  • position: fixed auf Modalen: positioniert weiterhin relativ zum Viewport
  • selectionchange-Event auf document: feuert weiterhin für Selektionen innerhalb des Shadow-Root

Was angepasst werden musste

  • document.activeElement: gibt den Shadow-Host zurück, nicht das fokussierte Element darin. Der selectionchange-Handler prüft stattdessen content.contains(selection.anchorNode)
  • destroy(): entfernt das Shadow-Host-Element statt des Wrappers direkt
  • selectionchange-Listener-Bereinigung: als benannte Referenz (this._onSelectionChange) gespeichert, damit er beim Destroy sauber entfernt werden kann

Kompromisse

  • Dateigröße: gesamtes CSS als String in der JS-Datei dupliziert (~3KB). Akzeptabel für eine eigenständige Komponente
  • Debugging: Styles leben im <style>-Element des Shadow-Root statt in einer separaten Datei. Browser-DevTools handhaben das gut (Shadow-Root inspizieren, berechnete Styles sehen)
  • Kein externes Theming: Host-Seiten-CSS kann den Editor nicht stylen. Das ist der ganze Sinn, bedeutet aber, dass Anpassung über die static styles-Eigenschaft oder eine zukünftige Theming-API (CSS Custom Properties, die Shadow-Grenzen durchdringen) erfolgen muss

Browser-Support

Shadow DOM v1 wird von allen modernen Browsern seit 2020 unterstützt. Kein Polyfill nötig.

Browser Seit
Chrome53 (2016)
Firefox63 (2018)
Safari10 (2016)
Edge79 (2020, Chromium)
Chrome Android53 (2016)
Safari iOS10 (2016)
Samsung Internet6.0 (2017)