プレゼンテーションモードでページ番号を表示させる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)
// ==============================
// 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; // 進捗バー上端からの隙間
// .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、値は "..." '...' 非引用にも対応)
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))) {
ln.classList.add(FOOTER_DIRECTIVE_CLASS); // ← 目印
continue;
}
if ((m = FOOTER_DIRECTIVE_RE.exec(t))) {
ln.classList.add(FOOTER_DIRECTIVE_CLASS); // ← 目印
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;
};
// ==============================
// DOM helpers
// ==============================
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const app = () => $('.app');
// ==============================
// 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();
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();
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 系行を隠す */
.app.presentation .lines .${FOOTER_DIRECTIVE_CLASS} {
display: none !important;
}
`;
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) {
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;
const badge = ensureBadge();
ensureProgressBar();
ensureFooter();
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;
// <style> 追加/更新 or .lines 配下の変化のみ反応
const shouldUpdate = (mut) => {
if (mut.type === 'characterData') {
return mut.target?.parentNode?.nodeName === 'STYLE';
}
if (mut.type === 'childList') {
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();
ensureBadge();
ensureProgressBar();
ensureFooter();
recomputeTotal();
render();
moDom = new MutationObserver((muts) => {
if (muts.some(shouldUpdate)) render();
});
moDom.observe(document.body, { 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();
lastBadgeText = '';
lastProgress = -1;
lastDotsCount = 0;
cachedTotal = null;
cachedTitleCount = null;
};
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