GROWIの表示ページでファイルをアップロードできるプラグインを作りました

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

今回は、GROWIプラグインとして、GROWIの表示ページ（編集ページではなく）にてファイルのアップロードを実現するプラグインを作りました。編集ページに移動することなく、ファイルをアップロードできます。ファイルアップローダー的に使ってもらえます。

![FireShot Capture 628 - Dropzone - GROWI - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/324b9338-5798-434a-92ad-162777f48a21.jpeg)


## プラグインの動作

Remark Directiveとして、以下のように記述します。 `[]` ドロップゾーンで表示するテキストを入力します（デフォルトは `Drag here to preview` です）。

```markdown
::dropzone[Drag here to preview]
```

そうすると、以下のように表示されます。

![FireShot Capture 628 - Dropzone - GROWI - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/9a1f6398-5155-4267-a611-7ffe5b66ccd9.jpeg)


この枠の中にファイルをドラッグ&ドロップします。ファイルの場合は添付ファイルとして、画像の場合はインライン画像として表示されます。

![FireShot Capture 629 - Dropzone - GROWI - localhost.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/fa0c6b51-1800-4ae4-93cc-43eb9c1b264e.jpeg)


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

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

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

## コードについて

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

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

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

```tsx
export const remarkPlugin: Plugin = () => {
  return (tree: Node) => {
    visit(tree, 'leafDirective', (node: Node) => {
      const n = node as unknown as GrowiNode;
      if (n.name !== 'dropzone') return;
      const data = n.data || (n.data = {});
      // Render your component
      const { value } = n.children[0] || { value: '' };
      data.hName = 'a'; // Tag name
      data.hChildren = [{ type: 'text', value }]; // Children
      // Set properties
      data.hProperties = {
        href: 'https://example.com/rss',
        title: JSON.stringify({ ...n.attributes, ...{ dropzone: true } }), // Pass to attributes to the component
      };
    });
  };
};
```

### 外部データを取得して表示

[GROWI 7.2から、React Hooksに対応しました](https://qiita.com/goofmint/items/865d24b88e3641375d51)。今回のプラグインではそれを使ってファイルのドロップを検知しています。

```tsx
export const helloGROWI = (Tag: React.FunctionComponent<any>): React.FunctionComponent<any> => {
  return ({ children, ...props }) => {
    try {
      const { dropzone } = JSON.parse(props.title);
      if (dropzone) {
        // 7.2から対応したReact Hooksを使っています
        const { react } = growiFacade;
        const { useEffect, useCallback, useState } = react;
        const [isDragOver, setIsDragOver] = useState(false);
        // ページID
        const pageId = window.location.pathname.split('/').pop();
        // 編集ページかどうかの判定
        const edit = window.location.hash.includes('edit');
        const onDrop = useCallback(async(event: React.DragEvent<HTMLDivElement>) => {
          // ドロップした後の処理
        }, []);
        return (
          <>
            <div id="drop-area"
              onDragEnter={() => setIsDragOver(true)}
              onDragLeave={() => setIsDragOver(false)}
              onDragOver={() => setIsDragOver(true)}
              onDrop={onDrop}
              className={isDragOver ? 'drag-over' : ''}
            >
              {children || 'Drag here to preview'}
            </div>
            <input type="file" id="file-input" multiple hidden />
            <div id="preview-container"></div>
          </>
        );
      }
    }
    catch (err) {
      // console.error(err);
    }
    // Return the original component if an error occurs
    return (
      <Tag {...props}>{children}</Tag>
    );
  };
};
```

スタイルシートでは、ドロップゾーンに対して枠の表示を行っています。また、ドラッグしている際の表示反転処理も追加しています。

```css
#drop-area {
  width: 400px;
  height: 200px;
  margin: 20px auto;
  line-height: 200px;
  text-align: center;
  cursor: pointer;
  border: 2px dashed var(--bs-body-color);
  backgroundColor: var(--bs-body-bg);
}

#preview-container {
  text-align: center;
}

#drop-area.drag-over {
  color: var(--bs-body-bg);
  background-color: var(--bs-body-color);
  border: 2px dashed var(--bs-primary);
}
```

### ドロップした後の処理

ファイルをドロップすると `onDrop` が走ります。ここでは、ファイルをGROWI JS SDKを使ってアップロードし、ページに追記しています。詳細はコメントを参照してください。

```tsx
const onDrop = useCallback(async(event: React.DragEvent<HTMLDivElement>) => {
  event.preventDefault();
  // 編集モードなら何もしない
  if (edit) return;
  // ドロップしたファイルを取得
  const { files } = event.dataTransfer;
  // ファイルがない場合は何もしない
  if (files.length === 0) return;
  // 現在のページを取得
  const page = await growi.page({ pageId });
  // ファイルをアップロード
  const promises = [];
  for (const file of files) {
    promises.push(page.upload(file.name, file as any));
  }
  // レスポンスは添付ファイルオブジェクトの配列
  const attachments = await Promise.all(promises);
  // Markdown記法に変換
  const result = attachments.map((attachment) => {
    return attachment.fileFormat?.split('/')[0] === 'image'
      ? `![${attachment.fileName}](${attachment.filePathProxied})` // 画像の場合はインライン画像
      : `[${attachment.fileName}](${attachment.filePathProxied})`; // それ以外は添付ファイル
  });
  // 現在のページの内容を取得
  const contents = await page.contents();
  // 現在のページの内容に追記
  const newContents = `${contents}\n${result.join('\n')}`;
  // 保存
  await page.contents(newContents);
  await page.save();
  // ドラッグオーバーを解除
  setIsDragOver(false);
  // ページをリロード
  window.location.reload();
}, []);
```

最後にページをリロードしているのは、更新された内容をその場で表示に反映できないためです。

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

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

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

## まとめ

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

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