見出し記法と見出し一覧を表示するUserScript
見出し記法と見出し一覧を表示するUserScript
https://gyazo.com/bfe95601f82a0bc77d2a487301b896b2
code:script.js
// ==UserScript==
// @name Scrapbox: ... → 右下TOC(deco-#検出/入れ子装飾保持/省略表示/安定化/トップ&プレゼン非表示/遷移フォールバック)
// @namespace https://scrapbox.io/
// @version 1.7.2
// @description classList に deco-# を持つ要素のみを見出しとみなし、右下にTOCを表示。長い見出しは1行省略(…)。入れ子装飾をTOCにも反映。SPA遷移対応+URLポーリングで取りこぼし防止。トップ/プレゼンでは非表示。安定化&差分更新でチラつき抑制。元DOMは非破壊。
// @author you
// @match https://scrapbox.io/*/*
// @match https://scrapbox.io/*
// @run-at document-end
// @grant none
// ==/UserScript==
(function () {
'use strict';
/** ========================= 設定 ========================= */
const CONFIG = {
scrollOffset: 80,
panelWidth: 250,
panelMaxHeight: 480,
settleMs: 200,
contentReadyTimeout: 5000,
presentationClassHints: 'presentation', 'present', 'is-presentation', 'slideshow', 'fullscreen',
// TOCに許可する要素・属性(装飾を可能な範囲で温存)
allowedTags: new Set('SPAN','B','I','EM','STRONG','SMALL','SUB','SUP','CODE','KBD','SAMP','MARK','U','S','DEL'),
allowedAttrs: new Set('class','title','aria-label'),
// decoテキストの先頭に #/全角# が残っている場合はTOC表示用に削る
stripLeadingHash: true,
// URL監視フォールバック(ms)。低頻度で十分。
locationPollMs: 400,
};
/** ========================= ページ状態判定 ========================= */
const isProjectTop = () => {
// /<project> をトップとみなす。末尾スラッシュやエンコードの差も吸収。
const segs = decodeURI(location.pathname).replace(/\/+$/,'').split('/').filter(Boolean);
return segs.length === 1;
};
const isPresentationMode = () => {
const path = location.pathname.toLowerCase();
const q = (location.search || '').toLowerCase();
const h = (location.hash || '').toLowerCase();
if (/(^|\/)(present|presentation)(\/|$)/.test(path)) return true;
if (/?&(present|presentation)(=|&|$)/.test(q)) return true;
if (/(^#|&)(present|presentation)(=|&|$)/.test(h)) return true;
const hasHint = (el) =>
el && CONFIG.presentationClassHints.some((c) =>
el.classList?.contains(c) || [...(el.classList || [])].some(x => x.includes(c))
);
if (hasHint(document.body)) return true;
const app = document.querySelector('main,#app,.app,.root,.page');
return hasHint(app);
};
const shouldHide = () => isProjectTop() || isPresentationMode();
/** ========================= ユーティリティ ========================= */
const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()));
const idle = (timeout = 300) =>
new Promise((r) =>
(window.requestIdleCallback
? requestIdleCallback(() => r(), { timeout })
: setTimeout(() => r(), Math.min(timeout, 300)))
);
const smoothScrollToEl = (el) => {
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - CONFIG.scrollOffset;
window.scrollTo({ top, behavior: 'smooth' });
};
const getAppRoot = () => document.querySelector('main') || document.querySelector('#app') || document.body;
const getContentRoot = () => document.querySelector('.lines') || document.querySelector('.page') || getAppRoot();
const isLineNode = (el) =>
el && (
el.classList?.contains('line') ||
el.hasAttribute('data-line-id') ||
/^L\d+/.test(el.id || '')
);
const findLineAncestor = (el) => {
let cur = el;
while (cur && cur !== document.body) {
if (isLineNode(cur)) return cur;
cur = cur.parentElement;
}
return null;
};
const hashHeadings = (arr) => {
const s = arr.map(h => h.id + '|' + h.text).join('\n');
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 131 + s.charCodeAt(i)) >>> 0;
return ${arr.length}:${h};
};
/** ========================= パネル(UI) ========================= */
const styleId = 'sb-toc-style';
const panelId = 'sb-toc-panel';
const injectStyles = () => {
if (document.getElementById(styleId)) return;
const css = `
#${panelId} {
position: fixed; right: 16px; bottom: 26px;
width: ${CONFIG.panelWidth}px; max-height: ${CONFIG.panelMaxHeight}px;
background: rgba(30,30,30,.9); color: #fff; backdrop-filter: blur(6px);
border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,.25);
overflow: hidden; z-index: 99999;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple Color Emoji","Segoe UI Emoji";
opacity: .6; transition: opacity .15s ease, transform .15s ease;
}
#${panelId}.hidden { display: none !important; }
#${panelId}:hover { opacity: 1; transform: translateY(-2px); }
#${panelId} .sb-toc-list { list-style: none; margin: 0; padding: 6px 6px 8px; overflow: auto; max-height: ${CONFIG.panelMaxHeight - 44}px; }
#${panelId} .sb-toc-item { margin: 0; padding: 0; }
#${panelId} .sb-toc-link {
display: flex; align-items: center; gap: 6px;
width: 100%; text-align: left; background: transparent; border: none; color: #fff;
padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 13px; line-height: 1.35; opacity: .95;
}
#${panelId} .sb-toc-link:hover { background: rgba(255,255,255,.10); }
#${panelId} .sb-toc-link * { pointer-events: none; } /* ラベル内のリンク等はクリック不可に */
#${panelId} .sb-toc-label {
flex: 1 1 auto; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: block; /* ネストしたインライン要素があっても幅計算を安定させる */
}
.sb-heading-anchor { scroll-margin-top: ${CONFIG.scrollOffset + 10}px; }
`.trim();
const style = document.createElement('style');
style.id = styleId;
style.textContent = css;
document.head.appendChild(style);
};
const ensurePanel = () => {
let panel = document.getElementById(panelId);
if (panel) return panel;
panel = document.createElement('aside');
panel.id = panelId;
panel.innerHTML = `
<ul class="sb-toc-list" role="list"></ul>
`;
document.body.appendChild(panel);
return panel;
};
const hidePanel = () => {
const p = ensurePanel();
p.classList.add('hidden');
const list = p.querySelector('.sb-toc-list');
if (list) list.innerHTML = '';
};
const showPanel = () => ensurePanel().classList.remove('hidden');
const renderTOC = (headings) => {
const panel = ensurePanel();
const list = panel.querySelector('.sb-toc-list');
if (!list) return;
list.innerHTML = '';
if (!headings.length) {
const li = document.createElement('li');
li.className = 'sb-toc-item';
li.innerHTML = <div class="sb-toc-link" style="opacity:.6"><span class="sb-toc-label">このページに [# ...] はありません</span></div>;
list.appendChild(li);
return;
}
for (const h of headings) {
const li = document.createElement('li');
li.className = 'sb-toc-item';
const btn = document.createElement('button');
btn.className = 'sb-toc-link';
btn.type = 'button';
btn.title = h.text;
const label = document.createElement('span');
label.className = 'sb-toc-label';
label.innerHTML = h.html; // 入れ子装飾を保持したまま省略適用
btn.appendChild(label);
btn.addEventListener('click', (e) => {
e.preventDefault();
const el = document.getElementById(h.id);
smoothScrollToEl(el);
});
li.appendChild(btn);
list.appendChild(li);
}
};
/** ========================= ラベル抽出(装飾HTMLを保持、元DOM非破壊) ========================= */
const sanitizeCloneForTOC = (node) => {
const clone = node.cloneNode(true);
// 先頭に #/全角# が残っていたらテキストから除去(TOC表示用のみ)
if (CONFIG.stripLeadingHash) {
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null);
if (walker.nextNode()) {
const t = walker.currentNode;
t.nodeValue = t.nodeValue.replace(/^##\s*/, '');
}
}
// 許可されていないタグは unwrap、許可外属性は除去
const prune = (el) => {
if (el.nodeType === Node.TEXT_NODE) return;
if (!CONFIG.allowedTags.has(el.tagName)) {
const parent = el.parentNode;
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
return;
}
...el.attributes.forEach(attr => {
if (!CONFIG.allowedAttrs.has(attr.name)) el.removeAttribute(attr.name);
});
...el.childNodes.forEach(prune);
};
...clone.childNodes.forEach(prune);
return clone;
};
const buildLabelFromDeco = (decoNode) => {
const clone = sanitizeCloneForTOC(decoNode);
const div = document.createElement('div');
div.appendChild(clone);
const html = div.innerHTML.trim();
// ハッシュ用にはプレーンテキスト(装飾差異に左右されない)
let text = (decoNode.textContent || '').trim();
if (CONFIG.stripLeadingHash) text = text.replace(/^##\s*/, '').trim();
return { html, text };
};
/** ========================= 見出し抽出(deco-# 厳密選択 → 行解決) ========================= */
const makeAnchorId = (index) => sb-h-${index};
// class に deco-# を含む要素だけを厳密に抽出(複数クラスでもOK)
const selectDecoHashNodes = () => {
const root = getContentRoot();
if (!root) return [];
// 候補を広く拾い、classList.contains('deco-#') でフィルタ
const candidates = root.querySelectorAll('.deco, class*="deco-"');
const nodes = [];
candidates.forEach((n) => {
if (n.classList && (n.classList.contains('deco-#') || n.classList.contains('deco-#'))) {
nodes.push(n);
}
});
// パネル内は除外
return nodes.filter(n => !n.closest('#sb-toc-panel'));
};
const collectHeadingsOnce = () => {
if (shouldHide()) return [];
const decoNodes = selectDecoHashNodes();
const res = [];
let counter = 0;
decoNodes.forEach((node) => {
const lineEl = findLineAncestor(node);
if (!lineEl) return;
const { html, text } = buildLabelFromDeco(node);
if (!text) return;
// 既存ID(Lxxなど)があれば尊重。無ければ sb-h-* を付与(元DOMの class は一切変更しない)
if (!lineEl.id) {
lineEl.id = makeAnchorId(counter++);
}
lineEl.classList.add('sb-heading-anchor');
res.push({ id: lineEl.id, text, html });
});
return res;
};
// 2フレーム一致で「安定」とみなす
const collectHeadingsStable = async () => {
let a = collectHeadingsOnce();
await nextFrame();
let b = collectHeadingsOnce();
if (hashHeadings(a) !== hashHeadings(b)) {
await nextFrame(); a = collectHeadingsOnce();
await nextFrame(); b = collectHeadingsOnce();
}
return b;
};
/** ========================= スケジューラ(バッチング & 差分更新) ========================= */
let scheduled = false;
let settleTimer = 0;
let lastHash = '';
const scheduleRebuild = () => {
clearTimeout(settleTimer);
settleTimer = setTimeout(async () => {
if (scheduled) return;
scheduled = true;
try {
if (shouldHide()) {
lastHash = '';
hidePanel();
return;
}
showPanel();
await idle(200);
const headings = await collectHeadingsStable();
const h = hashHeadings(headings);
if (h !== lastHash) {
injectStyles();
renderTOC(headings);
lastHash = h;
}
} finally {
scheduled = false;
}
}, CONFIG.settleMs);
};
/** ========================= 監視 & ルーティング(SPA対応 + フォールバック) ========================= */
const setupMutationObserver = () => {
const root = getAppRoot();
if (!root) return;
const mo = new MutationObserver(() => scheduleRebuild());
mo.observe(root, { subtree: true, childList: true, attributes: true, characterData: true });
// プレゼン切替など body の class 変化にも反応
const bo = new MutationObserver(() => scheduleRebuild());
bo.observe(document.body, { attributes: true, attributeFilter: 'class' });
};
const waitContentReady = async () => {
if (shouldHide()) { hidePanel(); return; }
const deadline = Date.now() + CONFIG.contentReadyTimeout;
if (getContentRoot()?.querySelector('.line,data-line-id,id^="L"')) {
scheduleRebuild();
return;
}
await new Promise((resolve) => {
const content = getContentRoot() || document.body;
const mo = new MutationObserver(() => {
if (getContentRoot()?.querySelector('.line,data-line-id,id^="L"') || Date.now() > deadline) {
mo.disconnect(); resolve(true);
}
});
mo.observe(content, { subtree: true, childList: true });
setTimeout(() => { mo.disconnect(); resolve(true); }, CONFIG.contentReadyTimeout);
});
scheduleRebuild();
};
const installLocationHooks = () => {
const fire = () => window.dispatchEvent(new Event('sb:locationchange'));
const _push = history.pushState;
const _replace = history.replaceState;
history.pushState = function () { _push.apply(this, arguments); fire(); };
history.replaceState = function () { _replace.apply(this, arguments); fire(); };
window.addEventListener('popstate', fire, { passive: true });
window.addEventListener('hashchange', fire, { passive: true });
};
// ★ 追加:URL監視フォールバック(history フック取りこぼし対策)
let lastURL = location.href;
let lastTopState = isProjectTop();
const startLocationPoller = () => {
setInterval(() => {
const nowURL = location.href;
const nowTop = isProjectTop();
if (nowURL !== lastURL || nowTop !== lastTopState) {
lastURL = nowURL;
lastTopState = nowTop;
window.dispatchEvent(new Event('sb:locationchange'));
}
}, CONFIG.locationPollMs);
};
/** ========================= 初期化 ========================= */
const ready = () => {
injectStyles();
ensurePanel();
if (shouldHide()) hidePanel();
installLocationHooks();
setupMutationObserver();
startLocationPoller(); // フォールバック稼働
waitContentReady();
window.addEventListener('sb:locationchange', () => {
lastHash = '';
if (shouldHide()) hidePanel(); else showPanel();
waitContentReady();
}, { passive: true });
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready, { once: true });
} else {
ready();
}
})();