10分で作るSvelteマークダウンエディタ

10分で作るSvelteマークダウンエディタ

参考:

Build a Svelte JS App: Magic Framework (Svelte 3 Tutorial) - Snipcart

Svelteとは

これを見るとSvelteが何なのかだいたい分かる https://youtu.be/AdNJ3fydeao

これを読むとなぜSvelteを作ろうとしたかだいたい分かる https://svelte.dev/blog/virtual-dom-is-pure-overhead

マークダウンエディタを作る

プロジェクトセットアップ

まず雛形を作る:

npx degit sveltejs/template svelte-markdown-editor
cd svelte-markdown-editor && npm i

マークダウンをパースするライブラリを入れる

markedをインストールする:

npm i marked

ローカル開発する

必要なライブラリがインストールされていれば、次のコマンドでrollupが実行される:

npm run dev

App.svelte (1)

App.svelte<script>タグで囲まれた部分を次のように編集する:

<script>
    export let source;
</script>

export let source;で、App.svelteコンポーネントで使用するプロパティを定義している。これにより、親コンポーネントからsource propを渡すことができるようになる。

main.js

main.jsでは、App.svelteコンポーネントをどのように描画するかを設定している。App.svelteは、source propを受け取るので、次のように設定できる:

import App from './App.svelte';

const app = new App({
    target: document.body,
    props: {
        source: '# New document'
    }
});

export default app;

Editor.svelte

テキストエリアを持つ、Editor.svelteを作成する。まずファイルを作成し、次のように編集する:

<script>
    export let source;
</script>

<div class="markdown-editor__left-panel">
    <textarea bind:value={source} class="markdown-editor__source"></textarea>
</div>


<style>
    .markdown-editor__left-panel {
        width: 50%;
        border: solid 1px black;
        height: 90vh;
    }

    .markdown-editor__source {
        border: none;
        width: 100%;
        height: 100%;
    }

    .markdown-editor__source:focus {
        outline: none;
    }
</style>

以下の部分で、bind:valueを使ってsource propが、このテキストエリアの値と"バインド"していることをSvelteに伝える。これにより、親コンポーネントに"テキストエリアの値"(=source prop)が更新したことを伝えられるようになる。

<textarea bind:value={source} class="markdown-editor__source"></textarea>

スタイル(style)に関しては、コンパイルされたファイルを見ると、コンポーネントごとに.svelte-zafbdqのようなハッシュが与えられており、コンポーネントにスコープが閉じられている。

Preview.svelte (1)

マークダウンの入力を受け取り、HTMLとして出力するPreview.svelteを作成する。ファイルを作成して、次のように編集する:

<script>
    import marked from 'marked';
    export let source;
    let markdown = marked(source);
</script>

<div class="markdown-editor__right-panel">
    <div class="markdown-editor__output">{markdown}</div>
</div>

<style>
    .markdown-editor__right-panel {
        width: 50%;
        border: solid 1px black;
        height: 90vh;
        overflow: auto;
    }

    .markdown-editor__output {
        width: 100%;
        padding: 0 2em;
    }
</style>

App.svelte (2)

作成したコンポーネントApp.svelteでひとまとめにする:

<script>
    import Editor from './Editor.svelte'
    import Preview from './Preview.svelte'
    export let source;
</script>

<header class="header">
    <h1 class="header-title">Svelte powered markdown editor</h1>
</header>

<div class="markdown-editor">
    <Editor bind:source={source} />
    <Preview source={source} />
</div>

<style>
    .header {
        height: 10vh;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .header-title {
        margin: 0;
    }

    .markdown-editor {
        width: 100%;
        display: flex;
        align-items:flex-start;
        justify-content: space-evenly;
    }
</style>

作成したコンポーネントは、そのままインポートし、使用できる。その際、通常のHTMLタグと区別するため、必ず大文字で始める。

import Editor from './Editor.svelte'
import Preview from './Preview.svelte'

Editorのテキストエリアの値とsourceはバインドするので、次のようにする: <Editor bind:source={source} />

一方、Previewではsourceは更新されないため、バインドする必要はない。

この状態で、ブラウザを開いてみると、2点バグが確認できるはず。

  1. PreviewでHTMLが正しくレンダリングできていない
  2. テキストエリアの値を変更しても、Previewの値が更新されない

f:id:uraway:20210114124320p:plain

Preview.svelte (2)

1つ目のバグを解消するには、次のようにSvelteが用意した特別なタグである@htmlタグを変数の前に付けることで、markdownのHTMLを直接描画させる。

<div class="markdown-editor__output">{@html markdown}</div>

2つ目のバグは、markdownの値が、sourceが更新されても再計算されていないことによる。

下記では、JavaScriptの標準構文であるラベル付き文を使って、markdown変数が"再計算される値"であることをSvelteに伝える[^1]。ReactのuseEffect内でstateを変更するふるまいに近いが、deps(依存)を自由に決めることはできない。

これにより、source propが更新された時に、markdown変数も再計算されるようになる。

$: markdown = marked(source);

もし、次のように通常通り変数を設定してしまうと、markdownは再計算されず、DOMは更新されない。

let markdown = marked(source);
^1

コンパイルされたコード(下記)を見ると、ダーティーチェックしているだけで、ラベル付き文は使われていないっぽい。構文的にエラーを出さないようにするハック?

$$self.$$.update = () => {
    if ($$self.$$.dirty & /*source*/ 2) {
         $$invalidate(0, markdown = marked(source));
    }
};