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-editornpm 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 importierenimport css from 'rollup-plugin-css-only';...// und in plugins konfigurieren:export default {...plugins: [css({output: 'public/build/libs.css'}),...]...}
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'>...
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>
Das Template für den Editor ist sehr überschaubar:
<div class="editor-container"><textarea></textarea></div>
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>...
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>...
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 werdenconst editor = CodeMirror.fromTextArea(textArea);});</script>
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ückgebenreturn () => editor.toTextArea();});
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 entsprechendmarkdown
angeben.lineWrapping
, die Einstellung des automatischen Zeilenumbruchs. Wirdtrue
angegeben, so erfolgt automatisch ein Zeilenumbruch am Rand des Editors, beifalse
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();}});
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>
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:
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>
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