オリジナル目次作成と見出しリンクの自動生成
<!-- markdown-mode-on -->
<img alt="jettheme logo" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEih6tkuCAB6zwNyTYCvTrA07cGstQGH7GBjCipsk3a6HoUiKVea-159v4oK4uq7qhXUjbbnX0Hm5Fds5sw7iiCzhqObJHwcnKQEf9-0iAqgpH6bSzH6FxQCVVBh-XrghyphenhyphenMUwaIjVewBQXI/s1600/jettheme-cover.png" style="
display: none;"/>
## 概要
スケ郎さんの目次を使っていたが、私のブログは記事の構成がいい加減なのか、目次の表示がおかしくなる。
そこで、一念発起してAIの力を借りて目次を自作した。
ついでに、見出しをクリックしたら、リンクを自動取得する機能も追加してみた。
<figure class="blogcard b-link">
<a aria-label="記事詳細へ(別窓で開く)" href="https://www.sukerou.com/2018/10/blogger-table-of-contents-javascript.html" rel="noopener noreferrer" target="_blank">
<div class="blogcard-content">
<div class="blogcard-image bi-link">
<div class="blogcard-image-wrapper biw-link">
<img alt="[Blogger] 目次を簡単に自動生成(忙しい人向けのコピペ素材)" height="100" loading="lazy" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiKv7wQI6cny3cQMoGEHmdLYYR91gMCsavmWS-V-AiQx40v9JDfbU0VgqgnGQAuRRkqQEEk9za5W5TI4MAEIrD5zicyL4VY25y18GBA9w3a2M_fUeZ7p8eUewgiYaxenvKxc41jSxZ49e0v/w1200-h630-p-k-no-nu/" width="100"/>
</div>
</div>
<div class="blogcard-text">
<p class="blogcard-title bt-link">
[Blogger] 目次を簡単に自動生成(忙しい人向けのコピペ素材)
</p>
<p class="blogcard-description bd-link">
プログラミング関連の備忘録。AI関連APIの解説等を行っています。
少しでも誰かの役に立てれれば嬉しいです。
</p>
</div>
</div>
<div class="blogcard-footer bf-link">
<img alt="ファビコン" height="16" loading="lazy" src="https://www.google.com/s2/favicons?domain=https://www.sukerou.com/2018/10/blogger-table-of-contents-javascript.html" width="16"/>
www.sukerou.com
</div>
</a>
</figure>
<a name="more"></a>
## 目次の生成方式
<svg height="24" width="24">
<use xlink:href="#i-chatgpt">
</use>
</svg>
<span style="font-size:12px;">
</span>
ChatGPT に教えてもらうと、
<br/>
目次(TOC: Table of Contents)の生成方式には、大きく分けて以下の2つの方式がある。
<br/>
<br/>
結論から言えば、途中で構成を入れ替えたり、親子関係を変更したりするブログの書き方には、スケ郎さんの目次は不向きだった。
目次の表示が乱れる原因の根本はここであったと推定する。
---
### 方式1: スケ郎さん目次の特徴
**生成方式:階層構造型(ネスト型)**
- h2, h3, h4 などの見出しの階層構造をもとに、入れ子の形で目次を生成します。
- 見出し構成
```
1. 見出しA(h2)
1.1 見出しB(h3)
1.1.1 見出しC(h4)
2. 見出しD(h2)
```
- 見出しの順番よりも、「h2の下にh3、その下にh4…」という構造的な関係性を重視します。
もっとわかりやすく言うと、
この目次スクリプトは見出し(h2, h3, ...)を文書の出現順に1つずつ処理していません。
代わりに、以下のようなロジックを使っています。
```jvascript
var v = p.getElementsByTagName(q.target[0]); // 例えば h2 を全取得
for (var s = 0; s < v.length; s++) {
t(0, v[s], r, "");
}
```
つまり、まず h2 を全部取得してから、それぞれについて、その子の h3, h4 を探す、という階層構造ベースの処理です。
このため、見出しが階層に沿っていなくても強制的に階層構造に整理されるので、
**HTML上では先にある h2 が、後にある h2 の中に誤って入る**ことがあります。
- 見出しIDの例
- `heading-h2-1`、`heading-h3-2`、`heading-h4-3`など。
- 見出しの種類と出現順でIDが作られます。
- **正しい階層順に書かれていないと、目次構造が崩れる**ことがあるため、構造がしっかりした記事向きです。
- 本のように「章 → 節 → 項目」といった整理された構造のある記事。
- 階層付きのツリー状目次を作りたい場合。
### 方式2:**HTMLの順番に並べる方式(フラット型)**
**生成方式:出現順ベースのフラット型**
- h2, h3, h4 といった見出しの階層にはあまりこだわらず、HTML内に出てきた順番でそのままリストにして目次を作ります。
- 見出しが h2 → h4 → h3 のような順番でも、それをそのままの順で表示します。
- 構造に依存しないため、自由な書き方の記事でも崩れません。
- 見出し構成
```
・見出しA(h2)
・見出しB(h3)
・見出しC(h4)
・見出しD(h2)
```
- 見出しIDの例
- `heading-1`、`heading-2`、`heading-3`など。
- 見出しが出てきた**順番だけ**でIDが付けられます。
- 階層構造がバラバラでも、**見出しの順番を大事にしたい**場合。
- シンプルに、すぐ目的の場所に飛べる目次を作りたいとき。
---
### まとめ表
| 項目 | 階層構造方式(ネスト) | 順番方式(フラット) |
| ------- | -------------------------------- | -------------------------- |
| 見出しの扱い | h2 → h3 → h4 などの階層あり | h2/h3/h4関係なく順に表示 |
| 目次の見た目 | 入れ子構造(ネスト表示) | 一列表示(フラット構成) |
| 記事のスタイル |章・節・項で整理された構造 | ラフな構成で自由に書きたい | |
| 見出しIDの例 | `heading-h2-1`, `heading-h3-1`など | `heading-1`, `heading-2`など |
---
どちらの方式が良いかは、**記事の内容**や**好みによって変わります**。
たとえば、自由なブログ記事には「順番方式」が向いていて、整った構成の技術記事やマニュアルには「階層方式」がぴったりです。
## My TOC 仕様(
<svg height="24" width="24">
<use xlink:href="#i-chatgpt">
</use>
</svg>
<span style="font-size:12px;">
</span>
ChatGPT への要求仕様)
1. スケ郎さんの目次スクリプトを参考にする
1. 目次生成:順番方式(フラット)
1. 目次表示:HTMLの見出し順(h2→h3→h4)に忠実な階層構造を反映
- 見出しのタグレベル(h2, h3, h4)を数値で取得
- ツリー構造で再帰的に ul > li を作る
- stack を用いた親子関係の構築
1. Blogger向けにCDATAで囲んだスクリプト形式
1. サイドバーにも目次を複製 (copyToSidebar: true)
- 主記事のスクロール位置にサイドバー目次ボックスが追従
- 主記事のスクロール位置を拾って、サイドバー目次を強調リンク
- 強調リンクが、目次ボックスの外に出ることなく、常に視認可能な位置に保つように内部スクロールさせる
1. 見出しが2つ以上あるときのみ目次が表示される設定 (condTargetCount: 2)
## My TOC スクリプト
<svg height="24" width="24">
<use xlink:href="#i-chatgpt">
</use>
</svg>
<span style="font-size:12px;">
</span>
ChatGPT に前項の仕様をトスし、何度かやり取りして完成した目次作成スクリプトを示す。
これを`
` の前に書き込む。
<details>
<summary>
My TOC スクリプト
</summary>
```javascript
<!-- [START] 目次作成プラグイン-->
<b:if cond='data:blog.pageType == "item"'>
<script type="text/javascript">
//<![CDATA[
var toc_options = {
target: ["h2", "h3", "h4"],
autoNumber: false,
condTargetCount: 2,
insertPosition: "top",
showToc: false,
width: "auto",
marginTop: "20px",
marginBottom: "20px",
indent: "20px",
postBodySelector: ".widget.Blog",
ignoreURL: [],
copyToSidebar: true,
sidebarSelector: "#sidebar",
highlight: true
};
(function () {
var idCounter = 0;
function buildTocTree(targets, container) {
var headings = Array.from(container.querySelectorAll(targets.join(',')));
var root = { children: [], level: 0 };
var stack = [root];
headings.forEach(heading => {
const tagName = heading.tagName.toLowerCase();
const level = targets.indexOf(tagName) + 1;
if (level === 0) return;
const id = `toc_headline_${++idCounter}`;
heading.id = id;
const node = {
id: id,
text: heading.textContent.trim(),
children: [],
level: level
};
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
stack[stack.length - 1].children.push(node);
stack.push(node);
});
return root;
}
function renderToc(tree) {
const container = document.createElement("div");
container.className = "b-toc-container";
container.style.marginTop = toc_options.marginTop;
container.style.marginBottom = toc_options.marginBottom;
if (toc_options.width !== "auto") container.style.width = toc_options.width;
const toggle = document.createElement("p");
const label = document.createElement("span");
label.textContent = "目次";
const brOpen = document.createElement("span");
brOpen.textContent = "[";
const brClose = document.createElement("span");
brClose.textContent = "]";
const link = document.createElement("a");
link.href = "javascript:void(0);";
toggle.appendChild(label);
toggle.appendChild(brOpen);
toggle.appendChild(link);
toggle.appendChild(brClose);
container.appendChild(toggle);
const ul = document.createElement("ul");
ul.className = "toc-root-list";
createList(tree.children, ul, "");
container.appendChild(ul);
link.textContent = toc_options.showToc ? "非表示" : "表示";
if (!toc_options.showToc) ul.style.display = "none";
link.addEventListener("click", () => {
const isShown = ul.style.display !== "none";
ul.style.display = isShown ? "none" : "block";
link.textContent = isShown ? "表示" : "非表示";
});
return container;
}
function createList(items, parentUl, prefix) {
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = "toc-list-item";
li.style.paddingLeft = toc_options.indent;
const a = document.createElement("a");
a.href = `#${item.id}`;
const span = document.createElement("span");
span.className = "toc-text";
span.textContent = item.text;
a.appendChild(span);
li.appendChild(a);
if (item.children.length > 0) {
const subUl = document.createElement("ul");
subUl.className = "toc-sub-list";
createList(item.children, subUl, `${prefix}${index + 1}.`);
li.appendChild(subUl);
}
parentUl.appendChild(li);
});
}
function injectToc() {
if (toc_options.ignoreURL.some(re => location.href.match(re))) return;
const post = document.querySelector(toc_options.postBodySelector);
if (!post) return;
const tocTree = buildTocTree(toc_options.target, post);
if (tocTree.children.length < toc_options.condTargetCount) return;
const toc = renderToc(tocTree);
if (toc_options.insertPosition === "top") {
post.insertBefore(toc, post.firstChild);
}
if (toc_options.copyToSidebar) {
const sidebar = document.querySelector(toc_options.sidebarSelector);
if (sidebar) {
const clone = toc.cloneNode(true);
clone.classList.add("side-toc");
const a = clone.querySelector("a");
a.addEventListener("click", () => {
const ul = clone.querySelector("ul");
const shown = ul.style.display !== "none";
ul.style.display = shown ? "none" : "block";
a.textContent = shown ? "表示" : "非表示";
});
sidebar.appendChild(clone);
}
}
// ★ スクロール強調処理(highlight が true のとき)★
if (toc_options.highlight) {
window.addEventListener("scroll", () => {
const headings = Array.from(document.querySelectorAll(toc_options.target.join(',')));
let currentId = null;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
for (let i = 0; i < headings.length; i++) {
const top = headings[i].getBoundingClientRect().top + scrollTop;
if (top - 150 <= scrollTop) {
currentId = headings[i].id;
} else {
break;
}
}
document.querySelectorAll(".b-toc-container a[href^='#'], .side-toc a[href^='#']").forEach(link => {
link.parentElement.classList.remove("tl-active");
});
if (currentId) {
const mainTocLink = document.querySelector(`.b-toc-container a[href="#${currentId}"]`);
if (mainTocLink) {
mainTocLink.parentElement.classList.add("tl-active");
}
const sideTocLink = document.querySelector(`.side-toc a[href="#${currentId}"]`);
if (sideTocLink) {
sideTocLink.parentElement.classList.add("tl-active");
// ★ sidebar 内部スクロール追従 ★
const container = sideTocLink.closest(".side-toc");
if (container) {
// 少し遅延させてからスクロールさせることで、よりスムーズに見せる
setTimeout(() => {
sideTocLink.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
}
}
}
});
}
}
document.addEventListener("DOMContentLoaded", injectToc);
})();
//]]>
</script>
<style type="text/css">
.b-toc-container {
background: #f0f8ff;
padding: 10px;
margin-bottom: 1em;
width: auto;
display: table;
font-size: 95%;
}
.b-toc-container p {
text-align: left;
margin: 0;
padding-left: 1.2rem;
}
.b-toc-container ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.b-toc-container ul li {
margin: 0;
padding: 0 0 0 20px;
}
.b-toc-container ul li a {
color: inherit;
text-decoration: none;
}
.b-toc-container ul li .toc-text:hover {
text-decoration: underline;
}
.tl-active > a {
background-color: #fee5ee; /* 背景色を指定 */
border: 0;
border-radius: 0;
color: #f92672 !important;
margin: 0;
padding: 0;
display: block; /* ブロック要素として表示 */
width: 100%; /* 必要に応じて幅を行全体に設定 */
}
.side-toc {
position: sticky;
top: 100px;
max-height: 400px;
overflow-y: auto;
}
.side-toc ul {
padding-left: 0;
list-style: none;
}
@media (max-width: 575.98px) {
.b-toc-container {
margin: 1em auto;
width: 95% !important;
}
.side-toc {
display: none;
}
.b-toc-container li {
padding: 0 10px 5px 10px !important;
}
}
</style>
</b:if>
<!-- [END] 目次作成プラグイン-->
```
</details>
## カスタマイズ
うまくいって気分がいいので、さらなるカスタマイズを行う。
1. 目次タイトル下に空白行を追加
1. 見出しのリンクを自動取得
### 1. 目次タイトル下に空白行を追加
<div class="separator" style="clear: both;">
<a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgL6JFUE7j6TJryB4OBFN-4Q13-kzsARHcEaamctnNYpCsfs3PGUY-Qlf2bKF4AQvBxXQcKoV-qms8Il78RGogxfZxO5M2ACdijB9HZX0_swm_mAEdoNn8lGdrRJPcAeALlHkxeXx4dPqc7O606PJV7NPjA9PQyFKVkryobXq5e6xd-G3scM9QWv0-ws_jl/s1600/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202025-05-03%20102822.png" style="display: block; padding: 1em 0; text-align: center; ">
<img alt="目次ボックス" border="0" data-original-height="114" data-original-width="586" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgL6JFUE7j6TJryB4OBFN-4Q13-kzsARHcEaamctnNYpCsfs3PGUY-Qlf2bKF4AQvBxXQcKoV-qms8Il78RGogxfZxO5M2ACdijB9HZX0_swm_mAEdoNn8lGdrRJPcAeALlHkxeXx4dPqc7O606PJV7NPjA9PQyFKVkryobXq5e6xd-G3scM9QWv0-ws_jl/s1600/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88%202025-05-03%20102822.png"/>
</a>
</div>
CSSで空白行をつけると、上図のように目次リストの表示・非表示に関わらず、タイトル行の下に空白ができてしまい、これは好みではない。
目次リストが表示されている時のみ空白を表示するように修正するには、CSSではなくJavaScriptで制御する必要がある。具体的には、目次リストの `
<ul>
` 要素が存在する場合にのみ、タイトル行の margin-bottom を設定するようにする。
簡単だと思ったが、とても複雑な処理が必要だった。理解できていないので、
<svg height="24" width="24">
<use xlink:href="#i-chatgpt">
</use>
</svg>
<span style="font-size:12px;">
</span>
ChatGPT にお任せである。
---
#### avaScriptコード
空白行を作るためのJavaScriptのコードを以下に示す。
<details>
<summary>
空白行 Java Code
</summary>
```javascript
// 目次コンテナと ul 要素を取得
const tocContainer = document.querySelector('.b-toc-container');
const tocList = document.querySelector('.b-toc-container ul.toc-root-list');
// タイトル行の
<p>
要素を取得
const tocTitle = tocContainer ? tocContainer.querySelector('p') : null;
if (tocTitle) {
// 目次リストが存在する場合のみ margin-bottom を設定
const observer = new MutationObserver((mutationsList, observer) => {
const currentTocList = tocContainer.querySelector('ul.toc-root-list');
if (currentTocList && currentTocList.style.display !== 'none') {
tocTitle.style.marginBottom = '0.5em';
} else {
tocTitle.style.marginBottom = '0';
}
});
// 監視を開始(子要素の変更を監視)
observer.observe(tocContainer, { childList: true, subtree: true, attributes: true });
// 初期状態で目次が表示されていれば margin-bottom を設定
if (tocList && tocList.style.display !== 'none') {
tocTitle.style.marginBottom = '0.5em';
} else {
tocTitle.style.marginBottom = '0';
}
}
```
</p>
</details>
---
この部分が何をしているかを簡単に説明する。
1. **要素の取得**:
* `.b-toc-container` (目次全体の囲み)
* `.b-toc-container ul.toc-root-list` (目次リストの `
<ul>
` 要素)
* `.b-toc-container p` (目次のタイトル行 `
<p>
` 要素)
を取得しています。
2. **MutationObserver の設定**:
* `MutationObserver` という仕組みを使って、目次コンテナ (`tocContainer`) の子要素の状態変化を監視しています。これは、目次リストが表示されたり非表示になったりするのを検知するためです。
* コールバック関数の中で、現在の目次リスト (`currentTocList`) が画面に表示されている (`style.display !== 'none'`) かどうかを確認しています。
* 表示されている場合は、タイトル行 (`tocTitle`) の下の余白 (`style.marginBottom`) を `0.5em` に設定して空白を作ります。
* 表示されていない場合は、`marginBottom` を `0` に戻して空白をなくします。
3. **監視の開始**:
* `observer.observe(tocContainer, { childList: true, subtree: true, attributes: true });` の部分で、実際に監視を開始しています。`attributes: true` を加えることで、要素の `style` 属性の変化も監視できるようになります。
4. **初期状態の処理**:
* ページが最初に読み込まれた時点で、目次リストが表示されているかどうかを確認し、表示されていれば同様にタイトル行の下に空白を設定しています。
このようにして、JavaScriptが目次リストの状態に合わせて、タイトル行の下の空白を動的に調整しています。
---
### 2. 見出しのリンクを自動取得
この記事を見て、これをBlogger「Jettheme」で実現したくなった。
>このリンクを生成するためにDeveloper Toolsを表示して見出しのidを入手してURLにhash fragmentを設定して… という手順になってしまいやや煩雑。GitHubのMarkdownのように、hoverしたらリンクアイコンが表示されて見出しのリンクに飛べる、となっていたい。
<figure class="blogcard b-link">
<a aria-label="記事詳細へ(別窓で開く)" href="https://masawada.hatenablog.jp/entry/2022/08/19/190000" rel="noopener noreferrer" target="_blank">
<div class="blogcard-content">
<div class="blogcard-image bi-link">
<div class="blogcard-image-wrapper biw-link">
<img alt="はてなブログで見出しへのリンクを表示する - あんパン" height="100" loading="lazy" src="https://cdn.image.st-hatena.com/image/scale/9bee7a33cf55df64a0ff9e9c619acb6cf3e60480/backend=imagemagick;version=1;width=1300/https%3A%2F%2Fcdn-ak.f.st-hatena.com%2Fimages%2Ffotolife%2Fm%2Fmasawada%2F20220819%2F20220819142905.png" width="100"/>
</div>
</div>
<div class="blogcard-text">
<p class="blogcard-title bt-link">
はてなブログで見出しへのリンクを表示する - あんパン
</p>
<p class="blogcard-description bd-link">
つい最近、はてなブログの見出し(h1からh6までのタグ)にid属性が付与されるようになった。 staff.hatenablog.com ということは、見出しへのリンクを作って共有できる。ただ普通に暮らしていると、このリンクを生成するためにDeveloper Toolsを表示して見出しのidを入手してURLにhash fragmentを設定して… という手順になってしまいやや煩雑。GitHubのMarkdownのように、hoverしたらリンクアイコンが表示されて見出しのリンクに飛べる、となっていたい。 hoverすると見出しの横にリンクアイコンが出て見出しに飛べる ということで、自分のブログで同…
</p>
</div>
</div>
<div class="blogcard-footer bf-link">
<img alt="ファビコン" height="16" loading="lazy" src="https://www.google.com/s2/favicons?domain=https://masawada.hatenablog.jp/entry/2022/08/19/190000" width="16"/>
masawada.hatenablog.jp
</div>
</a>
</figure>
#### (
<svg height="24" width="24">
<use xlink:href="#i-chatgpt">
</use>
</svg>
<span style="font-size:12px;">
</span>
ChatGPT へトスする)見出しリンクの仕様
1. 見出しをホバーすると、左にリンクアイコンが表示され、
1. アイコンをクリックするとリンク先に飛べる。
1. ブラウザのURL欄にリンク付きURLが表示されて、コピペできる。
1. コピペは、アイコンクリックでURLを自動コピー
---
#### avaScriptコード
見出しリンクを作るためのJavaScriptのコードを以下に示す。
<details>
<summary>
見出しリンク Java Code
</summary>
```javascript
function injectHeadingLinks() {
const headings = document.querySelectorAll(toc_options.target.join(','));
headings.forEach(heading => {
if (!heading.id) return;
const wrapper = document.createElement("span");
wrapper.className = "heading-link-wrapper";
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "copy-heading-link";
link.title = "リンクをコピー";
link.innerHTML = "🔗";
link.addEventListener("click", e => {
// スクロールジャンプ有効(preventDefaultなし)
const url = location.origin + location.pathname + "#" + heading.id;
history.replaceState(null, null, "#" + heading.id);
navigator.clipboard.writeText(url).then(() => {
link.innerHTML = '
<span style="color: #f92672;">
🔗
</span>
'; // 濃いピンクの🔗
setTimeout(() => {
link.innerHTML = "🔗"; // 元に戻す
}, 2000);
}).catch(() => {
prompt("URLをコピーしてください:", url);
});
});
wrapper.appendChild(link);
heading.insertAdjacentElement("afterbegin", wrapper);
heading.style.position = "relative";
});
}
```
</details>
---
この部分の処理を簡単に説明する。
1. **見出し要素の特定**: `toc_options.target` で指定されたセレクターに一致するすべての見出し要素をページ内から取得します。
2. **各見出しへの処理**: 取得した見出し要素一つずつに対して以下の処理を行います。
3. **IDの確認**: 見出しが `id` 属性を持っているか確認します。なければ処理をスキップします。
4. **ラッパー要素の作成**: リンクアイコンを囲むための `
<span>
` 要素を作成します。
5. **リンク要素の作成**: クリック可能な `
<a>
` 要素を作成し、`href` に見出しの `#id` を設定します。
6. **リンクのデザイン**: リンクにクラス名、ツールチップテキスト、南京錠アイコン(??)を設定します。
7. **クリックイベントの登録**: リンクがクリックされた際の処理を定義します。
* 現在のURLに見出しの `#id` を追加したURLを作成します。
* ブラウザのアドレスバーのハッシュ部分を更新します。
* 作成したURLをクリップボードにコピーを試みます。
* 成功した場合、アイコンを一時的に変更してフィードバックを表示し、元に戻します。
* 失敗した場合、手動コピーを促すプロンプトを表示します。
8. **リンクの挿入**: 作成したリンク要素をラッパー要素に追加し、ラッパー要素を見出しの先頭に挿入します。
9. **見出しのスタイル調整**: 見出しの `position` を `relative` に設定します。
このフローにより、指定された見出しの隣に、クリックでリンクをコピーできるアイコンが自動的に追加されます。
---
## 完成形 全スクリプト
目次(My TOC)作成と,空白行と見出しリンクを自動生成のカスタマイズを追加した全スクリプトを載せる。
<details>
<summary>
カスタマイズ My TOC スクリプト
</summary>
```JavaScript
<!-- [START] 目次作成プラグイン (完全修正版リンク付き) -->
<b:if cond='data:blog.pageType == "item"'>
<script type="text/javascript">
//<![CDATA[
var toc_options = {
target: ["h2", "h3", "h4"],
autoNumber: false,
condTargetCount: 2,
insertPosition: "top",
showToc: false,
width: "auto",
marginTop: "20px",
marginBottom: "20px",
indent: "20px",
postBodySelector: ".widget.Blog",
ignoreURL: [],
copyToSidebar: true,
sidebarSelector: "#sidebar",
highlight: true
};
(function () {
var idCounter = 0;
function buildTocTree(targets, container) {
var headings = Array.from(container.querySelectorAll(targets.join(',')));
var root = { children: [], level: 0 };
var stack = [root];
headings.forEach(heading => {
const tagName = heading.tagName.toLowerCase();
const level = targets.indexOf(tagName) + 1;
if (level === 0) return;
const id = `toc_headline_${++idCounter}`;
heading.id = id;
const node = {
id: id,
text: heading.textContent.trim(),
children: [],
level: level
};
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
stack[stack.length - 1].children.push(node);
stack.push(node);
});
return root;
}
function renderToc(tree) {
const container = document.createElement("div");
container.className = "b-toc-container";
container.style.marginTop = toc_options.marginTop;
container.style.marginBottom = toc_options.marginBottom;
if (toc_options.width !== "auto") container.style.width = toc_options.width;
const toggle = document.createElement("p");
const label = document.createElement("span");
label.textContent = "目次";
const brOpen = document.createElement("span");
brOpen.textContent = "[";
const brClose = document.createElement("span");
brClose.textContent = "]";
const link = document.createElement("a");
link.href = "javascript:void(0);";
toggle.appendChild(label);
toggle.appendChild(brOpen);
toggle.appendChild(link);
toggle.appendChild(brClose);
container.appendChild(toggle);
const ul = document.createElement("ul");
ul.className = "toc-root-list";
createList(tree.children, ul);
container.appendChild(ul);
link.textContent = toc_options.showToc ? "非表示" : "表示";
if (!toc_options.showToc) ul.style.display = "none";
link.addEventListener("click", () => {
const isShown = ul.style.display !== "none";
ul.style.display = isShown ? "none" : "block";
link.textContent = isShown ? "表示" : "非表示";
});
return container;
}
function createList(items, parentUl) {
items.forEach(item => {
const li = document.createElement("li");
li.className = "toc-list-item";
li.style.paddingLeft = toc_options.indent;
const a = document.createElement("a");
a.href = `#${item.id}`;
const span = document.createElement("span");
span.className = "toc-text";
span.textContent = item.text;
a.appendChild(span);
li.appendChild(a);
if (item.children.length > 0) {
const subUl = document.createElement("ul");
subUl.className = "toc-sub-list";
createList(item.children, subUl);
li.appendChild(subUl);
}
parentUl.appendChild(li);
});
}
// 見出しリンク自動取得
function injectHeadingLinks() {
const headings = document.querySelectorAll(toc_options.target.join(','));
headings.forEach(heading => {
if (!heading.id) return;
const wrapper = document.createElement("span");
wrapper.className = "heading-link-wrapper";
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "copy-heading-link";
link.title = "リンクをコピー";
link.innerHTML = "🔗";
link.addEventListener("click", e => {
// スクロールジャンプ有効(preventDefaultなし)
const url = location.origin + location.pathname + "#" + heading.id;
history.replaceState(null, null, "#" + heading.id);
navigator.clipboard.writeText(url).then(() => {
link.innerHTML = '<span style="color: #f92672;">🔗</span>'; // 濃いピンクの🔗
setTimeout(() => {
link.innerHTML = "🔗"; // 元に戻す
}, 2000);
}).catch(() => {
prompt("URLをコピーしてください:", url);
});
});
wrapper.appendChild(link);
heading.insertAdjacentElement("afterbegin", wrapper);
heading.style.position = "relative";
});
}
// ここまで
function injectToc() {
if (toc_options.ignoreURL.some(re => location.href.match(re))) return;
const post = document.querySelector(toc_options.postBodySelector);
if (!post) return;
const tocTree = buildTocTree(toc_options.target, post);
if (tocTree.children.length < toc_options.condTargetCount) return;
const toc = renderToc(tocTree);
if (toc_options.insertPosition === "top") {
post.insertBefore(toc, post.firstChild);
}
if (toc_options.copyToSidebar) {
const sidebar = document.querySelector(toc_options.sidebarSelector);
if (sidebar) {
const clone = toc.cloneNode(true);
clone.classList.add("side-toc");
const a = clone.querySelector("a");
a.addEventListener("click", () => {
const ul = clone.querySelector("ul");
const shown = ul.style.display !== "none";
ul.style.display = shown ? "none" : "block";
a.textContent = shown ? "表示" : "非表示";
});
sidebar.appendChild(clone);
}
}
injectHeadingLinks();
if (toc_options.highlight) {
window.addEventListener("scroll", () => {
const headings = Array.from(document.querySelectorAll(toc_options.target.join(',')));
let currentId = null;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
for (let i = 0; i < headings.length; i++) {
const top = headings[i].getBoundingClientRect().top + scrollTop;
if (top - 150 <= scrollTop) {
currentId = headings[i].id;
} else {
break;
}
}
document.querySelectorAll(".b-toc-container a[href^='#'], .side-toc a[href^='#']").forEach(link => {
link.parentElement.classList.remove("tl-active");
});
if (currentId) {
const mainTocLink = document.querySelector(`.b-toc-container a[href="#${currentId}"]`);
if (mainTocLink) mainTocLink.parentElement.classList.add("tl-active");
const sideTocLink = document.querySelector(`.side-toc a[href="#${currentId}"]`);
if (sideTocLink) {
sideTocLink.parentElement.classList.add("tl-active");
const container = sideTocLink.closest(".side-toc");
if (container) {
setTimeout(() => {
sideTocLink.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, 50);
}
}
}
});
}
}
document.addEventListener("DOMContentLoaded", injectToc);
// 目次表題の下に空白行をつける
window.addEventListener('DOMContentLoaded', () => {
const tocContainers = document.querySelectorAll('.b-toc-container'); // メインとサイドバー両方取得
tocContainers.forEach(tocContainer => {
const tocList = tocContainer.querySelector('ul.toc-root-list'); // ul 要素を取得
const tocTitle = tocContainer.querySelector('p'); // タイトル行の <p> 要素を取得
// 目次リストが存在する場合のみ margin-bottom を設定
if (tocTitle) {
const observer = new MutationObserver((mutationsList, observer) => {
const currentTocList = tocContainer.querySelector('ul.toc-root-list');
if (currentTocList && currentTocList.style.display !== 'none') {
tocTitle.style.marginBottom = '0.8em';
} else {
tocTitle.style.marginBottom = '0';
}
});
// 監視を開始(子要素の変更を監視)
observer.observe(tocContainer, { childList: true, subtree: true, attributes: true });
// 初期状態で目次が表示されていれば margin-bottom を設定
if (tocList && tocList.style.display !== 'none') {
tocTitle.style.marginBottom = '0.5em';
} else {
tocTitle.style.marginBottom = '0';
}
}
});
});
// ここまで
})();
//]]>
</script>
<style type="text/css">
/* --- 見出し用リンクアイコン表示(改良版) --- */
h2, h3, h4 {
position: relative;
}
.copy-heading-link {
position: absolute;
left: -1.5em; /* h2・h3用の基本位置 */
top: 0.15em;
text-decoration: none;
font-size: 0.8em;
opacity: 0;
transition: opacity 0.3s;
padding-right: 0.3em;
display: inline-block;
cursor: pointer;
}
h4 .copy-heading-link {
left: -2.5em; /* h4の::before要素より左に表示 */
}
h2:hover .copy-heading-link,
h3:hover .copy-heading-link,
h4:hover .copy-heading-link {
opacity: 1;
}
/* --- 目次全体(既存スタイル) --- */
.b-toc-container {
background: #f0f8ff;
border: solid 1px;
border-color: var(--jt-primary);
padding: 10px;
margin-bottom: 1em;
width: auto;
display: table;
font-size: 95%;
}
.b-toc-container p {
text-align: left;
margin: 0;
}
.b-toc-container ul {
list-style-type: none;
margin: 0;
padding: 0;
}
.b-toc-container ul li {
margin: 0;
padding: 0 0 0 20px;
}
.b-toc-container ul li a {
color: inherit;
text-decoration: none;
}
.b-toc-container ul li .toc-text:hover {
text-decoration: underline;
}
.tl-active > a {
background-color: #fee5ee;
color: #f92672 !important;
display: block;
width: 100%;
}
.side-toc {
position: sticky;
top: 100px;
max-height: 400px;
overflow-y: auto;
}
.side-toc ul {
padding-left: 0;
list-style: none;
}
@media (max-width: 575.98px) {
.b-toc-container {
margin: 1em auto;
width: 95% !important;
}
.side-toc {
display: none;
}
.b-toc-container li {
padding: 0 10px 5px 10px !important;
}
}
</style>
</b:if>
<!-- [END] 目次作成プラグイン (完全修正済みリンク付き) -->
```
</details>
## 関連リンク
</a>
</span>
</p>
</ul>
</ul>