0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

漢字化エスペラント入力環境をVS Code拡張とMonaco Editorで実装した話

Posted at

はじめに

bon と入力すると が候補に出る」——そんな独自の入力システム「漢字化エスペラント」をブラウザでも使えるようにした開発記です。

VS Code拡張機能とMonaco Editorを使って、2,864件のスニペット辞書を搭載した入力環境を構築し、その過程で遭遇した技術的な問題とその解決策を詳しく解説します。

この記事で得られる知識

  • VS Code Web拡張の作り方(Node.js APIなしでブラウザ動作)
  • Monaco Editorでのカスタム補完プロバイダ実装
  • Monaco EditorのprovideCompletionItemsの落とし穴とその回避方法
  • Open VSXへの拡張公開手順
  • 大規模辞書の分割と遅延読み込みの実装

TL;DR(3行で)

  • エスペラント語根を漢字に変換する独自入力システム(辞書2,864件)
  • VS Code拡張に加え、Monaco版のWebデモ(PWA対応)を公開
  • Monacoは仕様上「候補を一度閉じて再表示」する実装で正確さを担保

目次

  1. 漢字化エスペラントとは
  2. 実現したかったこと
  3. VS Code拡張機能の実装
  4. Monaco Editorによるオンライン版
  5. 最大の問題:候補が正しく絞り込まれない
  6. 解決策:Monaco Editorの仕様を理解する
  7. ローカル環境との徹底比較
  8. 成果物とデモ
  9. 使い始めガイド
  10. PWA(オフライン対応)
  11. 辞書の作り方(.ke.txt → all.json → data/)
  12. GitHub Pages で公開
  13. トラブルシューティング
  14. 今後の課題

漢字化エスペラントとは

漢字化エスペラントは、エスペラント語の語根を漢字1文字で表記する独自の表記法です。

変換例

エスペラント語根 漢字 意味
bon 良い
pli より
am 愛する
sun 太陽

入力イメージ

ユーザーが bon と入力
↓
候補ウィジェットに「bon → 好」と表示
↓
Enterで確定すると「好」が挿入される

これをVS Codeとブラウザで快適に入力できる環境を構築しました。


実現したかったこと

ゴール

既にローカル環境(.ke.txtファイル)で動いている快適な入力環境を、以下の環境すべてで使えるようにする:

  1. VS Code Desktop(Windows/macOS/Linux)
  2. ブラウザ版VS Code(vscode.dev / github.dev)
  3. Monaco Editorベースの独自Webアプリ

技術要件

  • Web拡張対応:Node.js APIを使わず、ブラウザでも動作
  • 決定性:同じ入力に対して常に同じ候補
  • 即座のサジェスト:Backspace後も候補を自動表示
  • 大規模辞書:2,864件のスニペットを高速に処理

VS Code拡張機能の実装

基本構成

kanji-esperanto/
├─ package.json              # 拡張のメタデータ
├─ language-configuration.json
├─ snippets/
│   └─ kanji-esperanto.json  # 2,864件の辞書
├─ src/web/extension.ts      # Web拡張のコード
└─ scripts/
    ├─ build.mjs             # esbuildでバンドル
    └─ sync-snippets.mjs     # スニペット同期

言語定義

language-configuration.json
{
  "wordPattern": "([a-zA-Z]+)|([\\u3400-\\u9fff々〻]+)"
}

ポイント

  • ASCII語根(bon)と漢字()を単語として認識
  • これにより 更bon のように漢字直後に語根を入力しても正しく認識される

エディタ設定

package.json(抜粋)
{
  "configurationDefaults": {
    "[kanji-esperanto]": {
      "editor.wordBasedSuggestions": "off",
      "editor.suggest.showWords": false,
      "editor.snippetSuggestions": "top",
      "editor.quickSuggestionsDelay": 10
    }
  }
}

重要な設定

  • wordBasedSuggestions: "off":文書内の単語候補を無効化
  • snippetSuggestions: "top":スニペットを最優先表示
  • quickSuggestionsDelay: 1010msで即座に候補表示(快適性の要)

Backspace後の自動サジェスト

src/web/extension.ts
import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
  const deleteLeft = vscode.commands.registerCommand(
    'kanji-esperanto.deleteLeftAndSuggest',
    async () => {
      await vscode.commands.executeCommand('deleteLeft');
      await vscode.commands.executeCommand('editor.action.triggerSuggest');
    }
  );

  context.subscriptions.push(deleteLeft);
}

キーバインド

{
  "key": "backspace",
  "command": "kanji-esperanto.deleteLeftAndSuggest",
  "when": "editorTextFocus && !suggestWidgetVisible && editorLangId == 'kanji-esperanto'"
}

なぜこれが必要か
VS Codeのデフォルトでは、Backspaceで文字を削除しても候補ウィジェットは自動表示されません。この実装により、削除後も即座に候補が表示され、快適な編集体験が実現します。

ビルドとパッケージング

scripts/build.mjs
import esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/web/extension.ts'],
  bundle: true,
  format: 'cjs',
  platform: 'browser',  // ← ブラウザ環境向け
  outfile: 'dist/web/extension.js',
  external: ['vscode'],
  target: ['es2020']
});
npm run build
npx vsce package --target web

Open VSXへの公開

npx ovsx publish -p esperantokanjika -t <token> --target web

公開結果


Monaco Editorによるオンライン版

構成

ke-monaco-site/
├─ index.html         # UIとMonacoローダ
├─ app.js             # 補完ロジック
└─ data/
    ├─ ke-a.json      # aで始まる語根
    ├─ ke-b.json
    └─ ... (全26文字分)
URL 例:
- Monaco: https://takatakatake.github.io/ke-monaco-site/
(初回から決定的にするには `?strict=1` を付与)

strict=1 とは?

  • 初回アクセス時に A〜Z 26個の辞書バケツを先読み(preload)してから補完プロバイダを登録します。
  • 利点: 初回の数打鍵でも候補が確実に出る(ネットワーク遅延やキャッシュ未ヒットの揺らぎを排除)。
  • トレードオフ: 初回の読み込みがやや重くなります(その後はキャッシュで軽快)。

辞書の分割

2,864件の辞書を26ファイルに分割して遅延読み込み:

tools/split-dictionary.mjs
import fs from 'node:fs/promises';

const src = JSON.parse(await fs.readFile('all.json', 'utf8'));
const buckets = {};

for (const item of src.items) {
  const key = (item.prefix?.[0] || '#').toLowerCase();
  (buckets[key] ||= []).push(item);
}

for (const [key, arr] of Object.entries(buckets)) {
  arr.sort((a, b) => a.prefix.localeCompare(b.prefix));
  await fs.writeFile(
    `data/ke-${key}.json`,
    JSON.stringify({ items: arr }, null, 2)
  );
}

Monaco Editorのセットアップ

index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <title>Kanji Esperanto Editor</title>
  <script>
    // Self-contained worker (CORS回避)
    window.MonacoEnvironment = {
      getWorkerUrl: function () {
        return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
          self.MonacoEnvironment = {
            baseUrl: 'https://unpkg.com/monaco-editor@0.52.0/min/'
          };
          importScripts('https://unpkg.com/monaco-editor@0.52.0/min/vs/base/worker/workerMain.js');
        `)}`;
      }
    };
  </script>
  <script src="https://unpkg.com/monaco-editor@0.52.0/min/vs/loader.js"></script>
</head>
<body>
  <div id="editor"></div>
  <script src="./app.js"></script>
</body>
</html>

初期実装(問題あり版)

app.js(初期版)
monaco.languages.registerCompletionItemProvider('kanji-esperanto', {
  triggerCharacters: 'abcdefghijklmnopqrstuvwxyz'.split(''),
  provideCompletionItems: async (model, position) => {
    const line = model.getLineContent(position.lineNumber);
    const col = position.column - 1;
    const textBeforeCursor = line.slice(0, col);
    const match = textBeforeCursor.match(/[A-Za-z]+$/);

    if (!match) return { suggestions: [] };

    const prefix = match[0];
    const bucket = await loadBucket(prefix[0]);

    const items = bucket
      .filter(s => s.prefix && s.prefix.startsWith(prefix))
      .map(s => ({
        label: s.prefix + '' + s.body,
        kind: monaco.languages.CompletionItemKind.Snippet,
        insertText: s.body,
        sortText: '0' + s.prefix
      }));

    return { suggestions: items };
  }
});

// Backspace後にサジェスト再表示
editor.onKeyDown((e) => {
  if (e.keyCode === monaco.KeyCode.Backspace) {
    setTimeout(() => {
      editor.trigger('ke', 'editor.action.triggerSuggest', {});
    }, 0);
  }
});

最大の問題:候補が正しく絞り込まれない

発生した症状

ユーザーからのフィードバック:

「pliと打っているのに、pilに反応するのはなぜか?既にスペースキーを打っているのに候補がで続けるのはおかしくないですか?」

具体的な問題

  1. pli と入力しているのに、候補に pil → 球 が表示される
  2. papili → 蝶piedestal → 座台 など、pli で始まらない候補も表示される
  3. スペースを打った後も候補が残り続ける
  4. 次の単語(例:bon)の候補がうまく出ない

デバッグの過程

スクリーンショットを見ると、pli と入力しているのに以下の候補が表示されていた:

pil → 球        ← なぜこれが先頭?
papili → 蝶
piedestal → 座台

これらはすべて p で始まるが、pli で始まってはいない

原因の特定

フィルタリングロジック自体は正しい:

.filter(s => s.prefix && s.prefix.startsWith(prefix))

しかし、prefix"pli" ではなく "p" のままになっていることが判明。

つまり:

  1. p を入力 → provideCompletionItems(prefix="p") が呼ばれる
  2. l を入力 → triggerSuggest() を呼ぶが...?
  3. i を入力 → triggerSuggest() を呼ぶが...?

候補が "p" のまま更新されない!


解決策:Monaco Editorの仕様を理解する

Monaco Editorの内部仕様

Monaco Editorの SuggestController には、パフォーマンス最適化のための仕様がある:

重要な仕様
補完ウィジェットが既に表示されている状態では、triggerSuggest() を呼んでも provideCompletionItems が再実行されない

これにより:

✅ メリット:不要な再計算を防ぎ、パフォーマンスが向上
❌ デメリット:文字入力で候補を絞り込むには手動実装が必要

VS Code標準snippet機能との違い

実装方式 候補の更新 手動実装の必要性
VS Code標準snippet 文字入力で自動的に絞り込まれる 不要
Monaco CompletionProvider 手動で閉じる→開くが必要 必須

VS Code拡張では標準snippet機能を使っているため、この問題は発生しなかった。

最終的な修正

app.js(修正版)
// hideSuggest関数
function hideSuggest() {
  try {
    editor.trigger('ke', 'hideSuggestWidget', {});
  } catch {}
  try {
    const c = editor.getContribution('editor.contrib.suggestController');
    if (c && typeof c.cancel === 'function') c.cancel();
  } catch {}
}

// 文字入力ごとに候補を閉じてから再度開く
editor.onDidType((text) => {
  // スペース入力時は即座に閉じて終了
  if (/^\s$/.test(text)) {
    hideSuggest();
    return;
  }

  // a-z以外は閉じる
  if (!/^[a-z]$/i.test(text)) {
    hideSuggest();
    return;
  }

  // a-z入力時:候補を閉じてから再表示
  try {
    const model = editor.getModel();
    const pos = editor.getPosition();
    const col0 = pos.column - 1;
    const line = model.getLineContent(pos.lineNumber);
    const prefix = extractAsciiPrefix(line, col0);

    if (!prefix) {
      hideSuggest();
      return;
    }

    const maybe = loadBucket(prefix[0]);
    Promise.resolve(maybe).then(() => {
      hideSuggest();  // ← 重要:一度閉じる
      setTimeout(() => {
        editor.trigger('ke', 'editor.action.triggerSuggest', {});
      }, 10);  // ← 10ms後に再度開く
    });
  } catch {
    hideSuggest();
    setTimeout(() => {
      editor.trigger('ke', 'editor.action.triggerSuggest', {});
    }, 10);
  }
});

修正のポイント

  1. 文字入力ごとに hideSuggest() を呼ぶ
    → Monaco Editorが「新しい補完リクエスト」として認識
  2. 10msの遅延を設ける
    → 閉じる処理が完了してから再度開く
  3. スペース入力時は閉じて終了
    → 次の単語入力に備える

動作フローの比較

修正前

1. 'p' 入力 → provideCompletionItems(prefix="p") → 候補表示
2. 'l' 入力 → triggerSuggest() → ❌ 無視される
3. 'i' 入力 → triggerSuggest() → ❌ 無視される
→ 結果:pil, papili, piedestal がそのまま表示

修正後

1. 'p' 入力 → provideCompletionItems(prefix="p") → 候補表示
2. 'l' 入力 → hideSuggest() → 10ms後 → triggerSuggest()
   → ✅ provideCompletionItems(prefix="pl") 再実行
3. 'i' 入力 → hideSuggest() → 10ms後 → triggerSuggest()
   → ✅ provideCompletionItems(prefix="pli") 再実行
→ 結果:pli のみ表示

ユーザーの反応

「なんか、うまく行った気がします。一体どうして???」


ローカル環境との徹底比較

実装方式の違い

項目 ローカル(VS Code) Monaco Editor
実装方式 標準snippet機能 CompletionProvider
候補の更新 自動絞り込み 手動で閉じる→開く
onDidType処理 不要 必須
スペース混じり語根の正規化 VS Codeの単語認識に依存 現状: 未実装(ASCII連続のみ)。必要なら正規化を実装
UX スムーズ やや雑に感じる可能性

UXの違い

側面 ローカル Monaco版
候補の表示 スムーズに絞り込まれる 一瞬消えて再表示(ちらつき)
レスポンス 即座 10ms遅延(体感では問題なし)
正確性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐(修正後)
視覚的快適さ ⭐⭐⭐⭐⭐ ⭐⭐⭐

ちらつきの原因

  • Monaco Editorの制約により、候補を閉じる→開く処理が必要
  • この処理中、ユーザーには候補が一瞬消えて見える
  • しかし、正確な候補を表示するためには避けられない

成果物とデモ

VS Code拡張

対応環境

  • ✅ VS Code Desktop
  • ✅ vscode.dev
  • ✅ github.dev
  • ✅ VSCodium

Monaco Editor版

特徴

  • URL一発で使える(?strict=1 で初回から決定的)
  • 初期テキストは2行の見本文(localStorage 保存があると初期文は表示されない)
  • オフライン動作(Service Worker による事前キャッシュ)
  • 自動保存(localStorage)/簡易履歴(最大50)

使い始めガイド

  • アクセス
    • https://takatakatake.github.io/ke-monaco-site/
    • 初回から決定的: ?strict=1 を付与
    • strict=1 は「初回に辞書を全先読みしてから補完を有効化」するモードです(決定的ですが初回が少し重くなります)
  • 初期テキスト(2行)について
    • 既に localStorage に保存がある場合は初期文は表示されません
    • クリア操作: Ctrl+Alt+Backspace
  • 候補の操作
    • 通常入力で自動表示、Ctrl+Space で強制表示
    • Backspace/Delete 後は自動で再表示
  • 履歴の操作(最大50件)
    • 直前のスナップショットを復元: Ctrl+Alt+R

PWA(オフライン対応)

  • ルートに manifest.webmanifestsw.js を配置。初回アクセス時に主要アセットを事前キャッシュします
  • 事前キャッシュの例
    • index.html, app.js, all.json, data/ke-*.json, Monaco の最小依存
  • フォーク/派生で公開パスが変わる場合(例: /ke-monaco-site_me/)は manifest.webmanifeststart_urlscope を必ず変更
  • 配信更新時のコツ
    • Service Worker のキャッシュ名(例: ke-site-v3)を上げる
    • クライアント側は DevTools → Application → Service Workers で Unregister、Clear storage → ハードリロード

辞書の作り方(.ke.txt → all.json → data/)

  1. .ke.txt から all.json を生成
node tools/ke-txt-to-all.mjs /path/to/dictionary.ke.txt ./all.json
  1. all.json を26バケツに分割(先頭文字ごと)
node tools/split-dictionary.mjs ./all.json ./data

件数の目安: 本記事の辞書は合計 2,864 件です。


GitHub Pages で公開

  • リポに .github/workflows/pages.yml を同梱(actions/upload-pages-artifact + deploy-pages)
  • Settings → Actions → General
    • Actions permissions: Allow all actions
    • Workflow permissions: Read and write permissions(必須)
  • Settings → Pages → Source: GitHub Actions を選択
  • 失敗時の再実行
    • Actions タブで Re-run all jobs、または空コミットで再トリガー
    • 代替: 静的サイトなら「Deploy from a branch(main /root)」でも公開可能

トラブルシューティング

  • 「初期文が“更bon …”のまま」
    • localStorage をクリア(Ctrl+Alt+Backspace
    • それでも変わらない場合は SW を Unregister → Clear storage → ハードリロード
  • 「候補が出ない/古い」
    • ?strict=1 を付与、辞書の先頭文字バケツの配信可否を確認
    • ネットワーク失敗時は app.js が再試行・フォールバックする実装
  • 「fork で PWA が動かない」
    • manifest.webmanifeststart_url/scope を公開パスに合わせる
    • SW のキャッシュ名を更新後、クライアント側で SW を再登録

今後の課題

技術的な改善

  1. ちらつきの軽減

    • 遅延時間の調整(10ms → 5ms)
    • CSSアニメーションでの滑らか表示
  2. パフォーマンス最適化

    • 2文字×26×26の二段バケツ化
    • Trieデータ構造の導入

機能拡張

  1. 逆引き機能

    • 漢字 → 語根の候補表示
    • 例: と入力 → bon が候補に
  2. PWA化(実装済)

    • 本文「PWA(オフライン対応)」節を参照
  3. Microsoft Marketplace公開

    • VS Codeの拡張検索に表示
    • 自動更新の有効化

まとめ

学んだこと

  1. Monaco Editorの補完システムの仕様

    • 既に候補が表示されている状態では triggerSuggest() が無視される
    • 候補を更新するには「閉じる→開く」が必要
  2. VS Code標準snippet機能との違い

    • 標準機能は自動で候補を絞り込む
    • CompletionProviderは手動実装が必要
  3. Web拡張の作り方

    • Node.js APIを避け、ブラウザ互換のコードに
    • platform: 'browser' でビルド

技術スタック

  • VS Code Extension API (Web拡張対応)
  • Monaco Editor 0.52.0
  • esbuild (バンドル)
  • GitHub Pages (ホスティング)
  • PWA (Service Worker + Web Manifest)
  • Open VSX (拡張配布)

公開リポジトリ


参考資料


対象読者

  • VS Code拡張を作りたい人
  • Monaco Editorで独自補完を実装したい人
  • 大規模辞書を扱うWebアプリを作りたい人
  • ブラウザで動くエディタ拡張に興味がある人
0
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?