// ==UserScript==
// @name YouTubeコメント欄を拡大・縮小 🎥
// @name:ja YouTubeコメント欄を拡大・縮小 🎥
// @name:en Zoom Controls for YouTube Comments 🎥
// @name:zh-CN YouTube评论区缩放控制 🎥
// @name:zh-TW YouTube留言區縮放控制 🎥
// @name:ko YouTube 댓글 확대/축소 제어 🎥
// @name:fr Contrôle du zoom pour les commentaires YouTube 🎥
// @name:es Control de zoom en comentarios de YouTube 🎥
// @name:de Zoom-Steuerung für YouTube-Kommentare 🎥
// @name:pt-BR Controle de zoom nos comentários do YouTube 🎥
// @name:ru Управление масштабом комментариев на YouTube 🎥
// @version 2.1.1
// @description YouTubeのコメント欄を拡大・縮小するUIを追加!ホイールでズーム、クリックでリセット。状態は保存されます。
// @description:ja YouTubeのコメント欄を拡大・縮小するUIを追加!ホイールでズーム、クリックでリセット。状態は保存されます。
// @description:en Adds zoom controls to YouTube comments! Scroll to zoom in/out, click to reset. Zoom level is saved.
// @description:zh-CN 为YouTube评论区添加缩放控件!滚轮缩放,点击重置,缩放等级会被保存。
// @description:zh-TW 為YouTube留言區新增縮放控制!滾輪縮放,點擊重設,縮放設定會被儲存。
// @description:ko YouTube 댓글에 확대/축소 UI 추가! 스크롤로 조절, 클릭으로 리셋. 설정은 저장됩니다.
// @description:fr Ajoute un contrôle de zoom aux commentaires YouTube ! Molette pour zoomer, clic pour réinitialiser. Niveau de zoom sauvegardé.
// @description:es Agrega controles de zoom a los comentarios de YouTube. Rueda para ampliar/reducir, clic para restablecer. El nivel se guarda.
// @description:de Fügt Zoomsteuerung für YouTube-Kommentare hinzu! Scrollen zum Zoomen, Klick zum Zurücksetzen. Zoom-Level wird gespeichert.
// @description:pt-BR Adiciona controles de zoom aos comentários do YouTube! Role para ajustar, clique para redefinir. Nível salvo.
// @description:ru Добавляет управление масштабом к комментариям на YouTube. Прокрутка — зум, клик — сброс. Уровень сохраняется.
// @namespace https://github.com/koyasi777/youtube-comment-zoom-control
// @author koyasi777
// @match *://www.youtube.com/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// @license MIT
// @compatible firefox
// @homepageURL https://github.com/koyasi777/youtube-comment-zoom-control
// @supportURL https://github.com/koyasi777/youtube-comment-zoom-control/issues
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==
(function() {
'use strict';
class CommentZoomManager {
// --- 1. 設定 (Configuration) ---
constructor() {
this.pageType = this.getPageType();
this.containerSelector = '';
this.injectionTargetSelector = '';
this.zoomTargetSelector = '';
this.SCRIPT_ID = 'youtube-comment-zoom-controls-container';
this.STYLE_ID = 'youtube-comment-zoom-style-sheet';
this.ZOOM_STORAGE_KEY = 'yt-comment-zoom-level';
this.DEFAULT_ZOOM = 100;
this.MIN_ZOOM = 50;
this.MAX_ZOOM = 200;
this.ZOOM_STEP = 5;
// ツールチップの文言を定義
this.tooltips = {
ja: 'スクロールで拡大縮小 / クリックでリセット',
en: 'Scroll to zoom / Click to reset'
};
this.setSelectors();
this.currentZoom = this.DEFAULT_ZOOM;
this.uiObserver = null;
}
// --- 2. 初期化と破棄 (Initialization & Destruction) ---
async init() {
if (!this.pageType) {
console.log('[CommentZoom] Not a watch or shorts page. Skipping initialization.');
return;
}
await this.loadZoomState();
this.injectStyles();
this.observeHeader();
console.log(`[CommentZoom] Initialized for ${this.pageType} page with zoom: ${this.currentZoom}%`);
}
stop() {
if (this.uiObserver) {
this.uiObserver.disconnect();
this.uiObserver = null;
console.log('[CommentZoom] Observer stopped.');
}
}
getPageType() {
if (location.pathname.startsWith('/watch')) return 'watch';
if (location.pathname.startsWith('/shorts')) return 'shorts';
return null;
}
setSelectors() {
switch (this.pageType) {
case 'watch':
this.containerSelector = 'ytd-comments#comments';
this.injectionTargetSelector = 'ytd-comments-header-renderer #sort-menu';
this.zoomTargetSelector = 'ytd-comment-thread-renderer';
break;
case 'shorts':
this.containerSelector = 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-comments-section"]';
this.injectionTargetSelector = 'ytd-engagement-panel-title-header-renderer #menu';
this.zoomTargetSelector = 'ytd-comment-thread-renderer';
break;
}
}
// --- 3. 状態管理 (State Management) ---
async loadZoomState() {
let storedZoom = await GM_getValue(this.ZOOM_STORAGE_KEY, this.DEFAULT_ZOOM);
if (typeof storedZoom !== 'number' || isNaN(storedZoom)) {
storedZoom = this.DEFAULT_ZOOM;
}
this.currentZoom = storedZoom;
}
async saveZoomState() {
await GM_setValue(this.ZOOM_STORAGE_KEY, this.currentZoom);
}
// --- 4. スタイル管理 (Style Management) ---
injectStyles() {
if (document.getElementById(this.STYLE_ID)) {
this.updateZoomStyle();
return;
}
const styleElement = document.createElement('style');
styleElement.id = this.STYLE_ID;
(document.head || document.documentElement).appendChild(styleElement);
this.updateZoomStyle();
}
updateZoomStyle() {
const styleElement = document.getElementById(this.STYLE_ID);
if (!styleElement) return;
const staticStyles = `
ytd-comments-header-renderer #sort-menu #icon-label,
ytd-engagement-panel-title-header-renderer #menu #label { font-size: 10.5px !important; }
ytd-comments-header-renderer h2#count yt-formatted-string.count-text { font-size: 1.48rem !important; }
`;
const dynamicZoomStyle = this.currentZoom === 100 ? '' :
`${this.zoomTargetSelector} { zoom: ${this.currentZoom}%; }`;
styleElement.textContent = staticStyles + dynamicZoomStyle;
}
// --- 5. UIの生成とイベントハンドリング (UI Creation & Event Handling) ---
createZoomControls() {
const container = document.createElement('div');
container.id = this.SCRIPT_ID;
Object.assign(container.style, {
position: 'relative', // ツールチップの基準点として必要
display: 'flex', alignItems: 'center', marginLeft: '8px', padding: '4px 8px',
borderRadius: '8px', transition: 'background-color 0.2s', cursor: 'pointer', userSelect: 'none'
});
// ツールチップをYT標準コンポーネントで作成
const tooltip = document.createElement('tp-yt-paper-tooltip');
tooltip.setAttribute('role', 'tooltip');
const tooltipText = document.createElement('div');
tooltipText.id = 'tooltip';
tooltipText.className = 'style-scope tp-yt-paper-tooltip';
const lang = document.documentElement.lang.startsWith('ja') ? 'ja' : 'en';
tooltipText.textContent = this.tooltips[lang];
tooltip.appendChild(tooltipText);
const zoomIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
zoomIcon.setAttribute('viewBox', '0 0 24 24');
Object.assign(zoomIcon.style, {
width: '18px', height: '18px', marginRight: '6px',
fill: 'var(--yt-spec-text-secondary)', transition: 'fill 0.2s',
});
zoomIcon.innerHTML = `<path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path>`;
const zoomDisplay = document.createElement('div');
Object.assign(zoomDisplay.style, {
color: 'var(--yt-spec-text-secondary)', fontSize: '14px', fontWeight: '500',
fontVariantNumeric: 'tabular-nums', transition: 'color 0.2s',
});
const updateDisplay = () => { zoomDisplay.textContent = `${this.currentZoom}%`; };
updateDisplay();
const handleZoomUpdate = () => {
this.saveZoomState();
this.updateZoomStyle();
updateDisplay();
};
container.onmouseenter = () => {
container.style.backgroundColor = 'var(--yt-spec-badge-chip-background-hover)';
zoomIcon.style.fill = 'var(--yt-spec-text-primary)';
zoomDisplay.style.color = 'var(--yt-spec-text-primary)';
};
container.onmouseleave = () => {
container.style.backgroundColor = 'transparent';
zoomIcon.style.fill = 'var(--yt-spec-text-secondary)';
zoomDisplay.style.color = 'var(--yt-spec-text-secondary)';
};
container.addEventListener('wheel', (e) => {
e.preventDefault();
e.stopPropagation();
const oldZoom = this.currentZoom;
if (e.deltaY < 0) {
this.currentZoom = Math.min(this.MAX_ZOOM, this.currentZoom + this.ZOOM_STEP);
} else {
this.currentZoom = Math.max(this.MIN_ZOOM, this.currentZoom - this.ZOOM_STEP);
}
if (oldZoom !== this.currentZoom) handleZoomUpdate();
}, { passive: false });
container.addEventListener('click', () => {
if (this.currentZoom !== this.DEFAULT_ZOOM) {
this.currentZoom = this.DEFAULT_ZOOM;
handleZoomUpdate();
}
});
container.append(zoomIcon, zoomDisplay, tooltip);
return container;
}
// --- 6. DOM監視 (DOM Observation) ---
observeHeader() {
const commentsElement = document.querySelector(this.containerSelector);
if (!commentsElement) {
if (this.pageType === 'shorts') {
setTimeout(() => this.observeHeader(), 500);
}
return;
}
this.uiObserver = new MutationObserver(() => this.updateHeaderUI());
this.uiObserver.observe(commentsElement, { childList: true, subtree: true });
this.updateHeaderUI();
}
updateHeaderUI() {
if (document.getElementById(this.SCRIPT_ID)) return;
const injectionTarget = document.querySelector(this.injectionTargetSelector);
if (injectionTarget) {
const zoomControls = this.createZoomControls();
if (this.pageType === 'shorts') {
injectionTarget.insertAdjacentElement('afterend', zoomControls);
} else {
injectionTarget.parentElement.insertBefore(zoomControls, injectionTarget.nextSibling);
}
console.log(`[CommentZoom] UI injected for ${this.pageType}.`);
}
}
}
// --- 実行制御 (Execution Control) ---
let zoomManager = null;
function main() {
if (zoomManager) {
zoomManager.stop();
zoomManager = null;
}
(async () => {
zoomManager = new CommentZoomManager();
await zoomManager.init();
})();
}
window.addEventListener('yt-navigate-finish', main);
main();
})();