プレゼンテーションモードを魔改造するUserScript
プレゼンテーションモードでページ番号を表示させるUserScript
https://gyazo.com/af473bfa02fc1718f5c553d86ffe56cb
テスト
プレゼン用サンプルページ
hr.icon
実装
code:script.js
// ページ番号の表示
// 現在のページ合わせてアニメーション付きで現在地を表示
// フッターに名前とタイトルを表示
(() => {
'use strict';
// ==============================
// Constants
// ==============================
const BADGE_ID = 'sbx-slide-number';
// Progress bar
const PROG_ID = 'sbx-slide-progress';
const PROG_TRACK_ID = 'sbx-slide-progress-track';
const PROG_FILL_ID = 'sbx-slide-progress-fill';
// Dots inside progress bar
const DOTS_ID = 'sbx-slide-progress-dots';
const DOT_BTN_CLASS = 'sbx-slide-progress-dot';
// Sizes
const BAR_H = 8; // バーの太さ(px)
const DOT_SIZE = 8; // ドット直径(px)
const DOT_HIT_H = 20; // ドットのヒット領域高さ(px)
// ==============================
// Captions (image -> next line)
// ==============================
const CAPTION_CLASS = 'sbx-img-caption';
const CAPTION_SRC_CLASS = 'sbx-img-caption-src';
const CAPTION_STYLE_ID = 'sbx-img-caption-style';
// 「次の行を薄くする/消す」挙動(好みで)
const HIDE_CAPTION_SOURCE_LINE = true; // trueにすると次行を非表示
// ==============================
// Footer
// ==============================
const FOOTER_ID = 'sbx-slide-footer';
const AUTHOR_NAME = "Sample Author Name";
const FOOTER_EXTRA = scrapbox.Page.title;
const FOOTER_ALIGN = 'center'; // 'left' | 'center' | 'right'
const FOOTER_FONT_PX = 12; // フッター文字サイズ(px)
const FOOTER_OPACITY = 0.6; // 0..1 さりげなさ調整
const FOOTER_GAP_PX = 5; // 進捗バー上端からの隙間
// ==============================
// Background (page directive)
// ==============================
const BG_STYLE_ID = 'sbx-background-style';
const BG_DIRECTIVE_CLASS = 'sbx-background-directive';
// 例: @background color="#003366"
const BG_DIRECTIVE_RE = /^\s*@background\b(.*)$/i;
// .lines .line:not(.section-N){...} から N を抽出する正規表現
const STYLE_RULE_RE = /\.line\s*:not\(\.section-(\d+)\)/;
const FOOTER_DIRECTIVE_CLASS = 'sbx-footer-directive';
const FOOTER_STYLE_ID = 'sbx-footer-style';
// ==============================
// Footer page directives
// ==============================
const AUTHOR_DIRECTIVE_RE = /^\s*@author\s+(.+)$/i;
const FOOTER_DIRECTIVE_RE = /^\s*@footer\b(.*)$/i;
// key=value(; 区切り/空白OK、値は "..." '...' 非引用にも対応)
const KV_RE = /(a-zA-Z\w-*)\s*=\s*(?:"(^"*)"|'(^'*)'|(^;+))/g;
let cachedFooterFromPage = null;
let cachedFooterSignature = '';
const readLinesSignature = () => {
const els = $$('.lines .line');
const count = els.length;
const tail = count ? (elscount - 1.textContent || '').length : 0;
return ${count}:${tail};
};
const parseFooterFromPage = () => {
const sig = readLinesSignature();
if (cachedFooterFromPage && cachedFooterSignature === sig) return cachedFooterFromPage;
let author=null, extra=null, align=null, opacity=null, fontSize=null, hide=null;
// 以前の目印をクリア(内容変更・削除に追従)
$$(.${FOOTER_DIRECTIVE_CLASS}).forEach(el => el.classList.remove(FOOTER_DIRECTIVE_CLASS));
const lines = $$('.lines .line');
for (const ln of lines) {
const t = (ln.textContent || '').trim();
if (!t) continue;
let m;
if ((m = AUTHOR_DIRECTIVE_RE.exec(t))) {
author = m1.trim();
ln.classList.add(FOOTER_DIRECTIVE_CLASS); // ← 目印
continue;
}
if ((m = FOOTER_DIRECTIVE_RE.exec(t))) {
ln.classList.add(FOOTER_DIRECTIVE_CLASS); // ← 目印
const rest = m1 || '';
let kv;
while ((kv = KV_RE.exec(rest))) {
const key = kv1.toLowerCase();
const val = (kv2 ?? kv3 ?? kv4 ?? '').trim();
if (key === 'author') author = val;
else if (key === 'extra') extra = val;
else if (key === 'align') align = val;
else if (key === 'opacity') opacity = val;
else if (key === 'font' || key === 'fontsize' || key === 'font_px') fontSize = val;
else if (key === 'hide') hide = /^(1|true|yes|on)$/i.test(val) ? true : hide;
}
}
}
cachedFooterFromPage = { author, extra, align, opacity, fontSize, hide };
cachedFooterSignature = sig;
return cachedFooterFromPage;
};
// ------------------------------
// @background color=... をページから読む
// ------------------------------
let cachedBgFromPage = null;
let cachedBgSignature = '';
const parseBackgroundFromPage = () => {
// フッターで使っている「ページの状態指紋」を再利用
const sig = readLinesSignature();
if (cachedBgFromPage && cachedBgSignature === sig) return cachedBgFromPage;
let color = null;
// 以前の目印をクリア(内容変更・削除に追従)
$$(.${BG_DIRECTIVE_CLASS}).forEach(el => el.classList.remove(BG_DIRECTIVE_CLASS));
const lines = $$('.lines .line');
for (const ln of lines) {
const t = (ln.textContent || '').trim();
if (!t) continue;
const m = BG_DIRECTIVE_RE.exec(t);
if (!m) continue;
ln.classList.add(BG_DIRECTIVE_CLASS); // ← 目印(プレゼン時は隠す)
// @background の残り(key=value 群)を読む
const rest = m1 || '';
let kv;
while ((kv = KV_RE.exec(rest))) {
const key = kv1.toLowerCase();
const val = (kv2 ?? kv3 ?? kv4 ?? '').trim();
if (key === 'color') color = val;
}
}
cachedBgFromPage = { color };
cachedBgSignature = sig;
return cachedBgFromPage;
};
// ==============================
// DOM helpers
// ==============================
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const app = () => $('.app');
// ==============================
// Captions: DOM helpers
// ==============================
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim();
const ensureCaptionStyle = () => {
let st = document.getElementById(CAPTION_STYLE_ID);
if (!st) {
st = document.createElement('style');
st.id = CAPTION_STYLE_ID;
document.head.appendChild(st);
}
st.textContent = `
.app.presentation .${CAPTION_CLASS}{
display: block;
margin-top: 2px;
font-size: 0.85em;
line-height: 1.2;
opacity: 0.88;
text-align: center;
}
/* 画像下の余白(ベースライン隙間)を消す定番 */
.app.presentation .line span.modal-image a{ display:inline-block; line-height:0; }
.app.presentation .line span.modal-image img{ display:block; margin:0 auto; }
.app.presentation .line.${CAPTION_SRC_CLASS}{
${HIDE_CAPTION_SOURCE_LINE ? 'display:none !important;' : 'opacity:0.25 !important;'}
}
`;
st.textContent += `
/* 画像が縦に間延びしないようラッパ的な振る舞いを CSS で吸収 */
.app.presentation .line span.modal-image{
display: inline-block;
text-align: center;
}
`;
};
const cleanupCaptions = () => {
// 挿入キャプションを消す
$$(.${CAPTION_CLASS}).forEach(el => el.remove());
// マーク解除
$$(.line.${CAPTION_SRC_CLASS}).forEach(el => el.classList.remove(CAPTION_SRC_CLASS));
// styleも消す
const st = document.getElementById(CAPTION_STYLE_ID);
if (st) st.remove();
};
// 画像が属する .line を取る(あなたのDOMではこれでOK)
const findLineOfImage = (img) => img.closest?.('.line');
// 次の「line」を取る(途中にline以外が挟まる場合に備えて軽くスキップ)
const nextLine = (lineEl) => {
let n = lineEl?.nextElementSibling || null;
while (n && !(n.classList && n.classList.contains('line'))) {
n = n.nextElementSibling;
}
return n;
};
// lineのテキスト抽出:span.text を優先し、img等を除去して textContent
const extractLineText = (lineEl) => {
if (!lineEl) return '';
const root = lineEl.querySelector('span.text') || lineEl;
const clone = root.cloneNode(true);
clone.querySelectorAll?.('img, video, iframe, svg').forEach(e => e.remove());
return norm(clone.textContent);
};
// 「このlineが画像行か?」判定(次行がまた画像行だとキャプション誤爆するので避ける余地)
const lineHasImage = (lineEl) => !!lineEl?.querySelector?.('span.modal-image img');
const isDirectiveLine = (lineEl) => {
if (!lineEl) return false;
// 既存の指示行クラス(あなたのスクリプトで付与済み)も見る
if (lineEl.classList?.contains(BG_DIRECTIVE_CLASS)) return true;
if (lineEl.classList?.contains(FOOTER_DIRECTIVE_CLASS)) return true;
// テキスト的にも判定(@background など)
const t = norm(lineEl.textContent || '');
return /^@\w+/i.test(t);
};
const upsertCaptionForImage = (img) => {
const host = img.closest?.('span.modal-image');
if (!host) return;
// host直後に既にキャプションがあるか(srcで紐づけ)
const existing = host.parentElement?.querySelector?.(:scope > .${CAPTION_CLASS}[data-for="${img.src}"]);
if (existing) return;
const lineEl = findLineOfImage(img);
if (!lineEl) return;
let capLine = nextLine(lineEl);
if (!capLine) return;
// @background/@footer/@author などをスキップ(競合回避は維持)
while (capLine && (isDirectiveLine(capLine) || lineHasImage(capLine))) {
capLine = nextLine(capLine);
}
if (!capLine) return;
const text = extractLineText(capLine);
if (!text) return;
// ★ div ではなく span を挿入(span.text 内でもDOMが壊れにくい)
const cap = document.createElement('span');
cap.className = CAPTION_CLASS;
cap.textContent = text;
cap.setAttribute('data-for', img.src);
// ★ 位置を“競合解消前”と同じ:modal-image の直後へ
host.insertAdjacentElement('afterend', cap);
capLine.classList.add(CAPTION_SRC_CLASS);
};
const applyImageCaptions = () => {
if (!app()?.classList.contains('presentation')) return;
// あなたのDOMに合わせて modal-image 内の img を拾う
const imgs = $$('span.modal-image img');
imgs.forEach(upsertCaptionForImage);
};
// ==============================
// Footer
// ==============================
const getFooterConfig = () => {
const page = parseFooterFromPage(); // ← ページから読む
const ovr = (window.SBX_SLIDE_FOOTER || {}); // 一時上書き(任意)
const pick = (...vals) => {
for (const v of vals) if (v != null && String(v).trim() !== '') return String(v).trim();
return '';
};
const author = pick(page.author, ovr.author, AUTHOR_NAME);
const extra = pick(page.extra, ovr.extra, FOOTER_EXTRA);
// align: 日本語も受ける(左/中央/右)
let align = pick(page.align, ovr.align, FOOTER_ALIGN).toLowerCase();
if ('左','left'.includes(align)) align = 'left';
else if ('右','right'.includes(align)) align = 'right';
else align = 'center';
const opNum = Number(page.opacity ?? ovr.opacity ?? FOOTER_OPACITY);
const opacity = Number.isFinite(opNum) ? Math.max(0, Math.min(1, opNum)) : FOOTER_OPACITY;
const fNum = parseInt(page.fontSize ?? ovr.fontSize ?? FOOTER_FONT_PX, 10);
const fontPx = Number.isFinite(fNum) && fNum > 6 ? fNum : FOOTER_FONT_PX;
const hidden = !!(page.hide || ovr.hide) || (author === '' && extra === '');
return { author, extra, align, opacity, fontPx, hidden };
};
const buildFooterText = () => {
const { author, extra } = getFooterConfig();
const parts = author, extra.filter(Boolean);
return parts.join(' — ');
};
const ensureFooter = () => {
let el = document.getElementById(FOOTER_ID);
if (!el) {
el = document.createElement('div');
el.id = FOOTER_ID;
el.setAttribute('aria-hidden', 'true');
Object.assign(el.style, {
position: 'fixed',
left: '16px',
right: '16px',
bottom: ${BAR_H + FOOTER_GAP_PX}px,
zIndex: 2147483647,
pointerEvents: 'none',
lineHeight: '1.2',
color: 'rgba(192,192,192,1)',
textShadow: '0 0px 0px rgba(0,0,0,0.7)',
display: 'flex',
alignItems: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
document.body.appendChild(el);
}
const { align, opacity, fontPx, hidden } = getFooterConfig();
el.style.justifyContent = (align === 'left') ? 'flex-start'
: (align === 'right') ? 'flex-end' : 'center';
el.style.color = rgba(192,192,192,${opacity});
el.style.fontSize = ${fontPx}px;
el.style.display = hidden ? 'none' : 'flex';
const text = buildFooterText();
if (el.textContent !== text) el.textContent = text;
return el;
};
const ensureFooterStyle = () => {
let st = document.getElementById(FOOTER_STYLE_ID);
if (!st) {
st = document.createElement('style');
st.id = FOOTER_STYLE_ID;
st.textContent = `
/* プレゼンテーション中のみ @footer/@author/@background 系行を隠す */
.app.presentation .lines .${FOOTER_DIRECTIVE_CLASS},
.app.presentation .lines .${BG_DIRECTIVE_CLASS} {
display: none !important;
}
`;
document.head.appendChild(st);
} else {
// 既存ユーザーが以前のCSSを持っている可能性に備えて同期
if (!st.textContent.includes(BG_DIRECTIVE_CLASS)) {
st.textContent = `
.app.presentation .lines .${FOOTER_DIRECTIVE_CLASS},
.app.presentation .lines .${BG_DIRECTIVE_CLASS} {
display: none !important;
}
`;
}
}
};
// ------------------------------
// 背景スタイル適用
// ------------------------------
const ensureBackgroundStyle = () => {
// プレゼンテーション以外は外す
if (!app()?.classList.contains('presentation')) {
const old = document.getElementById(BG_STYLE_ID);
if (old) old.remove();
return;
}
const { color } = parseBackgroundFromPage() || {};
const existing = document.getElementById(BG_STYLE_ID);
// 指定が無い場合は外す
if (!color || color.trim() === '') {
if (existing) existing.remove();
return;
}
const css = `
/* ページ全体の背景のみ変更。文字色は既定のまま */
body, .page { background-color: ${color} !important; transition: background-color 240ms ease; }
`;
if (existing) {
if (existing.textContent !== css) existing.textContent = css;
return;
}
const st = document.createElement('style');
st.id = BG_STYLE_ID;
st.textContent = css;
document.head.appendChild(st);
};
// ==============================
// Badge
// ==============================
const ensureBadge = () => {
let el = document.getElementById(BADGE_ID);
if (!el) {
el = document.createElement('div');
el.id = BADGE_ID;
el.setAttribute('role', 'status');
el.setAttribute('aria-live', 'polite');
Object.assign(el.style, {
position: 'fixed',
right: '24px',
bottom: '16px',
padding: '6px 15px',
fontSize: '24px',
lineHeight: '1',
background: 'rgba(0.1,0.1,0.1,0.5)',
color: '#fafafa',
borderRadius: '9999px',
zIndex: 2147483647,
pointerEvents: 'none',
opacity: '0.7'
});
document.body.appendChild(el);
}
return el;
};
// ==============================
// Progress bar + dots (centered on bar)
// ==============================
let lastDotsCount = 0;
const ensureProgressBar = () => {
let wrap = document.getElementById(PROG_ID);
if (!wrap) {
wrap = document.createElement('div');
wrap.id = PROG_ID;
Object.assign(wrap.style, {
position: 'fixed',
left: '0',
right: '0',
bottom: '0',
height: '0px',
zIndex: 2147483647,
pointerEvents: 'none',
transform: 'translateZ(0)'
});
// Track
const track = document.createElement('div');
track.id = PROG_TRACK_ID;
Object.assign(track.style, {
position: 'absolute',
left: '0',
right: '0',
bottom: '0',
height: ${BAR_H}px,
background: 'rgba(200,200,200,0.1)',
borderRadius: ${Math.max(1, BAR_H / 2)}px,
overflow: 'hidden'
});
// Fill
const fill = document.createElement('div');
fill.id = PROG_FILL_ID;
Object.assign(fill.style, {
height: '100%',
width: '100%',
background: 'rgba(128,128,128,0.5)',
transformOrigin: 'left center',
transform: 'scaleX(0)',
transition: 'transform 240ms cubic-bezier(.22,.61,.36,1)',
willChange: 'transform',
pointerEvents: 'none'
});
track.appendChild(fill);
// Dots layer(クリックは子ボタンのみ)
const dots = document.createElement('div');
dots.id = DOTS_ID;
Object.assign(dots.style, {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: ${DOT_HIT_H}px,
pointerEvents: 'none'
});
dots.addEventListener('click', (e) => {
const btn = e.target.closest?.(.${DOT_BTN_CLASS});
if (!btn) return;
const idx = Number(btn.dataset.index);
if (Number.isFinite(idx)) goToSlide(idx);
});
wrap.appendChild(track);
wrap.appendChild(dots);
document.body.appendChild(wrap);
}
return wrap;
};
const setProgress = (ratio) => {
ensureProgressBar();
const fill = document.getElementById(PROG_FILL_ID);
const wrap = document.getElementById(PROG_ID);
if (!fill || !wrap) return;
const clamped = Math.max(0, Math.min(1, ratio));
const target = scaleX(${isFinite(clamped) ? clamped : 0});
if (fill.style.transform !== target) fill.style.transform = target;
// 情報不足時はフェードアウト
wrap.style.opacity = (isFinite(ratio) && ratio >= 0) ? '1' : '0';
};
// 総数変化時のみドットを再構築
const rebuildDotsIfNeeded = (total) => {
ensureProgressBar();
const layer = document.getElementById(DOTS_ID);
if (!layer) return;
if (lastDotsCount === total) return;
layer.textContent = '';
if (total <= 0) { lastDotsCount = 0; return; }
for (let i = 1; i <= total; i++) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = DOT_BTN_CLASS;
btn.dataset.index = String(i);
btn.title = ${i} / ${total};
// 位置(0..1)。1枚のみは左端(=0)
const ratio = (total > 1) ? (i - 1) / (total - 1) : 0;
Object.assign(btn.style, {
position: 'absolute',
left: ${ratio * 100}%,
bottom: '0',
transform: translate(-50%, calc(50% - ${BAR_H / 2}px)), // ドット中心=バー中心
width: ${DOT_SIZE}px,
height: ${DOT_SIZE}px,
borderRadius: '9999px',
border: '0',
padding: '0',
background: 'rgba(100,100,100,0.3)',
transition: 'transform 160ms ease, background 160ms ease',
cursor: 'pointer',
outline: 'none',
pointerEvents: 'auto'
});
btn.addEventListener('mouseenter', () => {
btn.style.transform = translate(-50%, calc(50% - ${BAR_H / 2}px)) scale(1.35);
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = translate(-50%, calc(50% - ${BAR_H / 2}px));
});
layer.appendChild(btn);
}
lastDotsCount = total;
};
const updateDotsActive = (cur) => {
const layer = document.getElementById(DOTS_ID);
if (!layer) return;
const nodes = layer.querySelectorAll(.${DOT_BTN_CLASS});
nodes.forEach((n) => {
const idx = Number(n.dataset.index);
const active = idx === cur;
n.style.background = active ? 'rgba(100,100,100,0.9)' : 'rgba(100,100,100,0.35)';
n.style.transform = active
? translate(-50%, calc(50% - ${BAR_H / 2}px)) scale(1.2)
: translate(-50%, calc(50% - ${BAR_H / 2}px));
});
};
// ==============================
// Navigation
// ==============================
const pressKey = (key, code, keyCode) => {
const ev = new KeyboardEvent('keydown', {
key, code, keyCode, which: keyCode, bubbles: true, cancelable: true
});
document.dispatchEvent(ev);
};
const goToSlide = (targetIndex) => {
const cur = getCurrentIndexFromStyle() ?? getCurrentIndexFallback();
if (!Number.isFinite(cur) || !Number.isFinite(targetIndex)) return;
const delta = targetIndex - cur;
if (delta === 0) return;
const key = delta > 0 ? 'ArrowRight' : 'ArrowLeft';
const code = key;
const keyCode = delta > 0 ? 39 : 37;
const steps = Math.abs(delta);
let i = 0;
const tick = () => {
if (i >= steps) return;
pressKey(key, code, keyCode);
i += 1;
setTimeout(tick, 30);
};
tick();
};
// ==============================
// Current / Total
// ==============================
const getCurrentIndexFromStyle = () => {
const styles = $$('style');
for (let i = styles.length - 1; i >= 0; i--) {
const txt = stylesi.textContent || '';
if (txt.indexOf('.section-') === -1) continue;
const m = STYLE_RULE_RE.exec(txt);
if (m) {
const n = Number(m1);
if (Number.isFinite(n)) return n + 1; // 1-origin
}
}
return null;
};
let cachedTotal = null;
let cachedTitleCount = null;
const recomputeTotal = () => {
const titles = $$('.line.section-title');
const nums = [];
for (const el of titles) {
for (const cls of el.classList) {
const m = /^section-(\d+)$/.exec(cls);
if (m) { nums.push(Number(m1)); break; }
}
}
cachedTitleCount = titles.length;
cachedTotal = nums.length ? Math.max(...nums) + 1 : 0;
};
const getTotal = () => {
const titles = $$('.line.section-title');
if (cachedTotal == null || cachedTitleCount !== titles.length) {
recomputeTotal();
}
return cachedTotal;
};
// フォールバック(<style> 取得失敗時)
const getCurrentIndexFallback = () => {
const titles = $$('.line.section-title');
const idx = titles.findIndex(el => {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
const r = el.getBoundingClientRect();
return r.bottom > 0 && r.top < (innerHeight || document.documentElement.clientHeight);
});
return idx >= 0 ? idx + 1 : null;
};
// ==============================
// Render
// ==============================
let rafPending = false;
let lastBadgeText = '';
let lastProgress = -1;
const render = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
//if (!app()?.classList.contains('presentation')) return;
if (!app()?.classList.contains('presentation')) {
// プレゼン外では背景スタイルを外す
const old = document.getElementById(BG_STYLE_ID);
if (old) old.remove();
return;
}
// ここを追加:背景の適用
ensureBackgroundStyle();
const badge = ensureBadge();
ensureProgressBar();
ensureFooter();
applyImageCaptions();
const total = getTotal();
const cur = getCurrentIndexFromStyle() ?? getCurrentIndexFallback();
const text = (total > 0 && cur != null) ? ${cur} / ${total} : '';
if (text !== lastBadgeText) {
lastBadgeText = text;
badge.textContent = text;
}
// 進捗(0..1)※1枚のみは常に100%
const prog = (total > 1 && cur != null) ? (cur - 1) / (total - 1) : (total === 1 ? 1 : NaN);
if (!Number.isFinite(lastProgress) || Math.abs(prog - lastProgress) > 1e-6) {
lastProgress = prog;
setProgress(prog);
}
rebuildDotsIfNeeded(total);
if (Number.isFinite(cur)) updateDotsActive(cur);
});
};
// ==============================
// Lifecycle & Observers
// ==============================
let moClass = null;
let moDom = null;
let started = false;
const isSelfInserted = (node) => {
if (!(node instanceof Element)) return false;
return node.id === BADGE_ID
|| node.id === PROG_ID
|| node.id === FOOTER_ID
|| node.classList?.contains(CAPTION_CLASS);
};
// <style> 追加/更新 or .lines 配下の変化のみ反応
const shouldUpdate = (mut) => {
if (mut.type === 'characterData') {
return mut.target?.parentNode?.nodeName === 'STYLE';
}
if (mut.type === 'childList') {
const nodes = ...mut.addedNodes, ...mut.removedNodes;
return nodes.some(n => {
if (n.nodeName === 'STYLE') return true;
if (n.nodeType !== 1) return false;
return n.matches?.('.lines, .lines *') || n.closest?.('.lines');
});
}
return false;
};
const start = () => {
if (started) return;
started = true;
ensureFooterStyle();
ensureCaptionStyle();
ensureBadge();
ensureProgressBar();
ensureFooter();
recomputeTotal();
applyImageCaptions();
render();
moDom = new MutationObserver((muts) => {
if (muts.some(shouldUpdate)) render();
});
const linesRoot = document.querySelector('.lines') || document.body;
moDom.observe(linesRoot, { subtree: true, childList: true, characterData: true });
};
const stop = () => {
if (!started) return;
started = false;
if (moDom) moDom.disconnect();
const badge = document.getElementById(BADGE_ID);
if (badge) badge.remove();
const prog = document.getElementById(PROG_ID);
if (prog) prog.remove();
const footer = document.getElementById(FOOTER_ID);
if (footer) footer.remove();
const bg = document.getElementById(BG_STYLE_ID);
if (bg) bg.remove();
lastBadgeText = '';
lastProgress = -1;
lastDotsCount = 0;
cachedTotal = null;
cachedTitleCount = null;
cleanupCaptions();
};
const watchPresentationToggle = () => {
const root = app();
if (!root) return;
const handle = () => (root.classList.contains('presentation') ? start() : stop());
moClass = new MutationObserver(handle);
moClass.observe(root, { attributes: true, attributeFilter: 'class' });
handle();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', watchPresentationToggle, { once: true });
} else {
watchPresentationToggle();
}
})();
hr.icon
based on ${TITLE}するUserScript