cs.Tutorial();

Markdown Editor in Svelte umsetzen

  • Svelte
  • JavaScript
  • Markdown

Es gibt viele Notizen- und Schreib-Apps, die auf Markdown setzen — eine Auszeichnungssprache, der sich viele Schreiberinnen und Schreiber bedienen, um ihre Texte ohne Ablenkung verfassen zu können. Auch ich schreibe gern in einer App, die Markdown versteht und mir somit den Fokus auf das Wesentliche ermöglicht.

Ich habe mich gefragt, wie solche Apps implementiert werden; wieviel Code muss ich heute noch selbst schreiben, wenn es mittlerweile doch für nahezu jedes Problem eine Lösung in Form einer Bibliothek oder eines Frameworks gibt? Dieser Frage bin ich nachgegangen und habe eine kleine App in Svelte implementiert — einem Compiler für optimierte Web-Apps, wie Rich Harris, der Erfinder des Frameworks, es zu sagen pflegt. Svelte soll in diesem Artikel jedoch nicht im Vordergrund stehen, denn dazu gibt es bereits ein sehr gutes und ausführliches Tutorial. Vielmehr sollte das Framework einen Rahmen für die App bieten, in dem der Markdown-Editor entwickelt werden soll.

Neues Projekt anlegen

Um ein neues Projekt in Svelte anzulegen, genügt ein einzelner Befehl in der Kommandozeile, dem wir als Parameter das zu beziehende Svelte-Template und den gewünschten Projektnamen übergeben. So einfallsreich wie ich bin, nenne ich das Projekt markdown-editor.

npx degit sveltejs/template markdown-editor

Das Programm degit kopiert den letzten Stand des sveltejs/template Repository und legt ihn im angegebenen Verzeichnis ab. Im Vergleich zu clone läuft der Befehl viel schneller ab, weil hierbei nicht die gesamte Git-History heruntergeladen wird. Ist der Download abgeschlossen, können wir in das eben angelegte Verzeichnis wechseln und die Abhängigkeiten des Projekts installieren:

cd markdown-editor
npm install

Die Anwendung kann anschließend mit dem folgenden Kommando im Entwicklungsmodus gestartet werden:

npm run dev

CSS aus Fremdbibliotheken importieren

Da wir uns den Aufwand das Rad neu zu erfinden sparen und im Projekt deshalb Fremdbibliotheken einsetzen wollen, sollte auch das Einbinden von Assets dieser Ressourcen konfiguriert werden. Für den Import von JavaScript müssen wir nichts weiter tun: hier können wir wie üblich mit ES-Modulen arbeiten und den Code über die Import-Anweisungen laden. Bei Stylesheets hingegen sieht die Sache etwas anders aus: hier bedarf es noch einer kleinen Konfiguration, damit das CSS von Drittanbietern importiert und gebündelt werden kann.

Als Module-Bundler wird in Svelte per Default Rollup eingesetzt, auch wenn die Integrationen anderer Bundler wie Webpack möglich ist. Für Rollup wird das Plug-in rollup-plugin-css-only benötigt, um das in Komponenten importierte CSS in eine einzige Datei zu bündeln.

npm install --save-dev rollup-plugin-css-only

Nach der Installation muss das Plug-in in der rollup.config.js importiert und konfiguriert werden, wobei sich die Konfiguration lediglich auf die Festlegung des Zielverzeichnisses beschränkt:

...
// Plug-in importieren
import css from 'rollup-plugin-css-only';
...
// und in plugins konfigurieren:
export default {
...
plugins: [
css({output: 'public/build/libs.css'}),
...
]
...
}
Datei: rollup.config.js

Es bleibt noch die Anpassung der index.html im Verzeichnis /public: Das gebündelte Stylesheet libs.css muss im Head eingebunden werden; es sollte in der Reihenfolge vor dem eigenen CSS liegen, um Überschreibungen zu ermöglichen.

...
<!-- Fremdbibiliotheken CSS -->
<link rel="stylesheet" href="/build/libs.css">
<!-- Eigenes CSS -->
<link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/build/bundle.css'>
...
Datei: /public/index.html

Nun ist es möglich in unseren Svelte-Komponenten mit der import-Anweisung CSS aus NPM-Paketen zu importieren.

Die passende Fremdbibliothek finden

Auf der Suche nach einer passenden Library, bin ich auf mehrere fertige Lösungen für Editoren gestoßen, in denen mittels Markdown Text eingegeben werden konnte. Nennenswert wäre hier z. B. der EasyMDE Markdown Editor. Er ist vollständig in JavaScript implementiert und stellt neben der Eingabe mittels Markdown, eine Toolbar bereit, mit deren Hilfe der Text zusätzlich formatiert werden kann. Bei der Eingabe hat man auch direkt das visuelle Feedback der Syntax: die Überschriften werden größer, betonte Wörter kursiv oder fett dargestellt und Links wie üblich unterstrichen. Diese Lösung würde sich hervorragend für dedizierte Eingabebereiche in einer Anwendung eignen, für unser Vorhaben — einer Art Notizen App — wäre es jedoch unpassend, weil wir lediglich an dem Eingabebereich interessiert sind. Interessant war jedoch die Tatsache, dass diese Implementierung als auch einige andere zusammen den gleichen Unterbau nutzen: sie alle verwenden die Library CodeMirror.

CodeMirror

CodeMirror ist in puncto Editor ein Alleskönner. Die Library ersetzt eine einfache <textarea> durch einen Editor und stellt eine umfangreiche API für die Verarbeitung der Eingabe zur Verfügung. Sie bietet außerdem unzählige Modi für das Highlighting unterschiedlicher Programmier- und Auszeichnungssprachen, mitunter auch Markdown. Auch der REPL-Editor auf der Svelte-Tutorial Seite setzt unter der Haube CodeMirror ein, was für mich ein gutes Zeichen war, um mir die Library etwas näher anzusehen. Gesagt, getan: wir installieren CodeMirror mit dem folgenden Befehl:

npm install codemirror

Im Projektverzeichnis legen wir eine neue Svelte-Datei an, in der wir den Editor implementieren werden, Editor.svelte. Als Erstes importieren wir die erforderlichen Abhängigkeiten von CodeMirror: das grundlegende CSS und das JavaScript für den Markdown-Modus, sowie die Factory CodeMirror.

<script>
import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/markdown/markdown';
import CodeMirror from 'codemirror';
</script>
Datei: /src/Editor.svelte

Das Template für den Editor ist sehr überschaubar:

<div class="editor-container">
<textarea></textarea>
</div>
Datei: /src/Editor.svelte

Dem div verpassen wir noch eine CSS-Klasse, um es im style-Tag selektieren und formatieren zu können. Da wir hier nun einen div haben werden und in Svelte die CSS-Kapselung auf Komponentenebene gilt, könnten wir genauso gut auf die CSS-Klasse verzichten und den div direkt über den Element-Selektor selektieren. Mein innerer Monk besteht jedoch darauf die Dinge von vornherein richtig zu benennen, um sie beispielsweise auch im style-Tag später besser zuordnen zu können.

Die textarea wird außerdem mit der Property value verknüpft: Somit schaffen wir die Möglichkeit einen Text von außen, beim Benutzen der Komponente, zu setzen und ihn wieder auszulesen, wenn er im Editor bearbeitet wurde. Als Defaultwert setzen wir eine leere Zeichenkette.

<script>
...
export let value = '';
</script>
...
<textarea {value}></textarea>
...
Datei: /src/Editor.svelte

Weil das textarea Attribut und die Property value gleich benannt sind, können wir hier von der Kurzform des Property-Bindings profitieren. Statt value={value}, kann einfach nur {value} geschrieben werden.

Für die Initialisierung des Editors wird eine Referenz auf das DOM-Element der textarea benötigt. Dies ist mit der Direktive bind:this ein Kinderspiel:

<script>
...
export let value = '';
let textArea;
</script>
...
<textarea bind:this={textArea} {value}></textarea>
...
Datei: /src/Editor.svelte

Der Zugriff auf die Referenz wird jedoch erst dann möglich sein, wenn die Komponente in das DOM eingebunden wurde. In Svelte kann dafür die Lebenszyklus-Funktion onMount benutzt werden, die den übergebenen Callback dann ausführt, wenn die Komponente vollständig geladen wurde.

<script>
...
import {onMount} from 'svelte';
export let value = '';
let textArea;
onMount(() => {
// erst hier kann auf die Referenz zugegriffen werden
const editor = CodeMirror.fromTextArea(textArea);
});
</script>
Datei: /src/Editor.svelte

Nun müssen wir auch dafür sorgen, dass keine Speicherlecks entstehen, wenn die Komponente wieder gelöscht wird: Die Editor-Instanz muss dann mit allen ihren Event-Listenern und Handlern vernichtet werden, was durch den Aufruf der Methode toTextArea() erfolgt. Für diese Zwecke kann in Svelte die weitere Lebenszyklus-Funktion onDestroy benutzt werden, die einen Callback registriert und ihn dann aufruft, wenn die Komponente aus dem DOM wieder entfernt wird. Ein solcher Callback kann auch direkt als Rückgabewert der an die onMount übergebenen Arrow-Function definiert werden.

onMount(() => {
const editor = CodeMirror.fromTextArea(textArea);
// onDestroy-Callback zurückgeben
return () => editor.toTextArea();
});
Datei: /src/Editor.svelte

Mit diesem Ansatz schlagen wir zwei Fliegen mit einer Klappe: Variablen müssen nicht im Geltungsbereich außerhalb von onMount deklariert werden, um sie in onDestroy wieder verwenden zu können; und der zusammengehörende Code wird nicht unnötig auseinander gezogen.

Als Nächstes muss der Editor konfiguriert werden. Wir können der Factory-Methode fromTextArea noch ein Konfigurationsobjekt als zweites Argument mitgeben. Über dieses können sämtliche Einstellungen am Editor vorgenommen werden; uns interessieren allerdings nur zwei davon:

  • mode, die Einstellung der Programmier- bzw. Auszeichnungssprache im Editor. Wir wollen den Modus Markdown verwenden und würden hier entsprechend markdown angeben.
  • lineWrapping, die Einstellung des automatischen Zeilenumbruchs. Wird true angegeben, so erfolgt automatisch ein Zeilenumbruch am Rand des Editors, bei false können hingegen Zeilen über die durch den äußeren Container definierte Breite hinauswachsen, sodass dann horizontal gescrollt werden muss.
onMount(() => {
const editor = CodeMirror.fromTextArea(textArea, {
lineWrapping: true,
mode: 'markdown',
});
editor.focus();
return () => {
editor.toTextArea();
}
});
Datei: /src/Editor.svelte

Mit dem Aufruf editor.focus() verbessern wir zusätzlich die User Experience: unsere Endnutzer werden nach dem Öffnen der App direkt mit dem Schreiben loslegen können und werden nicht gezwungen, vorher in den Schreibbereich reinzuklicken.

CSS Anpassungen

Auch wenn wir den Editor jetzt schon verwenden und die Syntax einzelner Strukturelemente im Text erkennen könnten, so wäre es doch ganz nett, wenn die Überschriften nicht nur fett markiert, sondern sich auch größer darstellen würden als der restliche Text. Diese Anpassung können wir im <style>-Tag der Komponente vornehmen.

<style>
.editor-container :global(.cm-header) {
color: black;
}
.editor-container :global(.cm-header-1) {
font-size: 200%;
}
.editor-container :global(.cm-header-2) {
font-size: 150%;
}
.editor-container :global(.cm-header-3) {
font-size: 120%;
}
</style>
Datei: /src/Editor.svelte

Das CodeMirror CSS hat, obwohl es in die Komponente importiert wurde, immer noch eine globale Geltung und kann nur mithilfe des Modifikators :global() überschrieben werden. Würden wir bei den CodeMirror CSS-Klassen auf die Angabe des Modifikator verzichten, so würde das Framework sie als Komponenten-eigene interpretieren und sie, wegen der CSS-Kapselung auf Komponentenebene, mit einem Hash versehen: Dadurch würden wir neue Klassen definieren, anstatt bestehende zu überschreiben. Da wir mit der Angabe von :global() die CSS-Kapselung ausgehobelt haben, ergibt es Sinn zusätzlich eine eigene CSS-Klasse als Vorfahren-Selektor zu benutzen, um Konflikte mit anderen Komponenten vorzubeugen. So können wir die Farbe für alle Überschriften festlegen und die Größe einzelner .cm-header-* anpassen. Zusätzlich geben wir dem Container noch ein paar nette Formatierungen mit und passen auch die Darstellung des Cursors an:

<style>
...
.editor-container {
margin: 2rem auto 0;
width: 800px;
padding: 2rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.editor-container :global(.CodeMirror-cursor) {
border-left: 2px solid rgb(26, 154, 240);
}
</style>

Der Container kriegt abgerundete Ecken, eine feste Breite zugewiesen, einen weißen Hintergrund und wird durch eine leichte Schattierung von umgebender Fläche abgesetzt. Dem Cursor geben wir eine Breite von 2 Pixeln und färben ihn in ein schönes Blau ein. Damit lässt sich zwar noch keine Waschmaschine gewinnen, verstecken muss sich der Editor aber nun auch nicht mehr:

Der fertige Markdown Editor

Die Komponente Editor ist fertig und kann in andere Komponenten oder wie in unserem Fall in die Hauptkomponente App eingebunden werden:

<script>
import Editor from './Editor.svelte';
let value = '# FooBar';
</script>
<main>
<Editor value={value}></Editor>
</main>
Datei: /src/App.svelte

Fazit

Nun kann ich die initial gestellte Frage damit beantworten, dass es tatsächlich kaum Code geschrieben werden musste, um einen lauffähigen Markdown-Editor zu erhalten. Die Library des In-Browser-Code-Editors ist sehr mächtig, hat viele Konfigurationsmöglichkeiten, wird laufend weiterentwickelt und lässt sich mit sehr wenig Aufwand in ein Framework wie Svelte integrieren. Für andere Frameworks gibt es bereits fertige Wrapper, die als NPM-Pakete bezogen werden können: Zu nennen sind hier z. B. ngx-codemirror für Angular und react-codemirror2 für React. Ich werde CodeMirror auf jeden Fall weiter im Auge behalten und die Library auch in anderen Projekten, die einen Editor benötigen, in Betracht ziehen.

Die beispielhafte Implementierung aus diesem Artikel befindet sich wie immer auf Github.

Zur Blog-Post Übersicht