👏

【Chrome拡張機能】単体ファイルアップロード制限にさよなら!複数アップロード対応を実現 大学編

2024/12/06に公開

https://github.com/kk3desuyo/extentionPrint?tab=readme-ov-file

実際の画面

目的

大学のプリンターを使う時には大学の印刷データを登録するサイトを使用することで印刷データをアップロードすることができます。
しかし、このサイトには欠点があり、ファイルを一つずつしかアップロードすることができません。これを解決するために、Chromeの拡張機能を作成して擬似的に複数投入できるようにしようと考えました。

使用技術

・Javascript
・HTML
・CSS
・IndexDB
※元のシステムの使用で画面遷移が行われるため、JavaScriptの変数の情報をずっと保持することができないためIndexDBを採用。

クライアント側だけで処理を集約させたかったのと、内容的にもJavaScriptのみで実装できそうなのでJavaScriptを選択しました。

大まかな設計

※今回大学の印刷サイトをベースにして拡張機能を作成しているため、DOM操作を行う必要があります。そのため、DOM-based XSS(クロスサイトスクリプティング)が起こる可能性があるので配布を前提とせず、あくまで個人で利用するための拡張機能として作成をします。

スクリーンショット 2024-12-06 14.59.59.png
スクリーンショット 2024-12-06 15.00.29.png

データ保存方法

印刷設定(両面指定の有無や部数など)といったテキストデータは、通常であれば簡単に保存が可能です。しかし、今回は PDF などのバイナリデータを扱うため、一旦 Base64 に変換してから保存することにしました。保存にはブラウザのデータベースとして IndexDB を使用します。

複数ファイルを「擬似的」に一括アップロードする仕組み

本来、1ファイルずつしかアップロードできない仕様のページに対して、複数ファイルを自動で連続アップロードする機能を実装しました。
ここでは、その背景と実装方法について紹介します。

データの保存方法について

  • 通常のテキストデータ(印刷の両面指定や部数などの印刷設定)は、そのまま保存可能
  • PDFなどのバイナリデータは直接保存しにくいため、一旦Base64に変換してからIndexDBに保存

サイトアクセス時の変更点

  • アップロード済みファイル一覧表示用HTML要素の追加
    → これにより、ユーザーは現在アップロード済みのファイルを一覧で確認可能
  • 独自のファイル選択ボタンとアップロードボタンを用意
    → 元のボタンは1ファイルのみ対応のため、新規ボタンで複数ファイル選択をサポート
    → 元のアップロードボタンは非表示にし、独自ボタンでアップロード操作を行う

「擬似的な」複数アップロードの流れ

(1) ユーザーが独自アップロードボタンをクリック
→ ユーザーが新規設置したアップロードボタンを押すと、選択されたファイルを処理するフローが開始される

(2) JavaScriptによる印刷設定の自動反映と元ボタンの利用
→ JavaScriptで、ユーザーが選択したファイルの印刷設定を元の印刷設定入力欄に自動的に適用
→ その上で、JavaScriptが元々あったアップロードボタンをプログラム的にクリック
※ 元々のボタンは1ファイル前提だが、JavaScriptが繰り返し利用

(3) 印刷アップロード結果画面への遷移 & 再度メインページへ戻る
→ アップロード完了後、印刷アップロード結果画面へ遷移
→ その後、JavaScriptで再度メインページへ戻る

(4) 残ファイルの自動処理の繰り返し
→ メインページ戻り時、まだアップロードされていないファイルがあれば、JavaScriptが再度独自アップロードボタンを自動クリック
→ ファイルがなくなるまで繰り返すことで、複数ファイルを連続アップロードしたような体験を実現

以上により、ユーザーはあたかも複数ファイルを一度にアップロードできているような操作感が得られます。

実際に作ってみる

Chrome拡張機能の土台を作成する

拡張機能の設定方法について以下の記事を参考にさせていただきました。

https://qiita.com/shiro1212/items/12f0a767494a7b2ab0b3

manifest.json
{
  "manifest_version": 3,
  "name": "printExtention",
  "version": "1.0.1",
  "background": {
    "service_worker": "service-worker.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "matches": [
        "https://main"
      ],
      "js": ["mainPage.js"]
    },
    {
      "matches": [
        "https://result"
      ],
      "js": ["resultPage.js"]
    }
  ],
  "web_accessible_resources": [
    {
      "resources": ["img/*"],
      "matches": ["<all_urls>"]
    }
  ],
  "description": "大学の印刷データ登録サイトにおける複数ファイルの投入を可能にする拡張機能です。"
}


・"matches":指定したURLの時にどのファイルを実行するれば良いのかを指定できます。
・ "web_accessible_resources":拡張機能がアクセスできる資源を設定することができます。今回であれば、ファイルのアイコンの画像をimgフォルダーの中に格納しました。

この設定によって、"http://main" の時にはmainPage.js "https://result" の時にはresultPage.jsが起動されるように設定できました。

記事に設定方法等詳しいことは載ってあるので割愛します。

サイトアクセス時のDOM操作

まず、元からある、ファイル選択ボタンとファイルアップロードボタンを非表示にする

検証で確認してみると、fieldsetタグによって囲まれてファイル選択部分が作られていたので、fieldsetタグをquerySelectorで指定しようとしましたが、他の印刷設定の部分もfieldsetタグで作成されているため、fieldsetタグを持つHTMLを取得してもさらに絞り込む必要があります。

検証からHTMLを見てみるとファイル選択部分だけ、legendタグの子要素であるspanタグのidに"FileSelect"が指定されているのでこれを元にファイル選択のfieldsetタグを取得するようにしました。

ファイルアップロードボタンについてはdivタグ(class="button")で囲まれていて、ページにbuttoクラスが一つしかなかったのでquerySelectorでそのまま取得。

mainPage.js
function hideFileSelectUpload() {
  document.querySelectorAll("fieldset").forEach((fieldset) => {
    const legend = fieldset.querySelector("legend");
    if (legend) {
      const span = legend.querySelector("span#fileSelect");
      if (span) {
        fieldset.style.display = "none";
      }
    }
  });
  //元のアップロード開始ボタンを非表示
  document.querySelector("div.button").style.display = "none";
}

不要な部分の非表示の実装が終わったので、次に独自の送信ボタン、複数選択可能なファイル選択ボタン、アップロードファイルの表示部分のHTML追加していきます。

独自の送信ボタン、複数選択可能なファイル選択画面、アップロード一覧画面の追加

insertAsjacentHTMLを使用して、元の送信ボタンの後にHTMLを追加します。もし、要素が見つからない場合にはalertでページの更新を促すようにしました。同様にして、複数選択可能なファイル選択、アップロード一覧画面を追加しました。
アップロード一覧画面については多くのファイルをアップロードされるのを想定して、スライダーを使用しました。スライダー機能については、以下のサイトを参考にさせていただきました。

https://griponminds.jp/blog/html-web-components-image-slider/

mainPage.js
  let main = document.querySelector(".main");

  if (main) {
    //アップロードされたファイル一覧の表示部分
    main.insertAdjacentHTML(
      //アップロード一覧のHTML追加
      "afterbegin",
      '<fieldset id="displayUploads"><legend><span id="fileSelect" style="font-size: Medium">アップロードされているファイル</span></legend><file-slider><image-slider><div class="c-inner"><div class="c-slider" data-slider></div></div></image-slider></file-slider></fieldset>'
    );
    //jsの追加
    main.insertAdjacentHTML(
      //アップロード一覧のJS追加
      "afterbegin",
      '<script>class ImageSlider extends HTMLElement { edge = "start"; styles = getComputedStyle(this); move = parseInt(this.styles?.getPropertyValue("--move"), 10) || 1; gap = parseInt(this.styles?.getPropertyValue("--gap"), 10) || 0; slider = this.querySelector("[data-slider]"); navBtns = this.querySelectorAll("[data-nav-btn]"); image = this.slider?.querySelector("img"); controller = new AbortController(); timer; constructor() { super(); } connectedCallback() { if (this.slider) { this.detectScrollEdge(); const { signal } = this.controller; this.slider.addEventListener("scroll", () => { this.debounce(this.detectScrollEdge, 50); }, { signal }); window.addEventListener("resize", () => { this.debounce(this.detectScrollEdge, 50); }, { signal }); this.navBtns.forEach((btn) => btn.addEventListener("click", () => { this.slider.scrollLeft = this.calcScrollLeft(btn); }, { signal })); } } disconnectedCallback() { this.controller.abort(); } static get observedAttributes() { return ["edge"]; } attributeChangedCallback(name, oldValue, newValue) { if (this.navBtns && name === "edge") { this.navBtns.forEach((btn) => btn.removeAttribute("aria-disabled")); if (this.navBtns[0] && newValue === "start") { this.navBtns[0].setAttribute("aria-disabled", "true"); } else if (this.navBtns[1] && newValue === "end") { this.navBtns[1].setAttribute("aria-disabled", "true"); } } } detectScrollEdge = () => { const scrollLeft = this.slider.scrollLeft; const scrollRight = this.slider.scrollWidth - (scrollLeft + this.slider.clientWidth); if (scrollLeft <= 0) { this.edge = "start"; } else if (scrollRight <= 1) { this.edge = "end"; } else { this.edge = "false"; } if (this.getAttribute("edge") !== this.edge) { this.setAttribute("edge", this.edge); } }; calcScrollLeft = (btn) => { const dir = btn.getAttribute("data-nav-btn") === "prev" ? -1 : 1; const imageSize = this.image?.clientWidth ?? 300; const totalItemsSize = imageSize * this.move; const totalGap = this.gap * this.move; return this.slider.scrollLeft + dir * (totalItemsSize + totalGap); }; debounce = (fn, interval = 50) => { clearTimeout(this.timer); this.timer = setTimeout(() => fn(), interval); }; } customElements.define("image-slider", ImageSlider);</script>'
    );
    //cssの追加
    main.insertAdjacentHTML(
      "afterbegin",
      '<style>  #displayUploads{height:170px}file-slider {padding-top:5px height: 100%;}image-slider {height: 100%;}.fileCard-1{height:90%}.side-panel { width: 100vw; height: 80vh; } .file-img { width: 100px; height: 100px; } .file-name { margin: 2px 0 0; padding-bottom: 0; } image-slider { --show-items: 2; --glance: 0.5; --move: 1; --gap: 20px; --item-min-size: 150px; --item-max-size: 200px; --scroll-snap-align: start; --scrollbar-margin: 20px; --scrollbar-width: 12px; display: block; padding: var(--slider-padding); } image-slider .c-inner { --item-size: calc((100% - var(--show-items) * var(--gap)) / (var(--show-items) + var(--glance))); display: grid; row-gap: 20px; } image-slider .c-slider { display: flex; gap: var(--gap); overflow-x: auto; padding-block-end: var(--scrollbar-margin); scroll-snap-type: inline mandatory; scroll-behavior: smooth; } image-slider .c-item { flex: 0 0 clamp(var(--item-min-size), var(--item-size), var(--item-max-size)); scroll-snap-align: var(--scroll-snap-align); } image-slider .c-item img { display: block; inline-size: 100%; block-size: auto; aspect-ratio: 1; object-fit: cover; } image-slider .c-nav { display: grid; grid-template-columns: repeat(2, auto); column-gap: 16px; justify-content: end; transition: opacity 0.2s ease; } image-slider .c-nav-btn { --btn-size: 50px; --btn-color: #004d4d; --btn-color-hover: color-mix(in srgb, var(--btn-color) 80%, white); display: grid; gap: 4px; align-content: center; place-items: center; inline-size: var(--btn-size); block-size: var(--btn-size); padding: 4px; border: 2px solid var(--btn-color); border-radius: 50%; background: none; cursor: pointer; transition: opacity 0.2s ease; } image-slider .c-nav-btn[aria-disabled="true"] { opacity: 0.4; cursor: default; } image-slider .c-nav-svg { inline-size: 18px; margin-inline: auto; } image-slider .c-nav-svg path { fill: none; stroke: var(--btn-color); stroke-linecap: round; stroke-linejoin: round; stroke-width: 1px; transition: stroke 0.2s ease; } @media (hover) { image-slider .c-nav-btn:not([aria-disabled="true"]):hover { background-color: var(--btn-color-hover); } image-slider .c-nav-btn:not([aria-disabled="true"]):hover path { stroke: #fff; } } @layer utilities { .sr-only { position: absolute; overflow: hidden; clip: rect(0, 0, 0, 0); inline-size: 1px; block-size: 1px; margin: -1px; padding: 0; border-width: 0; white-space: nowrap; } }</style>'
    );

  } else {
    alert("ページの更新をしてください。");
  }
  let button = document.querySelector(".button");
  //ファイル送信ボタンの追加
  if (button) {
    button.insertAdjacentHTML(
      "beforebegin",
      '<input type="button" id="submitFile"style="font-size:Medium;height:25px;width:277px;margin-bottom: 4px" />'
    );
    //リスナーの追加
    document.getElementById("submitFile").addEventListener("click", () => {
      uploadStart();
    });
  } else {
    alert("ページの更新をしてください。");
  }
  //複数選択可能なファイル選択画面を追加
  let displayUploads = document.querySelector("#displayUploads");
  if (displayUploads) {
    var customHTML =
      '<fieldset id="customFileUploadFieldset"><legend><span id="customLbFileSelect" style="font-size:Medium;">ファイル選択 (Select File)</span></legend><div class="custom-disp-nbsp">&nbsp;&nbsp;&nbsp;</div><br></fieldset>';
    displayUploads.insertAdjacentHTML("afterend", customHTML);

  } else {
    alert("ページを更新してください");
  }


現在の画面

スクリーンショット 2024-06-16 16.45.34.png

ファイルアップロード時にファイルアイコンの表示

スクリーンショット 2024-06-06 16.49.12.png
ファイルをアップロードすると、ファイルアップロード一覧の画面にファイルのアイコンを表示するようにします。
まず、ファイルの情報を管理するためのクラスを作成しました。
コンストラクターでは、デフォルトの印刷設定で初期化し、ファイルの名前、データに関しては仮引数で渡されたFileオブジェクトから取得しました。初期化後に、現在アップロードされている、ファイル一覧のデータを持つfileListにpushしています。

mainPage.js
var selectedFileds = [];
class PrintFile {
  //アップロードファイル一覧
  static fileList = [];
  // コンストラクター
  constructor(fileData) {
    //Fileオブジェクトかの確認
    if (!(fileData instanceof File)) {
      throw new Error("fileData must be a File object");
    }
    this.isExist = true;
    this.name = fileData.name;
    this.id = PrintFile.fileList.length;
    this.fileData = fileData;
    this.paperSize = "デフォルトサイズ";
    this.sidedPrint = "両面指定しない";
    this.multipleUp = "複数アップしない";
    this.outputColor = "白黒";
    this.collated = true;
    this.sheets = "1";

    // インスタンスをfileListに追加
    PrintFile.fileList.push(this);
    console.log("ファイルインスタンス作成しました。");
  }
}

ファイル一覧画面にHTMLを追加する

上記で作成したPrintFileクラスのインスタンスを仮引数として受け取り、ファイル名から拡張子を取得するようにしました。
スクリーンショット 2024-06-06 17.14.30.png

mainPage.js
//ファイルインスタンスを受け取って、ファイル一覧のところに表示
function addFileHtml(file) {
  console.log(PrintFile.fileList);
  var fileName, extension;
  //ファイル名が長い場合の短縮
  if (file.name.length >= 7) {
    fileName = file.name.substring(0, 6);
  }
  extension = file.name.substring(file.name.indexOf("."));

  var fileImgPath;
  //ファイルのアイコン判定
  switch (extension) {
    case ".pdf":
      fileImgPath = chrome.runtime.getURL("img/pdf.png");
      break;
      //tiff,jpg等がありますが、長いのでカットします
//ファイルカードの作成
  const nodeFile =
    '<div class="file-card" id="fileCard-' +
    file.id +
    '" style="position: relative;"> <button type="button" class="file" id="file-' +
    file.id +
    '"><p class="file-name">' +
    fileName +
    extension +
    '</p><img class="file-img" src="' +
    fileImgPath +
    '" alt="ファイルのイメージ画像"></button><span class="round_btn" id="roundButton-' +
    file.id +
    '"></span>  <!-- バツボタンを追加 --></div><style>.blue-border{      border-color: aqua;border-width: 3px;border-style: solid;}</style>';
  const nodeCss =
    '<style>.file-card{}.round_btn { display: block; position: absolute; top: 120; right: 0; width: 30px; height: 30px; border: 2px solid #333; border-radius: 50%; background: #fff; cursor: pointer; transform: translate(50%, -50%); box-shadow: 0 2px 5px rgba(0,0,0,0.2); } .round_btn::before, .round_btn::after { content: ""; position: absolute; top: 50%; left: 50%; width: 3px; height: 22px; background: #333; } .round_btn::before { transform: translate(-50%, -50%) rotate(45deg); } .round_btn::after { transform: translate(-50%, -50%) rotate(-45deg); }</style>';

  const cSlider = document.querySelector(".c-slider");
  if (cSlider) {
    cSlider.insertAdjacentHTML("afterbegin", nodeFile);
    cSlider.insertAdjacentHTML("afterbegin", nodeCss);
    //消去バタンのリスナー追加
    const deleteButton = document.getElementById("roundButton-" + file.id);
    deleteButton.addEventListener("click", () => deleteFile(file.id));

複数、単体の印刷設定の実装

・複数選択の方法
JavaScriptではクリック時にどのキーが押されているかを情報として持っているので、SHIFTキーと同時に押された場合には複数選択モードとして、ファイル選択一覧に追加するようにしました。

・複数選択時の印刷設定
一番最初に選択されたファイルの設定が、2,3..目のファイルの印刷設定に上書きされるようにしました。

・選択中のファイルについては青色の枠線を表示する仕様

mainPage.js
//イベントリスナーの追加
    const fileButton = document.getElementById("file-" + file.id);
    fileButton.addEventListener("click", (event) => {
      //枠線を取り除く(複数、単体選択共通処理)
      removeBrueBorder();

      //ファイルの設定を保存
      var outputSizeSelect = document.getElementById("outputSeatSize");
      var sidedPrintSelect = document.getElementById("duplex");
      var multipleUpSelect = document.getElementById("multipleUp");
      var colorModeSelect = document.getElementById("colorMode");
      var collatedCheckbox = document.getElementById("collated");
      var sheetsInput = document.getElementById("sheets");
      for (const fileId of selectedFileds) {
        PrintFile.fileList[fileId].paperSize = outputSizeSelect.value;
        PrintFile.fileList[fileId].sidedPrint = sidedPrintSelect.value;
        PrintFile.fileList[fileId].multipleUp = multipleUpSelect.value;
        PrintFile.fileList[fileId].outputColor = colorModeSelect.value;
        PrintFile.fileList[fileId].collated = collatedCheckbox.checked;
        PrintFile.fileList[fileId].sheets = sheetsInput.value;
        console.log(fileId + "の設定変更");
      }
      //セッションに設定を保存
      saveFilesToSession();
      //SHIFTキー同時のクリック(ファイル複数選択)
      if (event.shiftKey) {
        //すでにせんたくされている場合(選択中リストから取り除く)
        if (selectedFileds.includes(file.id)) {
          selectedFileds = selectedFileds.filter(function (value) {
            return value != file.id;
          });
        }
        //含まれていない場合
        else {
          selectedFileds.push(file.id);
        }
        //一番最後に選択したファイルの印刷設定で初期
        resetSetting();
        //単体でのファイル印刷設定
      } else {
        //選択ファイルのリセット
        selectedFileds = [];
        selectedFileds.push(file.id);
      }

      console.log("選択中ファイル一覧" + selectedFileds);
      displaySetting(file.id);
      //枠線の追加(複数単体共通処理)
      addBrueBorder();
    });
  } else {
    console.log("cSliderタグが見つかりせん。");
  }
}

印刷設定、ファイルデータの保存

印刷設定については、枚数:1、用紙サイズ:A4のようにテキストデータであるため、簡単にローカルストレージに保存することができました。しかし、pdf等様ざまなファイルデータを保存するには、テキストデータに変換する必要があります。そこで、ファイルデータをBase64に変換することで、ローカルストレージに保存するようにしました。

##Base64への変換

mainPage.js

const promise = new Promise((resolve, reject) => {
        reader.onload = function (event) {
          const base64Data = event.target.result;

          const fileJson = {
            isExist: file.isExist,
            name: file.name,
            id: file.id,
            fileData64: base64Data, // Base64データを直接格納
            paperSize: file.paperSize,
            sidedPrint: file.sidedPrint,
            multipleUp: file.multipleUp,
            outputColor: file.outputColor,
            collated: file.collated,
            sheets: file.sheets,
          };

          filesData.push(fileJson);
          resolve();
        };

indexDBへの保存、データロードの実装

mainPage.js
// IndexedDBの初期化
function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("printFilesDB", 1);

    request.onupgradeneeded = function (event) {
      const db = event.target.result;
      if (!db.objectStoreNames.contains("files")) {
        db.createObjectStore("files", { keyPath: "id" });
      }
    };

    request.onsuccess = function (event) {
      resolve(event.target.result);
    };

    request.onerror = function (event) {
      reject(event.target.error);
    };
  });
}

// ファイルをIndexedDBに保存
function saveFilesToIndexedDB(files) {
  return initDB().then((db) => {
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(["files"], "readwrite");
      const store = transaction.objectStore("files");

      // 既存のレコードをクリア
      const clearRequest = store.clear();

      clearRequest.onsuccess = function () {
        files.forEach((file) => {
          store.put(file);
        });

        transaction.oncomplete = function () {
          resolve();
        };

        transaction.onerror = function (event) {
          reject(event.target.error);
        };
      };

      clearRequest.onerror = function (event) {
        reject(event.target.error);
      };
    });
  });
}
// IndexedDBからファイルをロード
function loadFilesFromIndexedDB() {
  return initDB().then((db) => {
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(["files"], "readonly");
      const store = transaction.objectStore("files");

      const request = store.getAll();

      request.onsuccess = function (event) {
        resolve(event.target.result);
      };

      request.onerror = function (event) {
        reject(event.target.error);
      };
    });
  });
}

状況整理

ここまで、ファイルの追加、ファイルの複数設定、ファイルデータの保存、ロードの実装が終了しました。
残りは、擬似的な一括アップロードの実装です。

擬似的な一括アップロード

まず、独自の送信ボタンにアップロード処理を記述します。jsのDataTransferを用いて、元のinputタグにファイルをコピー後に元のファイル送信ボタンをJSからクリックすることでファイルの送信を行うようにしました。送信後に、resultPage(正常にアップロードされているかを確認するページ)に遷移します。ここで、manifest.jsonにresultPageの時にはresultPage.jsを呼び出すようにします。

manifest.json
      "matches": [
        "https://resultPage"
      ],
      "js": ["resultPage.js"]
resultPage.js
//メインページに再度遷移するように設定
window.location.href =
  "https://mainPage";

上記実装によって、resultPage遷移してもすぐにmainPageに戻るようにしています。また、これを利用してuploadStart関数をmainPageのload時に毎回実行するようにすることで、fileList.lengthが0より大きい場合(まだ、アップロードするファイルが残っている時)には、再度アップロードを行い、ファイルがない場合には何も行わないようにします。これによって、擬似的にユーザー側は一回の送信ボタンのクリックでファイルの一括アップロードを実装しました。

mainPage.js
async function uploadStart() {
  console.log(PrintFile.fileList);
  console.log("ファイルのアップロード開始");
  await sleep(500);
  if (PrintFile.fileList.length <= 0) {
    console.log("アップロードファイルがありません。");
    return;
  }

  const file = PrintFile.fileList[0];

  if (file.isExist === true) {
    file.isExist = false;
    saveFilesToSession();

    // 元のinputタグにファイルをコピー
    const dataTransfer = new DataTransfer();
    dataTransfer.items.add(file.fileData);
    const input = document.getElementById("FileUpload1");
    input.files = dataTransfer.files;
    console.log(dataTransfer.files, file.fileData);
    await sleep(1000);

    // 元の投入開始ボタン
    const submit = document.getElementById("uploadStart");
    submit.click();
    console.log("元のsubmitボタンをクリックしました。");
  }
}

最後に

元々、想像していた通りに実装することができたが、ここからさらにユーザーのお気に入り印刷設定、デフォルトの印刷設定をユーザーが決められるようにすればさらにより便利な拡張機能になるのではないかと思います。また、今回は大学のサイトのみを対象にしましたが、どんなサイトでも対応できるような拡張機能を作成できればと考えています。

Discussion