こんにちは🌤 自称、駆け出しフルスタックエンジニアのここあです!
普段は WezTerm + Neovim でターミナルに引きこもり、マウスをできるだけ触らない生活を送っています。
そんな私が長年悩んでいたのが「スライドの図問題」です。 Obsidian で Marp を使ってスライドを作っているのですが、ガントチャートだけパワポで作って画像として貼り付けるという微妙なワークフローを続けていました。
「パワポのためだけにパワポを残すのはやめたい」
その一念で、SVG コードをキーボードのみで快適に編集できるツール「VSvgCode」を作りました。
背景:なぜ SVG エディタが必要だったのか
Marp でスライドを作るエンジニアの悩み
スライドの作成はMarpとObsidianのプラグインを用いて行っております。
こちらを参照ください。
このプラグインを用いて発表までは出来るようになったのですが、一方でスライドの作成時には、こんな問題にぶつかります。
- Marp は Mermaid のガントチャートに未対応
- Mermaid 感あふれるガントチャートはデザイン的にイマイチ
- 図のためだけにパワポを使い続けるのが苦痛
そこで SVG を手書きするようにしたのですが、今度は別の問題が生まれました。
「SVG コードとプレビューを行き来する作業がつらい」
VSCode でコードを書いてブラウザでプレビューを確認する…という往復が地味に面倒で、しかも「どのタグがどの要素に対応しているのか」がすぐわからない。
既存の SVG エディタ(draw.io など)はマウス操作前提で、ターミナルから離れたくない自分には合いませんでした。
解決策:双方向ハイライト付き SVG エディタを自作する
ないなら作る。ということで VSvgCode を作りました。
作ったものはこちら
VSvgCode とは
リアルタイムプレビュー + 双方向ハイライト が特徴の、キーボード完結 SVG コードエディタです。
デモはこちら👇
VSvgCodeを試す
ローカルで使いたい場合は、ビルド済みの index.html を1ファイルダウンロードするだけでOKです。Node.js 不要。
主な機能
① リアルタイムプレビューと双方向ハイライト
エディタでコードを編集すると即座にプレビューへ反映されます。 さらに以下の双方向ハイライトが動作します。
| 操作 | 動作 |
|---|---|
| エディタでカーソルを移動する | プレビュー上の対応要素がハイライト |
| プレビュー上の要素にホバーする | エディタの対応行がハイライト |
| プレビュー上の要素をクリックする | エディタのカーソルがその行にジャンプ |
「この要素を直したい」と思ったとき、ソースを探す手間がゼロになります。
② Vim モード(Ctrl+Shift+V)
Ctrl+Shift+V で Vim キーバインドのオン・オフを切り替えられます。 Vim モード時は**相対行番号(relative line numbers)**にも自動で切り替わります。 hjkl で動きたい人向けのこだわりポイントです。
③ アーカイブ(Ctrl+Shift+A)
作成した SVG を名前をつけて localStorage に保存・管理できます。 ページを閉じる前にドラフトを自動保存し、次回起動時に復元します。
④ エクスポート(SVG / PNG / JPEG)
| 形式 | 補足 |
|---|---|
| SVG | ソースコードをそのままダウンロード |
| PNG | 2x 高解像度でラスタライズ |
| JPEG | 白背景を自動塗り、2x 高解像度でラスタライズ |
技術構成
シングル HTML ファイルにすべてをバンドルしている点が最大の特徴です。
Vite + vite-plugin-singlefile → index.html(完全単体)
TypeScript(フレームワークなし)
CodeMirror 6(エディタ本体)
├─ @codemirror/lang-xml(SVG/XML シンタックスハイライト)
├─ @codemirror/theme-one-dark
└─ @replit/codemirror-vim(Vim キーバインド)
React を使わなかった理由
React や Vue を入れるほど状態管理が複雑ではなく、バンドルサイズを抑えたかったため、フレームワークなしの TypeScript にしました。コアロジックは以下のモジュールに分離しています。
| モジュール | 役割 |
|---|---|
editor.ts |
CodeMirror エディタの構築・管理 |
preview.ts |
SVG レンダリング・要素ハイライト |
highlighter.ts |
エディタ ↔ プレビューの双方向同期 |
line-mapper.ts |
ソース行 ↔ DOM 要素のマッピング |
export.ts |
SVG/PNG/JPEG エクスポート |
archive.ts |
localStorage による保存・復元 |
シングルファイルにした理由
社内で使うことを想定して、ダウンロードして即使えるツールにしたかったためです。
vite-plugin-singlefile を使うと CSS・JS・フォントをすべて HTML に埋め込んだ単体ファイルが生成されます。
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
});
実装でハマったポイント
1. SVG ソースと DOM 要素のマッピング
双方向ハイライトを実現するには「ソースの何行目が DOM のどの要素か」を知る必要があります。
SVG ソースをパースして <タグ> の出現位置を走査し、レンダリング後の DOM 要素と突き合わせる line-mapper.ts を実装しました。
コメント・CDATA・DOCTYPE を除外しながらバイナリサーチでオフセットから行番号を求める処理が地味に面倒でした。
// バイナリサーチで文字オフセット → 行番号を求める
function getLineAt(charOffset: number): number {
let lo = 0, hi = lineOffsets.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineOffsets[mid] <= charOffset) lo = mid;
else hi = mid - 1;
}
return lo + 1; // 1-indexed
}
2. Vim モードと相対行番号の連動
@replit/codemirror-vim を使うと Vim キーバインドは比較的簡単に導入できますが、「Vim モードのときだけ相対行番号にしたい」という要件が少し手間でした。
CodeMirror 6 の Compartment を使うと拡張機能を動的に差し替えられます。vimCompartment と lineNumberCompartment をそれぞれ独立して管理し、トグル時に dispatch で入れ替えます。
const vimCompartment = new Compartment();
const lineNumberCompartment = new Compartment();
// Vim モードのオン・オフ
editorView.dispatch({
effects: [
vimCompartment.reconfigure(vimExtension(enabled)),
lineNumberCompartment.reconfigure(
enabled ? relativeLineNumbers() : lineNumbers()
),
],
});
3. PNG/JPEG エクスポートの透過処理
SVG を Canvas 経由でラスタライズするとき、JPEG は透過を扱えないため、drawImage の前に白背景を塗る必要があります。これを忘れると透過部分が真っ黒になります。
if (mimeType === "image/jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height); // 先に白背景を塗る
}
ctx.drawImage(img, 0, 0, width, height);
高解像度化のために 2x スケールで Canvas を確保し、ctx.scale(2, 2) で描画しています。
4. localStorage の容量とアーカイブ設計
localStorage は 5MB 前後の制限があり、大量の SVG を保存しようとすると詰まります。書き込み失敗時にアラートを出す最低限のガードを入れています。
function writeArchives(archives: Archive[]) {
try {
localStorage.setItem(ARCHIVES_KEY, JSON.stringify(archives));
} catch {
alert("localStorage is full. Could not save archives.");
}
}
使い方
Web で使う
デモサイトにアクセスするだけで使えます。
ローカルで使う(社内配布など)
GitHub Releases から index.html をダウンロードして、ブラウザで開くだけです。
# ダウンロード後、ダブルクリック or ブラウザにドラッグするだけ
index.html
Node.js・npm・インターネット接続は一切不要です。
自分でビルドする
git clone https://github.com/cocoaai-it/VSvgCode.git
cd VSvgCode
pnpm install
pnpm build
# dist/index.html が生成される
キーボードショートカット一覧
| ショートカット | 動作 |
|---|---|
Ctrl+S |
SVG ファイルとして保存 |
Ctrl+Shift+V |
Vim モードのオン・オフ |
Ctrl+Shift+A |
アーカイブパネルの開閉 |
おわりに
「パワポを捨てたい」という動機から始まったツールですが、実装してみると CodeMirror のカスタマイズ、SVG のパース、DOM とのマッピングなど学びが多かったです。
普段のターミナル環境から離れずに図を作れるようになって、スライド制作の工程がだいぶスッキリしました。
同じ課題(Marp で図が作れない問題・パワポを捨てたい問題)を感じている方の参考になれば嬉しいです!
ソースコードはこちら👇
https://github.com/cocoaai-it/VSvgCode
この記事が役に立ったらいいね👍やシェアをお願いします!