Skip to content

Instantly share code, notes, and snippets.

@manabuyasuda
Last active June 21, 2022 09:54
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save manabuyasuda/0b1ac7fab3ba66ec21b7f093235a6650 to your computer and use it in GitHub Desktop.
Save manabuyasuda/0b1ac7fab3ba66ec21b7f093235a6650 to your computer and use it in GitHub Desktop.
/**
* @classdesc 対象要素内の見出しを検索して目次を生成します。
* @author Manabu Yasuda <info@manabuyasuda.com>
* @example
* import Toc from '@lib/Toc'
* const toc = new Toc({
* tocSelector: '.toc',
* contentSelector: '.content',
* headingSelector: 'h2',
* listClass: 'list',
* itemClass: 'item',
* linkClass: 'link',
* textClass: 'text',
* insertAfterbegin: (currentLevel) => {
* if (currentLevel === 2) {
* return `
* <svg role="img">
* <use xlink:href="/assets/svg/sprite.svg#arrow-circle-down"></use>
* </svg>
* `
* }
*
* return ''
* }
* })
* toc.init()
*/
export default class Toc {
/**
* @param {object} options
* @param {string} options.tocSelector ['[data-toc]'] 目次を出力するセレクター
* @param {string} options.contentSelector ['[data-toc-content]'] 目次を検索するセレクター
* @param {string} options.headingSelector ['h2, h3'] 検索対象の見出し
* @param {string} options.prefix ['heading-'] 見出しとリンクに設定するid属性値とhref属性値の接頭辞(1から始まる数字が続く)
* @param {boolean|string} options.listClass [false] olタグのクラス名
* @param {boolean|string} options.itemClass [false] liタグのクラス名
* @param {boolean|string} options.linkClass [false] aタグのクラス名
* @param {boolean|string} options.textClass [false] aタグ内のテキストを囲っているspanのクラス名
* @param {boolean|string} options.insertAfterbegin(currentLevel) [false] リンクの最初の子要素
* @param {boolean|string} options.insertBeforeend(currentLevel) [false] リンクの最後の子要素
* @param {boolean|string} options.beforeInit() [false] 目次を生成後に実行するコールバック関数
*/
constructor(options) {
const defaultOptions = {
tocSelector: '[data-toc]',
contentSelector: '[data-toc-content]',
headingSelector: 'h2, h3',
prefix: 'heading-',
listClass: false,
itemClass: false,
linkClass: false,
textClass: false,
insertAfterbegin: false,
insertBeforeend: false,
beforeInit: false,
}
this.options = Object.assign(defaultOptions, options)
Object.keys(this.options).forEach(key => {
this[key] = this.options[key]
})
this.selector = {
toc: document.querySelector(this.tocSelector),
content: document.querySelector(this.contentSelector),
headings: document.querySelector(this.contentSelector)
? document.querySelector(this.contentSelector).querySelectorAll(this.headingSelector)
: null,
}
}
init() {
if (!this.selector.toc) return
if (!this.selector.content) return
this.createToc()
}
createToc() {
const listClass = this.listClass ? ` class="${this.listClass}"` : ''
const itemClass = this.itemClass ? ` class="${this.itemClass}"` : ''
const linkClass = this.linkClass ? ` class="${this.linkClass}"` : ''
const textClass = this.textClass ? ` class="${this.textClass}"` : ''
let baseLevel = 2
let html = ''
Array.from(this.selector.headings).forEach((heading, index) => {
const count = index + 1
const titleId = `${this.prefix}${count}`
const currentLevel = Number(heading.nodeName.toLowerCase().split('h')[1])
const isEqualLevel = currentLevel === baseLevel
const isFallLevel = currentLevel > baseLevel
const isRiseLevel = currentLevel < baseLevel
const firstOpeningTag = count === 1
const lastClosingTag = count === this.selector.headings.length
let openingTag = ''
let closingTag = ''
// 挿入するリンク。
const link = `<a${linkClass} href="#${titleId}">${
this.insertAfterbegin !== false ? this.insertAfterbegin(currentLevel, index) : ''
}<span${textClass}>${heading.textContent.trim()}</span>${
this.insertBeforeend !== false ? this.insertBeforeend(currentLevel, index) : ''
}</a>`
// 見出しにid属性を追加する。
heading.setAttribute('id', titleId)
// 見出しレベルが同じ場合は、liのままにする。
if (isEqualLevel) {
openingTag = `</li><li${itemClass}>`
}
// 見出しレベルが下がったら、olタグで入れ子にする。
if (isFallLevel) {
openingTag = `<ol${listClass}><li${itemClass}>`
baseLevel++
}
// 見出しレベルが上がったら、olタグを閉じる。
if (isRiseLevel) {
const closeTag = Array.from({ length: baseLevel - currentLevel }).reduce(acc => {
acc += `</li></ol${listClass}>`
return acc
}, '')
openingTag = closeTag + `</li><li${itemClass}>`
baseLevel += Number(currentLevel - baseLevel)
}
// 最初の要素の開始タグ
if (firstOpeningTag) {
openingTag = `<li${itemClass}>`
}
// 最後の要素の終了タグ
if (lastClosingTag) {
const closeTag = Array.from({ length: baseLevel - 2 }).reduce(acc => {
acc += '</li></ol>'
return acc
}, '')
closingTag = currentLevel === 2 ? '</li>' : closeTag + '</li>'
}
html += openingTag + link + closingTag
})
this.selector.toc.insertAdjacentHTML('beforeend', `<ol${listClass}>` + html + '</ol>')
if (this.beforeInit !== false) {
this.beforeInit()
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment