// resources/js/modules/reader.js
export function initReader() {
injectCommentaryMarkers();
injectEvaluateMarkers();
initTocToggle();
initHashScroll();
}
// 处理 URL hash(#354 / #94-354 / #para-354):滚动到对应段落并加高亮底色
function initHashScroll() {
const scrollToParagraph = (paragraph, smooth = false) => {
const el = document.getElementById(`para-${paragraph}`);
if (!el) {
return;
}
document
.querySelectorAll('.reader-para-anchor')
.forEach((node) => node.classList.remove('reader-para-anchor'));
el.classList.add('reader-para-anchor');
el.scrollIntoView({ block: 'start', behavior: smooth ? 'smooth' : 'auto' });
};
// 统一解析为段落号:取最后一个 '-' 之后的数字,兼容 #354 与 #94-354
const parseHash = (hash) => {
if (!hash || hash === '#') {
return null;
}
const paragraph = parseInt(hash.replace(/^#/, '').split('-').pop(), 10);
return Number.isInteger(paragraph) && paragraph > 0 ? paragraph : null;
};
window.addEventListener('hashchange', () => {
const paragraph = parseHash(window.location.hash);
if (paragraph !== null) {
scrollToParagraph(paragraph, true);
}
});
const paragraph = parseHash(window.location.hash);
if (paragraph !== null) {
scrollToParagraph(paragraph);
}
}
// TOC 折叠/展开:点击按钮切换所在 .toc-tree 的 .toc-expanded 类
// offcanvas 与侧边栏各有一份 toc,故对每个按钮分别绑定
function initTocToggle() {
document.querySelectorAll('[data-toc-toggle]').forEach((btn) => {
btn.addEventListener('click', () => {
const tree = btn.closest('[data-toc-tree]');
if (tree) {
tree.classList.toggle('toc-expanded');
}
});
});
}
function injectCommentaryMarkers() {
let counter = 0;
document
.querySelectorAll('div.sentence[data-note-id]')
.forEach((sentenceEl) => {
const uid = sentenceEl.dataset.noteId;
counter++;
const id = `commentary-${counter}`;
// checkbox:控制展开,紧跟在 sentence 后
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'commentary-toggle';
checkbox.id = id;
// label(icon):行内,插在句子文字末尾
const label = document.createElement('label');
label.htmlFor = id;
label.className = 'commentary-icon';
label.innerHTML = '';
// 注释块:紧跟在 checkbox 后(CSS 相邻选择器依赖此顺序)
const note = document.createElement('div');
note.className = 'commentary-note';
note.dataset.uuid = uid;
note.dataset.loaded = 'false';
// label 插入句子内文字末尾(行内不打断文字流)
const innerSpan = sentenceEl.querySelector(':scope > span');
(innerSpan ?? sentenceEl).appendChild(label);
// sentence 后:先插 note,再插 checkbox(after 逆序)
// 最终顺序:div.sentence → input.commentary-toggle → div.commentary-note
sentenceEl.after(note);
sentenceEl.after(checkbox);
// 点击时懒加载
checkbox.addEventListener('change', async () => {
if (!checkbox.checked) {
return;
}
if (note.dataset.loaded === 'true') {
return;
}
note.innerHTML =
'加载中…';
await fetchCommentary(note);
});
});
}
// 译文质量评估标注:把 就地
// 升级为 Tufte 旁注。保留原 span(含背景色高亮)不动,在其后注入
// span.evaluate → label.margin-toggle → input.margin-toggle → span.marginnote
// 复用既有旁注样式:桌面端 marginnote 自动浮到右栏,手机端点击 label 展开。
// 用 marginnote(而非 sidenote)以避免 ::before 计数器的 [N] 编号。
function injectEvaluateMarkers() {
let counter = 0;
document
.querySelectorAll('article.reader-body span.evaluate[title]')
.forEach((evalEl) => {
const info = evalEl.getAttribute('title').trim();
if (info === '') {
return;
}
counter++;
const id = `evaluate-${counter}`;
// label:手机端点击触发(桌面隐藏,marginnote 自动浮动)
const label = document.createElement('label');
label.htmlFor = id;
label.className = 'margin-toggle';
label.innerHTML = '';
// checkbox:控制手机端展开
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'margin-toggle';
checkbox.id = id;
// 旁注内容:取自 title,| 分隔的「问题简述/建议」分行显示
const note = document.createElement('span');
note.className = 'marginnote evaluate-note';
info.split('|').forEach((part, index) => {
if (index > 0) {
note.appendChild(document.createElement('br'));
}
note.appendChild(document.createTextNode(part.trim()));
});
// 顺序:span.evaluate → label → input → span.marginnote
// (input 与 marginnote 相邻,CSS :checked + .marginnote 依赖此顺序)
evalEl.after(label, checkbox, note);
});
}
async function fetchCommentary(noteEl) {
const uuid = noteEl.dataset.uuid;
try {
const res = await fetch(`/api/v2/sentence/${uuid}?format=html`);
if (!res.ok) {
throw new Error(res.status);
}
const json = await res.json();
if (!json.ok) {
throw new Error('api error');
}
noteEl.innerHTML = json.data.html;
noteEl.dataset.loaded = 'true';
} catch {
noteEl.innerHTML = '加载失败';
}
}