ChatGPTとWordPressに文字サイズを変更するボタンを自作してみた!

SWELLに文字サイズを変更するボタンを自作したので、方法を共有します。

基本的にはChatGPTに丸投げで作成しました。

プロンプトなどを参考にしてください。

テーマ:SWELL

AI:ChatGPT

目次

セッションの保存なし

ChatGPTに投げたプロンプト

1回目に投げたプロンプト

1回目のプロンプトでは要件などを伝えました。

アクセシビリティ向上のためボタン{大・中・小}を選択する事でサイト上の文字サイズを変更できる機能を作成したいです。
関数名や変数名は英単語表記にしてください。コードはJSファイル内で完結するように変更して下さい。
初めなのでコードは最小要件で新婦理にしてください。

保存機能:無
対象範囲:サイト全体
UI設計:テキスト(大・中・小)
エラー表示:console出力のみ

2回目に投げたプロンプト

1回目のプロンプトで返ってきたコードでほぼ実装できていましたが、pxでしてされている箇所に対応できていなかったので、対応してもらいました。

font-sizeがpxで設定されている要素もあるので、それらの要素もサイズ変更できるようにしたいです。

最終的なコード

下記のコードの特徴をまとめました。

  • セッションの保存なし
  • px、rem、emに対応
  • vanillaJSのみで実装
// =====================================
// 設定値(変更しうる固有名や数字はここで一元管理)
// =====================================
const fontScaleConfig = {
	// UIラベル(日本語表示)
	labelLarge: "大",
	labelMedium: "中",
	labelSmall: "小",

	// 論理キー
	defaultKey: "medium", // "large" | "medium" | "small"

	// ルートfont-size(rem/em系要素用)
	// ここを変えると rem / em ベースの文字は素直に拡大縮小されます。
	rootFontPercentMap: Object.freeze({
		large: "115%",
		medium: "100%",
		small: "90%",
	}),

	// px指定要素も含めて拡大するための比率
	// まずは zoom(Chromium/Safari系)を優先し、未対応ブラウザでは transform: scale を使う
	// ※ zoom は非標準。Firefox は未対応のため transform をフォールバックします。
	zoomScaleMap: Object.freeze({
		large: 1.15,
		medium: 1.0,
		small: 0.9,
	}),

	// DOM/アクセシビリティ関連
	controlId: "js-font-size-control",
	controlAriaLabel: "サイトの文字サイズを変更",
	insertPosition: "afterbegin", // "afterbegin" | "beforeend" など
	rootTarget: "documentElement", // サイト全体の基準

	// 見た目(最小限のインラインスタイル)
	styleContainer: "position:relative; display:flex; gap:8px; font-family:system-ui, sans-serif; font-size:14px; align-items:center; padding:8px; border:1px solid #ddd; border-radius:8px; width:max-content; background:#fff;",
	styleLabel: "margin-right:4px;",
	styleButton: "appearance:none; border:1px solid #ccc; background:#fff; padding:4px 10px; border-radius:6px; cursor:pointer; font:inherit; line-height:1.4;",
	styleButtonActive: "border-color:#333; outline:2px solid transparent; box-shadow:0 0 0 2px rgba(0,0,0,.2);",
	styleButtonFocus: "outline:2px solid #2684FF; outline-offset:2px;",
};

// =====================================
// 内部状態(永続化なし)
// =====================================
let currentFontKey = fontScaleConfig.defaultKey;

// =====================================
// 初期化(JS単体で完結:UI生成・適用)
// =====================================
(function initGlobalFontScaler() {
	try {
		if (!document || !document.body) {
			console.error("【文字サイズ】初期化に失敗しました:document または body が利用できません。");
			return;
		}

		const control = buildControl();
		if (!control) {
			console.error("【文字サイズ】コントロールの生成に失敗しました:予期しないエラーです。");
			return;
		}

		// 既定サイズを適用
		const applied = applyGlobalFontScale(currentFontKey);
		if (!applied) {
			console.error("【文字サイズ】初期サイズの適用に失敗しました:設定値が不正な可能性があります。");
		}

		// ページに挿入
		document.body.insertAdjacentElement(fontScaleConfig.insertPosition, control);
	} catch (error) {
		console.error("【文字サイズ】初期化中にエラーが発生しました:", error);
	}
})();

// =====================================
// コントロール生成
// =====================================
function buildControl() {
	try {
		const container = document.createElement("div");
		container.id = fontScaleConfig.controlId;
		container.setAttribute("role", "group");
		container.setAttribute("aria-label", fontScaleConfig.controlAriaLabel);
		container.setAttribute("style", fontScaleConfig.styleContainer);

		const label = document.createElement("span");
		label.textContent = "文字サイズ:";
		label.setAttribute("style", fontScaleConfig.styleLabel);
		container.appendChild(label);

		const btnLarge = createButton("large", fontScaleConfig.labelLarge);
		const btnMedium = createButton("medium", fontScaleConfig.labelMedium);
		const btnSmall = createButton("small", fontScaleConfig.labelSmall);

		container.appendChild(btnLarge);
		container.appendChild(btnMedium);
		container.appendChild(btnSmall);

		updateAriaPressed(container, currentFontKey);
		updateActiveStyle(container, currentFontKey);

		container.addEventListener("focusin", (e) => {
			if (e.target && e.target.matches("button")) {
				e.target.setAttribute("data-prev-style", e.target.getAttribute("style") || "");
				e.target.setAttribute("style", (e.target.getAttribute("style") || "") + ";" + fontScaleConfig.styleButtonFocus);
			}
		});
		container.addEventListener("focusout", (e) => {
			if (e.target && e.target.matches("button")) {
				const prev = e.target.getAttribute("data-prev-style") || fontScaleConfig.styleButton;
				e.target.setAttribute("style", prev);
				e.target.removeAttribute("data-prev-style");
			}
		});

		return container;
	} catch (error) {
		console.error("【文字サイズ】コントロール生成中にエラーが発生しました:", error);
		return null;
	}
}

// =====================================
// ボタン生成(共通)
// =====================================
function createButton(sizeKey, labelText) {
	try {
		const btn = document.createElement("button");
		btn.type = "button";
		btn.textContent = labelText;
		btn.dataset.sizeKey = sizeKey;
		btn.setAttribute("style", fontScaleConfig.styleButton);
		btn.setAttribute("aria-pressed", "false");

		btn.addEventListener("click", onClickFontButton);
		btn.addEventListener("keydown", (e) => {
			if (e.key === "Enter" || e.key === " ") {
				e.preventDefault();
				btn.click();
			}
		});

		return btn;
	} catch (error) {
		console.error("【文字サイズ】ボタン生成中にエラーが発生しました:", error);
		return document.createElement("button");
	}
}

// =====================================
// クリック処理
// =====================================
function onClickFontButton(e) {
	try {
		const target = e.currentTarget;
		// datasetが存在するか確認してから参照
		const sizeKey = (target && target.dataset) ? target.dataset.sizeKey : undefined;
		if (!sizeKey) {
			console.error("【文字サイズ】クリック処理に失敗しました:sizeKey が取得できません。");
			return;
		}

		const ok = applyGlobalFontScale(sizeKey);
		if (!ok) {
			console.error(`【文字サイズ】"${sizeKey}" の適用に失敗しました:設定に存在しないキーです。`);
			return;
		}

		currentFontKey = sizeKey;

		const container = document.getElementById(fontScaleConfig.controlId);
		if (!container) {
			console.error("【文字サイズ】状態更新に失敗しました:コントロール要素が見つかりません。");
			return;
		}
		updateAriaPressed(container, currentFontKey);
		updateActiveStyle(container, currentFontKey);
	} catch (error) {
		console.error("【文字サイズ】クリック処理中にエラーが発生しました:", error);
	}
}

// =====================================
// 適用本体(rem/em + px を包括的にスケール)
// =====================================
function applyGlobalFontScale(sizeKey) {
	try {
		// 1) rem/em 系:<html> の font-size を変更
		const rootValue = fontScaleConfig.rootFontPercentMap[sizeKey];
		if (!rootValue) return false;

		const root = document[fontScaleConfig.rootTarget];
		if (!root) {
			console.error("【文字サイズ】適用に失敗しました:root 要素が取得できません。");
			return false;
		}
		root.style.fontSize = rootValue;

		// 2) px 系:まずは body.style.zoom を試し、未対応なら transform: scale を使う
		const scale = fontScaleConfig.zoomScaleMap[sizeKey];
		if (typeof scale !== "number" || Number.isNaN(scale)) {
			console.error("【文字サイズ】px要素向けスケール値が不正です。");
			return false;
		}

		// 既存のフォールバック状態をリセット
		resetBodyScaleArtifacts();

		// zoom が使用可能か判定(プロパティの存在と反映を簡易確認)
		const supportsZoomProp = "zoom" in document.body.style;
		if (supportsZoomProp) {
			document.body.style.zoom = String(scale);
		} else {
			// Firefox 等:transform フォールバック
			// ページ全体が縮尺されるため、レイアウト全体も拡大縮小されます(画像・余白含む)
			document.body.style.transformOrigin = "0 0";
			document.body.style.transform = `scale(${scale})`;
			// 横スクロール防止のため、幅をスケールに応じて調整
			// scale > 1 → 見かけ上拡大されるので、%で相殺
			// 例: scale=1.15 → 幅を 100/1.15% にする
			const widthPercent = (100 / scale).toFixed(4) + "%";
			document.body.style.width = widthPercent;
		}

		return true;
	} catch (error) {
		console.error("【文字サイズ】フォントスケール適用中にエラーが発生しました:", error);
		return false;
	}
}

// =====================================
// ARIA 更新
// =====================================
function updateAriaPressed(container, activeKey) {
	try {
		const buttons = container.querySelectorAll("button[data-size-key]");
		buttons.forEach((btn) => {
			const isActive = btn.dataset.sizeKey === activeKey;
			btn.setAttribute("aria-pressed", isActive ? "true" : "false");
		});
	} catch (error) {
		console.error("【文字サイズ】aria-pressed 更新中にエラーが発生しました:", error);
	}
}

// =====================================
// 視覚的アクティブ状態の更新
// =====================================
function updateActiveStyle(container, activeKey) {
	try {
		const buttons = container.querySelectorAll("button[data-size-key]");
		buttons.forEach((btn) => {
			const base = fontScaleConfig.styleButton;
			const active = fontScaleConfig.styleButtonActive;
			const isActive = btn.dataset.sizeKey === activeKey;
			btn.setAttribute("style", isActive ? `${base};${active}` : base);
		});
	} catch (error) {
		console.error("【文字サイズ】アクティブ見た目更新中にエラーが発生しました:", error);
	}
}

// =====================================
// フォールバック時の副作用リセット
// =====================================
function resetBodyScaleArtifacts() {
	try {
		// zoom は transform と競合しうるため、両方いじる前にリセット
		document.body.style.zoom = "";
		document.body.style.transform = "";
		document.body.style.transformOrigin = "";
		document.body.style.width = "";
	} catch (error) {
		console.error("【文字サイズ】スケール状態のリセット中にエラーが発生しました:", error);
	}
}

セッションの保存あり

文字サイズを変更した状態を保ったまま、別ページに移動できるようにしたくなった為、セッションの保存機能の追加をお願いしました。

ここで実装したセッションの保存は「ブラウザやタブを閉じるとリセット」される仕様です。

ChatGPTに投げたプロンプト

永続的な保存は必要ないですが、再読み込みしても文字サイズが保たれるようにしたいです。

最終的なコード

下記のコードの特徴をまとめました。

  • セッションの保存あり
  • px、rem、emに対応
  • vanillaJSのみで実装
// =====================================
// 設定値(変更しうる固有名や数字はここで一元管理)
// =====================================
const fontScaleConfig = {
	// UIラベル(日本語表示)
	labelLarge: "大",
	labelMedium: "中",
	labelSmall: "小",

	// 論理キーの既定値
	defaultKey: "medium", // "large" | "medium" | "small"

	// ルートfont-size(rem/em系要素用)
	rootFontPercentMap: Object.freeze({
		large: "115%",
		medium: "100%",
		small: "90%",
	}),

	// px指定要素も含めて拡大するための比率(zoom or transform用)
	zoomScaleMap: Object.freeze({
		large: 1.15,
		medium: 1.0,
		small: 0.9,
	}),

	// DOM/アクセシビリティ関連
	controlId: "js-font-size-control",
	controlAriaLabel: "サイトの文字サイズを変更",
	insertPosition: "afterbegin", // "afterbegin" | "beforeend" など
	rootTarget: "documentElement",

	// 見た目(最小限のインラインスタイル)
	styleContainer: "position:relative; display:flex; gap:8px; font-family:system-ui, sans-serif; font-size:14px; align-items:center; padding:8px; border:1px solid #ddd; border-radius:8px; width:max-content; background:#fff;",
	styleLabel: "margin-right:4px;",
	styleButton: "appearance:none; border:1px solid #ccc; background:#fff; padding:4px 10px; border-radius:6px; cursor:pointer; font:inherit; line-height:1.4;",
	styleButtonActive: "border-color:#333; outline:2px solid transparent; box-shadow:0 0 0 2px rgba(0,0,0,.2);",
	styleButtonFocus: "outline:2px solid #2684FF; outline-offset:2px;",

	// セッション保持(タブ内のみ有効。タブを閉じると消える)
	// 保存されるのは "large" | "medium" | "small" の文字列のみ。
	useSessionPersistence: true,
	sessionStorageKey: "fontScaleKey", // 保存するキー名
};

// =====================================
// 内部状態(永続化なし。必要に応じてセッション保存のみ)
// =====================================
let currentFontKey = fontScaleConfig.defaultKey;

// =====================================
// 初期化(JS単体で完結:UI生成・適用・セッション読込)
// =====================================
(function initGlobalFontScaler() {
	try {
		if (!document || !document.body) {
			console.error("【文字サイズ】初期化に失敗しました:document または body が利用できません。");
			return;
		}

		// セッションの値を読み込み(有効なキーなら反映)
		const restoredKey = loadFontKeyFromSession();
		if (restoredKey && fontScaleConfig.rootFontPercentMap[restoredKey]) {
			currentFontKey = restoredKey;
		}

		const control = buildControl();
		if (!control) {
			console.error("【文字サイズ】コントロールの生成に失敗しました:予期しないエラーです。");
			return;
		}

		// 現在キーを適用
		const applied = applyGlobalFontScale(currentFontKey);
		if (!applied) {
			console.error("【文字サイズ】初期サイズの適用に失敗しました:設定値が不正な可能性があります。");
		}

		// ページに挿入
		document.body.insertAdjacentElement(fontScaleConfig.insertPosition, control);
	} catch (error) {
		console.error("【文字サイズ】初期化中にエラーが発生しました:", error);
	}
})();

// =====================================
// コントロール生成
// =====================================
function buildControl() {
	try {
		const container = document.createElement("div");
		container.id = fontScaleConfig.controlId;
		container.setAttribute("role", "group");
		container.setAttribute("aria-label", fontScaleConfig.controlAriaLabel);
		container.setAttribute("style", fontScaleConfig.styleContainer);

		const label = document.createElement("span");
		label.textContent = "文字サイズ:";
		label.setAttribute("style", fontScaleConfig.styleLabel);
		container.appendChild(label);

		const btnLarge = createButton("large", fontScaleConfig.labelLarge);
		const btnMedium = createButton("medium", fontScaleConfig.labelMedium);
		const btnSmall = createButton("small", fontScaleConfig.labelSmall);

		container.appendChild(btnLarge);
		container.appendChild(btnMedium);
		container.appendChild(btnSmall);

		updateAriaPressed(container, currentFontKey);
		updateActiveStyle(container, currentFontKey);

		container.addEventListener("focusin", function (e) {
			if (e.target && e.target.matches("button")) {
				e.target.setAttribute("data-prev-style", e.target.getAttribute("style") || "");
				e.target.setAttribute("style", (e.target.getAttribute("style") || "") + ";" + fontScaleConfig.styleButtonFocus);
			}
		});
		container.addEventListener("focusout", function (e) {
			if (e.target && e.target.matches("button")) {
				const prev = e.target.getAttribute("data-prev-style") || fontScaleConfig.styleButton;
				e.target.setAttribute("style", prev);
				e.target.removeAttribute("data-prev-style");
			}
		});

		return container;
	} catch (error) {
		console.error("【文字サイズ】コントロール生成中にエラーが発生しました:", error);
		return null;
	}
}

// =====================================
// ボタン生成(共通)
// =====================================
function createButton(sizeKey, labelText) {
	try {
		const btn = document.createElement("button");
		btn.type = "button";
		btn.textContent = labelText;
		btn.dataset.sizeKey = sizeKey;
		btn.setAttribute("style", fontScaleConfig.styleButton);
		btn.setAttribute("aria-pressed", "false");

		btn.addEventListener("click", onClickFontButton);
		btn.addEventListener("keydown", function (e) {
			if (e.key === "Enter" || e.key === " ") {
				e.preventDefault();
				btn.click();
			}
		});

		return btn;
	} catch (error) {
		console.error("【文字サイズ】ボタン生成中にエラーが発生しました:", error);
		return document.createElement("button");
	}
}

// =====================================
// クリック処理
// =====================================
function onClickFontButton(e) {
	try {
		const target = e.currentTarget;
		// オプショナルチェイニングを使わず互換性を確保
		const sizeKey = (target && target.dataset) ? target.dataset.sizeKey : undefined;
		if (!sizeKey) {
			console.error("【文字サイズ】クリック処理に失敗しました:sizeKey が取得できません。");
			return;
		}

		const ok = applyGlobalFontScale(sizeKey);
		if (!ok) {
			console.error('【文字サイズ】"' + sizeKey + '" の適用に失敗しました:設定に存在しないキーです。');
			return;
		}

		currentFontKey = sizeKey;

		// セッション保存(タブを閉じるまで有効)
		saveFontKeyToSession(currentFontKey);

		const container = document.getElementById(fontScaleConfig.controlId);
		if (!container) {
			console.error("【文字サイズ】状態更新に失敗しました:コントロール要素が見つかりません。");
			return;
		}
		updateAriaPressed(container, currentFontKey);
		updateActiveStyle(container, currentFontKey);
	} catch (error) {
		console.error("【文字サイズ】クリック処理中にエラーが発生しました:", error);
	}
}

// =====================================
// 適用本体(rem/em + px を包括的にスケール)
// =====================================
function applyGlobalFontScale(sizeKey) {
	try {
		// 1) rem/em 系:<html> の font-size を変更
		const rootValue = fontScaleConfig.rootFontPercentMap[sizeKey];
		if (!rootValue) return false;

		const root = document[fontScaleConfig.rootTarget];
		if (!root) {
			console.error("【文字サイズ】適用に失敗しました:root 要素が取得できません。");
			return false;
		}
		root.style.fontSize = rootValue;

		// 2) px 系:まずは body.style.zoom を試し、未対応なら transform: scale を使う
		const scale = fontScaleConfig.zoomScaleMap[sizeKey];
		if (typeof scale !== "number" || Number.isNaN(scale)) {
			console.error("【文字サイズ】px要素向けスケール値が不正です。");
			return false;
		}

		// 既存のフォールバック状態をリセット
		resetBodyScaleArtifacts();

		// zoom が使用可能か判定(プロパティの存在で簡易判定)
		const supportsZoomProp = ("zoom" in document.body.style);
		if (supportsZoomProp) {
			document.body.style.zoom = String(scale);
		} else {
			// Firefox 等:transform フォールバック(ページ全体が拡大縮小されます)
			document.body.style.transformOrigin = "0 0";
			document.body.style.transform = "scale(" + scale + ")";
			// 横スクロール防止のため、幅をスケールに応じて調整
			var widthPercent = (100 / scale).toFixed(4) + "%";
			document.body.style.width = widthPercent;
		}

		return true;
	} catch (error) {
		console.error("【文字サイズ】フォントスケール適用中にエラーが発生しました:", error);
		return false;
	}
}

// =====================================
// ARIA 更新
// =====================================
function updateAriaPressed(container, activeKey) {
	try {
		const buttons = container.querySelectorAll("button[data-size-key]");
		buttons.forEach(function (btn) {
			const isActive = btn.dataset.sizeKey === activeKey;
			btn.setAttribute("aria-pressed", isActive ? "true" : "false");
		});
	} catch (error) {
		console.error("【文字サイズ】aria-pressed 更新中にエラーが発生しました:", error);
	}
}

// =====================================
// 視覚的アクティブ状態の更新
// =====================================
function updateActiveStyle(container, activeKey) {
	try {
		const buttons = container.querySelectorAll("button[data-size-key]");
		buttons.forEach(function (btn) {
			const base = fontScaleConfig.styleButton;
			const active = fontScaleConfig.styleButtonActive;
			const isActive = btn.dataset.sizeKey === activeKey;
			btn.setAttribute("style", isActive ? (base + ";" + active) : base);
		});
	} catch (error) {
		console.error("【文字サイズ】アクティブ見た目更新中にエラーが発生しました:", error);
	}
}

// =====================================
// フォールバック時の副作用リセット
// =====================================
function resetBodyScaleArtifacts() {
	try {
		document.body.style.zoom = "";
		document.body.style.transform = "";
		document.body.style.transformOrigin = "";
		document.body.style.width = "";
	} catch (error) {
		console.error("【文字サイズ】スケール状態のリセット中にエラーが発生しました:", error);
	}
}

// =====================================
// セッション保存(tab内のみ)
// =====================================
function saveFontKeyToSession(key) {
	try {
		if (!fontScaleConfig.useSessionPersistence) return;
		if (!window || !window.sessionStorage) return;
		window.sessionStorage.setItem(fontScaleConfig.sessionStorageKey, String(key));
	} catch (error) {
		console.error("【文字サイズ】セッション保存に失敗しました:", error);
	}
}

function loadFontKeyFromSession() {
	try {
		if (!fontScaleConfig.useSessionPersistence) return null;
		if (!window || !window.sessionStorage) return null;
		var value = window.sessionStorage.getItem(fontScaleConfig.sessionStorageKey);
		return value || null;
	} catch (error) {
		console.error("【文字サイズ】セッション読込に失敗しました:", error);
		return null;
	}
}

セッションに関する疑問を聞いてみた

このセッション保存の方法では、どこにデータが保存されますか?
サイトが重くなったりしませんか?
またどのような情報を保存していますか?個人情報などは保存したくありません。

結論から言うと、この実装のセッション保存はブラウザ内(同一タブ・同一オリジン専用の Web Storage:sessionStorage)に、“large” / “medium” / “small” のいずれか1語だけを保存します。サーバーには送信されません。負荷もほぼゼロです。

セッションで保存したデータはどこに保存される?

sessionStorage はブラウザ内の Web Storage(同一オリジン・同一タブ専用のキー/値ストア) に保存されます。

  • スコープ:同一プロトコル+ホスト+ポート(=同一オリジン)、かつそのタブ(ウィンドウ)だけ
  • 寿命:タブ/ウィンドウを閉じると消滅。リロードや同タブ内のページ遷移では残ります。
  • 通信HTTPリクエストには自動送信されません(Cookieと違う点)。サーバー側へ勝手に送られることはありません。
  • 容量:ブラウザごとに概ね数 MB 程度(5MB 前後)。今回の保存は数文字なので無視できるサイズです。
セッションを保存すると重くならない?

文字列1つ(例:"medium")を 読み書き1回ずつ するだけなので体感差はゼロです。
影響があるとすれば保存ではなく、拡大処理(zoom/transform)によるレイアウト再計算や再描画ですが、
これはユーザーがボタンを押した瞬間のみ発生し、常時負荷にはなりません。

セッション保存で何を保存している?個人情報は?

保存するのは fontScaleKey というキーに “large” | “medium” | “small” のいずれかのみ。
個人情報・識別子・履歴等は一切保存しません。
また、不正値は採用しないようバリデーションを入れています(上記コード参照)。

セッション保存によるセキュリティ面は大丈夫?

sessionStorage は同一オリジンのスクリプトからのみ読み書き可能です(第三者サイトからは参照不可)。
ただし、もしサイト内に XSS 脆弱性があると、そのスクリプトから読み取られる可能性はあります。
→ 今回は保存内容が文字列1語のみで機微情報ではないためリスクは極小です。

セッションの保存ありで文字サイズのみ変更する

ChatGPTに投げたプロンプト

追加の指示です。
画像や余白まで拡大されるのを避けたいので、処理は重くなりますが「文字だけ」を検出して動的に上書きする方式に変更して下さい。

最終的なコード

下記のコードの特徴をまとめました。

  • セッションの保存あり
  • px、rem、emに対応
  • vanillaJSのみで実装
  • 文字サイズのみ変更
// =====================================
// 設定値(すべてここで一元管理)
// =====================================
var textScaleConfig = {
  // UIラベル
  labelLarge: "大",
  labelMedium: "中",
  labelSmall: "小",

  // 選択キー
  defaultKey: "medium", // "large" | "medium" | "small"

  // rem/em 対応(<html> の font-size を変更)
  rootFontPercentMap: { large: "200%", medium: "100%", small: "50%" },

  // px の文字サイズに適用する倍率(“文字だけ”を上書き)
  pxScaleMap: { large: 2, medium: 1.0, small: 0.5 },

  // UI / アクセシビリティ
  controlId: "js-font-size-control",
  controlAriaLabel: "サイトの文字サイズを変更",
  insertPosition: "afterbegin", // "afterbegin" | "beforeend"

  // 見た目(最小限のインラインスタイル)
  styleContainer: "position:relative; display:flex; gap:8px; font-family:system-ui, sans-serif; font-size:14px; align-items:center; padding:8px; border:1px solid #ddd; border-radius:8px; width:max-content; background:#fff;",
  styleLabel: "margin-right:4px;",
  styleButton: "appearance:none; border:1px solid #ccc; background:#fff; padding:4px 10px; border-radius:6px; cursor:pointer; font:inherit; line-height:1.4;",
  styleButtonActive: "border-color:#333; outline:2px solid transparent; box-shadow:0 0 0 2px rgba(0,0,0,.2);",
  styleButtonFocus: "outline:2px solid #2684FF; outline-offset:2px;",

  // 検出対象から除外するタグ(文字を持たない/非テキスト用)
  excludeTagSet: { SCRIPT:1, STYLE:1, NOSCRIPT:1, IMG:1, SVG:1, CANVAS:1, VIDEO:1, AUDIO:1, IFRAME:1, OBJECT:1, EMBED:1, PICTURE:1 },

  // セッション保持(タブを閉じると消える)
  useSessionPersistence: true,
  sessionStorageKey: "fontScaleKey",

  // data-* 属性名(元サイズを保持するために要素へ付与)
  dataBasePxAttr: "data-fontscale-base-px",       // 例: "16"(medium基準)
  dataInlineFontAttr: "data-fontscale-inline-font",// 例: "14px" or ""

  // 動的DOM監視
  useMutationObserver: true,
  observeAttributes: ["class", "style"], // これらが変わったら再採取を試みる
  maxRepairPerTick: 400 // 1ティックで再採取する最大要素数(負荷制御)
};

// =====================================
// 内部状態
// =====================================
var allowedKeys = { large:true, medium:true, small:true };
var currentFontKey = textScaleConfig.defaultKey;
var repairScheduled = false;

// =====================================
// 初期化
// =====================================
(function initTextOnlyScaler() {
  try {
    if (!document || !document.body) {
      console.error("【文字サイズ】初期化に失敗しました:document または body が利用できません。");
      return;
    }

    // セッションからキーを復元
    var restored = loadKeyFromSession();
    if (restored && allowedKeys[restored]) currentFontKey = restored;

    // コントロール生成
    var control = buildControl();
    if (!control) {
      console.error("【文字サイズ】コントロールの生成に失敗しました:予期しないエラーです。");
      return;
    }

    // 基準(medium)に一旦合わせ、pxテキストの“基準px”を採取
    applyRootFontPercent("medium");
    collectBaselinePxForText();

    // 希望キーを適用(不足分は内部でリペア)
    applyTextScale(currentFontKey);

    // UI を挿入
    document.body.insertAdjacentElement(textScaleConfig.insertPosition, control);

    // 動的DOMに対応(後から来るテキストにも基準pxを付与)
    if (textScaleConfig.useMutationObserver && "MutationObserver" in window) {
      startMutationObserver();
    }
  } catch (error) {
    console.error("【文字サイズ】初期化中にエラーが発生しました:", error);
  }
})();

// =====================================
// コントロール生成
// =====================================
function buildControl() {
  try {
    var container = document.createElement("div");
    container.id = textScaleConfig.controlId;
    container.setAttribute("role", "group");
    container.setAttribute("aria-label", textScaleConfig.controlAriaLabel);
    container.setAttribute("style", textScaleConfig.styleContainer);

    var label = document.createElement("span");
    label.textContent = "文字サイズ:";
    label.setAttribute("style", textScaleConfig.styleLabel);
    container.appendChild(label);

    container.appendChild(createButton("large",  textScaleConfig.labelLarge));
    container.appendChild(createButton("medium", textScaleConfig.labelMedium));
    container.appendChild(createButton("small",  textScaleConfig.labelSmall));

    updateAriaPressed(container, currentFontKey);
    updateActiveStyle(container, currentFontKey);

    container.addEventListener("focusin", function (e) {
      if (e.target && e.target.matches("button")) {
        e.target.setAttribute("data-prev-style", e.target.getAttribute("style") || "");
        e.target.setAttribute("style", (e.target.getAttribute("style") || "") + ";" + textScaleConfig.styleButtonFocus);
      }
    });
    container.addEventListener("focusout", function (e) {
      if (e.target && e.target.matches("button")) {
        var prev = e.target.getAttribute("data-prev-style") || textScaleConfig.styleButton;
        e.target.setAttribute("style", prev);
        e.target.removeAttribute("data-prev-style");
      }
    });

    return container;
  } catch (error) {
    console.error("【文字サイズ】コントロール生成中にエラーが発生しました:", error);
    return null;
  }
}

function createButton(sizeKey, labelText) {
  try {
    var btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = labelText;
    btn.dataset.sizeKey = sizeKey;
    btn.setAttribute("style", textScaleConfig.styleButton);
    btn.setAttribute("aria-pressed", "false");

    btn.addEventListener("click", onClickScaleButton);
    btn.addEventListener("keydown", function (e) {
      if (e.key === "Enter" || e.key === " ") { e.preventDefault(); btn.click(); }
    });
    return btn;
  } catch (error) {
    console.error("【文字サイズ】ボタン生成中にエラーが発生しました:", error);
    return document.createElement("button");
  }
}

// =====================================
// クリック処理
// =====================================
function onClickScaleButton(e) {
  try {
    var target = e.currentTarget;
    var key = (target && target.dataset) ? target.dataset.sizeKey : undefined;
    if (!key || !allowedKeys[key]) {
      console.error("【文字サイズ】クリック処理に失敗しました:sizeKey が取得できないか不正です。");
      return;
    }

    applyTextScale(key); // 内部で不足分の基準pxもリペア
    currentFontKey = key;
    saveKeyToSession(currentFontKey);

    var container = document.getElementById(textScaleConfig.controlId);
    if (!container) { console.error("【文字サイズ】状態更新に失敗しました:コントロール要素が見つかりません。"); return; }
    updateAriaPressed(container, currentFontKey);
    updateActiveStyle(container, currentFontKey);
  } catch (error) {
    console.error("【文字サイズ】クリック処理中にエラーが発生しました:", error);
  }
}

// =====================================
// 文字だけをスケール適用(rem/em + px)
// - スケール直前に「不足分の基準px」をリペア採取
// - data属性が無い要素には怠惰採取してから適用
// =====================================
function applyTextScale(sizeKey) {
  try {
    // 1) rem/em:<html> の font-size
    var okRoot = applyRootFontPercent(sizeKey);
    if (!okRoot) { console.error("【文字サイズ】root の font-size 適用に失敗しました。"); }

    // 2) px:基準(medium)pxから倍率計算し“文字だけ”上書き
    var scale = textScaleConfig.pxScaleMap[sizeKey];
    if (typeof scale !== "number" || Number.isNaN(scale)) {
      console.error("【文字サイズ】px 用スケール値が不正です。");
      return false;
    }

    // 2-α) スケール直前のリペア(基準未採取の要素に data を付与)
    repairMissingBaselines(textScaleConfig.maxRepairPerTick);

    // 2-β) 適用
    // 既知(data付き)の要素へ適用
    var selector = "[" + textScaleConfig.dataBasePxAttr + "]";
    var nodes = document.querySelectorAll(selector);
    for (var i = 0; i < nodes.length; i++) {
      applyScaleToElement(nodes[i], scale);
    }
    return true;
  } catch (error) {
    console.error("【文字サイズ】テキストスケール適用中にエラーが発生しました:", error);
    return false;
  }
}

// dataを持つ要素にスケールを適用(無ければ怠惰採取してから適用)
function applyScaleToElement(el, scale) {
  try {
    // 対象外タグ・UI本体はスキップ
    if (textScaleConfig.excludeTagSet[el.tagName]) return;
    if (el.id === textScaleConfig.controlId) return;

    // 非表示はスキップ
    var cs = window.getComputedStyle(el);
    if (!cs || cs.display === "none" || cs.visibility === "hidden") return;

    // 必要なら怠惰採取
    if (!el.hasAttribute(textScaleConfig.dataBasePxAttr)) {
      collectIfTextPx(el);
      if (!el.hasAttribute(textScaleConfig.dataBasePxAttr)) return; // まだ対象にならなければ終了
    }

    var basePxStr = el.getAttribute(textScaleConfig.dataBasePxAttr);
    var basePx = parseFloat(basePxStr);
    if (Number.isNaN(basePx)) return;

    if (scale === 1) {
      var origInline = el.getAttribute(textScaleConfig.dataInlineFontAttr) || "";
      el.style.fontSize = origInline;
    } else {
      var newPx = Math.round(basePx * scale);
      el.style.fontSize = newPx + "px";
    }
  } catch (error) {
    console.error("【文字サイズ】要素単位のスケール適用中にエラーが発生しました:", error);
  }
}

// =====================================
// <html> の font-size を変更(rem/em 用)
// =====================================
function applyRootFontPercent(sizeKey) {
  try {
    var value = textScaleConfig.rootFontPercentMap[sizeKey];
    if (!value) return false;
    document.documentElement.style.fontSize = value;
    return true;
  } catch (error) {
    console.error("【文字サイズ】root font-size 変更中にエラーが発生しました:", error);
    return false;
  }
}

// =====================================
// 基準px採取(初回フルスキャン)
// =====================================
function collectBaselinePxForText() {
  try {
    var useWalker = !!document.createTreeWalker;
    if (useWalker) {
      var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
      var node;
      while ((node = walker.nextNode())) {
        collectIfTextPx(node);
      }
    } else {
      var all = document.getElementsByTagName("*");
      for (var i = 0; i < all.length; i++) {
        collectIfTextPx(all[i]);
      }
    }
  } catch (error) {
    console.error("【文字サイズ】基準 px 採取中にエラーが発生しました:", error);
  }
}

// =====================================
// 基準px採取(不足分を軽量にリペア)
// - maxCount までに限定して負荷を抑制
// =====================================
function repairMissingBaselines(maxCount) {
  try {
    var count = 0;
    var useWalker = !!document.createTreeWalker;
    if (useWalker) {
      var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
      var node;
      while ((node = walker.nextNode())) {
        if (!node.hasAttribute || node.hasAttribute(textScaleConfig.dataBasePxAttr)) continue;
        collectIfTextPx(node);
        if (node.hasAttribute(textScaleConfig.dataBasePxAttr)) {
          count++;
          if (count >= maxCount) break;
        }
      }
    } else {
      var all = document.getElementsByTagName("*");
      for (var i = 0; i < all.length; i++) {
        var el = all[i];
        if (el.hasAttribute && !el.hasAttribute(textScaleConfig.dataBasePxAttr)) {
          collectIfTextPx(el);
          if (el.hasAttribute(textScaleConfig.dataBasePxAttr)) {
            count++;
            if (count >= maxCount) break;
          }
        }
      }
    }
  } catch (error) {
    console.error("【文字サイズ】基準 px リペア採取中にエラーが発生しました:", error);
  }
}

// =====================================
// ノードが“px文字”であれば data を付与
// =====================================
function collectIfTextPx(el) {
  try {
    if (!el || !el.tagName) return;
    if (textScaleConfig.excludeTagSet[el.tagName]) return;
    if (el.id === textScaleConfig.controlId) return;

    var cs = window.getComputedStyle(el);
    if (!cs || cs.display === "none" || cs.visibility === "hidden") return;

    if (!hasNonWhitespaceText(el)) return;

    var fs = cs.fontSize; // "16px" 等
    if (!fs || fs.indexOf("px") === -1) return;

    if (el.hasAttribute(textScaleConfig.dataBasePxAttr)) return;

    var px = parseFloat(fs);
    if (Number.isNaN(px)) return;

    el.setAttribute(textScaleConfig.dataBasePxAttr, String(px));
    var origInline = el.style && el.style.fontSize ? el.style.fontSize : "";
    el.setAttribute(textScaleConfig.dataInlineFontAttr, origInline);
  } catch (error) {
    console.error("【文字サイズ】基準 px 判定中にエラーが発生しました:", error);
  }
}

function hasNonWhitespaceText(el) {
  try {
    for (var i = 0; i < el.childNodes.length; i++) {
      var n = el.childNodes[i];
      if (n.nodeType === 3 && /\S/.test(n.nodeValue)) return true; // TEXT_NODE
    }
    return false;
  } catch (error) {
    console.error("【文字サイズ】テキスト有無判定中にエラーが発生しました:", error);
    return false;
  }
}

// =====================================
// MutationObserver(動的追加/変更に追随)
// - 子追加/削除・class/style変更で再採取を試行
// - 連続イベントを1tickにまとめる(スロットリング)
// =====================================
function startMutationObserver() {
  try {
    var observer = new MutationObserver(function(mutations) {
      // 変更があれば次のマイクロタスクで軽量リペアを1回だけ実行
      if (!repairScheduled) {
        repairScheduled = true;
        Promise.resolve().then(function() {
          repairScheduled = false;
          repairMissingBaselines(textScaleConfig.maxRepairPerTick);
          // 現在のキーで再適用(新規要素にも反映)
          applyTextScale(currentFontKey);
        });
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: textScaleConfig.observeAttributes
    });
  } catch (error) {
    console.error("【文字サイズ】DOM監視開始に失敗しました:", error);
  }
}

// =====================================
// ARIA と見た目の状態更新
// =====================================
function updateAriaPressed(container, activeKey) {
  try {
    var buttons = container.querySelectorAll("button[data-size-key]");
    buttons.forEach(function (btn) {
      var isActive = btn.dataset.sizeKey === activeKey;
      btn.setAttribute("aria-pressed", isActive ? "true" : "false");
    });
  } catch (error) {
    console.error("【文字サイズ】aria-pressed 更新中にエラーが発生しました:", error);
  }
}

function updateActiveStyle(container, activeKey) {
  try {
    var buttons = container.querySelectorAll("button[data-size-key]");
    buttons.forEach(function (btn) {
      var base = textScaleConfig.styleButton;
      var active = textScaleConfig.styleButtonActive;
      var isActive = btn.dataset.sizeKey === activeKey;
      btn.setAttribute("style", isActive ? (base + ";" + active) : base);
    });
  } catch (error) {
    console.error("【文字サイズ】アクティブ見た目更新中にエラーが発生しました:", error);
  }
}

// =====================================
// セッション保存(タブ内のみ)
// =====================================
function saveKeyToSession(key) {
  try {
    if (!textScaleConfig.useSessionPersistence) return;
    if (!window || !window.sessionStorage) return;
    if (!allowedKeys[key]) return;
    window.sessionStorage.setItem(textScaleConfig.sessionStorageKey, String(key));
  } catch (error) {
    console.error("【文字サイズ】セッション保存に失敗しました:", error);
  }
}
function loadKeyFromSession() {
  try {
    if (!textScaleConfig.useSessionPersistence) return null;
    if (!window || !window.sessionStorage) return null;
    var v = window.sessionStorage.getItem(textScaleConfig.sessionStorageKey);
    return (v && allowedKeys[v]) ? v : null;
  } catch (error) {
    console.error("【文字サイズ】セッション読込に失敗しました:", error);
    return null;
  }
}

まとめ

ChatGPTと会話するだけでも、立派なプログラムになったと思います。

しかし、細かい箇所は人間がコードレビューする必要がありますし、知識が無いとChatGPTに指示する事もできません。

AIの成長速度は凄まじいですが、人間自身の勉強の大切さを改めて実感しました。

よかったらシェアしてね!
  • URLをコピーしました!
目次