GROWIで手書き風ドローができるExcalidrawを使えるプラグインを作りました



[オープンソースのWikiであるGROWI](https://growi.org/ja/)にはプラグイン機能が用意されています。自社のデータを表示したり、表示をカスタマイズするのに利用できます。

今回は、GROWIプラグインとしてExcalidrawを表示・編集できるプラグインを作りました。GROWIページで、手書き風ドローイングが利用できます。

![image.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/3ba2a6df-52b0-4e26-930c-1758d198e5c7.jpeg)


## プラグインの動作

Remark Directiveとして、以下のように記述します。 `ID` は適当な文字列 `001` などを指定します。

```markdown
:::excalidraw[ID]{theme=dark size=600px}
{
  // Excalidraw JSON
}
:::
```

最初は `Excalidraw JSON` の部分は何もなしで大丈夫です。

```markdown
:::excalidraw[ID]{theme=dark size=600px}
:::
```

### オプション

オプションは以下が利用できます。

| パラメータ名 | 説明                 | デフォルト |
| ------------ | -------------------- | ---------- |
| theme        | テーマ（light/dark） | light      |
| size         | サイズ（600pxなど）  | 500px      |

## 編集

編集は、GROWIの表示画面で行います。編集画面では表示のみとなっているので注意してください。

## 制限事項

本プラグインでは、幾つかの制限事項があります。

- 1ページに1つのドローイングまで  
複数作成すると、最後のExcalidrawのみチェンジイベントが発生するため…
- 編集画面では表示のみ  
編集結果をエディタに反映する方法がないため
- 保存は、何もしない状態が2秒間経過したタイミング  
Excalidrawのチェンジイベントは非常に激しく、マウスを動かすだけで発生します。そのため、放置して2秒後に保存を実行するようにしています

## 注意点

ドローイングを編集後、ページの編集ページに入って保存しようとすると衝突します。これは、GROWIの編集時には表示画面の内容を保持しており、ドローイングの編集結果が変数として反映されていないためです。

Excalidraw以外の部分を編集する際には、一度編集画面をリロードしてください。

## プラグインを追加する

利用する際には、GROWIの管理画面の `プラグイン` にて追加してください。URLは `https://github.com/goofmint/growi-plugin-excalidraw` です。

![Admin](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f5b3e2d3-ccf3-d536-d59e-7301ea1b2f7e.jpeg)

## コードについて

コードは[goofmint/growi\-plugin\-excalidraw](https://github.com/goofmint/growi-plugin-excalidraw)にて公開しています。ライセンスはMIT Licenseになります。

最初に `:::excalidraw` というRemark Directiveを処理します。この記述があれば、 `code` タグに変換します。また、 `excalidraw = true` を追加して、他の `code` タグとの区別をつけています。

`vist` メソッドで、RemarkのAST `containerDirective` を処理します。2つ目の引数を指定すると、そのディレクティブの場合のみ呼び出されるので便利です。

```tsx
export const remarkPlugin: Plugin = () => {
  return (tree: Node) => {
    visit(tree, 'containerDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'excalidraw') return;
      const data = n.data || (n.data = {});
      const value = n.children[1] ? (n.children[1] as GrowiNode).children.map((ele) => {
        if (ele.type === 'text') return ele.value;
        return `:${ele.name}`;
      }).join('') : '';
      const id = n.children[0] ? (n.children[0] as GrowiNode).children[0].value : 'excalidraw';
      // Render your component
      const { size, theme } = n.attributes;
      data.hName = 'code'; // Tag name
      data.hChildren = [
        {
          type: 'text',
          value,
        },
      ];
      // Set properties
      data.hProperties = {
        title: JSON.stringify({
          size, theme, excalidraw: true, id,
        }),
      };
    });
  };
};
```

### ReactコンポーネントをWeb Components化

ExcalidrawはReactコンポーネントを提供しています。しかし、GROWI本体もReactなので、複数のReactアプリケーションはそのままでは利用できません。

そこで使えるのが `@r2wc/react-to-web-component` です。これは、Reactコンポーネントをラップして、Web Componentsとして利用できるようにします。

```tsx
import r2wc from '@r2wc/react-to-web-component';
const ExcalidrawComponentRap = r2wc(Excalidraw, {
  props: {
    viewModeEnabled: 'boolean',
    isCollaborating: 'boolean',
    initialData: 'json',
    theme: 'string',
    onChange: 'function',
  },
});
customElements.define('excalidraw-component', ExcalidrawComponentRap);
```

このライブラリは、React以外でも使える（はず）なので、かなり汎用性がありそうな気がします。

### Excalidrawの埋め込み

上記ライブラリでWeb ComponentsにしたExcalidrawを、GROWIのページに表示します。

```tsx
const { size, excalidraw, theme, id } = JSON.parse(props.title);
const editMode = window.location.hash === '#edit';
const data = children ? JSON.parse(children) : {};
if (data.appState && data.appState.collaborators) {
  data.appState.collaborators = [];
}
return (
  <div style={{ height: size || '500px' }}>
    <excalidraw-component
      view-mode-enabled={editMode}
      is-collaborating={false}
      initial-data={JSON.stringify(data)}
      on-change='excalidrawOnChange'
      theme={theme || 'light'}
    />
  </div>
);
```

`@r2wc/react-to-web-component` の制限として、 `on-change` などのイベントハンドラは、文字列で指定する + グローバル関数として定義する必要があります。そのため、 `window` オブジェクトに関数を登録しておきます。

```js
let timerId: ReturnType<typeof setTimeout>;
window.excalidrawOnChange = async(elements: object, appState: any, files: any) => {
  const data = { elements, appState, files };
  clearTimeout(timerId);
  // onChangeが2秒間呼ばれなければ、保存処理を実行する
  timerId = setTimeout(window.excalidrawOnSave, 2000, data);
};
```

実際の保存処理です。GROWIのJavaScript SDKを使って、既存のページを取得し、内容を正規表現で置き換えて更新します。

```js
window.excalidrawOnSave = async(data: any) => {
  // 編集画面の場合は何もしない
  if (editMode) return;
  // SDKの初期化
  const growi = new GROWI();
  // コンテンツの作成
  if (data.appState && data.appState.collaborators) {
    data.appState.collaborators = [];
  }
  // ページの取得
  const page = await growi.page({ pageId: window.location.pathname.replace('/', '') });
  // コンテンツの取得
  const contents = await page.contents();
  // 正規表現
  const regString = `^(.*)\n:::excalidraw\\[${id}\\](.*?)\n(.*?)\n:::(.*)$`;
  const match = contents.match(new RegExp(regString, 's'));
  if (!match) return;
  // 新しいページの内容
  const newContents = `${match[1]}\n:::excalidraw[${id}]${match[2]}\n${JSON.stringify(data)}\n:::${match[4]}`;
  // ページ更新
  page.contents(newContents);
  try {
    await page.save();
  }
  catch (err) {
    // console.error(err);
  }
};
```

## GROWIコミュニティについて

プラグインの使い方や要望などがあれば、ぜひGROWIコミュニティにお寄せください。実現できそうなものがあれば、なるべく対応します。他にもヘルプチャンネルなどもありますので、ぜひ参加してください！

[GROWI Slackへの参加はこちらから](https://communityinviter.com/apps/wsgrowi/invite)

## まとめ

GROWIプラグインを使うと、表示を自由に拡張できます。足りない機能があれば、どんどん追加できます。ぜひ、自分のWikiをカスタマイズしましょう。

[OSS開発wikiツールのGROWI | 快適な情報共有を、全ての人へ](https://growi.org/ja/)