// resources/js/modules/reader.js
export function initReader() {
injectCommentaryMarkers();
injectEvaluateMarkers();
initTocToggle();
}
// 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 = '加载失败';
}
}