LoginSignup
325

More than 3 years have passed since last update.

【100行で出来る】在宅でもブレストがしたいので、オンラインホワイトボード(付箋アプリ)を作ろう stayhome-board

Last updated at Posted at 2020-04-27

エンジニアとしては最近の在宅勤務ブームは割とハッピーですが、ブレストはやっぱりやりにくいなぁという感想です。とはいえ、出社してしまうと感染リスクもあるので、ブレストもオンラインで出来た方が人類のためになりそうだったので、作ってみました。(色々オンラインコラボレーションツールはありますが、モダンなフロント技術でサクッと作れないかなぁと思って試したところ、思った以上にオンライン付箋アプリを作るのは簡単だったので、作り方をシェアできればと思って書いてます。本当のオンラインホワイトボードにするには、もうちょっと修行が必要そうでした・・・)

追記 2020/04/30

  • 思ったより記事が伸びたので、firebase だけでなく socket-io での作り方も用意してみました。社内でホスティングしたい場合などはそちらのバージョンを参考にしてください。 [EXTRA STEP] に詳細は記述しています。

ぬるぬる動く、成果物

result.gif

できること

  • 付箋の追加、削除、テキストの編集、*カードの色変更(*作り方には含めていません)
  • Drag and Drop で付箋の移動
  • リアルタイムに複数人で編集
  • / 以降の url 変更で新しいボードを作れる

技術スタック

  • React
  • Firebase Realtime Database

firebase の realtime database を設定し、 git clone して、 .env に firebase の設定を書き換えるとローカルでもすぐに動きます。

作り方を練習用リポジトリを見ながら解説

Demo は少しリッチに作っていますが、ビルド環境などを用意するのが面倒な私のために、index.html 1枚 & 100行程度に収まる練習用コードを書いてみました。
React がわからなくても、特に難しい機能は使っていないので Vue や Angular に読み替えて(できれば作り変えたりして)もらうと、良いと思います。

github に練習用のコードも全部置いてます。1機能づつプルリクエスクとを作ってるので、順に追っていくとわかりやすいと思います。

  1. STEP1 React のビルドレス構成
  2. STEP2 最低限の Drag and Drop の実装
  3. STEP3 最低限の Text 編集の実装
  4. STEP4 複数のカードへの対応
  5. STEP5 リアルタイムにするための設定
  6. EXTRA STEP firebase ではなく socket-io を利用する

練習用コードの最終形態

STEP1: ミニマムな React 環境の準備(index.html だけ)

https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/1
step1.png
コピペで使えるReact最小構成

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>StayHomeBoard</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
    <script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
    <script type="text/babel">
      const Board = () => {
        return <div>Wash your hands clean!</div>;
      };
      ReactDOM.render(<Board />, document.getElementById("root"));
    </script>
  </head>
  <body>
    <div id="root" />
  </body>
</html>

ポイント

  1. React を CDN から読み込む
  2. babel を CDN から読み込んで <script type="text/babel"> にすることで、 <script> タグ内で JSX が使える

STEP2: Drag on Drop で位置を変更する

https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/2
step2.gif
DragOnDropのコード

const Board = () => {
  return <div>Wash your hands clean!</div>;
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  return (
    <div
      style={{ width: "1000px", height: "1000px", position: "relative" }}
      onDrop={(e) => setPos({ x: e.clientX, y: e.clientY })}
      onDragOver={(e) => e.preventDefault()} // enable onDrop event
    >
      <div style={{ position: "absolute", top: pos.y + "px", left: pos.x + "px" }} draggable={true}>
        Wash your hands clean!
      </div>
    </div>
  );
};

ポイント

  1. 全体を position: "relative" のスタイルで囲む
  2. 移動させたい要素を position: "absolute" にして left, top を絶対位置指定して、1の要素の中に入れる
  3. Drag で移動させたい要素を draggable={true} にする
  4. 1で作った divonDrop のイベントで、 Drop されたときのポジションが取れるので state に入れて利用する
  5. onDragOver={(e) => e.preventDefault()} しないと、 onDrop イベントが発火しないので注意

STEP3: text の中身を編集

https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/3
step3.gif
textを編集できるようにする
+    const [text, setText] = React.useState("Wash your hands clean!");
+    const [editMode, setEditMode] = React.useState(false);

     <div style={{ position: "absolute", top: pos.y + "px", left: pos.x + "px" }} draggable={true}>
-      Wash your hands clean!
+      {editMode ? (
+         <textarea
+           onBlur={(e) => setEditMode(false)}
+           onChange={(e) => setText(e.target.value)}
+           defaultValue={text}
+         />
+      ) : (
+        <div onClick={(e) => setEditMode(true)}>{text}</div>
+      )}
    </div>

ポイント
1. 文字をクリックしたときに編集モードにする
2. <textara>defaultValue に state を入れておくと、スムーズに編集できる
3. onBlur のイベントでマウスが別の場所をクリックしたときに、text を確定する

STEP4: 複数枚のカードを編集したりうごかせるようにする

https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/4
step4-3.gif

少し多いので順番に
4-1 テキストを編集できるようにする

const Board = () => {
+  const [cards, setCards] = React.useState({
+    id1: { t: "Wash", x: 100, y: 100 },
+    id2: { t: "your hands", x: 200, y: 300 },
+  });
+  const update = (key, card) => setCards({ ...cards, [key]: card });
+
   const [pos, setPos] = React.useState({ x: 0, y: 0 });
-  const [text, setText] = React.useState("Wash your hands clean!");
-  const [editMode, setEditMode] = React.useState(false);
+  const [editMode, setEditMode] = React.useState({ key: "" });

return (
  <div
@@ -20,17 +25,23 @@
  onDrop={(e) => setPos({ x: e.clientX, y: e.clientY })}
  onDragOver={(e) => e.preventDefault()} // enable onDrop event
  >
-  <div style={{ position: "absolute", top: pos.y + "px", left: pos.x + "px" }} draggable={true}>
-    {editMode ? (
-      <textarea
-        onBlur={(e) => setEditMode(false)}
-        onChange={(e) => setText(e.target.value)}
-        defaultValue={text}
-      />
-    ) : (
-      <div onClick={(e) => setEditMode(true)}>{text}</div>
-    )}
-  </div>
+  {Object.keys(cards).map((key) => (
+    <div
+      key={key}
+      style={{ position: "absolute", top: cards[key].y + "px", left: cards[key].x + "px" }}
+      draggable={true}
+    >
+      {editMode.key === key ? (
+        <textarea
+          onBlur={(e) => setEditMode({ key: "" })}
+          onChange={(e) => update(key, { ...cards[key], t: e.target.value })}
+          defaultValue={cards[key].t}
+        />
+      ) : (
+        <div onClick={(e) => setEditMode({ key })}>{cards[key].t}</div>
+      )}
+    </div>
+  ))}
</div>

差分を github で見る

  1. cards という state を作成し、テキストの内容とポジションをそれぞれ持たせる。 配列ではなく object にすることで update しやすくする
  2. 今までは editMode をフラグ管理していたが、「どのカードを編集するか?」を明示出来るように、 cards の key で管理する
  3. Object.keys(cards).map(key => cards[key]) で、オブジェクトを配列のように回して、 editMode にセットされている key と一致したときに、テキストを編集できるようにする
  4. 編集は const update = (key, card) => setCards({ ...cards, [key]: card }); のようにして、 特定の key を指定して update する

4-2 Drag and Drop も、複数対応する

-const [pos, setPos] = React.useState({ x: 0, y: 0 });
+const [dragging, setDragging] = React.useState({ key: "", x: 0, y: 0 });

 return (
   <div
     style={{ width: "1000px", height: "1000px", position: "relative" }}
-    onDrop={(e) => setPos({ x: e.clientX, y: e.clientY })}
+    onDrop={(e) => {
+      if (!dragging || !cards) return;
+      update(dragging.key, { ...cards[dragging.key], x: e.clientX - dragging.x, y: e.clientY - dragging.y });
+    }}
     onDragOver={(e) => e.preventDefault()} // enable onDrop event
   >
     {Object.keys(cards).map((key) => (
       <div
        key={key}
        style={{ position: "absolute", top: cards[key].y + "px", left: cards[key].x + "px" }}
        draggable={true}
+       onDragStart={(e) => setDragging({ key, x: e.clientX - cards[key].x, y: e.clientY - cards[key].y })}
    >

差分を github で見る

  1. draggable={true} の要素に対して onDragStart のイベントを取得し、どの key のカードを Drag 中かを state に保存
  2. このとき、要素のどの位置をクリックしたかも保存しているのは、Drop したときにオフセットを計算して、スムーズな配置を行うため

STEP5: firebase でリアルタイムに送受信できるようにする

画像 概要
step5-2.png プロジェクトを追加
step5-6.png アナリティクスなどを設定したら、プロジェクトができる
step5-7.png database を選んで
step5-8.png CloudFirestore ではなく RealtimeDatabse を選択。 (単純な JSON しか扱わないのと、料金体系的にリアルタイムに多くの変更が発生するケースに向いているため)
step5-9.png セキュリティルールをテストモードにして
step5-10.png データベースが作成されたら完了

WEBアプリの設定

画像 概要
step5-11.png ウェブを選択
step5-12.png アプリ名を入力.(hosting も必要であれば。今回はサンプル公開のために設定した)
step5-13.png firebase の cli も hosting などする場合はインストールしておく
step5-14.png 作ったアプリを選択して
step5-15.png Settings の下まで行って、CDN の内容を index.html にコピーする

RealTimeDatabase を使った同期処理を実装する

https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/5
step5-16.gif

5-1 realtime database の設定を入れる

<script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-database.js"></script>
...
<script type="text/babel">
  // ご自身のプロジェクトでコピーした key を入れてください
  var firebaseConfig = {
    apiKey: "",
    authDomain: "",
    databaseURL: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: "",
    measurementId: "",
  };
  const firebaseApp = firebase.initializeApp(firebaseConfig);
  const firebaseDb = firebaseApp.database();
  let db = null;

  const Board = () => {
...

5-2 リアルタイムに同期するように、add と update を書き変える

       let db = null;
       const Board = () => {
+        React.useEffect(() => {
+          db = firebaseDb.ref("/room_url");
+          db.on("value", (value) => setCards(value.val()));
+        }, []);
+
         const [cards, setCards] = React.useState(null);
-        const add = () =>
-          setCards({
-            ...cards,
-            [Math.random().toString(36).slice(-8)]: {
+        const add = () => {
+          const newPostKey = db.push().key;
+          db.update({
+            [newPostKey]: {
               t: "wash",
               x: Math.floor(Math.random() * (200 - 80) + 80),
               y: Math.floor(Math.random() * (200 - 80) + 80),
             },
           });
-        const update = (key, card) => setCards({ ...cards, [key]: card });
+        };
+        const update = (key, card) => db.update({ [key]: card });
+        const remove = (key) => db.child(key).remove();


+        <button onClick={() => remove(key)}>x</button>

差分をgithubで見る

必要な変更はたったこれだけですが、これでリアルタイムに通信できるようになりました。

ポイント
1. useEffect で、コンポーネントが最初にレンダリングした時、firebaseDb.ref("/room_url") で RealtimeDatabase を読み込み db.on("value", (value) => setCards(value.val())); で中身を取得する。 JSON をリアルタイムに配信してくれる受け皿だと思うとわかりやすい。 db.on すると、オンラインに保存されている JSON に変更があるたび、第二引数の関数を実行するので、データが変更されるたびに、 state を上書きしていく。(現状は、丸ごと置き換えているので特定の kye の変更だけを監視するとか、もっと頭の良い方法はありそう。)
2. カードを追加する add では const newPostKey = db.push().key; で先にキーを追加したあとに db.update({[newPostKey]{ 中身 }) でアップデートすることで対応できる
3. カードの中身を変更する updatedb.update({ [key]: card }); のように、特定のキーを指定して中身を書き換えればOK。 STEP4-1 で、配列ではなくオブジェクトで保存していた効果が発揮された。
4. ついでに削除も db.child(key).remove(); にて、 key を指定すれば良い

おまけ
5-3 ボードを複数作りたいので、url パラメータを利用する

STEP6 デプロイ

  • npm install -g firebase-tools しておく
  • firebase init する
画像 概要
step6-1.png Hosting をスペースキーで選んでからエンター
step6-2.png 既存のプロジェクトを選択する
step6-3.png デプロイ元は public フォルダを選択
step6-4.png single page application も yes にしておく。(どの url にきても index.html を返してくれる)

してから firebase deploy する前に index.html を修正

-    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js"></script>
-    <script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-database.js"></script>
+    <script src="/__/firebase/7.14.2/firebase-app.js"></script>
+    <script src="/__/firebase/7.14.2/firebase-database.js"></script>
+    <script src="/__/firebase/init.js"></script>
     <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
     <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
     <script crossorigin src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
     <script type="text/babel">
-      var firebaseConfig = {
-        apiKey: "",
-        authDomain: "",
-        databaseURL: "",
-        projectId: "",
-        storageBucket: "",
-        messagingSenderId: "",
-        appId: "",
-        measurementId: "",
-      };
-      const firebaseApp = firebase.initializeApp(firebaseConfig);
-      const firebaseDb = firebaseApp.database();
+      const firebaseDb = firebase.database();

ポイント

  • firebase の hosting を利用する場合は、 cdn からではなく /__/firebase/ から読み込まないと、エラーになるので注意
  • <script src="/__/firebase/init.js"></script> すると firebase という変数に初期化したライブラリが読み込まれる

デプロイする

# public フォルダにコピーする
$ cp index.html public/
# デプロイコマンド
$ firebase deploy

URLにアクセスすると、アプリが動作するはずです。お疲れ様でした。
(ちなみに、この設定だとローカルで動かなくなるので、github に commit はしていません。)

result_minimum.gif

カードの色を変えたり、スタイリングなどは、是非オリジナルなものを作ってみてください。
create-react-app と typescript で書いたバージョンは以下からお試しください。
https://github.com/TsuyoshiNumano/stayhome-board

注意点

[追記]セキリュティ周りが心配な方に、社内やローカルにホスティングできる socket-io のバージョンも用意しました。以下の EXTARA STEP を参照ください

EXTRA STEP (追記: 2020/04/30)

firebase を使いたくない人に向けて、 socket-io を利用したバージョンも作りました。ローカルでホスティングできるので、こちらの方がとっつきやすいかもしれません。

node.js が動く環境があれば、以下のコートからすぐに試すことができます。
コード: https://github.com/TsuyoshiNumano/stayhome-board-minimum/tree/master/socket-io-version

  • node.js が動く環境を準備
  • socket-io, express の導入
  • index.html を firebase から、socket-io に対応する
socket-ioを使ったserverの実装

var app = require("express")();
var http = require("http").createServer(app);
var io = require("socket.io")(http);
var fs = require("fs");

// index.html をホスティング
app.get("/", (req, res) => {
  res.sendFile(__dirname + "/index.html");
});

io.on("connection", (socket) => {
  var socketId = socket.id;
  var roomId = socket.handshake.query.id;
  socket.join(roomId);
  try {
    // 接続があったら、ファイルからデータを読み込んで送信
    var data = fs.readFileSync(`data/${roomId}.json`, "utf-8");
    io.to(socketId).emit("server_item_update", JSON.parse(data));
  } catch (e) {
    console.log(e);
  }
  // クライアントからメッセージがきたら、自分以外のルームにデータを送信し、ファイルに保存
  socket.on("client_item_update", (data) => {
    socket.broadcast.to(roomId).emit("server_item_update", data);
    fs.writeFile(`data/${roomId}.json`, JSON.stringify(data), (err) => {
      if (err) throw err;
    });
  });
});

http.listen(3000, () => {
  console.log("listening on *:3000");
});

socket-ioを利用したクライアント側のコードの変更
+    <script src="/socket.io/socket.io.js"></script>
-      let db = null;
+      var socket = null;

       const Board = () => {
         React.useEffect(() => {
@@ -32,23 +19,33 @@
             let roomName = window.prompt("please input room's name");
             window.location.href = window.location.href + "?room_name=" + roomName;
           }
-          db = firebaseDb.ref(roomName);
-          db.on("value", (value) => setCards(value.val()));
+          socket = io();
+          socket.on("server_item_update", (msg) => setCards(msg));
         }, []);

         const [cards, setCards] = React.useState(null);
         const add = () => {
-          const newPostKey = db.push().key;
-          db.update({
-            [newPostKey]: {
+          const newCards = {
+            ...cards,
+            [Math.random().toString(36).slice(-8)]: {
               t: "wash",
               x: Math.floor(Math.random() * (200 - 80) + 80),
               y: Math.floor(Math.random() * (200 - 80) + 80),
             },
-          });
+          };
+          setCards(newCards);
+          socket.emit("client_item_update", newCards);
+        };
+        const update = (key, card) => {
+          const newCards = { ...cards, [key]: card };
+          setCards(newCards);
+          socket.emit("client_item_update", newCards);
+        };
+        const remove = (key) => {
+          delete cards[key];
+          setCards(cards);
+          socket.emit("client_item_update", cards);
         };
-        const update = (key, card) => db.update({ [key]: card });
-        const remove = (key) => db.child(key).remove();

github での差分

ポイント

  • express で index.html ごとホスティングするので localhost:3000 でアクセスし、ホスティングと socket-io のサーバーが一緒に立てれる
  • それによって、クライアント側では <script src="/socket.io/socket.io.js"></script> で socket-io のクライアントライブラリを読み込むことができる
  • サーバー側でのデータの永続化には DB などは使わず、直接ファイルに json 形式で保存
  • サーバー側では、クライアントから来たデータを丸ごと他のクライアントに返しているだけ
  • クライアント側の socket-io でデータのやり取りは useEffect で最初にマウントした時に socket.on("server_item_update", (msg) => setCards(msg)); とするとことで、 server_item_update のメッセージがあるたびに、新しい state に更新する。
  • ロジックをシンプルにした結果、fireabase バージョンとほとんど変更がないものの、毎回全てのデータを転送するので、転送コストが大きいのが微妙なところ。

あとがき

家に引きこもっていると、友人からメッセージが来て「こんな世の中だからこそ、自分たちに出来ることを探そうよ」と言われて、自分にできることはなんだろうと考えた結果、自分はサービスを作るよりも、サービスの作り方のようなものを伝搬して、より良いサービスを誰かが作るきっかけ自体を作る方が良さそうな気がしたので、記事を書いた次第です。役に立たなくても何かのきっかけになれば嬉しいです。
#stayhome

おすすめのコラボレーションツール

わざわざ自分で作らなくても、良さそうなツールがたくさん出てきてるので紹介しておきます。

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
325