TL;DR (概要)
- Claude・ChatGPT・Gemini(Canvasモード)で生成されたp5.jsコードを、p5.js Web Editor / OpenProcessing / CodePen にワンクリックで転送するChrome拡張「p5.js Relay」を作りました
- 単一HTMLで出力されたコードをHTML / CSS / JSに自動分割し、送信先エディタの構造に合わせて書き分けます
- ソースは公開しています → https://github.com/akichika/p5js-relay
- Chrome Web Store / Microsoft Edge Add-onsへ提出済みです(審査中のため、公開後にURLを追記します)
作った背景
生成AIにp5.jsのコードを書いてもらう機会が増えましたが、コードブロックを毎回
- コピーする
- p5.js Web Editorのタブに切り替える
- 既存コードを選択して消す
- 貼り付ける
- HTML/CSS/JSに分かれているコードであれば、ファイルごとに貼り直す
という手順を踏む必要があり、地味に手間がかかっていました。特にHTML/CSS/JSが1ファイルにまとまった形で出力されることが多く、p5.js Web Editorの3ファイル構成(sketch.js / index.html / style.css)に毎回手作業で分割する必要があったため、この作業を自動化するChrome拡張を作成しました。
例えばChatGPTやGeminiにp5.jsのコードを書いてもらうと、コードブロックの右上に「✳ 転送」ボタンが表示されます。
これをクリックするだけで、p5.js Web Editorに反映されそのまま実行できます。
できること
- コードブロック横の転送ボタンをクリックするだけで、登録済みのエディタへ反映します
- 送信先タブが無ければ自動で新規タブを開きます
- 送信先は自由に登録できます(URLパターン・エディタ種別・CSSセレクタ)
- 既定の送信先は、直近にアクティブにしたエディタタブに自動で追従します
- ライト/ダーク/システムテーマに対応し、UIは英語・日本語を含む10言語に対応しています(ブラウザ言語に自動追従)
claude.aiのArtifactパネルからも、同じボタンでそのままCodePen等へ転送できます。
転送先はp5.js Web Editorだけでなく、CodePenのようにHTML/CSS/JSパネルが分かれているエディタにも自動で振り分けて反映できます。
OpenProcessingも同様に対応しています。「HTML/CSS/JS」モードを選択したうえで転送すると、mySketch.js / style.css / index.htmlの3ファイルに自動で振り分けてコードを貼り付けます。
技術的なポイント
1. Service WorkerからページのJSオブジェクトを直接操作する
Manifest V3のService WorkerにはDOMもwindowもありません。しかし送信先のCodeMirrorやMonacoのインスタンスを直接操作するには、そのページのJSコンテキストで動く必要があります。これにはchrome.scripting.executeScriptのworld: "MAIN"
オプションを使用します。
const [res] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN", // ページ本体のJSコンテキストで実行
func: applyCodeInPage,
args: [payload, rule, clearBefore, messages]
});
funcに渡した関数は直列化されてページ側に注入されるため、外側のスコープの変数は参照できません。すべての依存(ヘルパー関数を含む)を関数内に閉じ込める必要があり、「ページの世界に完全に閉じたスクリプトを書く」という制約に沿って実装しています。
CodeMirror 5への書き込みは次のようになります。
const el = document.querySelector(".CodeMirror");
const cm = el.CodeMirror; // DOM要素に生えているインスタンスを直接操作する
cm.setValue(code);
CodeMirror 6は仮想DOM的な構造を持ち、cmView.viewからdispatchでトランザクションを発行する形になります。バージョンによってAPIが大きく異なるため、auto判定モードでCM5→CM6→Monaco→Ace→textarea→contenteditableの順に候補を確認する実装にしています。
2. Service WorkerにはDOMParserが無い
生成AIが単一HTMLでコードを返してくる場合、<style>と<script>を抜き出してCSS/JSファイルに分割する必要があります。DOMを使えば簡単ですが、Service WorkerにはDOMParserが存在しません。そのため正規表現ベースでパースしています。
html = html.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (m, body) => {
css += dedent(body) + "\n";
return "%%PLACEHOLDER%%"; // 抜き取った跡は後で<link>タグに置換する
});
厳密なHTMLパーサーではありませんが、生成AIが出力する比較的定型的なHTML
(単一の<style>と単一の<script>)であれば実用上問題なく動作します。
3. p5.js Web Editorのindex.htmlを壊さずに更新する
生成コードに含まれるライブラリ(p5.js標準以外のCDN)をindex.htmlに反映する必要がありますが、index.htmlを丸ごと生成AIの出力で置き換えると、エディタが最初から持っているp5.js/p5.sound読込構造を壊すおそれがあります。
そこで、index.htmlは全置換せず、既存の内容を読み取ったうえで不足しているライブラリタグだけを</head>直前に差し込む、というマージ方式にしています。
const current = readCurrent(); // 現在のindex.htmlを読み取る
const missing = extraLibs.filter(tag => {
const url = extractUrl(tag);
return url && !current.includes(url); // 既に読み込み済みでないものだけ
});
const merged = current.replace(/<\/head>/i, missing.join("\n") + "</head>");
この方式が失敗した場合(何らかの理由でエディタの内容が読み取れない場合など)は、生成AIの出力そのままでindex.htmlを全置換するフォールバックも用意しています。
4. React系UIでのボタン重複への対応
ChatGPT/Claude/GeminiはいずれもReactベースのSPAで、こちらがappendChildで挿入したDOM要素が、再レンダリング時に複製されたり、別の場所へ移動させられたりすることがあります。これにより「転送ボタンが2個表示される」「ボタンが点滅する」といった不具合が発生していました。
最初は毎回すべて削除してから付け直す方式で対処していましたが、この方法では再レンダリングのたびにDOMの生成・破棄が起きて点滅してしまいます。最終的に以下の2段構えで対応しました。
- 自分が生成した「本物」のボタンを
WeakSetで管理し、DOM上に同じクラス名の
要素があっても本物でなければ除去する(複製への対応) - 本物のボタンと「あるべき設置先」を
WeakMapで紐付け、スキャンのたびに
本物が設置先の外へ移動させられていないか確認し、外に出ていれば除去する
(Reactによって移動させられた本物への対応)
const LIVE = new WeakSet(); // 本物のボタン
const WRAP_HOST = new WeakMap(); // ボタン → あるべき設置先
function globalCleanup() {
document.querySelectorAll(".coderelay-btn-wrap").forEach(w => {
if (!LIVE.has(w)) { w.remove(); return; } // 複製は消す
const host = WRAP_HOST.get(w);
if (!host?.contains(w)) w.remove(); // 設置先の外に出ていれば消す
});
}
本物には触れずに複製・移動の判定のみを行う方式にしたことで、点滅を起こさずに両方の問題に対応できています。
5. Shadow DOMを跨いだ要素探索
Gemini Canvasのコードエディタパネルは、カスタム要素のShadow DOM内にCodeMirrorが存在することがあります。通常のquerySelectorはShadow DOMの境界を越えられないため、openなShadowRootを再帰的に辿るdeepQueryという関数を実装しています。
function deepQuery(selector, root = document) {
const direct = root.querySelector(selector);
if (direct) return direct;
for (const el of root.querySelectorAll("*")) {
if (el.shadowRoot) {
const found = deepQuery(selector, el.shadowRoot);
if (found) return found;
}
}
return null;
}
なおclosedなShadowRootにはアクセスできないため、この方式がすべてのケースに対応できるわけではありません。
6. chrome.i18nは実行時に言語を切り替えられない
Chrome拡張の標準i18n機構(chrome.i18n.getMessage)は、ブラウザの言語設定に基づいて起動時に1言語だけロードされる仕組みのため、アプリ内の言語切替UIには使用できません。オプションページで「言語: 日本語/English/ブラウザに合わせる」を選択式にする必要があったため、_locales/*/messages.jsonをfetch()で読み込んで切り替える薄いi18nレイヤーを実装しています。
async function load(langSetting) {
let code = langSetting;
if (code === "system") {
const ui = chrome.i18n.getUILanguage(); // "ja-JP" など
code = SUPPORTED.find(s => ui.startsWith(s)) || "en"; // 非対応言語は英語にフォールバック
}
const res = await fetch(chrome.runtime.getURL(`_locales/${code}/messages.json`));
return res.json();
}
まとめ
- Service WorkerからページのJSを直接操作するには
world: "MAIN"が必要です - Service WorkerにDOMParserは無いため、正規表現でのパースが必要になります
- 既存ファイルを壊さない更新には、全置換ではなくマージ方式が有効です
- Reactアプリへの要素挿入は、複製・移動の両方を想定した監視が必要です
- Shadow DOMを跨いだ探索には再帰的なクエリ関数が必要です
- 実行時の言語切替にはchrome.i18nではなく自前のfetchベースの仕組みが必要です
使ってみたい方へ
- リポジトリ: https://github.com/akichika/p5js-relay
- インストール(開発者モード): リポジトリをクローンし、
chrome://extensions(Edgeはedge://extensions)でデベロッパーモードをONにしたうえで、 「パッケージ化されていない拡張機能を読み込む」からフォルダを選択してください - Chrome Web Store / Microsoft Edge Add-ons: 審査中のため、公開後に本記事へURLを追記します
フィードバックやPull Requestを歓迎します。送信先ルールは設定ベースで追加できる設計にしているため、OpenProcessing/CodePen以外のオンラインエディタへの対応も試しやすいはずです。






