🦐

PRエビデンス作成時に便利なChrome拡張機能をFlutterで実装してみた

に公開

こんにちは、スペースマーケットでモバイルエンジニアをしている村田です。
今回、普段開発で触っているFlutterでChromeの拡張機能を作ってみたので、その内容を本記事でご紹介します!

作成物

タイトルの通り、PR作成時に便利な以下の2つの機能を備えたツールを作成しました

  • Markdown形式の画像記法 ![タイトル](URL) をHTMLの <img>タグ に変換
  • 最大4x3のMarkdownテーブル生成

背景

PRのエビデンスを作成する際に愛用していた、ImgConverterが利用不可になってしまい、作業効率が落ちてしまいました。代替サービスも見当たらなかったため、それならもう自分で作ってしまおうと思い、今回拡張機能を実装しました。

恐らくManifest V3未対応で利用不可になったのかな?

https://japan.zdnet.com/article/35230444/

開発

プロジェクト作成

Webのみに対応したプロジェクト構成にしたいため、プラットフォームへwebを指定してcreateコマンドを実行

$ fvm flutter create --platforms web pr_evidence_chrome_extension
.
├── README.md
├── analysis_options.yaml
├── lib
│   └── main.dart
├── pr_evidence_chrome_extension.iml
├── pubspec.lock
├── pubspec.yaml
├── test
│   └── widget_test.dart
└── web
    ├── favicon.png
    ├── icons
    │   ├── Icon-192.png
    │   ├── Icon-512.png
    │   ├── Icon-maskable-192.png
    │   └── Icon-maskable-512.png
    ├── index.html
    └── manifest.json

ImgTagConverterコンポーネント

Markdownテーブル生成についても記載すると長くなるので、<img>タグ変換に関するコンポーネントに焦点を当てて紹介します!

<img>タグ変換JavaScriptメソッド

Dartから呼び出すJavaScriptメソッドを実装します。
ユーザーが選択した画像幅を受け取り、変換した<img>タグへ受け取ったwidthを指定します。

タブ取得

chrome.tabs APIを利用し、現在アクティブなタブ(=開いているPR画面)を取得

let queryOptions = { active: true, currentWindow: true };
let [tab] = await chrome.tabs.query(queryOptions);

DOM操作のためのJavaScript実行

chrome.scripting APIを利用し、取得したタブに対してJavaScriptの関数を実行

await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: (width) => {
    // 実行内容
  },
  args: [width]
});

<img>タグ変換DOM操作

executeScript で実行するDOM操作は以下になります

  • PR本文を書く要素 <textarea> を取得
  • Markdown形式の画像をHTMLの<img>タグに変換
  • すでに <img>タグになっているものも、指定した幅に揃えて更新
const textarea = document.querySelector('textarea[name="pull_request[body]"]');
textarea.value = textarea.value.replace(/!\[.*?\]\((.*?)\)/g, `<img src="$1" width=${width}>`);
textarea.value = textarea.value.replace(/<img src="(.*?)" width=\d+>/g, `<img src="$1" width=${width}>`);

コード全体

まとめると、Dartから呼び出すJavaScriptメソッドは以下になります

// github_pr_evidence.js

async function convertImgTag(width) {
  let queryOptions = { active: true, currentWindow: true };
  let [tab] = await chrome.tabs.query(queryOptions);
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: (width) => {
      const textarea = document.querySelector('textarea[name="pull_request[body]"]');
      textarea.value = textarea.value.replace(/!\[.*?\]\((.*?)\)/g, `<img src="$1" width=${width}>`);
      textarea.value = textarea.value.replace(/<img src="(.*?)" width=\d+>/g, `<img src="$1" width=${width}>`);
    }
    ,args: [width]
  });
}

Dart -> JavaScript 連携

DartからJavaScriptのメソッドを呼び出すため、公式パッケージのdart:js_interopを利用します。
以前はjsライブラリが主流だったようですが、dart:js_interop の改善によりDart3.3以降こちらの利用が推奨されているようです。

JavaScript連携定義

@JSは、DartからJavaScriptと連携するためのアノテーションです。
@JS('JavaScriptメソッド名') のように、JavaScript側で定義した名前を指定できます。

先ほど実装した convertImgTag(width) と連携するための定義は以下になります。

('convertImgTag')
external JSPromise _convertImgTag(JSNumber width);

実行メソッド

宣言されたJS連携メソッドをDartから呼び出すためのメソッドを定義します。

受け取ったint型のwidthをJSNumber型に変換します。
その後、JavaScriptのPromiseをDartのFutureに変換するためにtoDartを使い、_convertImgTagを実行します。

Future<void> convertImgTag(int width) async {
  await _convertImgTag(width.toJS).toDart;
}

コード全体

まとめると、JavaScriptメソッドと連携し、それを実行するためのDartコードは以下になります

// github_pr_evidence.dart

import 'dart:js_interop';

('convertImgTag')
external JSPromise _convertImgTag(JSNumber width);

Future<void> convertImgTag(int width) async {
  await _convertImgTag(width.toJS).toDart;
}

UIコンポーネント

github_pr_evidence.dart をインポートし、width変更ボタン押下時にconvertImgTagをコール

import 'package:flutter/material.dart';
import 'package:pr_evidence_chrome_extension/github_pr_evidence.dart';

class ImgTagConverterView extends StatelessWidget {
  const ImgTagConverterView({super.key});

  
  Widget build(BuildContext context) {
    const widthList = [150, 200, 250, 300, 350, 400];

    return Padding(
      padding: const EdgeInsets.all(8),
      child: Column(
        spacing: 6,
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          Text(
            'Img Tag Converter',
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.bold,
              color: Color(0xFFfb4b28),
            ),
          ),
          GridView.count(
            shrinkWrap: true,
            physics: NeverScrollableScrollPhysics(),
            crossAxisCount: 3,
            mainAxisSpacing: 10,
            crossAxisSpacing: 10,
            childAspectRatio: 2,
            children: widthList.map((width) {
              return TextButton(
                onPressed: () async {
                  // よろしくお願いしまああああすッ!!!
                  await convertImgTag(width);
                },
                style: ButtonStyle(
                  backgroundColor: WidgetStateProperty.all(Color(0xFFfb4b28)),
                ),
                child: Text(
                  '${width}px',
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.bold,
                    color: Colors.white,
                  ),
                ),
              );
            }).toList(),
          ),
        ],
      ),
    );
  }
}

webフォルダ内設定

web/manifest.json

拡張機能API使用するため、permissionsへ以下設定します

  • activeTab: 現在アクティブなタブへのアクセス権
  • scripting: JavaScriptを使いウェブページ上のコードを操作・挿入するための権限

https://developer.chrome.com/docs/extensions/reference/manifest?hl=ja

{
    "version": "1.0.0",
    "name": "PR EVIDENCE",
    "action": {
      "default_popup": "index.html"
    },
    "icons": {
      "16": "icons/Icon.png",     
      "32": "icons/Icon.png",
      "48": "icons/Icon.png",
      "128": "icons/Icon.png"
    },
    "permissions": ["scripting", "activeTab"],
    "manifest_version": 3
}

web/index.html

実装したJavaScriptファイルを埋め込み、表示したいサイズをstyleタグとして記述します

<!DOCTYPE html>
<html style="height: 390px; width: 250px">
<head>
  <meta charset="UTF-8">
  <title>PR EVIDENCE</title>
</head>
<body>
  <script src="github_pr_evidence.js" type="application/javascript"></script>
  <script src="main.dart.js" type="application/javascript"></script>
</body>
</html>

まとめ

JavaScriptとの連携が型安全かつ楽に実装できるようになり、簡単なWebアプリケーションであればFlutterでサクッと実装できるなと感じました!自分が自由にカスタマイズできるChrome拡張機能を持てたことは、今後の開発効率を爆上げできる予感がしています。

それぞれひとつのChrome拡張機能
それぞれが選んだstyle

https://www.youtube.com/watch?v=6LbPC4ZWFok

最後に

スペースマーケットでは一緒に働く仲間を絶賛募集中です!
詳しくは以下をご確認の上ご応募ください。

https://jobs.forkwell.com/spacemarket/jobs/28583

GitHubで編集を提案
スペースマーケット Engineer Blog

Discussion