埋もれているハクスラ系ゲームを掘り起こす
レベル16で覚える 変成 でデーモンパワーを強化
低レアリティのオーラ同じもの3つを合成して1つ上のレアリティに強化できる
(() => {
'use strict';
const CONFIG = {
articleSelector: '.entry-content',
headingSelector: 'h2, h3, h4, h5',
minimumHeadingsForToc: 3,
enableImageZoom: true,
enableProgressBar: true,
enableBackToTop: true
};
const ready = (fn) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
};
const safeId = (text, index) => {
const normalized = String(text || '')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}_-]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 70);
return normalized || `have-reader-heading-${index + 1}`;
};
const uniqueId = (base, used) => {
let id = base;
let n = 2;
while (used.has(id) || document.getElementById(id)) {
id = `${base}-${n++}`;
}
used.add(id);
return id;
};
const buildToc = (article) => {
const headings = [...article.querySelectorAll(CONFIG.headingSelector)]
.filter((heading) => heading.textContent.trim());
if (headings.length < CONFIG.minimumHeadingsForToc) return;
const usedIds = new Set();
const nav = document.createElement('nav');
nav.className = 'have-reader-toc';
nav.setAttribute('aria-label', '記事内の見出し');
const head = document.createElement('div');
head.className = 'have-reader-toc__head';
const title = document.createElement('p');
title.className = 'have-reader-toc__title';
title.textContent = 'この記事の見出し';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'have-reader-toc__toggle';
toggle.textContent = '閉じる';
toggle.setAttribute('aria-expanded', 'true');
const list = document.createElement('ol');
list.className = 'have-reader-toc__list';
const links = new Map();
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = uniqueId(safeId(heading.textContent, index), usedIds);
}
const item = document.createElement('li');
item.dataset.level = heading.tagName.slice(1);
const link = document.createElement('a');
link.href = `#${encodeURIComponent(heading.id)}`;
link.textContent = heading.textContent.trim();
link.addEventListener('click', (event) => {
event.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`);
});
item.appendChild(link);
list.appendChild(item);
links.set(heading, link);
});
toggle.addEventListener('click', () => {
const willHide = !list.hidden;
list.hidden = willHide;
toggle.textContent = willHide ? '開く' : '閉じる';
toggle.setAttribute('aria-expanded', String(!willHide));
});
head.append(title, toggle);
nav.append(head, list);
article.parentNode.insertBefore(nav, article);
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (!visible.length) return;
links.forEach((link) => link.classList.remove('is-current'));
const current = links.get(visible[0].target);
if (current) current.classList.add('is-current');
}, {
rootMargin: '-12% 0px -72% 0px',
threshold: [0, 1]
});
headings.forEach((heading) => observer.observe(heading));
}
};
const installProgressBar = (article) => {
if (!CONFIG.enableProgressBar) return;
const bar = document.createElement('div');
bar.className = 'have-reader-progress';
bar.setAttribute('aria-hidden', 'true');
document.body.appendChild(bar);
let ticking = false;
const update = () => {
ticking = false;
const rect = article.getBoundingClientRect();
const articleTop = window.scrollY + rect.top;
const start = articleTop - Math.min(window.innerHeight * 0.2, 140);
const end = articleTop + article.offsetHeight - window.innerHeight;
const span = Math.max(1, end - start);
const ratio = Math.min(1, Math.max(0, (window.scrollY - start) / span));
bar.style.width = `${ratio * 100}%`;
};
const requestUpdate = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
window.addEventListener('scroll', requestUpdate, { passive: true });
window.addEventListener('resize', requestUpdate, { passive: true });
update();
};
const installBackToTop = () => {
if (!CONFIG.enableBackToTop) return;
const button = document.createElement('button');
button.type = 'button';
button.className = 'have-reader-top';
button.textContent = '↑';
button.setAttribute('aria-label', 'ページ上部へ戻る');
document.body.appendChild(button);
const update = () => {
button.classList.toggle('is-visible', window.scrollY > 700);
};
button.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
window.addEventListener('scroll', update, { passive: true });
update();
};
const installImageZoom = (article) => {
if (!CONFIG.enableImageZoom) return;
const images = [...article.querySelectorAll('img')]
.filter((img) => !img.closest('a[href]'));
if (!images.length) return;
const box = document.createElement('div');
box.className = 'have-reader-lightbox';
box.hidden = true;
box.setAttribute('role', 'dialog');
box.setAttribute('aria-modal', 'true');
box.setAttribute('aria-label', '画像の拡大表示');
const close = document.createElement('button');
close.type = 'button';
close.className = 'have-reader-lightbox__close';
close.textContent = '×';
close.setAttribute('aria-label', '拡大表示を閉じる');
const large = document.createElement('img');
large.alt = '';
const closeBox = () => {
box.hidden = true;
large.removeAttribute('src');
document.documentElement.style.overflow = '';
};
const openBox = (img) => {
large.src = img.currentSrc || img.src;
large.alt = img.alt || '';
box.hidden = false;
document.documentElement.style.overflow = 'hidden';
close.focus();
};
images.forEach((img) => {
img.tabIndex = 0;
img.setAttribute('role', 'button');
img.setAttribute('aria-label', `${img.alt || '記事画像'}を拡大`);
img.addEventListener('click', () => openBox(img));
img.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openBox(img);
}
});
});
close.addEventListener('click', closeBox);
large.addEventListener('click', closeBox);
box.addEventListener('click', (event) => {
if (event.target === box) closeBox();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !box.hidden) closeBox();
});
box.append(close, large);
document.body.appendChild(box);
};
ready(() => {
const articles = [...document.querySelectorAll(CONFIG.articleSelector)];
if (!articles.length) return;
articles.forEach((article) => buildToc(article));
const primaryArticle = articles[0];
installProgressBar(primaryArticle);
installImageZoom(primaryArticle);
installBackToTop();
});
})();