「SVGed」という簡易的なSVG編集ツールを作りました。

これは一般的なグラフィックエディタとは異なり、あくまで「コード画面でSVGコードを編集する作業」を主体に置いたツールです。
ただし、コードだけで直すには面倒な部分をGUIで補助できるよう、要点だけビジュアル編集が可能になっています。
- 要素をクリックして対応するコード位置を表示できる
- テキスト要素をプレビュー画面で直接変更できる
- ドラッグでグループ要素の位置を調整できる
コードの編集がすぐにプレビューに反映されるので、確認しやすいです。
- 生成AIでインフォグラフィックを作れる – スマホ教室ちいラボ
- WordPressにSVG画像をアップロードできるようにした(functions.php) – スマホ教室ちいラボ
- WordPressでSVGアイキャッチを表示するプラグインを開発した(svg-eyecatch-iconプラグイン) – Chiilabo Note
1. 基本機能:3つの編集方法
SVGファイルを手作業で編集していると、どうしても面倒な場面に遭遇します。テキストの内容を変えたい、図形の位置を少しずらしたい、色を調整したい。そのたびにコードを開いて座標や文字列を探して…という作業の繰り返しです。
「プレビューを見ながら直感的に編集できたら便利だよね」
そんな思いから、SVGをビジュアル編集できるWebアプリを作ることにしました。ただし、完全なグラフィックエディターを目指すのではなく、あくまで「コードの手直しを楽にする」ことが目的です。
最初に実装したのは、3つの基本的な編集機能でした。
- テキスト編集は、テキスト要素をクリックするとポップアップが表示され、内容をその場で変更できます。
- ドラッグ移動では、グループ要素(
<g>)をマウスでドラッグすると、transform属性のtranslate値が更新されます。図形の配置を視覚的に調整できるわけです。 - 色変更は、図形をクリックするとプロパティパネルが開き、カラーパレットまたはカスタムカラーで色を変更できます。ここで工夫したのは、SVGの
<defs>内で定義されたスタイルクラスをパレットとして表示する仕組みです。fill: #3eb370のような定義を自動的に抽出して、クリック一つでクラスを切り替えられるようにしました。
1.1. ファイル構成
当初は単一のHTMLファイルでしたが、公開を見据えて適切にファイル分割することにしました。重要なのは「関心の分離」です。
最終的に以下の構成に落ち着きました。
svg-editor/
├── index.html
├── styles.css
└── js/
├── main.js # 初期化
├── config.js # 定数と状態管理
├── svg-utils.js # SVG処理
├── editor.js # 編集機能
└── event-handlers.js # イベント統合Code language: PHP (php)
- config.jsには、DOM要素への参照と、グローバルな状態(現在編集中の要素、ドラッグ中かどうか、など)を集約しました。これが「Single Source of Truth」として機能します。
- svg-utils.jsは、SVGのパース、シリアライズ、カラーパレット抽出といった、純粋な処理を担当します。
- editor.jsには、テキスト編集やドラッグ&ドロップといった、UIに関わる編集ロジックを配置しました。
- event-handlers.jsは、すべてのイベントリスナーをセットアップし、各機能を統合する役割です。
依存関係は一方向になるように設計しました。main.js → event-handlers.js → editor.js/svg-utils.js → config.jsという流れです。
2. クリーンなコード出力
開発を進めるうちに、大きな問題に気づきました。
編集中のDOMには、ホバー時のハイライトやドラッグ用のクラス、カーソルスタイルなど、編集用の属性が追加されています。これをそのままコードに出力すると、ユーザーが手に入れるSVGが「汚れて」しまうわけです。
解決策は、コード生成時にクローンを作ることでした。
const cleanSvg = state.svgDoc.cloneNode(true);
// 編集用クラスを削除
cleanSvg.querySelectorAll('.hover-highlight, .selected-element').forEach(el => {
el.classList.remove('hover-highlight', 'selected-element');
});
// cursor スタイルを削除
cleanSvg.querySelectorAll('[style*="cursor"]').forEach(el => {
// 処理...
});Code language: JavaScript (javascript)
この仕組みにより、内部では編集用のDOM操作を自由に行いつつ、出力は常にクリーンな状態を保てるようになりました。
3. 要素クリックでコードの該当行にジャンプする機能
ここまでは比較的スムーズでしたが、次の機能で大きく躓きました。
「プレビューで要素をクリックしたら、左のコードエディタで対応する行にジャンプしてハイライトする」
この逆方向の同期機能です。一見簡単そうですが、実装してみると問題だらけでした。
3.1. 第一の壁:要素とコード行の対応付け
最初に考えたのは、要素の特徴(id、class、textContent、transform属性など)から、コード内を検索してマッチする行を探す方法です。
// このような検索ロジック
if (id && line.includes(`id="${id}"`)) {
return lineIndex;
}Code language: JavaScript (javascript)
これは動くには動きましたが、精度が低いんです。同じテキスト内容を持つ要素が複数あったり、属性の順序が変わったりすると、誤マッチが起きてしまいます。
そこで思いついたのが、「最初から対応関係を記録しておく」という発想でした。
SVGをパースする時点で、すべての編集可能要素にdata-editor-id="elem-0"のような一意なIDを振ります。同時に、コード内でその要素が何行目に書かれているかをdata-line-number="5"として記録するわけです。
function assignEditorIds(originalCode) {
const lines = originalCode.split('\n');
const allElements = state.svgDoc.querySelectorAll('text, rect, circle, ...');
// コード内の要素開始行を検索
const tagLineIndices = [];
lines.forEach((line, lineIndex) => {
if (trimmed.startsWith(`<text `) || ...) {
tagLineIndices.push(lineIndex);
}
});
// DOM要素にID+行番号を設定
allElements.forEach((el, index) => {
el.setAttribute('data-editor-id', `elem-${index}`);
el.setAttribute('data-line-number', tagLineIndices[index]);
});
}Code language: JavaScript (javascript)
要素をクリックした時は、data-line-numberを読むだけ。シンプルで確実です。
ただし、ユーザーがコードを編集したり、ドラッグで要素を移動したりすると、行番号がずれてしまいます。そこで、DOM更新のたびに行番号を再計算する仕組みも追加しました。
export function updateCodeFromDOM() {
const cleanCode = serializeSVG();
DOM.codeInput.value = cleanCode;
// 行番号を再割当
reassignLineNumbers(cleanCode);
buildElementToLineMap();
}Code language: JavaScript (javascript)
もちろん、コード出力時にはdata-editor-idとdata-line-numberは削除されます。これらはあくまで内部管理用の属性です。
3.2. 第二の壁:スクロール位置の計算
行番号がわかっても、それだけでは不十分でした。コードエディタを該当行までスクロールさせる必要があります。
最初は単純な計算で実装しました。
const lineHeight = 1.6 * 15; // line-height * font-size
const scrollTop = lineNumber * lineHeight - viewportHeight / 2;
DOM.codeInput.scrollTop = scrollTop;Code language: JavaScript (javascript)
しかし、これがうまくいかない。特に下の方の要素ほどズレが大きくなるんです。
原因は2つありました。
- 1つ目は、paddingを考慮していなかったこと。CSSで
padding: 20pxが設定されているので、実際の要素位置はpaddingTop + lineNumber * lineHeightになります。 - 2つ目は、もっと深刻でした。コードが折り返されることを想定していなかったんです。
長い行が画面幅で2行に折り返されると、実際の高さは2倍になります。でも単純計算では1行分としてカウントしてしまう。これでは正確なスクロールは不可能です。
最終的に採用したのは、「実際にレンダリングして高さを測る」という方法でした。
// ダミー要素を作成
const measureDiv = document.createElement('div');
measureDiv.style.cssText = `
position: absolute;
visibility: hidden;
width: ${DOM.codeInput.clientWidth - 40}px;
font-family: ${computedStyle.fontFamily};
font-size: ${computedStyle.fontSize};
line-height: ${computedStyle.lineHeight};
white-space: pre-wrap;
word-wrap: break-word;
`;
// 対象行の直前までのテキストを入れる
const textBeforeTarget = lines.slice(0, lineNumber).join('\n');
measureDiv.textContent = textBeforeTarget;
document.body.appendChild(measureDiv);
const heightBeforeTarget = measureDiv.scrollHeight;
document.body.removeChild(measureDiv);
// この高さでスクロール
const desiredScrollTop = heightBeforeTarget - viewportHeight / 3;
DOM.codeInput.scrollTop = desiredScrollTop;Code language: JavaScript (javascript)
textareaと同じスタイルでdivを作り、対象行の手前までのテキストを入れて、実際の高さを測定します。この高さがわかれば、折り返しがあっても正確にスクロール位置を計算できるわけです。
測定用のdivはvisibility: hiddenで見えないようにし、測定後すぐに削除するので、ユーザーには何も見えません。
4. 完成した機能
こうして、最終的に以下の機能を持つSVGエディターが完成しました。
- テキスト、図形、グループの編集
<defs>で定義されたスタイルクラスのパレット表示- 双方向同期(コード↔プレビュー)
- 要素クリックでコード行へジャンプ
- クリーンなSVG出力
- コピーボタンで即座にコード取得