はじめに
※拡張機能自体は、大学の印刷データ登録サイトに使えるものですが、今回記事に使用している、画像などは大学の印刷データ登録サイトを参考に架空の印刷データ登録サイトを作成して、それを元に記事を作成しています。
大学のプリンターを使う時には大学の印刷データを登録するサイト※下記画像 を使用することで印刷データをアップロードすることができます。
しかし、このサイトには欠点があり、ファイルを一つずつしかアップロードすることができません。これを解決するために、Chromeの拡張機能を作成して擬似的に複数投入できるようにしようと考えました。
使用技術
・Javascript
・HTML
・CSS
・IndexDB
クライアント側だけで処理を集約させたかったのと、内容的にもJavaScriptのみで実装できそうなのでJavaScriptを選択しました。
大まかな設計
※今回大学の印刷サイトをベースにして拡張機能を作成しているため、どうしてもDOM操作を行う必要があります。そのため、DOM-based XSS(クロスサイトスクリプティング)が起こる可能性があるので配布を前提とせず、あくまで個人で利用するための拡張機能として作成をします。
データの保存方法:
印刷設定の両面指定ありなし、部数のようにテキストデータであれば簡単に、保存することができるが、今回はpdf等のデータであるため、一度、Base64に変換して保存するようにしました。
使用技術:IndexDB
サイトアクセス時の処理:
・アップロードされているファイルの一覧を表示できるHTMLを追加
・元のファイル選択ボタンは一つずつしか対応していないので、独自のファイル選択ボタンを追加
・元のアップロードボタンを非表示にして、独自のアップロードボタンを追加
擬似的な複数アップロード実現方法:
()の中は誰がその操作をするのかを明記しています。
(ユーザー)独自のアップロードボタンを押す ->
(JS)アップロードされたファイルの印刷設定を元の印刷設定入力場所に擬似的に入力し元のアップロードボタンを押す -> 印刷アップロード結果画面に遷移 ->
(JS)再度、Mainページに遷移 ->
(JS)ファイルがまだ残っていたら独自のアップロードボタンをJSから押しを繰り返すことで擬似的に複数アップロードを実現します
実際に作ってみる
Chrome拡張機能の土台を作成する
拡張機能の設定方法について以下の記事を参考にさせていただきました。
{
"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でそのまま取得。
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でページの更新を促すようにしました。同様にして、複数選択可能なファイル選択、アップロード一覧画面を追加しました。
アップロード一覧画面については多くのファイルをアップロードされるのを想定して、スライダーを使用しました。スライダー機能については、以下のサイトを参考にさせていただきました。
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"> </div><br></fieldset>';
displayUploads.insertAdjacentHTML("afterend", customHTML);
} else {
alert("ページを更新してください");
}
現在の画面
ファイルアップロード時にファイルアイコンの表示
ファイルをアップロードすると、ファイルアップロード一覧の画面にファイルのアイコンを表示するようにします。
まず、ファイルの情報を管理するためのクラスを作成しました。
コンストラクターでは、デフォルトの印刷設定で初期化し、ファイルの名前、データに関しては仮引数で渡されたFileオブジェクトから取得しました。初期化後に、現在アップロードされている、ファイル一覧のデータを持つfileListにpushしています。
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クラスのインスタンスを仮引数として受け取り、ファイル名から拡張子を取得するようにしました。
//ファイルインスタンスを受け取って、ファイル一覧のところに表示
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..目のファイルの印刷設定に上書きされるようにしました。
・選択中のファイルについては青色の枠線を表示する仕様
//イベントリスナーの追加
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への変換
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への保存、データロードの実装
// 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を呼び出すようにします。
"matches": [
"https://resultPage"
],
"js": ["resultPage.js"]
//メインページに再度遷移するように設定
window.location.href =
"https://mainPage";
上記実装によって、resultPage遷移してもすぐにmainPageに戻るようにしています。また、これを利用してuploadStart関数をmainPageのload時に毎回実行するようにすることで、fileList.lengthが0より大きい場合(まだ、アップロードするファイルが残っている時)には、再度アップロードを行い、ファイルがない場合には何も行わないようにします。これによって、擬似的にユーザー側は一回の送信ボタンのクリックでファイルの一括アップロードを実装しました。
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ボタンをクリックしました。");
}
}
最後に
元々、想像していた通りに実装することができたが、ここからさらにユーザーのお気に入り印刷設定、デフォルトの印刷設定をユーザーが決められるようにすればさらにより便利な拡張機能になるのではないかと思います。