MonacoがSafariで嘘をついた日 ── 日本語IME専用のエディタがなぜなかったのか~砂紋が育つビジュアルエンジンの設計まで

はじめに
日本語でMarkdownを書くとき、あなたはどのくらいの頻度で 半角/全角キーを押していますか?
見出し を入力したい。ここは **太字** にしたい。そのたびに、IMEをオフにしてマークダウン用の記号を入力し、またIMEをオンに戻し日本語で内容を書く。面倒くさくないですか? この切り替えこそが、日本語でMarkdownを書く際の最大の摩擦です。
Notionで記事を書いていても、Obsidianでメモを取っていても、ZennやQiitaで下書きをしていても、この問題からは逃げられません。既存のエディタはほぼすべて英語ファーストで設計されています。「日本語入力のまま、Markdownも書ける」という体験を提供しているものは調べた限り皆無でした。
ならば作ろう。 それが suna.md の出発点です。
この記事では、コンセプト策定からtextareaプロトタイプ、Monaco EditorとCodeMirror 6の比較・採用・挫折・再移行、数々のバグとの戦い、そして「砂紋が書くほど育つ」ビジュアルエンジン SamonEngine の設計まで、現在進行形の開発の全経緯を記録します。
- URL: https://8avo.com/suna/
- 技術スタック: React + TypeScript + Vite + CodeMirror 6 + Dexie.js (IndexedDB) + PWA
プロダクトの哲学 ── なぜ「砂」なのか
マークダウン記号、そして英字キーボードを見ると、マークダウンはいかに英字ありき、言葉を変えれば、English-centricなのか嫌でもわかります。2バイト圏は、そんな英字王国の圧倒的な影響力の下、
- 「みだし」で「#_」(ハッシュと半角スペース)に変換できるよう辞書登録しておく
- 半角⇔全角切り替えを便利なキーに割り当てておく
- 仕方ないとあきらめて半全往復を受け入れる
などのいずれかを迫られていました。
それを解決するエディタを作る―― そして生まれたのがsuna.mdです。そして日本語を大切にする以上、さらなる哲学も必要だ。そう思いいたった背景には、以下のような、文章に対する思い入れも大きく影響しています。
書くことへの敬意
文章を書くという行為は、孤独で地味な作業です。下書きを書いては消し、書いては消す。その過程で生まれた無数の「失敗」は、どのエディタにも記録されません。Undo履歴は揮発し、消した段落は消えたまま。
suna.mdでは、書いた文字を「砂粒」として背景に積み重ねる SamonEngine を実装しました。一文字書くたびに砂紋が育ち、削除しても砂は減りません。書いて悩んで消した、その痕跡もすべて思考の一部として肯定します。
「思考を砂に描くように、さらさらと書く」
日本庭園の砂紋(さもん)は、僧侶が毎朝丁寧に描き、風が吹けば崩れ、また描き直されます。一時的でありながら、その「描く行為」に価値があります。
文章も同じだと考えています。完成品だけが価値なのではなく、書くという行為そのものが価値を持つ。砂紋は、その思想の可視化です。
デザイン哲学 ── 「和モダン」と「静謐」
コンセプト:静謐と鋭敏
UIデザインのコンセプトは「静謐」です。
執筆中、UIは邪魔をしてはならない。ボタンもメニューも、使うときだけ現れて、書いている間は消える。フォーカスモードに入ると、サイドバーは左にスライドし、ヘッダーは画面上端に隠れ、エディタだけが残ります。
「ミニマル」はデザインの目的ではなく、集中を守るための手段です。
カラーパレット
メインカラーはターコイズグリーン(#0D9488 / Tailwindの teal-600)を採用しました。
青は主張が強すぎる。オリーブは存在感が薄い。ターコイズは「静けさの中の生命感」を持ちます。バックグラウンドの墨と和紙の渋さに、水草の清涼感を添えるような色です。
/* ライトモード */
--bg-primary: #FAFAF8; /* 和紙 */
--bg-secondary: #F3F0EB; /* 薄墨 */
--text-primary: #1C1917; /* 墨黒 */
--accent: #0D9488; /* ターコイズ */
--accent-hover: #0F766E;
/* ダークモード */
--bg-primary: #0C0A09; /* 漆黒 */
--bg-secondary: #1C1917; /* 深墨 */
--text-primary: #FAFAF8; /* 月白 */
--accent: #14B8A6; /* 発光ターコイズ */
アニメーション:蛍の光
ホバーやフォーカスの遷移時間は意図的に長め(600〜800ms)に設定しました。素早くポップするUIではなく、蛍の光が点灯するようにゆっくりと変化する。慌ただしさを排した執筆環境のためのアニメーション設計です。
カーソルのブリンクも同様で、6秒周期で緩やかに明滅します。点滅ではなく、呼吸しているような演出です。
第1章:問題の発見と核心の言語化
最大の問題:半角/全角の切り替え
日本語でMarkdownを書くときの煩わしさの根本は、半角/全角の切り替え頻度にあります。
この章の冒頭部分にあるH2見出し以下を書こうとすると、
見出しを書こうとする
→ 半角/全角キー(IMEオフ) → ## + 半角スペース
→ 半角/全角キー(IMEオン) → H2見出し文字を入力 + 変換確定 + 改行
→ 半角/全角キー(IMEオフ) → ### + 半角スペース
→ 半角/全角キー(IMEオン) → H3見出し文字を入力 + 変換確定 + 改行
→ 本文入力
この切り替えは1段落に何度も発生します。頻度が高いほど集中が途切れ、文章のリズムが壊れます。
suna.mdの核心的な解決策は「日本語のまま書いて、右シフトキーでMarkdownに変換する」という独自フローです。
「みだし」と入力 → Enter → 右Shift → #
「みだし」行で右Shift → ## → ### → #### → #(ループ)
「ふとじ」と入力 → Enter(変換確定)→ 右Shift → **|**
IMEをオンオフにする必要が一切ありません。日本語の流れのまま、Markdownが書けます。
副次的だが重要な問題:変換確定と入力確定の混同
もう一つ、既存エディタが解決できていない問題があります。日本語IMEにおいて、Enterキーには2つの役割があります。
- 変換確定:IMEの候補を選んで確定する。アプリには届かない。
- 入力確定:確定した文字をアプリに送信する。アプリが受け取る。
多くのエディタはこれを区別できておらず、変換確定のEnterでリストが増えたり、ファイル名変更が誤確定したりします。
// ❌ 誤った実装:IME変換中のEnterも拾ってしまう
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
createNewListItem();
}
};
// ✅ 正しい実装
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
createNewListItem(); // 入力確定のみ発火
}
};
keyCode === 229 はIME処理中のキーコードです。isComposing だけでは拾えないケースの保険として両方チェックします。
なぜ右シフトキー変換?
この機能は、日本語IME固有の「未確定状態(uncommitted state)」アーキテクチャに依存しています。
日本語IME:
「ふとじ」→ [未確定状態] → Enter → 「太字」(確定)
↑
この「未確定状態」の間、アプリは keyCode: 229 を受け取る
確定後に初めてアプリにキーイベントが届く
上述の通り、日本語では変換確定にReturn/Enterが必ず介在します。そしてそのキーの直下には、MacであろうとWindowsであろうと、そして日本語キーボードであろうと、エンジニアに愛好者の多い英字キーボードであろうと、右シフトがあります。
中国語(ピンイン)・韓国語・ベトナム語・タイ語も2バイト文字を使いますが、各国語IMEはアーキテクチャが異なり、「未確定状態」の概念が存在しないか、挙動が根本的に違うようです。従って、suna.mdの核心機能は日本語IME専用となります。
第2章:textareaによるMVP
最初の実装はシンプルな <textarea> でした。
日本語の読みをキーに、変換後のMarkdown記法とカーソル配置オフセットをマッピングする辞書テーブルを用意しました。代表的なエントリは次のようなイメージです。
// 代表例(実際にはインライン装飾・見出し・リスト・テーブル・リンクなど多数を収録)
const JapaneseDictionary = {
'みだし': { markdown: '# ', cursorOffset: 0 },
'ふとじ': { markdown: '**|**', cursorOffset: 2 }, // カーソルを ** の中央に
// ...
};
cursorOffset が設計の肝です。**|** であればカーソルは2文字戻って ** と ** の間へ、変換直後にカーソルが「次に入力すべき場所」に自動で移動するため、追加操作が不要になります。
変換処理の流れはシンプルで、右シフトを受け取ったら現在行の末尾テキストを取り出し、辞書と照合して置換するだけです。見出し行であれば # → ## → ### → #### → # とH1からH4のレベルをループさせる特別処理も加えました。
textareaのMVPは動きました。変換確定と入力確定を区別し、右シフト変換も機能しました。しかしシンタックスハイライトがなく、10,000文字を超えると表示と処理が重くなりました。より良いエディタエンジンへの移行を検討し始めます。
第3章:Monaco Editorの採用──そして Safari での挫折
Monaco採用の理由
Monaco EditorはVS CodeおよびCursorのエディタエンジンです。採用を決めた最大の理由は「Microsoftが世界中のIMEをテスト済みであり、日本語IMEの完璧な動作を保証している」という触れ込みでした。
textareaで積み上げた isComposing の手動管理から解放され、シンタックスハイライトと高パフォーマンスが得られる。この判断は合理的に見えました。
editor.updateOptions({
quickSuggestions: false,
suggestOnTriggerCharacters: false,
acceptSuggestionOnEnter: 'off',
wordBasedSuggestions: 'off',
minimap: { enabled: false },
lineNumbers: 'off',
wordWrap: 'on',
fontFamily: '"Hiragino Sans", "Noto Sans JP", sans-serif',
});
バグ集:ScrollSyncの三重苦
MonacoでのScrollSync実装でハマりました。suna.mdは大きく分けて左側のエディタ画面と右側のプレビュー画面からなります。エディタ画面をスクロールするとプレビュー画面も対応して動く。エディタとして当たり前の動作です。が。
問題1 — previewRef.current がエディタのマウント時に null。
// ❌ editorのみを依存配列に入れていた
useEffect(() => {
if (!editorRef.current || !previewRef.current) return;
const sync = new ScrollSync(editorRef.current, previewRef.current);
}, []);
// ✅ 両方を依存配列に
useEffect(() => {
if (!editorRef.current || !previewRef.current) return;
const sync = new ScrollSync(editorRef.current, previewRef.current);
}, [editorRef.current, previewRef.current]);
問題2 — スクロール位置が半画面ずれる。scrollToLine() 内でハードコードした行の高さがMonaco内部計算と乖離していました。MonacoのAPIを直接使うことで解決。
// ❌ 手動計算(ずれる)
editor.setScrollTop(lineNumber * 24);
// ✅ Monaco API使用
editor.revealLine(lineNumber, monaco.editor.ScrollType.Smooth);
問題3 — isEditorScrolling と isPreviewScrolling フラグの競合による無限ループ。そもそもスクロールしなかったり、いったんスクロールしたものが元に戻ったりしました。これはタイムアウトのリセットタイミングを慎重に調整して解決しました。
バグ:PDF出力のVariable Font問題
PDF出力(pdfmakeを使用)で見出しと太字が細いままになる問題が発生しました。原因はVariable Font(可変フォント)の使用です。Noto Sans JPのVariable Fontは wght 軸で太さを表現しますが、pdfmakeはそれを解釈できません。GitHubのnoto-cjkリポジトリからStatic Fontを取得し、Base64エンコードして登録することで解決しました。
pdfMake.fonts = {
NotoSansJP: {
normal: 'NotoSansJP-Regular.ttf', // Static Font(Variable Fontは不可)
bold: 'NotoSansJP-Bold.ttf',
},
};
致命的な問題:Safari + Monaco での二重表示
バグとの格闘を続けていたある時点で、回避不能な問題が発覚しました。
Safari上でMonaco Editorを使うと、日本語入力中に文末で文字が二重表示される。
「です」と入力してReturn/Enterを押す前、確定前の文字列がエディタに二重に表示されてしまいます。変換確定後はいったん消えますが、入力のたびに繰り返されます。
MonacoのIssueを確認したところ、Microsoft側の公式見解は「Chromiumベースのブラウザを推奨する」というものでした。つまり「日本語IMEの動作を保証している」というのは、Chromiumブラウザに限った話だったのです。
macOSユーザーの標準ブラウザはSafariです。日本語を日常的に書くユーザーの多くはMacユーザーでもあります。SafariにおけるMonacoEditorの日本語処理は手当されておらず、英字王国の独裁ぶりがまた明らかになった瞬間でした。この問題はMonacoEditor側、もしくはSafari側の構造的な欠陥であり、個人で乗り越えるのは高すぎる壁でした。Monaco Editorからの移行を決断しました。
第4章:CodeMirror 6への大転換
移行の判断
CodeMirror 6はモダンなモジュラー設計で、必要な機能だけをインポートできます。また、Composition Event(IMEイベント)の扱いをライブラリ内部で厳密に管理しており、Safari + IMEの組み合わせでも安定した動作が確認できました。
バンドルサイズが大幅に削減されたのは結果論でしかありませんが、Monacoの ~2MB(gzip後 ~600KB)から軽量化され、LCP < 1.5秒という目標達成にも貢献しました。
右シフトキーの実装
CodeMirror 6のキーマップは 'Shift-Enter' でシフト+Enterを受け取りますが、左右のシフトキーを区別する手段がデフォルトでは存在しません。EditorView.domEventHandlers を使ってDOMイベントを直接ハンドルすることで解決しました。
const rightShiftHandler = EditorView.domEventHandlers({
keydown(event, view) {
if (event.code === 'ShiftRight' && !event.isComposing) {
event.preventDefault();
handleMarkdownConversion(view);
return true;
}
return false;
},
});
event.code === 'ShiftRight' で物理キーを識別します。event.key === 'Shift' では左右の区別ができません。
CM6でのIME関連バグ
バグ1 — 「みだし」と入力してEnterで変換確定すると、稀に # が2重挿入される。CompositionEndとKeydownのタイミング競合が原因でした。view.dispatch() でトランザクションをアトミックに適用することで解消できました。
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + cursorPosition },
});
バグ2 — コンテンツ変更ハンドラが、IME変換中の各候補選択でも呼ばれてしまう。view.composing プロパティで変換中を除外しました。
EditorView.updateListener.of((update) => {
if (update.docChanged && !view.composing) {
onContentChange(update.state.doc.toString());
}
}),
第5章:SamonEngine ── 「思考の堆積」を可視化するビジュアルエンジン
重み付き文字数システム
suna.mdの哲学を担うエンジンです。思考の痕跡を愛でるための砂紋ではありますが、すべての文字を同等に扱うわけではありません。インポートした文章とコピペした文章、一文字一文字手打ちした文章では、「思考の密度」が違います。
const WEIGHTS = {
typed: 1.0, // 手打ち:100%
pasteSmall: 0.5, // 小規模ペースト(10〜100文字):50%
pasteLarge: 0.2, // 大規模ペースト(100文字以上):20%
import: 0.1, // インポート:10%
} as const;
判定はシンプルです。ファイルインポート中かどうか、IME変換中(=手打ち)かどうか、一度に追加された文字数の多さ、この3軸で入力方法を推定し、重みを乗じて加算します。手打ちで書くほど砂紋が早く大きく育ち、まとめてインポートした文書は砂としての重さが軽くなります。
Canvas描画アーキテクチャ
エディタの背後にCanvasを配置し、pointer-events: none で操作を貫通させます。
// Monaco / CM6 のコンテナに対して
const canvas = document.createElement('canvas');
canvas.style.cssText = `
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
z-index: -1; pointer-events: none;
`;
editorContainer.appendChild(canvas);
タイピング中は一切処理しないのが設計の核心です。描画は requestIdleCallback で低優先度実行し、Enter・IME確定・アイドル時にのみ更新します。
const scheduleDraw = () => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => drawSamon(), { timeout: 2000 });
} else {
setTimeout(() => drawSamon(), 1000);
}
};
渦紋のアルゴリズム(Free版基本パターン)
渦紋(Free版の基本パターン)はアルキメデスの螺旋を応用した同心円で描画します。重み付き文字数が増えるほど円の本数が増え、砂紋が外側へ広がっていきます。
描画時の色はライト/ダークモードで切り替わりますが、不透明度はどちらも 0.05 という極めて低い値に固定しています。砂紋は主張しないのです。書いているうちに気づいたら育っている、という奥ゆかしさが必要です。線の太さ 2.0px、間隔 12px も、視認性と静謐さのバランスを取った結果です。
なお、今後実装するPRO版では、より多くのパターンを用意しています。
第6章:多言語展開調査と「日本語専用」という結論
「中国語にも対応できないか?」という問いに対してIMEアーキテクチャを詳しく調査しました。
日本語IMEの「未確定状態」は独自概念で、これがあるからこそ右シフト変換が実現できています。中国語(ピンイン)、韓国語、ベトナム語、タイ語のIMEはすべてアーキテクチャが異なり、この機能の移植は技術的に不可能でした。(もしあれば教えてください)
結論:suna.mdは「日本語IMEに最適化されたMarkdownエディタ」として特化する。
これは妥協ではなく、意図的なポジショニングです(ということにしておこう)。日本語でMarkdownを書く人すべてが対象であり、その課題に正面から向き合っているプロダクトは世界に存在しません。
現在の状態とこれから
完成済み
- 日本語IME最適化(右シフト変換・変換確定区別)
- CodeMirror 6統合(Safari含む全ブラウザでIME動作確認済み)
- PWA(オフライン動作)、Vercelデプロイ
- SamonEngine基本実装(渦紋)
実装予定
- Clerk認証(Google OAuth)
- Stripe課金
- Supabaseクラウド同期(PRO版)
- SamonEngine全9パターンとアンロックシステム
- SamonEngineのイベント
おわりに
テキストエディタはすでに飽和した市場です。それでも「日本語でMarkdownを書く」というニッチに向き合うことで、既存のどのツールも解決していない問題が残っていることに気づきました。
suna.mdは現在、https://8avo.com/suna/ から無料で使えます。
右シフトキーで変換するという操作に最初は違和感があるかもしれません。でも慣れると、IMEとMarkdownの切り替えコストがゼロになり、思考のスピードで打鍵できる感覚になるでしょう。
砂紋、一緒に育てましょう。
この記事で紹介したコードはすべて概念的な抜粋です。
Discussion