エンジニアとしては最近の在宅勤務ブームは割とハッピーですが、ブレストはやっぱりやりにくいなぁという感想です。とはいえ、出社してしまうと感染リスクもあるので、ブレストもオンラインで出来た方が人類のためになりそうだったので、作ってみました。(色々オンラインコラボレーションツールはありますが、モダンなフロント技術でサクッと作れないかなぁと思って試したところ、思った以上にオンライン付箋アプリを作るのは簡単だったので、作り方をシェアできればと思って書いてます。本当のオンラインホワイトボードにするには、もうちょっと修行が必要そうでした・・・)
追記 2020/04/30
- 思ったより記事が伸びたので、firebase だけでなく socket-io での作り方も用意してみました。社内でホスティングしたい場合などはそちらのバージョンを参考にしてください。 [EXTRA STEP] に詳細は記述しています。
ぬるぬる動く、成果物
- デモ: https://stayhome-board.firebaseapp.com/demo (デモページは予告なく消すかもしれません。)
- コード: https://github.com/TsuyoshiNumano/stayhome-board
できること
- 付箋の追加、削除、テキストの編集、*カードの色変更(*作り方には含めていません)
- 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機能づつプルリクエスクとを作ってるので、順に追っていくとわかりやすいと思います。
- STEP1 React のビルドレス構成
- STEP2 最低限の Drag and Drop の実装
- STEP3 最低限の Text 編集の実装
- STEP4 複数のカードへの対応
- STEP5 リアルタイムにするための設定
- EXTRA STEP firebase ではなく socket-io を利用する
STEP1: ミニマムな React 環境の準備(index.html だけ)
https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/1 |
---|
<!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>
ポイント
- React を CDN から読み込む
- babel を CDN から読み込んで
<script type="text/babel">
にすることで、<script>
タグ内で JSX が使える
STEP2: Drag on Drop で位置を変更する
https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/2 |
---|
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>
);
};
ポイント
- 全体を
position: "relative"
のスタイルで囲む - 移動させたい要素を
position: "absolute"
にしてleft
,top
を絶対位置指定して、1の要素の中に入れる - Drag で移動させたい要素を
draggable={true}
にする - 1で作った
div
のonDrop
のイベントで、 Drop されたときのポジションが取れるので state に入れて利用する -
onDragOver={(e) => e.preventDefault()}
しないと、onDrop
イベントが発火しないので注意
STEP3: text の中身を編集
https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/3 |
---|
+ 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>
ポイント
- 文字をクリックしたときに編集モードにする
-
<textara>
のdefaultValue
に state を入れておくと、スムーズに編集できる -
onBlur
のイベントでマウスが別の場所をクリックしたときに、text を確定する
STEP4: 複数枚のカードを編集したりうごかせるようにする
https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/4 |
---|
少し多いので順番に
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>
- cards という state を作成し、テキストの内容とポジションをそれぞれ持たせる。 配列ではなく object にすることで update しやすくする
- 今までは
editMode
をフラグ管理していたが、「どのカードを編集するか?」を明示出来るように、 cards の key で管理する -
Object.keys(cards).map(key => cards[key])
で、オブジェクトを配列のように回して、 editMode にセットされている key と一致したときに、テキストを編集できるようにする - 編集は
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 })}
>
-
draggable={true}
の要素に対してonDragStart
のイベントを取得し、どの key のカードを Drag 中かを state に保存 - このとき、要素のどの位置をクリックしたかも保存しているのは、Drop したときにオフセットを計算して、スムーズな配置を行うため
STEP5: firebase でリアルタイムに送受信できるようにする
- https://console.firebase.google.com/ firebase にログインしてプロジェクトを作っていきます
画像 | 概要 |
---|---|
プロジェクトを追加 | |
アナリティクスなどを設定したら、プロジェクトができる | |
database を選んで | |
CloudFirestore ではなく RealtimeDatabse を選択。 (単純な JSON しか扱わないのと、料金体系的にリアルタイムに多くの変更が発生するケースに向いているため) | |
セキュリティルールをテストモードにして | |
データベースが作成されたら完了 |
WEBアプリの設定
画像 | 概要 |
---|---|
ウェブを選択 | |
アプリ名を入力.(hosting も必要であれば。今回はサンプル公開のために設定した) | |
firebase の cli も hosting などする場合はインストールしておく | |
作ったアプリを選択して | |
Settings の下まで行って、CDN の内容を index.html にコピーする |
RealTimeDatabase を使った同期処理を実装する
https://github.com/TsuyoshiNumano/stayhome-board-minimum/pull/5 |
---|
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>
必要な変更はたったこれだけですが、これでリアルタイムに通信できるようになりました。
ポイント
- useEffect で、コンポーネントが最初にレンダリングした時、
firebaseDb.ref("/room_url")
で RealtimeDatabase を読み込みdb.on("value", (value) => setCards(value.val()));
で中身を取得する。 JSON をリアルタイムに配信してくれる受け皿だと思うとわかりやすい。db.on
すると、オンラインに保存されている JSON に変更があるたび、第二引数の関数を実行するので、データが変更されるたびに、 state を上書きしていく。(現状は、丸ごと置き換えているので特定の kye の変更だけを監視するとか、もっと頭の良い方法はありそう。) - カードを追加する
add
ではconst newPostKey = db.push().key;
で先にキーを追加したあとにdb.update({[newPostKey]{ 中身 })
でアップデートすることで対応できる - カードの中身を変更する
update
はdb.update({ [key]: card });
のように、特定のキーを指定して中身を書き換えればOK。 STEP4-1 で、配列ではなくオブジェクトで保存していた効果が発揮された。 - ついでに削除も
db.child(key).remove();
にて、 key を指定すれば良い
おまけ
5-3 ボードを複数作りたいので、url パラメータを利用する
STEP6 デプロイ
-
npm install -g firebase-tools
しておく -
firebase init
する
画像 | 概要 |
---|---|
Hosting をスペースキーで選んでからエンター | |
既存のプロジェクトを選択する | |
デプロイ元は public フォルダを選択 |
|
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 はしていません。)
カードの色を変えたり、スタイリングなどは、是非オリジナルなものを作ってみてください。
create-react-app と typescript で書いたバージョンは以下からお試しください。
https://github.com/TsuyoshiNumano/stayhome-board
注意点
- ざっくりと流れを説明しただけなので、セキュリティのルールがガバガバです。実運用する際にはお気をつけください。
- 内輪で使うにしても、最低限 firebase で basic 認証 をかけると良いと思います。(参考)Firebaseでベーシック認証をかけるベストな方法
- DB自体に制限をかけるのがベストです、認証システムを通して組み込んでください (参考)firebase realtime database でよく使う rule
- Firebase apiKey ってさらしていいの? ほんとに?
[追記]セキリュティ周りが心配な方に、社内やローカルにホスティングできる 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 に対応する
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");
});
+ <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();
ポイント
- 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
おすすめのコラボレーションツール
わざわざ自分で作らなくても、良さそうなツールがたくさん出てきてるので紹介しておきます。
- リモブレ(リモートブレスト)で創造的なアイデアを生み出す方法 で紹介されている Miro
- この際リモートでどこまででもやる Mural登場オンラインで付箋ワーク で紹介されている Mural
- Good Patch の 「Strap」
- (昔 kpt が流行ったときに自分が作ったものと全く同じような動きをするオンライン付箋アプリがあったが、名前が今だに思い出せない)