[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);
}
```