自分好みのアウトライナーを開発した記録
はじめに
Twirlinerというアウトライナーアプリを作成しました
既存のアウトライナーアプリと大きな違いはありませんが、自分が欲しい機能を少しずつ追加しています。バグや要望等ありましたら、お気軽にコメントをお願いします。
Demoページ
更新は保存されません。記述記法等の確認に使用してください。
App(Local)ページ
更新は LocalStorage に保存されます
※LocalStorage の容量を超えるデータを格納した場合の動作は保証できません。文字数にもよりますが、15000 行くらいで上限に到達します。
ログイン機能
現在は開放していません
使用技術
- フロントエンド
- Typescript + React(Next.js)
- バックエンド(現在は停止中)
- Typescript + Node.js
何故作ったか
アウトライナーのすべてを行で表す簡潔さと構造操作の自由度に惚れ込み、情報整理やタスク管理のツールとして毎日使用しています。
しかし、既存のアウトライナーにはそれぞれ痒いところに手が届かない部分が存在します。
ツールに人間が合わせるのが合理的ですが、どうしても合わない部分と言うのは残ります。不満を感じて他のアウトライナーを探すという行動を何度か繰り返していましたが、どうせなら自分で作ってみようと思い作成に乗り出しました。
コンセプト
純粋な2ペイン式
アウトライナーは通常の文章より多く行を区切るため、縦方向に長いツリーが展開されます。内容を整理するときに左右にツリーを開くことができると非常に便利です。
しかし、既存のアウトライナーには以下の様な製品が多く見受けられます。
- 1 ペインでしか開けない
- サイドペインを開けるが、あくまで「メイン」と「サイド」の関係で、両方対等な操作ができない
2 ペインをネイティブでサポートすることで、ペイン間での移動などを実現させます。
純粋なアウトライナ × 複数のビュー
アウトライナーはすべてが行という制約があるからこそ、構造化された情報の作成に集中できると考えています。
しかし、行が積み重なった形式よりも整理し易い形式が存在することは否定できません。
- タスク管理 → カンバン形式
- 2 軸の情報整理 → テーブル形式
この 2 つを矛盾なく解決するために、純粋なアウトライナー構造を保ったまま複数の表示形式(ビュー)の切り替えを実装します。
キーボードファースト
アウトライナーは、行をキーボードで自由自在に動かして情報構造を柔軟に変化させられる点に大きな魅力があります。マウスを挟まなければ実現できない操作があると、情報の書き出しにブレーキを書けることとなります。
今回の開発では可能な限りキーボードのみで操作を完結できるように努力しました。
基本操作の実装
アウトライナーにおける基本要素はいくつか存在します。
- 文字入力
- 展開・収納
- カーソルの移動
- 行の組み換え
- ズームイン・アウト
- エクスポート
- リンク(必須ではない)
- 装飾(必須ではない)
文字入力に使用した ContentEditable 特有の癖には苦労しました。今回は入力と出力を別々(入力はプレーンテキスト、出力は装飾付き)で実装しましたが、WYSIWYG エディタは作れる気がしません。
カーソル移動
他のアウトライナーより充実していると思うのはカーソル移動の機能です。
上下ノードへの移動のみ実装されているアウトライナーが多いですが、Twirliner では以下のカーソル移動を実装しました。
- 兄弟ノードに移動
- 子ノードの展開時に子ノードを介さずに、次の兄弟ノードへすぐに移れます
- 親ノードに移動
- 今のツリーから早く抜けたいときに、一気に親ノードへジャンプできます
- 最後の子ノードに移動
- 今のノードの最後に新しいノードをすぐに追加したいときに便利です
最後の子ノードへの移動はそこまで使いませんが、親と兄弟ノードへの移動は思っていたより便利でした。
キーボード操作の感触は伝わりづらい部分なので、ご興味があれば是非一度触ってみてください。操作の選択肢が複数あるので作者の私もたまに混乱しますが、その時は素直に一行ずつ移動するようにしています。
エクスポート
エクスポートもワンタッチで実行できるようにしました(Ctrl + E
)
現在は以下の形式に対応しています
- Markdown
- Scrapbox
- Text
- OPML
- JSON(デバック用)
アウトライナーだとつい軽率にネストしてしまうため、マークダウン形式に変換するとやけにヘッダーが多い文章構造となってしまいます。この問題は解決不可能なので、マークダウン形式にする予定の文章は、構造をできるだけフラットにする意識が必要です。
2 ペインの実装
Twirliner は基本的に URL ドリブンで状態を変化させています。
これはかなり管理が容易でしたが、SPA フレームワークだからできる方法かもしれません。
ページ内容が遷移しているのに URL は変わらないページ設計のサイトに時々出会いますが私は好きではありません。ページをリロードしても URL を直接指定しても常に同じ状態でいて欲しいのです。
id0=foo
とするとペイン 0 にfoo
ノードをルートにしたツリーが表示され、id1=bar
とするとペイン 1 が開きます。両方指定するとペインが 2 つ開きます。
仕組み上は無限にペインを開くことも可能ですが、あまり意味は無いので 1 ~ 2 ペインのみに制限しました。
ただ、2 ペインはあまり使用していません。アウトライナーでは別のツリーを見ながら記述をするという状況が殆ど発生しません。ブラウザで別サイトを見ながら情報を整理することの方が多いです。
1 ペインで中央に表示していたほうが書きやすいです。無くても良かったかもしれません。
ビュー形式の実装
以下の 7 つの形式を実装してみました(2022-06-09 現在)
- ライン(通常)
- カンバン
- グリッド
- フセン
- スクラップ
- チャット
- ブロック
普段遣いでは通常の形式(ライン形式)以外はほとんど使用しませんが「ビュー」という概念は実装してよかったと思っています。
それぞれのビューに対する所感
カンバン
デイリーのタスク管理にカンバン形式は(わかりきったことですが)非常に有用です。それをアウトライナーのメンタルを保ったまま操作できる体験は非常に心地良いです。
既存のカンバンツールでは、一覧性を保ったままワンステップでタスクの中身を編集することができません。
- クリックで画面遷移 → 編集
- クリックでモーダルが出現 → 編集
アウトライナー内でのカンバンは見た目が違うだけのただのアウトライン構造です。一覧のまま何階層でも小タスクやメモを追加できます。
グリッド
次に実装したのがグリッドビューです。はじめは「テーブル」と名付けていましたが、行・列操作が困難でしたので折り返しによるグリッド表示で実装をしました。このビューは子要素をほとんど持たないフラットな情報の羅列に有用だと思い実装しました。
ただ、このビューはあまり使用していません。
情報を羅列するならライン表示で十分ですし、子要素に情報を追加するとセルが縦に長くなり表示が崩れるのが良い体験ではなかったからです。
擬似的なテーブル表示も可能ですが、列の増減や移動ができないので使い勝手はあまり良くありません。
フセン
フセンは分類された小タスクの書き出しに有用かと思い実装しました。
今あるビューの中では見た目が与える意味が一番強いので、特定の情報に対しては有用に使えています。
- 後で整理したり見返したりしない小タスク
- 今日中に捨てることが確定している小タスク
スクラップ
これはZenn のスクラップに感化されて作り(パクリ)ました。
私は情報の保存には Scrapbox を使用しています。非常に気に入っていますが、ページをまとめて羅列することができないことが不満でした。
Zenn が普及してくるにつれてスクラップで書かれたメモ記事をよく見かけるようになり、これが欲しかった形だとわかりました。この形式は情報を書き加えていくという運用がしやすく、情報の「作成」と「更新」のハードルを下げてくれます。
スクラップビューはあくまでビューですので、擬似的にスクラップが分かれている様に見えるだけですが、この形式によって記事っぽい情報がかなり書きやすくなりました。
ライン(通常)形式以外では一番良く使用しています。
チャット
Slack や Discord をメモツールとして使用している人も多いと思います。
チャット形式は「情報を入力する」ハードルが非常に低いです。文章を書こうとすると装飾や文体を気にしてしまいますが、チャットだと殆ど気にせずに内容だけを考えて入力ができます。
最近見かけたこちらのツイートは、凄く良いアイデアだと思います
気分が乗ったのでこの仮説をもとに github issue をチャット UI にするのを嫌にならない程度に真面目に車輪の再開発してるけど、やはりメモしやすい。
https://twitter.com/terrierscript/status/1534339097120362496
実際のチャットは、開けば常に最新の情報が表示される実装が一般的です。前の情報を捨てやすい(常に最新に集中できる)ので、刹那的なメモに適しています。
アウトライナーだと良くも悪くも情報が積もってしまうので、チャット形式はあまり有用では無いかもしれません。この問題は後述のピン機能で多少解消できます。
対話式で考えを整理したいときにはある程度有用かと思います。※見返しはしない
ブロック
アウトライナーはすべてが行という制約があるからこそ~
と前述しましたが、改行がほしい状況も無いとは言えません。
- ソースコードの記述
- 複数行の引用
これらの情報をアウトライナーに記述する状況自体が間違っている(アウトライナーは情報の保存には適していない)とも思いますが、あったほうが便利なのも事実です。
ブロックビューは他のビューと異なり基本動作自体を変更(キーボードショートカットを幾つかブロック)する実装をしました。なので基本的に使用するべきではありません。どうしても複数行の記述が必要な場合にのみ使用する様にしています。
ビュー全体の感想
ビューを切り替えるという動作は思考を邪魔します。どのビューで情報を書こうか考えたり頻繁にビューを切り替えるくらいなら、普通のライン表示で書いたほうがアウトライナー体験としては良いと感じました。
ただし見た目が情報に与える力はやはり否定できません。
アウトライナー作業全体の 1%しか使わないとしても、この機能は実装して価値があったと思います。また、様々なビューを使っても最終的に同じアウトライナー構造は保っているという安心感は重要だと感じました。
今後も良いビューが思いついたら実装していこうと思います。
ピン機能の実装
チャットビューで少し触れましたが、アウトライナーは全ての情報が縦に積み重なっていきます。
これは利点でもありますが、特定の情報や最新(一番下)の情報にアクセスするにはストレスとなる場合があります。これを解決するために、「ピン」機能を実装しました。
ノードに#pin
と入力するとピン留めが行われます。
特定のフラグを用意しても良かったのですが、自由度が高そうなのでこの方式を採用しました。
ただし、ピン情報の保存領域を用意していないのでピンの表示順は保証されませんし、並び替えもできません。表示順に意味が出る様なピン留めはしないのが正しい運用だと思います。
他ツールで使われるブックマークやピンと同じ様な機能ですが、こだわりが 2 点あります。
左上に固定表示
ブックマーク系にアクセスするためにサイドペインを開くアクションを取らされることがありますが、私はその 1 動作を面倒だと思ってしまう性格です(一度面倒に思ったらもう使いません)。
Twirliner ではペインの左上にフロート状態で表示されます。
※非表示も可能、一定の文字数以上は省略
ピン数が増えて邪魔に思ったり同じ様なピン名で見分けがつかなくなったら、それはピンの運用を間違えている状態です。多くても 5 つ程度の、明確に意味のあるピンのみ保持すべきだと思います。
私は現状「yyyy-mm-dd(デイリーのノード)」「アイデア(Index)」「タスク(Inbox)」の 3 つだけピン留めしています。
デイリーのノード
特定のコンテキストに依存しない今日のタスクや情報を書く場所なので、頻繁にアクセスします。
アイデアとタスクの Inbox
何か思いついたら取り敢えず突っ込んでおく場所です。コンテキストがない思いつきを突っ込む場所を用意しておくと精神衛生上良いのでおすすめです。
Slack メモと同じ様な運用と思っていただいて構いません。
Append と Bottom 属性
#pin{append}
と入力
そのピンへのアクセス時、新しいノードを一番下に追加し、そこにフォーカスがあたります。チャットソフトの様に、アクセスしたらすぐに最新(一番下)の入力を開始できる様な仕組です。
前述の「アイデアの Inbox」にはこの属性を付与しています。
#pin{bottom}
と入力
そのピンへのアクセス時、一番下の子ノードにフォーカスがあたります。append
と異なり新しい要素は追加されません。
こちらは使用シーンがあまり見当たりませんが、更新は頻繁にしないが最新(一番下)の情報にすぐにアクセスしたい場合に有用かもしれません。
苦労した点
パフォーマンスの問題
初期描画の問題
基本動作が実装できた当時、10000 アイテムを描画するのに13000ms
の時間がかかりました。
10000 アイテムは多すぎるとしても、数百行程度の描画は普通に起こり得ます。これでは通常使用に耐えません。
13000ms -> 11000ms
配列から Map への変更 最初はアイテムの検索などを配列に対して行って([...array].find(item=>item.id===id)
)いました。配列から Map に変更することで検索速度が大幅に向上しました。(単純な計算だけ見ると 1000 倍、実アプリでは 1.2 倍ほど向上)
11000ms -> 6000ms
backlink 用の Map を用意 Twirliner では backlink に対応しています。
この backlink を取得するためにアイテム毎に全体検索を行っていましたが、初期描画時に(全体に対して 1 回)backlink の Map を作成し、アイテム毎の取得は Map に対して行う様に変更しました。こちらも充分な速度向上が実現できたので、backlink に限らずアイテム毎に取得する必要のある情報は個別で Map を作成するようにしました。
react-intersection-observerを導入 6000ms->500ms以内?
画面範囲外の行は初期描画しないようにしました。レンダリングのコストが一番高いので、この対策によって他の対策が霞むほど劇的に早くなりました
非常に効果的ですが他のパフォーマンス低下原因を隠してしまうことにもなるので、これだけに頼るのも良くないかもしれません。
再描画の問題
初期描画の問題はある程度解消されましたが、再描画時に問題が残っていました。
※初期描画では数 100ms 程度待たされても我慢できますが、更新のたびに待たされるのは苦痛です
React を代表とする仮想 DOM は、javascript のオブジェクトから DOM を作成する宣言的な UI 作成を実現しています。これは非常に優れた概念で、バグを生みにくく変更に強いシステム開発が可能であることは間違いありません。
しかし、アウトライナーのように多重で深く再描画が多い構造には不向きな点があると感じました。※私の知識が乏しいため、宣言的な書き方だけで実現できる可能性はあります。
ネストが深い要素を更新する場合を考えます。
Outliner コンポーネントでは ItemType
の配列を State として保持し、この配列からアウトライン構造を作成します。
type ItemType = { id: string; content: string; children: string[] }; // 他プロパティは省略
const [itemList, setItemList] = useState<ItemType[]>([
{ id: "0", content: "root", children: ["0-0", "0-1"] },
{ id: "0-0", content: "1階層目:0", children: ["0-0-0"] },
{ id: "0-0-0", content: "2階層目:0", children: ["0-0-0-0"] },
{ id: "0-0-0-0", content: "3階層目:0", children: ["0-0-0-0-0"] },
{ id: "0-0-0-0-0", content: "4階層目:0", children: [] },
{ id: "0-1", content: "1階層目:1", children: [""] },
]);
以下のような構造が作成されます。
- root(0)
- 1 階層目:0(0-0)
- 2 階層目:0(0-0-0)
- 3 階層目:0(0-0-0-0)
- 4 階層目:0(0-0-0-0-0)
- 1 階層目:1(0-1)
ここで4階層目:0(0-0-0-0-0)
の DOM を更新するとします。
Stete ではない配列の値を書き換えるだけであればsplice
等を使用して個別に変更をすれば問題ありません。
itemList.splice(4, 1, { id: "0-0-0-0-0", content: "更新", children: [] });
命令的な UI 操作であれば対象の要素をqueryselector
等で取得して削除や更新を行うため、再描画は対象の要素に限定できます。
しかし、React に状態の更新を通知するためにはオブジェクト自体を更新する必要があるため、配列全体の更新が必要となります。
setItemList(
itemList.map((item) => (item.id === "0-0-0-0-0" ? { ...item, content: "更新" } : item))
);
更新に従って React が DOM を更新しますが、配列全体を書き換えたので全体のノードの再描画が発生します。
React.memo
などよって抑制できる再描画もありますが、この例では抑制できません。
アウトライナーでは数百行以上の要素を何度も更新するため、操作するたびにラグが発生してしまいました。
この問題点を宣言的な仕組みのみで解決することができなかったため、handlerMap
という仕組みを導入しました。
各ノードのコンポーネントマウント時に、描画に使用する State の Setter を全体からアクセスできる Map に格納します。何らかの操作によって情報に更新があった場合は、handlerMap 経由で更新をトリガーすることで、特定のコンポーネントのステート更新を発火させます。
これによって再描画を更新要素のみに限定できたため、かなりのパフォーマンス向上を実現できました。React 本来の更新手順ではないため何らかの問題が発生する可能性はありますが、今のところ特に問題は発生していません。
宣言的な記述から外れるため、バグが入り込む大きな原因となり得ます。この様な方法を取るなら初めから命令的な仕組みで構築したほうが良いのかもしれません。
キーボードショートカットの割当
アウトライナー(に限らず生産性向上や創作ツール)は、如何にストレス無くスピーディーな操作ができるかが重要な要素です。キーボードショートカットを多数実装する様なツールを作ったのが初めてだったので、その割当に苦労しました。
手に馴染むキーボードショートカットは、ある程度操作とキーに意味の結びつきが必要です。
修練すれば問題ないかもしれませんが、そのツールだけ常に触っていられるわけではない
同じキーを使いたい操作(b
→bold
or bottom
)がある場合どちらを優先すればよいのか、何でもかんでも操作できるようしてもただ混乱するだけなのではないか、今回のツールレベルでも悩んだのでもっと複雑なツールの実装は本当に大変だと感じました。
ctrl
やalt
等の修飾キーを組み合わせればバリエーションは増えますが、英数字キーより意味が結びつきづらいのでより混乱を引き起こす可能性もあります
もうこれについては手に馴染ませるしか無い部分もあるので、ある程度の諦めとともに実装をしました。
ショートカット一覧
課題
正しい動作が保証できない
複数端末からの同時編集やそもそものデータ保存のためバックエンドの動作も作成しましたが、堅牢な実装ができなかったので現状はフロントで完結させています。
認証も Auth0 を利用して作成しましたが、本当に正しい動作なのか保証ができなかったので廃止しました。フロントの動作にもまだ不安があります。
データの整合性、データベース、認証に対する知識の圧倒的な欠如を感じました。
ロジックと JSX の分離
カスタムフックを積極的に導入しましたが、保守性を高めるための分離ができていません。
ただファイルの分割をしてみたという感じなので、結局コンポーネントに渡されたプロパティをそのまま Hook にも流し込む様になり、むしろファイルを行ったり来たりする複雑性を増すだけになってしまった部分があります。
プレゼンテーション・コンテナコンポーネントという分離もあまりできていないので、状態やロジックがコンポーネントと密に結びつき、再利用性や保守性が悪い状態になっています。
世の中のコンポーネント設計を参考にしてリファクタを続けていく予定です。
テスト
純粋な Typescript モジュールのテストはある程度実装できましたが、React コンポーネントや UI のテストは全く実装できていません。
Storybook や React Testing Library の概念も使用方法もわかりはするのですが、メンテナンスしていける自信がありません。
下手に導入をして、実ソースコードも UI テストもメンテナンスしていかなければならない状況になるくらいなら、実ソースコードに集中したほうが良いのではないか?と思っています。
※間違いだとはわかっているのですが
ただ、コンポーネントロジックのテストは流石に少しずつ導入しようと思っています。前述のロジックと JSX の分離が充分にできていないと難しそうなので、先にそちらを解決する予定です。
今後
記述ツールの新しい形
思考を邪魔しない記述ツール(テキストに限らない)の新しい形はないかと構想をしています。
Notion や Craft、RoamResearch を始めとした素晴らしいツールは様々あるのですが、私には難しすぎるのです。
使えと言われれば使えるのですが、できることが多すぎて情報自体に集中ができません。そうは言ってもシンプルすぎるツールはドキュメントを運用していくにはやや不便です。
この辺は「僕が考えた最強の〇〇」につながっていくので、早々に理想は砕け散るとは思いますが、ある程度あがいてみようと思います。
結局 Markdown で記述する Wiki ツールが最強となるのかもしれません。
アウトライナ × タスク管理
アウトライナ × タスク管理にはもう少し現実的な希望を感じています。
Asana、Backlog、Trello、ClickUp、Google の有象無象、色々なタスク管理ツールを使ってみたのですがどれもしっくりこず、3 年ほど前からはずっとアウトライナーでタスク管理をしています。
アウトライナー的操作感を保ったまま、タスクの取りこぼしを避け、スケジュール管理も有効に使えるツールの形はまだあるような気がしています。
タスク管理ツールとアウトライナーの比較
スケジュール管理
タスク管理ツール
- カレンダーとの連携、リマインダー、スヌーズ、ガントチャートなど
- タスクの時間的な管理が簡単に実現可能
アウトライナー
- スケジュールを追加する機能を持っているものもあるが、効果的には機能させづらい
タスクとそれ以外の情報の自由度
タスク管理ツール
- 基本的にタスクのみをプロジェクトに配置可能
- メモを記述するにはタスク内にモーダル遷移もしくはページ遷移する
- メモは決められた場所に記述する
アウトライナー
- 基本的にどんな情報をどう組み合わせるかが自由
- タスクとただの文章を並列に記述可能
- サブタスクも制限や遷移なし
タスクの取りこぼし
アウトライナー
- 前述の自由度が高いという特徴がゆえにタスクが埋もれるリスクがある
- チェックボックスを付けるかどうかは個人の自由
- ただの文章として書き起こしたタスクを何処かに収納したら、永久に日の目は見ない
タスク管理ツール
- 追加するものは全てタスクなので、見逃しは基本的に発生しない
モバイル版
バックエンドが堅牢に構築できたら、モバイル版も作成する予定です。
私はモバイルのツール操作をあまり信用していないので、モバイル版は機能を大幅に削ろうと考えています。
- 情報の検索と展開、収納だけができるツリー状の閲覧専用モード
- 新しい情報を特定のノードに追加するだけのチャットモード
最後に
コンセプトを満たした実装はできましたが、既存のツールを置き換える様な優位性は作り出せませんでした。世の中の開発者達はやっぱりすごいですね。
自分用に作成したツールなので使い続けて行きたいですが、モチベーション維持も含めた個人開発の難しさを実感しました。
技術、アイデア、リサーチその他知識の取得を頑張っていきます。
Discussion
Googleドライブとかで良いので、バックアップが取れたら(ローカルだとデータが消えるのが怖い)メインで使いたいぐらいな出来の良さに驚きました。