[Obsidianでオートコンプリートプラグインを作ってみた](https://blog.mamansoft.net/2021/02/14/create-auto-complete-plugin-for-obsidian/) ## 経緯 [[Obsidian]]では`Ctrl + Space`などによる補完機能がない。`[[..]]`の構文を使えば困る機会は少ないが、Noteとして定義したくないシーンもしばしばある。 ## アクティブドキュメントの内容を取得する `CodeMirror.Editor`の`getValue()`で取得できる。 ```ts const currentView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!currentView) { // Do nothing if the command is triggered outside a MarkdownView return; } const cmEditor: Editor = currentView.sourceMode.cmEditor; console.log(cmEditor.getValue()); ``` ## トークンへの分割 はじめは[[CodeMirror]]のトークン単位を使おうとしたが、日本語対応が弱いので断念。 フロントエンドのみで完結する日本語/英語の構文解析として[[TinySegmenter]]が良さそうなので、[オリジナルコード](http://chasen.org/~taku/software/TinySegmenter/tiny_segmenter-0.2.js)を[TypeScriptに対応したtiny-segmenter.ts](https://github.com/tadashi-aikawa/obsidian-various-complements-plugin/blob/bd74eddf1069740a0136036425fd5c6e3e677b54/tiny-segmenter.ts)に書き換えてimportする。 ### Token抽出ロジック 基本は[[TinySegmenter]]の素晴らしい解析に従っている。追加で微調整した部分を日本語コメントで補足する。 ```ts function pickTokens(cmEditor: Editor): string[] { return cmEditor .getValue() .split(`\n`) .flatMap<string>((x) => segmenter.segment(x)) // [, ], (, ), <, >, ", ', ` はノイズになるので除去 .map((x) => x.replace(/[\[\]()<>"'`]/, "")); } ``` bracketsの類いを除外しているだけだ。 ### CodeMirrorのトークンとTinySegmenterのトークン [[Obsidian]]ではカーソル配下のトークンを[[CodeMirror]]経由で取得できる。 ```ts // 現在のカーソル位置 const cursor = cmEditor.getCursor(); // カーソル配下のトークン const token = cmEditor.getTokenAt(cursor); ``` この情報は補完候補を決める際に利用する。しかし、CodeMirrorのトークンとTinySegmenterのトークンは単位が異なるため問題が発生する。 たとえば『Today いい天気です』の場合、以下のように差分が生じる。 ``` // TinySegmenter [Today, いい, 天気, です] // CodeMirror [Today, いい天気です] ``` この差分によって、補完候補に出るべき情報が表示されなかったり、補完候補を決定したときに変更される位置がずれてしまう。 #### 補完候補に出るべき情報が表示されない例 ``` Today いい天気ですob ``` ここで補完したら『Obsidian』が候補に出て欲しいが、**[[CodeMirror]]は『いい天気ですob』を1トークンとして捉える**ため『Obsidian』は補完候補に表示されない(部分一致していない)。 #### 対策 [[CodeMirror]]のTokenを[[TinySegmenter]]で更に解析し、最後の1トークンを現在のトークンとして扱う。 ``` // CodeMirror [Today, いい天気ですob] ↓ // いい天気ですob を TinySegmenter で更に解析 [いい, 天気, です, ob] ``` これで、最後の『ob』が現在のトークンとして扱われるため、『Obsidian』は補完候補に表示される。ソースコードだと下記のような感じ。 ```ts const cursor = cmEditor.getCursor(); // カーソル配下のトークン取得 const token = cmEditor.getTokenAt(cursor); if (!token.string) { return; } // カーソル配下のトークンをTinySegmenterで解析 const words = segmenter.segment(token.string); // 最後の1つを現在のトークンとしてword, 残りをwordsとする const word = words.pop(); // アクティブドキュメントのコンテンツに対してTinySegmenterで解析されたトークンを取得 const tokens = pickTokens(cmEditor); // 現在のトークンと比較して最適な候補を選出 const suggestedTokens = selectSuggestedTokens(tokens, word); ``` #### 補完候補を決定したときに変更される位置がずれる例 先ほどの対策には副作用がある。補完候補を決定したとき挿入位置がずれてしまう。 ``` Today いい天気ですob ↓ 『Obsidian』で決定すると.. Today Obsidian ``` これは[[CodeMirror]]から見た現在のトークンは『いい天気ですob』であるため、ここを置き換えようとするからだ。 #### 対策 [[CodeMirror]]に『いい天気ですob』ではなく『ob』が置き換え対象であると伝える必要がある。そのために[[CodeMirror]]が置き換えるトークンの先頭をずらしてあげればいい。 ``` // いい天気ですob を TinySegmenter で更に解析 [いい, 天気, です, ob] ``` ずれは『いい』『天気』『です』の6文字分。つまり、`CodeMirrorから見た現在のトークン長 - TinySegmenterから見た現在のトークン長`になる。これをコードで表現すると以下の通り。 ```ts const cursor = cmEditor.getCursor(); const token = cmEditor.getTokenAt(cursor); if (!token.string) { return; } const words = segmenter.segment(token.string); const word = words.pop(); // 追加: `CodeMirrorから見た現在のトークン長 - TinySegmenterから見た現在のトークン長` const restWordsLength = words.reduce( (t: number, x: string) => t + x.length, 0 ); const tokens = pickTokens(cmEditor); const suggestedTokens = selectSuggestedTokens(tokens, word); return { list: suggestedTokens, // トークンの開始位置をrestWordsLengthだけずらす from: CodeMirror.Pos(cursor.line, token.start + restWordsLength), to: CodeMirror.Pos(cursor.line, cursor.ch), }; ``` ### ソート順 ソートロジックは`selectSuggestedTokens`で決めている。日本語でコメントを添えて説明したコードが以下。 ```ts function selectSuggestedTokens(tokens: string[], word: string) { return Array.from(new Set(tokens)) // 現在のトークンと同じワードは除外 (常に表示されるのでノイズ) .filter((x) => x !== word) // 小文字に統一して部分一致するもののみ対象に残す .filter((x) => lowerIncludes(x, word)) // 文字列が短いものをより優先する .sort((a, b) => a.length - b.length) // 前方一致のものをより優先する .sort( (a, b) => Number(lowerStartsWith(b, word)) - Number(lowerStartsWith(a, word)) ) // 候補の表示は5つまでとする .slice(0, 5); } ``` 各設定はユーザーの好みによってGOOD/BADは分かれると思う。そこは必要に応じて今後設定できるようにすればいい。少なくとも私はこの設定で直感的な補完ができていたと思う。 ## 補完ウィンドウを表示する [[CodeMirror]]の[[show-hint]]プラグインを使う。 [オリジナルのコード](https://codemirror.net/addon/hint/show-hint.js)を一部変更して[TypeScript用のshow-hint.ts](https://github.com/tadashi-aikawa/obsidian-various-complements-plugin/blob/bd74eddf1069740a0136036425fd5c6e3e677b54/show-hint.ts)を作り、それをimportする。 [[CodeMirror]]インスタンスに対してprototype拡張していくので冒頭に以下のコード。 ```ts var CodeMirror: any = window.CodeMirror; import "./show-hint"; ``` そして`showHint`を呼び出す。 ```ts const currentView = this.app.workspace.getActiveViewOfType(MarkdownView); if (!currentView) { // Do nothing if the command is triggered outside a MarkdownView return; } const cmEditor: Editor = currentView.sourceMode.cmEditor; CodeMirror.showHint( cmEditor, () => { // 中略。。 return { list: suggestedTokens, from: CodeMirror.Pos(cursor.line, token.start + restWordsLength), to: CodeMirror.Pos(cursor.line, cursor.ch), }; } ); } ``` しかし、これだけではUIに表示されない。これは[[CSS]]の問題であるため`styles.css`に設定を追加する。 ```css .CodeMirror-hints { position: absolute; background-color: var(--background-primary); border: 2px solid var(--background-primary-alt); list-style: none; padding-left: 0; } .CodeMirror-hint { padding: 5px; } .CodeMirror-hint-active { background-color: var(--tooltip-bg); } ```