🪐

Antigravityと気軽に作るChrome拡張機能

に公開

この記事は MICIN Advent Calendar 2025 の 2 日目の記事です。
前回は manimoto さんの、 情シス・セキュリティ担当者向けWebプログラミングハンズオン「Hono + JSX + SQLiteで作る掲示板」 でした。

はじめに

MICIN オンライン医療事業部エンジニアのgenmei です!
普段は患者さんにスマートな通院体験を届ける「クロンスマートパス」というプロダクトを開発しています。

みなさん、業務改善してますか?

私は普段の開発中に感じた面倒臭さや非効率さを見つけると、つい「何とかできないかな...」と考えてしまいます。
ちょっとした不便さでも、毎日繰り返すと積み重なって大きな時間のロスになりますし、何よりストレスが溜まりますよね。

今回は、テスト工程でちょっとした面倒臭さを感じたので、サクッとツールを作って解決することにしました。

改善したい不満

MICINではプロダクトの品質を担保するテスト工程の管理にGoogle スプレッドシート が利用されています。

テスト用のシートでは、チェック項目と同じ行のセルにエビデンスの画像を埋め込むことで対応付けを行なっているのですが、埋め込まれた画像はセルの縦幅に合わせて画像がリサイズされてしまうので「これ、何の画像だ...?」となってしまうことが度々あります。

画像のメニューから「セルの上に画像を置く」を行うことで、元々のサイズで再展開できますが、複数人が共有するシートなので「誰か見ている間、他のエビデンスが隠れてしまう」という事象が発生します。

ということでそのフラストレーションを解消すべく、「Google スプレッドシートに埋め込まれた画像をプレビューする」ツールを作成することにしました。

作ったもの

はじめに完成品を提示しておくと、以下のようになっています。

プレビューの開閉 画像の拡大・縮小/移動
  • 画像が埋め込まれたセル上で alt(option) + クリックすることで、画像のプレビューが開く
  • プレビュー上でタッチジェスチャーを行うことで画像の拡大・縮小/移動できる

エンジニアではないメンバーも利用することを想定して、「Chrome拡張機能」という形で提供することにしました。

一方、ただ業務改善ツールを作るだけでは味気ないですし、主業務以外に充てられる時間は有限です。
せっかくならAIツールのキャッチアップをしながら開発してみようということで、(作成日時点で)ちょうど2~3日前にリリースされたAIエディタである Antigravity を使ってバイブコーディングしてみることにしました。

※ 2025年12月2日現在、Google Antigravityにはプロンプトインジェクションの危険性が報告されているので、セキュリティ担当者と相談の上で利用することを推奨します。

Antigravityの概要

Antigravity とは Google が米国時間2025/11/18に発表したAIエディタです。
同日に発表された Gemini 3 などの最新モデルをエージェントとして利用しており、エンジニアを監督者に置くことで、「エージェントファースト」の開発プラットフォームを実現した。とのことです。
Google Chrome 上で「ブラウザサブエージェント」を動かすことができ、従来 Dev tool MCP や Playwright MCP で行なっていた機能を標準搭載しています。

製作

それでは実際に製作していきます。

※ Antigravity のセットアップ・日本語化については VS Code と大体同じなので割愛します。

設計

設計といっても、作りたいものをREADMEにまとめて、Gemini 3 Proに吟味してもらうだけです。

README.md
# spreadsheet-image-previewer

スプレッドシートのセルに埋め込まれた画像をプレビューさせるための Chrome 拡張機能です。

## 対応したいもの
- 直接セルに埋め込まれた画像
- `=IMAGE()`でリンク表示された画像

## UI
- option+clickで画像のプレビューを表示
- 右上の× ボタンでプレビューを閉じる
- ジェスチャーで画像を拡大・縮小/移動


エージェントとの対話ウィンドウは Cursor っぽい

リアルタイム検索も当然完備していますね

少し待つと、調査結果と実装計画が返ってきました。

実装計画の精度はカスタムした Claude Code と遜色ない

ざっくりいうと

  • UIの設計は問題なく、技術的にも可能である
  • =IMAGE()によって埋め込まれたものは数式バーから画像URLを取得可能
  • 直接埋め込みは技術的に困難
    • Google スプレッドシート が Canvas を利用しているのでDOM から取得できない

とのことでした。

テストケース管理のスプレッドシートは直接埋め込みが多いので、少し困ります。
そこで、調べてみると「セルをコピペする際のクリップボードにbase64イメージがそのまま入っている」ということがわかりました。

Chrome 拡張機能にクリップボードの権限を触らせるのは少し怖いですが、利便性との引き換えということで、今回はそのまま行くことにしました。

実装

設計の壁打ちが終わり、ひとしきりの実現可能性調査が完了すると、自動的に実装が始まりました。
どうやら、権限セットが Agent Decides になっているので「ユーザーに聞くまでもない」とAIが判断したものに関しては承認フローをスキップするみたいです。

ここら辺の設定に関しては、ユーザによって好みはあるかもしれませんが、私はいちいち承認するのが面倒くさい派なので、素直にありがたいです。

評価

実装が完了すると Walkthrough というファイルが生成され、ユーザに成果物の確認を促してきます。


英語出力なのは~/.gemini/GEMINI.mdで変えられるっぽい

作成した Chrome 拡張機能のインポート方法から、テストケースの手順・期待値を列挙していますね。
実装に対して、ユーザが確認するというフローにかなり合っていると思います。

動かない

試しに導入してみたのですが、動きません。一発生成の限界を感じつつ、改良サイクルも丸投げしてみます。


ブラウザサブエージェントを使って自律的にログ収集をさせてみる

すると新たな Google Chrome が立ち上がり Antigravity 拡張機能のインポートを進めてきます。
インポートすると、開いているスプレッドシートを自動操作して調査と再実装が進みます。


自動操作中はウインドウの周囲が青くハイライトされます。かっこいいですね。

クリップボードのハンドリングに問題があったようで、1~2分程度の作業後動作するようになりました。

完成

その後、ピンチやスクロールの感度を調整して、完成となりました。
完成品については、あまりソース量も大きくないのでまとめて載せておきます。

ソース全文(300行ほど)
manifest.json
{
    "manifest_version": 3,
    "name": "SpreadSheet Embedded Image Previewer",
    "version": "1.0",
    "description": "Preview embedded images in Google Sheets cells with Alt+Click.",
    "permissions": [
        "activeTab",
        "clipboardRead",
        "clipboardWrite"
    ],
    "content_scripts": [
        {
            "matches": [
                "https://docs.google.com/spreadsheets/*"
            ],
            "js": [
                "src/content.js"
            ],
            "css": [
                "src/styles.css"
            ]
        }
    ]
}
content.js
// SpreadSheet Embedded Image Previewer

let modal = null;
let modalImg = null;
let imgContainer = null;
let scale = 1;
let translateX = 0;
let translateY = 0;

// ピンチジェスチャー用
let initialDistance = 0;
let initialScale = 1;

/**
 * モーダル要素の初期化とDOMへの注入
 */
function createModal() {
    if (modal) return;

    // モーダルオーバーレイの作成
    modal = document.createElement('div');
    modal.id = 'sip-modal';
    modal.style.display = 'none';

    // 閉じるボタンの作成
    const closeBtn = document.createElement('span');
    closeBtn.id = 'sip-close';
    closeBtn.innerHTML = '×';
    closeBtn.onclick = closeModal;
    modal.appendChild(closeBtn);

    // 画像コンテナの作成
    imgContainer = document.createElement('div');
    imgContainer.id = 'sip-img-container';

    // 画像要素の作成
    modalImg = document.createElement('img');
    modalImg.id = 'sip-modal-content';
    imgContainer.appendChild(modalImg);
    modal.appendChild(imgContainer);

    // 画像外クリック時にモーダルを閉じる
    modal.onclick = (e) => {
        if (e.target === modal || e.target === imgContainer) {
            closeModal();
        }
    };

    // 画像コンテナへのマウスホイールイベントリスナー追加
    // Ctrl+ホイール または トラックパッドのピンチ = ズーム
    // 通常のホイール = パン(移動)
    imgContainer.addEventListener('wheel', (e) => {
        e.preventDefault();

        if (e.ctrlKey) {
            // ズーム処理(トラックパッドのピンチ操作はCtrlキー付きホイールイベントとして送信される)
            const delta = e.deltaY > 0 ? -0.05 : 0.05;
            scale += delta;
            if (scale < 0.1) scale = 0.1;
            if (scale > 10) scale = 10;
        } else {
            // パン操作(移動)
            translateX -= e.deltaX;
            translateY -= e.deltaY;
        }

        updateTransform();
    });

    // ズーム用ピンチジェスチャーのサポート追加
    imgContainer.addEventListener('touchstart', handleTouchStart, { passive: false });
    imgContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
    imgContainer.addEventListener('touchend', handleTouchEnd, { passive: false });

    document.body.appendChild(modal);
}

/**
 * 指定された画像ソースでモーダルを表示
 * @param {string} src - 表示する画像のURL
 */
function openModal(src) {
    if (!modal) createModal();

    modalImg.src = src;
    modal.style.display = 'flex';
    scale = 1;
    translateX = 0;
    translateY = 0;
    updateTransform();
}



/**
 * モーダルを閉じる
 */
function closeModal() {
    if (modal) {
        modal.style.display = 'none';
        modalImg.src = '';
    }
}

/**
 * 画像への現在のスケールと移動変換の適用
 */
function updateTransform() {
    if (modalImg) {
        modalImg.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
    }
}

/**
 * ピンチズーム用タッチイベントハンドラ
 */
function handleTouchStart(e) {
    if (e.touches.length === 2) {
        e.preventDefault();
        initialDistance = getDistance(e.touches[0], e.touches[1]);
        initialScale = scale;
    }
}

function handleTouchMove(e) {
    if (e.touches.length === 2) {
        e.preventDefault();
        const currentDistance = getDistance(e.touches[0], e.touches[1]);
        const scaleChange = currentDistance / initialDistance;
        scale = initialScale * scaleChange;
        if (scale < 0.1) scale = 0.1; // 最小スケール
        if (scale > 10) scale = 10; // 最大スケール
        updateTransform();
    }
}

function handleTouchEnd(e) {
    if (e.touches.length < 2) {
        initialDistance = 0;
    }
}

function getDistance(touch1, touch2) {
    const dx = touch1.clientX - touch2.clientX;
    const dy = touch1.clientY - touch2.clientY;
    return Math.sqrt(dx * dx + dy * dy);
}

/**
 * ドキュメントのクリックイベント処理および画像のAlt+Click検出
 * 画像取得の主要メソッドとしてクリップボード抽出を使用
 * @param {MouseEvent} event 
 */
function handleGlobalClick(event) {
    // Altキー確認
    if (event.altKey) {
        // デフォルトのクリック動作を許可し、セル選択を有効化
        // 選択更新待機後、画像抽出を実行
        setTimeout(() => {
            extractImageFromSelection();
        }, 100);
    }
}

/**
 * 選択されたセルをクリップボードにコピーし、HTMLを解析して画像を抽出します。
 * 埋め込み画像(Canvas描画等)処理用メインロジック
 */
async function extractImageFromSelection() {
    let originalClipboardItems = null;
    try {
        // 元のクリップボード内容の保存を試行
        try {
            // 注: 権限またはセキュアコンテキストが必要な場合あり
            originalClipboardItems = await navigator.clipboard.read();
        } catch (e) {
            // 復元不可になるだけなので握りつぶす
        }

        // セル選択を確実にするためフォーカス
        window.focus();

        // コピー実行(選択セルの内容をクリップボードへ格納)
        document.execCommand('copy');

        // クリップボード更新待機
        await new Promise(r => setTimeout(r, 50));

        // クリップボード読み取り
        const items = await navigator.clipboard.read();
        const htmlItem = items.find(item => item.types.includes('text/html'));

        if (htmlItem) {
            const blob = await htmlItem.getType('text/html');
            const html = await blob.text();

            // HTML解析による画像検索
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const img = doc.querySelector('img');

            if (img?.src) {
                console.log('[SIP] Image found via clipboard:', img.src.substring(0, 50) + '...');
                openModal(img.src);
            } else {
                console.log('[SIP] No <img> tag found in clipboard HTML.');
            }
        } else {
            console.log('[SIP] No HTML content in clipboard.');
        }

    } catch (err) {
        console.error("[SIP] Clipboard extraction failed:", err);
    } finally {
        // 元のクリップボード内容を復元
        if (originalClipboardItems) {
            try {
                await navigator.clipboard.write(originalClipboardItems);
            } catch (e) {
                console.warn("[SIP] Failed to restore clipboard:", e);
            }
        }
    }
}

// 初期化
createModal();
document.addEventListener('click', handleGlobalClick, true); // 早期インターセプトのためキャプチャフェーズを使用
document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') closeModal();
});

styles.css
/* Styles for SpreadSheet Embedded Image Previewer */

#sip-modal {
    display: none;
    /* Hidden by default */
    position: fixed;
    /* Stay in place */
    z-index: 99999;
    /* Sit on top */
    left: 0;
    top: 0;
    width: 100%;
    /* Full width */
    height: 100%;
    /* Full height */
    overflow: hidden;
    /* Enable scroll if needed */
    background-color: rgba(0, 0, 0, 0.8);
    /* Black w/ opacity */
    justify-content: center;
    align-items: center;
    flex-direction: column;
}

#sip-img-container {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;
    overflow: auto;
}

#sip-modal-content {
    margin: auto;
    display: block;
    max-width: 90%;
    max-height: 90%;
    object-fit: contain;
}

#sip-close {
    position: absolute;
    top: 15px;
    right: 35px;
    color: #f1f1f1;
    font-size: 40px;
    font-weight: bold;
    transition: 0.3s;
    cursor: pointer;
    z-index: 100000;
}

#sip-close:hover,
#sip-close:focus {
    color: #bbb;
    text-decoration: none;
    cursor: pointer;
}

改善点・感想

改善点

今回作ったサポートツールには2つ、大きな改善点があります。

1. クリップボードを経由している

クリップボードに関する権限を要求していたり、スプレッドシートを開いてからすぐに利用するとクリップボードに画像が残ってしまったりします。
Google Sheets API を使えば解消できそうな気もしますが、今度は認証やらの壁がありそうなので、あまり手が出ません。

2. プレビューイメージの画質が粗い

Base64 をそのまま貼り付けているので、拡大した際に画像が粗くなります。
FHD程度の画像であれば問題なくプレビュー可能ですが、開発者コンソールから取れる全体スクリーンショットなどは、あまり綺麗になりません。

これらの問題は、内製ツールというのもあり、改善と工数の釣り合いが取れていないので今はスルーしていますが、良い解決策が思いついたら直していきたいですね。

感想

Chrome拡張機能

今まで Chrome 拡張機能を作ったことはなかったのですが manifest.jsoncontent.js だけでかなり複雑なツールが作れるので、今後も業務改善ツールの選択肢としてかなり良さそうな気がしました。

ファイル数が少ないので、動作対象を絞ることでAIエージェントとの開発でもコンテクストが溢れにくく、それなりに適性がありそうな気がします。

このツールは現在、一部のメンバーに展開されており、フィードバックをもらってさらに使いやすくしていこうと思っています!
あるいは、メンバー自身に改良してもらうのも良いかなと感じています。

Antigravity

非カスタム時の精度やユーザ体験は他のツールにかなり大きな差をつけていると感じていて、エンジニア以外のロールのメンバーが片手間にツールを作るハードルがグッと下がった感じがします。

ブラウザ統合によって、デバッグやログ調査も任せられるので「丸投げして、自分は他の作業をする」ということも視野に入ってきます。

とはいえ、複数のエージェントやプロジェクトを跨いで管理する一番の特徴の部分はまだ触れられていないので、引き続き業務内外問わず触っていきたいと感じました。

...Quota はきついですが。

どのモデルも利用不可

おわりに

今回のツール作成はインストールと合わせて30~40分程度で終わったので、実装の片手間や、行き詰まった時の気分転換でも十分できる範囲だなと感じました。
うまく使われていけば工数的にも十分に元が取れるツールになったと思います。

Antigravity を利用して、気軽に業務改善をしてはいかがでしょうか?


MICIN ではメンバーを大募集しています。
「とりあえず話を聞いてみたい」でも大歓迎ですので、お気軽にご応募ください!
https://recruit.micin.jp/

株式会社MICIN

Discussion