6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-08-22

はじめに

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

WebComponentsを使いたい

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

WebComponentstとは?

WebComponentstとは以下のような技術です。

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

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

Svelteとは

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

開発環境の準備

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

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
こちらの画面が表示されれば準備完了です。

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

  • 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ファイルも最小限にしてファイル名も固定しちゃいます。

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

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

次に myEditor.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>

ポイントは1行目にSvelteのCustom elements APIを使ってwebComponentsとして扱えるように設定します。

次に main.js にコンポーネントファイルを登録し、index.htmlで呼び出します。

/src/main.js
export * from './lib/MyEditor.svelte';
/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
エディターが表示されていたら成功です。

Pleasanter用に調整

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

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

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

/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>でネストした値を初期値として読み込みます。

/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

Tabキーでコードをインデント

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

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

/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に設定します。
併せて、エディターの文字サイズとを表示フォントを変更しています。

/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

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

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

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

$p.set($('変更したいフォームのid名'), '変更後の値')

変更したいフォームのJQueryObjectが必要なので、JS側でHTMLを発行したいと思います。
実際に差し替えたい要素のタグが以下のコードなので、それに寄せる形にしましょう。

<textarea id="ScriptBody" name="ScriptBody" class="control-textarea always-send"></textarea>

name name classが必要になります。
クラス名control-textareaだけcontrol-editorに変更した方がよさそうです。

/index.html
<!-- id属性を追加 -->
<code-editor id="ScriptBody" name="ScriptBody" ckass="control-editor always-send">const demo = "hello world"; 
console.log(`demo:${demo}`);</code-editor>
/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に出力されてるコードを参考にします。

/index.html
<script type="module" crossorigin src="/component.bundle.js"></script>
<link rel="modulepreload" crossorigin href="/vendor.bundle.js">

<script><link>ですね。
では該当するファイルに追加しましょう。

/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なので今回はそちらを拝借します。

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

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

/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;
}

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

/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関連のところに追加したいと思います。

/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をベースにして直下に追加します。

/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をベースに追加しましょう。

/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側でも設定できるのでこちらも一緒に取り込みます。
これでコンポーネントタグの登録は完了です。

コンポーネントタグの出力

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

/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

無事に表示されました。

image.png
動作もデータの登録も問題なさそうです。

最後に

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

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?