Svelteで作ったWebComponentsでPleasanterにコードエディターを実装する

## はじめに
PleasanterにはHTMLやCSS・JavaScriptなどをコードを登録し、UIや機能を拡張する為の設定が多くあります。
これらの登録フォームは操作性の軽さや取り回しの良さから`<textarea>`での入力を採用しているのですが、たまに長めのコードを書く必要があるケースもあったりします。
そんな時、多くの人は外部テキストエディターでコーディングしコピペしてしていると思うのですが、今回は既存の入力フォームをコードエディターに置き換えてみたいと思います。

## WebComponentsを使いたい
実装方法は様々かと思いますが、今回はWebComponentsを使いコードエディターを適用したいと思います。
理由は再利用が容易だということと、シャドウDOMでカプセル化できる恩恵がPleasanterにマッチしてると思ったからです。

### WebComponentstとは？
WebComponentstとは以下のような技術です。
>ウェブコンポーネントは、一連のさまざまな技術です。これにより、再利用可能なカスタム要素を作成し、その機能を他のコードから分離してウェブアプリケーションで利用できるようにします。

https://developer.mozilla.org/ja/docs/Web/API/Web_components

ざっくりいうと機能やUIをまるっとカプセル化した再利用可能な独自のHTML要素を発行できるといった感じでしょうか。
今回は`Svelte`というJavaScriptフレームワークの機能を使って開発したいと思います。

### Svelteとは

https://svelte.jp/

SvelteとはReactやVueのようなフレームワークですが、コンパイラーでもあるという特徴を持っています。
ReactやVueのようなライブラリではなく、純粋なJavaScriptを生成するコンパイラーなのでbuildで出力されるデータ容量が小さくなることが期待できます。
また、従来のライブラリではブラウザ上で行われる処理をSvelteはアプリのコンパイル時に行うので動作も軽く、Pleasanterで使っても影響が少ないのでは？と考えました。

## 開発環境の準備

まずはViteを使いSvelteの環境構築を用意しましょう。

https://ja.vitejs.dev/

```
npm create vite@latest
```
コマンド実行時にいくつか質問がありますのでframeworkに`Svelte`、variantに`JaveScript`を選択します。

```
 npm create vite@latest
√ Project name: ... vite-project
√ Select a framework: » Svelte
√ Select a variant: » JaveScript
```
出力されたディレクトリに移動し`npm install`から必要なパッケージをインストールします。
インストールが完了したら `npm run dev`からローカルサーバを立ち上げましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760693/d567359f-7b46-c7ff-bb13-4c3e7cedd0a6.png)
こちらの画面が表示されれば準備完了です。

次に導入するコードエディターですが候補は以下の二つ。
* CodeMirror
* MonacoEditor

**CodeMirror**より**MonacoEditor**の方が高性能ですが、ビルド時にファイルサイズが3MBもあるとのことなので**CodeMirror**を採用したいと思います。

### パッケージのインストール
コードエディターの選定も終わったので次に必要なパッケージをインストールします。
今回はcodemirror本体と、カスタムテーマとハイライト用の言語データです。
* codemirror
* @codemirror/theme-one-dark
* @codemirror/lang-javascript
```
npm install -D codemirror @codemirror/theme-one-dark @codemirror/lang-javascript
```
次に設定ファイルを編集します。
目的はPleasanter上でWebCompornantとして動かすので必要なのはjsファイルのみです。
chunkファイルも最小限にしてファイル名も固定しちゃいます。

```ts:vite.config.js
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';

export default defineConfig({
  plugins: [
    svelte({
      compilerOptions: {
        customElement: true,
      },
    }),
  ],
  server: {},
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor.bundle';
          } else {
            return 'component.bundle';
          }
        },
        chunkFileNames: 'vendor.bundle.js',
        entryFileNames: 'component.bundle.js',
        assetFileNames: '[name].[ext]',
      },
    },
  },
});
```

これで一通りの準備は完了です。

## SvelteでCodemirrorをWebコンポーネント化
さて、いよいよWebComponents化を行います。
今回扱うファイルとディレクトリ構成は以下の通り。
インストール時に他にもいろいろなファイルが入っていていますがそのままでOKです。

```
[ROOT]
├ [src]
│   ├ [lib]
│   │   ├ MyEditor.svelte // 新規
│   │   └ CodeMirror.svelte // 新規
│   └ main.js
└ index.html
```

OfficialのREPLにCodemirrorのwrapperコンポーネントがあったので、今回はこちらを使って用意したいと思います。

[CodeMirror 6 (reactive) • REPL • Svelte](https://svelte.dev/repl/91649ba3e0ce4122b3b34f3a95a00104?version=3.49.0)

こちらから`CodeMirror.svelte`のコードをコピーして、 同じファイル名でlibディレクトリに格納します。



次に `myEditor.svelte` です。
まずはエディターが表示できるように最低限のコードで。
```svelte:/src/lib/myEditor.svelte
<svelte:options customElement="code-editor" />

<script context="module">
  import CodeMirror, { basicSetup } from './CodeMirror.svelte';
  import { javascript } from '@codemirror/lang-javascript';
  import { oneDark } from '@codemirror/theme-one-dark';
</script>

<CodeMirror
  doc="// demo"
  extensions={[
    basicSetup,
    javascript(),
    oneDark
  ]}
></CodeMirror>
```
ポイントは１行目にSvelteの[*Custom elements API*](https://svelte.jp/docs/custom-elements-api)を使って**webComponents**として扱えるように設定します。

https://svelte.jp/docs/custom-elements-api

次に `main.js` にコンポーネントファイルを登録し、`index.html`で呼び出します。
```js:/src/main.js
export * from './lib/MyEditor.svelte';
```
```html:/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Editor DEMO</title>
  </head>
  <body>
    <div id="app">
      <code-editor></code-editor>
    </div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
```
ここまで出来たら`npm run dev`で確認しましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760693/41b3698f-27bf-7455-e8b5-d893bc7ff8b6.png)
エディターが表示されていたら成功です。

## Pleasanter用に調整
まだPleasanterで扱うには不十分なので外部から初期値を受け取れるようにしましょう。
Pleasanterの`testarea`要素と差し替える想定なので値も同様の持たせ方にします。

```html
<code-editor>const demo = "hello world";
console.log(`demo:${demo}`);</code-editor>
```

また、コンポーネント化したファイルはホットリロードが効かなくなるようなので、ホットリロード用のスクリプトを追記すると便利です。

```html:/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Editor DEMO</title>
  </head>
  <body>
    <div id="app">
      <code-editor>const demo = "hello world";
console.log(`demo:${demo}`);</code-editor>
      </div>
    <!--- ホットリロード --->
    <script src="//unpkg.com/redefine-custom-elements@0.1.2/lib/index.js"></script>
    <script>
      const _define = customElements.define;
      customElements.define = function (name, CustomElement, options) {
        const nativeDef = _define.bind(customElements);
        nativeDef(name, CustomElement, options);
        setTimeout(() => {
          [...document.querySelectorAll(name)].forEach((el) => {
            const container = document.createElement("div");
            container.innerHTML = el.outerHTML;
            const newNode = container.firstElementChild;
            el.parentNode.replaceChild(newNode, el);
          });
        }, 0);
      };
    </script>
    <!--- // ホットリロード  --->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
```

### 初期値の取得

次に `myEditor.svelte` 側で`<code-editor>`でネストした値を初期値として読み込みます。

```svelte:/src/lib/myEditor.svelte
<svelte:options
  customElement={{
    tag: 'code-editor',
    extend: (customElementConstructor) => {
      return class extends customElementConstructor {
        constructor() {
          super();
          this.myElem = this;
        }
      };
    },
  }}
/>

<script context="module">
  import CodeMirror, { basicSetup } from './CodeMirror.svelte';
  import { javascript } from '@codemirror/lang-javascript';
  import { oneDark } from '@codemirror/theme-one-dark';
</script>

<script>
  /*
    <svelte:options>でコンポーネント要素を取得し
    textContentでテキストノードを受け取り、
    <CodeMirror>のdoc属性に渡す
  */  
  export let myElem;
  let value = myElem.textContent || '';
</script>

<CodeMirror
  doc={value}
  extensions={[
    basicSetup,
    javascript(),
    oneDark
  ]}
></CodeMirror>
```
更新後、ブラウザで確認すると`<code-editor>`内の値がエディターの初期値として表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760693/d7443677-d855-ab08-0f76-118870902f88.png)

### Tabキーでコードをインデント
今回コードエディターを導入したかった理由のひとつです。
Pleasanterの入力欄でコードを書いていると手癖でついインデントしようとタブキーを押してフォームからフォーカスが外れてしまったという経験をした方も多いのではないでしょうか。

CodeMirrorにはTabキーでインデントするオプションがあるので有効化します。

```svelte:/src/lib/myEditor.svelte
-- 省略 --

<script context="module">
  import CodeMirror, { basicSetup } from './CodeMirror.svelte';
  import { keymap } from '@codemirror/view'; // 追加
  import {indentWithTab} from '@codemirror/commands' // 追加
  import { javascript } from '@codemirror/lang-javascript';
  import { oneDark } from '@codemirror/theme-one-dark';
</script>

-- 省略 --

<CodeMirror
  doc={value}
  extensions={[
    basicSetup,
    javascript(),
    oneDark,
    keymap.of([indentWithTab]) //追加
  ]}
></CodeMirror>
```
これでTabキーをタイプした時にフォーカスが外れてしまうことは無くなりました。


### スタイルの調整
今はコードが入力されている行のみ色がついているので、CSSで高さの調整を行います。
方法は様々ですが、将来的な拡張性を考慮して`<CodeMirror>`要素を`<div class="c-codeMirror">`でwrapし、`c-codeMirror"`に高さを定義します。
`index.html`にあるWebコンポーネント`<code-editor>`の中はカプセル化されていて通常どおりにスタイルを操作するのは難しいですが、svelteの子コンポーネントであれば`:global`疑似クラスを用いれば可能です。
`.codemirror`に由来する高さ指定まわりを親から引き継ぐように調整し、実際の高さの指定は一つ上の`.c-codeMirror`に設定します。
併せて、エディターの文字サイズとを表示フォントを変更しています。

```svelte:/src/lib/myEditor.svelte
-- 省略 --


<div class="c-codeMirror">
  <CodeMirror
    doc={value}
    extensions={[
      basicSetup,
      javascript(),
      oneDark,
      keymap.of([indentWithTab])
    ]}
  ></CodeMirror>
</div>

<style>
  .c-codeMirror{
    min-height: 40vh;
    max-height: 70vh;
    border: 1px solid #aaa;
  }
  :global(.codemirror) ,
  :global(.cm-editor) ,
  :global(.cm-scroller) {
    min-height: inherit;
    max-height: inherit;
  }
  :global(.cm-line) {
    font-size: 15px;
    font-family: Consolas, 'Courier New', monospace;
  }
</style>
```
保存してブラウザで確認するとこんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760693/875e0115-54c1-892d-dc49-aee929ca2e09.png)

これで高さの調整ができました。
フォントも見やすくなっているかと思います。



### Pleasanter側に編集したデータを渡す

最後に下記のメソッドを使い、エディターで編集したデータをPleasanter側に送信できるようにします。

https://pleasanter.org/ja/manual/script-set

```js:
$p.set($('変更したいフォームのid名'), '変更後の値')
```
変更したいフォームのJQueryObjectが必要なので、JS側でHTMLを発行したいと思います。
実際に差し替えたい要素のタグが以下のコードなので、それに寄せる形にしましょう。

```html:
<textarea id="ScriptBody" name="ScriptBody" class="control-textarea always-send"></textarea>
```
`name` `name` `class`が必要になります。
クラス名`control-textarea`だけ`control-editor`に変更した方がよさそうです。

```html:/index.html
<!-- id属性を追加 -->
<code-editor id="ScriptBody" name="ScriptBody" ckass="control-editor always-send">const demo = "hello world";　
console.log(`demo:${demo}`);</code-editor>
```
```svelte:/src/lib/myEditor.svelte
-- 省略 --
<script context="module">
  import { onMount } from 'svelte'; // 追加
  import CodeMirror, { basicSetup } from './CodeMirror.svelte';
  import { keymap } from '@codemirror/view';
  import {indentWithTab} from '@codemirror/commands'
  import { javascript } from '@codemirror/lang-javascript';
  import { oneDark } from '@codemirror/theme-one-dark';
</script>

<script>
  export let myElem;
  export let id; // 追加 > id属性のインポートを定義
  export let name; // 追加 > name属性のインポートを定義
  const ps = window.$p; // 追加 > $Pメソッドの定義
  const jQuery = window.$; // 追加 > jQueryの定義
  let value = myElem.textContent || '';
  let taElem = document.createElement('textarea'); // 追加 > textareaの生成

  // ここから追加
  onMount(() => {
    //コンポーネントがマウントされたときに実行
    if(!id) return false;
    myElem.removeAttribute('id'); // IDが重複するのでコンポーネント側からは削除
    taElem.setAttribute('id', id);
    taElem.setAttribute('name', name);
    taElem.style.display = 'none';
    taElem.readOnly = true;
    taElem.value = value;
    myElem.append(taElem);
    // $pメソッドがあれば値をセット
    if(ps) ps.set(jQuery(taElem), value);
  });

  // エディター上のコードが編集されたときに実行
  let doc;
  $: {
    if($doc){
      taElem.value = $doc;
      // $pメソッドがあれば値をセット
      if(ps) ps.set(jQuery(taElem), $doc);
    }
  }
  // 追加ここまで
</script>

// bind:docStore={doc}追加
<div class="c-codeMirror">
  <CodeMirror
    doc={value}
    bind:docStore={doc}
    extensions={[
      basicSetup,
      javascript(),
      oneDark,
      keymap.of([indentWithTab])
    ]}
  ></CodeMirror>
</div>
-- 省略 --

```

これでSvelteを使ったWebコンポーネントの作成は以上です。
最後に`npm run build`でJSファイルをビルドします。
`/dist/`フォルダ直下に
- index.html
- component.bundle.js
- vendor.bundle.js

の3ファイルがあれば完成です。

## PleasanterにWebコンポーネントを適用
ではPleasanterに先ほど作ったWebコンポーネントを組み込みたいと思います。
ここからはPleasanter側の作業になります。

### JSファイルの登録
まずは以下のディレクトリに先ほどビルドしたJSファイルを移動します。
`/wwwroot/scripts/plugins/`

次にJSファイルの読み込みです。
ビルド時に生成された`index.html`に出力されてるコードを参考にします。
```html:/index.html
<script type="module" crossorigin src="/component.bundle.js"></script>
<link rel="modulepreload" crossorigin href="/vendor.bundle.js">
```
`<script>`と`<link>`ですね。
では該当するファイルに追加しましょう。

```cs:/Libraries/HtmlParts/HtmlScripts.cs
.Script(src:
    Responses.Locations.Get(
        context: context,
        parts: "scripts/plugins/component.bundle.js"),
        type: "module",
        crossorigin: true
)
```
`vendor.bundle.js`ですが、`<link rel="modulepreload" ~ ">`要素は事前読み込みされたモジュールとその依存関係を早期にダウンロードするための処理なのですが、`<link>`がまとまっているのがCSSを取りまとめてる`HtmlStyles.cs`なので今回はそちらを拝借します。

```cs:/Libraries/HtmlParts/HtmlStyles.cs
//Script登録の最後に追加
.Link(
    href: Responses.Locations.Get(
        context: context,
        parts: "scripts/plugins/vendor.bundle.js"),
    rel: "modulepreload",
    crossorigin: true)
```

続いて引数に追加したattr属性を処理します。

```cs:/Libraries/HtmlParts/HtmlTags.cs
public static HtmlBuilder Link(
    this HtmlBuilder hb,
    string href = null,
    string rel = null,
    bool _using = true,
    bool crossorigin = false) //追加
{
    return _using
        ? hb.Append(
            tag: "link",
            closeLevel: 1,
            attributes: new HtmlAttributes()
                .Href(href)
                .Rel(rel)
                .Crossorigin(crossorigin)) //追加
        : hb;
}

// -- 省略 --

public static HtmlBuilder Script(
    this HtmlBuilder hb,
    string id = null,
    string src = null,
    string script = null,
    bool _using = true,
    string type = null, //追加
    bool crossorigin = false) //追加
{
    return _using
        ? hb.Append(
            tag: "script",
            id: id,
            css: null,
            attributes: new HtmlAttributes()
                .Src(src)
                .Type(type) //追加
                .Crossorigin(crossorigin), //追加
            action: () => hb
                .Raw(text: script))
        : hb;
}
```


今回引数に追加した`crossorigin`は`HtmlAttributes`に無かったので新しく登録します。

```cs:/Libraries/Html/HtmlAttributes.cs
public HtmlAttributes Crossorigin(bool value, bool _using = true)
{
    if (value && _using)
    {
        Add("crossorigin");
    }
    return this;
}
```


### コンポーネントタグの登録
続きまして、コンポーネントタグ`<code-editor>`の登録です。
最初にタグ自体をPleasanterに認識させるところから。
`<code-editor>`は既存の`<textarea>`を使ったコントローラーの代わりになるものなので、`HtmlControls.cs`関連のところに追加したいと思います。

```cs:/Libraries/Html/HtmlTypes.cs
namespace Implem.Pleasanter.Libraries.Html
{
    public class HtmlTypes
    {
        public enum TextTypes
        {
            Normal,
            DateTime,
            MultiLine,
            Password,
            File,
            CodeEditor //追加
        }
    }
}
```
次に`HtmlControls.cs`を編集します。
`<textarea>`と差し替えるので、ひとまず`hb.TextArea`をベースにして直下に追加します。

```cs:/Libraries/HtmlParts/HtmlControls.cs
case HtmlTypes.TextTypes.MultiLine:
    return hb.TextArea(
        attributes: new HtmlAttributes()
            .Id(controlId)
            .Name(controlId)
            .Class(Css.Class("control-textarea", controlCss))
            .Placeholder(placeholder)
            .Disabled(disabled)
            .DataAlwaysSend(alwaysSend)
            .DataId(dataId)
            .OnChange(onChange)
            .AutoComplete(autoComplete)
            .DataValidateRequired(validateRequired)
            .DataValidateNumber(validateNumber)
            .DataValidateDate(validateDate)
            .DataValidateEmail(validateEmail)
            .DataValidateEqualTo(validateEqualTo)
            .DataValidateMaxLength(validateMaxLength)
            .DataAction(action)
            .DataMethod(method)
            .Add(attributes),
        text: text);

// 以下追加
case HtmlTypes.TextTypes.CodeEditor:
    return hb.CodeEditor(
        attributes: new HtmlAttributes()
            .Id(controlId)
            .Name(controlId)
            .Class(Css.Class("control-editor", controlCss))
            .Placeholder(placeholder)
            .Disabled(disabled)
            .DataAlwaysSend(alwaysSend)
            .DataId(dataId)
            .OnChange(onChange)
            .AutoComplete(autoComplete)
            .DataValidateRequired(validateRequired)
            .DataValidateNumber(validateNumber)
            .DataValidateDate(validateDate)
            .DataValidateEmail(validateEmail)
            .DataValidateEqualTo(validateEqualTo)
            .DataValidateMaxLength(validateMaxLength)
            .DataAction(action)
            .DataMethod(method)
            .Add(attributes),
        text: text);
```

ではこちらも引数を処理します。
上と同じく`TextArea`をベースに追加しましょう。

```cs:/Libraries/HtmlParts/HtmlTags.cs
public static HtmlBuilder TextArea(
    this HtmlBuilder hb,
    string id = null,
    string name = null,
    string css = null,
    string placeholder = null,
    HtmlAttributes attributes = null,
    bool _using = true,
    string text = null)
{
    return _using
        ? hb.Append(
            tag: "textarea",
            attributes: (attributes ?? new HtmlAttributes())
                .Id(id)
                .Name(name)
                .Class(css)
                .Placeholder(placeholder),
            action: () => hb
                .Text(text: "\n" + text))
        : hb;
}

// 以下追加
public static HtmlBuilder CodeEditor(
    this HtmlBuilder hb,
    string id = null,
    string name = null,
    string css = null,
    string placeholder = null,
    HtmlAttributes attributes = null,
    bool _using = true,
    string text = null)
{
    return _using
        ? hb.Append(
            tag: "code-editor",
            attributes: (attributes ?? new HtmlAttributes())
                .Id(id)
                .Name(name)
                .Class(css)
                .Placeholder(placeholder),
            action: () => hb
                .Text(text: text))
        : hb;
}
```
`placeholder`は今回未設定ですが、codeMirror側でも設定できるのでこちらも一緒に取り込みます。
これでコンポーネントタグの登録は完了です。

### コンポーネントタグの出力
では既存のフォームを今回用意したコンポーネントタグへ差し替えましょう
該当するフォームはテーブル管理のスクリプトの登録ダイアログです。

```cs:/Models/Sites/SiteUtilities.cs

.FieldTextBox(
    textType: HtmlTypes.TextTypes.MultiLine,
    controlId: "ScriptBody",
    fieldCss: "field-wide",
    controlCss: " always-send",
    labelText: Displays.Script(context: context),
    text: script.Body)

// ↓↓↓↓↓

.FieldTextBox(
    textType: HtmlTypes.TextTypes.CodeEditor,
    controlId: "ScriptBody",
    fieldCss: "field-wide",
    controlCss: " always-send",
    labelText: Displays.Script(context: context),
    text: script.Body)
```
これでコーディングは完了です、
では実際の画面で確認してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760693/607f6ada-d12a-4edd-9105-fd2aed71d7e8.png)

無事に表示されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3760693/26480eeb-bec7-3c58-3030-41f06bafc2c6.png)
動作もデータの登録も問題なさそうです。

## 最後に
いかがだったでしょうか。
今回はスクリプト登録の画面だけでしたが、同じ要領でHTMLやCSSなどの画面も対応できると思います。
その際はハイライト言語の再設定が必要なので、引数で言語情報をコンポーネント側に渡すして処理する必要がありますのでご注意ください。
今回はコードエディターの実装でしたが、ボタン単体など小さなパーツでもコンポーネント化できますよ。
いつかモーダル・ダイアログあたりの画面でまた挑戦してみたいなと思います。