アウトライナー(Dynalist)と仲良くなったという話。最後はChrome拡張機能の活用について。
- アウトライナー(Dynalist)と仲良くなった
- ファイル・フォルダ機能と仲良くなった(Dynalist)
- ノート機能と仲良くなった(Dynalist)
- タグ機能と仲良くなった(Dynalist)
- ブックマークレットを活用する(Dynalist)
前回同様、Dynalistが備えている機能の話ではない。Webブラウザの拡張機能を自分で作り、Dynalistのページ上で実行することで自分が欲しい機能を実現しよう、という試みである。
必然的に実践にはJavaScriptの知識が必要となってしまうが、未経験の人もJavaScriptで何ができるかのイメージを持てれば今後の可能性が広がると思うので、よかったら参考にしてほしい。
Chrome拡張機能は自分で開発できる
Chrome拡張機能というのは普通は「ストアに行って欲しいものを検索してインストールするもの」だろう。ストアには多種多様の無数の拡張機能が公開されており、Chromiumベースのブラウザを使う人ならほとんどが何かをインストールして使っているのではないだろうか。
誰かすごいプログラマーが作ってくれるすごいもの、というイメージの強いChrome拡張機能だが、実は簡単に開発することができる。ストアに公開するのは色々と満たさなければならない条件があって大変だが、自分だけが使う分には自由に作って自由に実行できる。ローカルの任意のフォルダ内に必要なファイルを用意し、ブラウザの拡張機能ページで「開発者モード」をオンにしてフォルダを指定するだけだ。言語はJavaScript以外には必要ない。
簡単と言っても理解のために格闘しなければならないことは色々あるので、JavaScriptでのコーディングにあまり不自由しなくなってきたくらいの段階で挑んでみると良いのではないかと思う*1。(何事も苦労はある。)
Dynalistを快適な文章用テキストエディタにする
簡単に作れるはいいとして、じゃあ何を作るのか。「Chrome拡張機能でできること」は多分ほとんど無限の広がりになってしまうので、例によって今回は私が実際にやっていることだけ書いておくことにする。*2
Dynalistに於いてChrome拡張機能で実現していることは、一言でいうと「Dynalistを快適な文章用テキストエディタにする」ということだ。アウトラインの整理・操作に留まらず、本文までDynalist上で書いてしまうために、不足を感じる要素を自分で書いたプログラムで補ってしまおうということである。(なおDynalistでブログ記事を書くということについては以前トンネルChannelに書いたことがある*3。)
現時点で実現している機能は以下の三つである。
- 文字数カウント
- 疑似2ペイン(編集はできないが表示はできるサイドパネルの設置)
- Blogger用HTMLの生成
Dynalistで本文を書こうとした時、気になるのは「文字数がひと目でわからない」「1ペインしかないのでアウトラインを確認しようとするとスクロールが必要になる」ということだ。まあこの不便さは致命的なものではないので我慢できることではあるのだが、解消できるならするに越したことはない。
また、このブログに投稿する際、ブログのエディタ画面で画像の指定や見出しの調整などをやっていたのだが、決まった設定を毎度ぽちぽちやるのが面倒に感じたので、もうDynalistに書いたものをその場でHTMLにしてしまうことにした。(BloggerはHTMLモードで記事を作ることができる。)
これらの機能を追加した状態(+CSSを書き換えた状態)のスクリーンショットを撮るとこうなる。右のエリアが追加した領域。
説明を加えるとこんな感じ。
実例
具体的にどうやっているのかの話もしたいが、全部の話をすると大変なことになるので、今回見せるコードは文字数カウントの部分だけにしておく。他の二つはまだ改良中で決定版に至っていないということもある。
Chrome拡張機能の実行方法はいくつかあり、「特定のページで自動で実行する」「アドレスバー横にアイコンを表示してアイコンクリック時に実行する」「htmlファイルを用意し、ブラウザ起動時にそのページを開いて実行する」といった形がある。今回はDynalistを開くと自動で実行する形で作る。
この場合、必要なファイルは実行するコードを書いたjsファイルと「manifest.json」という設定ファイルの二つ。アイコンを作らないので画像は要らないし、ページを作って開くこともないのでhtmlファイルも要らない。
jsファイルのコードはこんな感じ。シェアしてはいるがあくまでこれは私個人用のものなので、コードの出来がおかしくても気にするべからず。なおファイル名は「counter.js」とする。
const PREFIX = 'my-counter'; // 既存のid、classと重複しないように | |
const IS_COMMENT = 'is-comment'; | |
/** 文字列を保存するオブジェクト */ | |
const text = { | |
whole: '', | |
active: '', | |
body: '', | |
} | |
/** | |
* 文字数カウントを表示する要素を生成または取得する | |
* @param {string} id | |
* @returns {HTMLDivElement} | |
*/ | |
const getArea = id => { | |
const prefixedId = `${PREFIX}-${id}`; | |
// 存在していなければ作る | |
if (!document.getElementById(prefixedId)) { | |
const elm = document.body.appendChild(document.createElement('div')); | |
elm.id = prefixedId; | |
elm.className = `${PREFIX} ${prefixedId}`; | |
// クリックで内容をクリップボードにコピーする | |
elm.title = 'クリックで文字列をコピー'; | |
elm.addEventListener('click', () => { | |
const txt = text[id].replace(/\n\n/g, '\n'); | |
navigator.clipboard.writeText(txt) | |
.then(() => { | |
// コピーできた時 | |
console.group('クリップボードにコピーしました。'); | |
console.log(txt.length < 100 ? txt : txt.slice(0, 100) + '…'); | |
console.groupEnd(); | |
alert('クリップボードにコピーしました。'); | |
}) | |
.catch(err => { | |
// エラーが発生してコピーに失敗した時 | |
console.log('コピーできませんでした。', err); | |
alert('コピーできませんでした。') | |
}) | |
}) | |
return elm; | |
} else { | |
return document.getElementById(prefixedId); | |
} | |
} | |
// 「hidden」classにdisplay:none;を設定しておき(下部に記述してある)、classの付け外しで表示非表示を切り替えられるようにした関数 | |
const hide = elm => elm.classList.add('hidden'); | |
const show = elm => elm.classList.remove('hidden'); | |
/** | |
* ノードの要素を指定し、子孫項目も含めた本文全体を取得する | |
* @param {HTMLDivElement} parent 自身を含めるなら`.Node`か`.Node-outer`の要素、子孫部分だけなら`.Node-children`の要素 | |
*/ | |
function getText(parent) { | |
const result = []; | |
const textElms = parent.querySelectorAll('.node-line.needsclick'); | |
textElms.forEach(elm => { | |
result.push(elm.textContent); | |
}) | |
return result.join('\n'); | |
} | |
const SECOND = 1; // 何秒ごとに実行するか | |
setInterval(() => { | |
// 変数名が重複してもいいように機能ごとに即時関数で包んで実行 | |
// 表示されている領域全体 | |
(() => { | |
const area = getArea('whole'); | |
// 開いているファイルまたはズームしているノードの子項目が入っている要素を取得 | |
const parent = document.getElementsByClassName('Node-children')[0]; | |
if (parent && parent.innerText) { | |
const txt = getText(parent); | |
text.whole = txt; | |
area.textContent = txt.length; | |
} | |
})(); | |
// 編集中の行 | |
(() => { | |
const area = getArea('active'); | |
const focus = document.activeElement.parentElement; // フォーカスしている要素(の親)を取得 | |
if (focus.classList.contains('node-line')) { // それがいずれかのノードの時 | |
show(area); | |
const txt = getText(focus); | |
text.active = txt; | |
area.textContent = txt.length; | |
} else { | |
hide(area); | |
text.active = ''; | |
area.textContent = ''; | |
} | |
})(); | |
// 本文部分(「🖋」という項目の子孫項目を本文とする) | |
(() => { | |
const area = getArea('body'); | |
const regexp = /^🖋\s?$/; | |
const nodes = document.getElementsByClassName('Node'); | |
// 「🖋」という項目を探す | |
const parent = Array.from(nodes).find(elm => elm.querySelector('.Node-content').innerText.match(regexp)); | |
if (parent) { | |
show(area); | |
const txt = getText(parent.querySelector('.Node-children')); | |
const innerText = txt.replace(/(^|\n)\/\/[^\n]*(\n|$)/g, '\n'); // 「//」はじまりの項目は除外する | |
text.body = innerText; | |
area.textContent = innerText.length; | |
} else { | |
hide(area); | |
text.body = ''; | |
area.textContent = ''; | |
} | |
})(); | |
// 「//」はじまりのノードにコメントアウト風スタイルを反映 | |
const lines = document.querySelectorAll('.node-line'); | |
lines.forEach(elm => { | |
if (elm.textContent.startsWith('//')) { | |
elm.classList.add(IS_COMMENT); | |
} else { | |
elm.classList.remove(IS_COMMENT); | |
} | |
}) | |
}, 1000 * SECOND); | |
// CSSを追加 | |
const style = document.body.appendChild(document.createElement('style')); | |
style.id = `${PREFIX}-style`; | |
style.textContent = ` | |
.${PREFIX} { | |
position: fixed; | |
right: 12px; | |
padding: 2px; | |
border-radius: 5px; | |
z-index: 1000; | |
color: #fff; | |
font-family: "UD デジタル 教科書体 NK-R", "MotoyaLMaru W3 mono","Roboto",Helvetica,Arial,"Hiragino Sans",sans-serif; | |
font-weight: 900; | |
cursor: pointer; | |
} | |
.${PREFIX}:after { | |
content: '字'; | |
} | |
.${PREFIX}-whole { | |
background-color: cornflowerblue; | |
top: 34px; | |
} | |
.${PREFIX}-active { | |
background-color: lightseagreen; | |
top: 62px; | |
} | |
.${PREFIX}-body { | |
background-color: red; | |
top: 90px; | |
} | |
.hidden { | |
display: none; | |
} | |
.${IS_COMMENT} span { | |
color: #bbb; | |
font-size: 80%; | |
} | |
` |
次にmanifest.json。作る機能によって与える権限が変わるのでそれに応じて記述を変える必要がある。今回はどのURLの時にどのファイルを実行するかを指定するだけで良いのでシンプルなものになっている。
{ | |
"name": "Text Counter of Dynalist", | |
"version": "1.0", | |
"manifest_version": 3, | |
"content_scripts": [ | |
{ | |
"matches": [ | |
"https://dynalist.io/d/*" | |
], | |
"js": [ | |
"counter.js" | |
] | |
} | |
] | |
} |
この二つを同じフォルダに入れ、ChromiumベースのWebブラウザ(ChromeとかEdgeとか)の拡張機能ページで開発者モードをオンにしてフォルダを読み込み、Dynalistをリロードすると機能を使えるようになる(はず)。
こんな感じで必要な機能を勝手にDynalistに追加し、Dynalistの利便性をブーストしている。Dynalist API*4も活用すれば可能性は無限に思える。
やれそうなことを想像し始めたらキリがないので、時間と労力を空費しないためにも「できそう」かどうかではなく今現実に「必要」かどうかで考えることにして、これさえあればと強く思うものを自分で補えるとツールの使用感は随分変わってくるのではないかと思う。
*1: やってやろうじゃんという人はChrome Extension development basics - Chrome for Developersへ。検索すれば試行錯誤を日本語で説明してくれている記事もいろいろ見つかる。
*2: 今回の例に関しては、ブックマークレットでも実行可能なコードであり、Chrome拡張機能でなければできないというわけではない。ただ、ブックマークレットにはブラウザによって文字数制限があること、文字数制限回避のための外部スクリプト読み込みの手法が禁じられているサイトがあること、コードをブックマークレット化する手間がかかること、実行は手動になることなどから、Chrome拡張機能を使った方がより自由度が高くメンテナンスも簡単になると言える。