// resources/js/modules/reader.js export function initReader() { applyAnnotationMode(); numberAnnotations(); injectCommentaryMarkers(); injectEvaluateMarkers(); initTocToggle(); initHashScroll(); initAnnotationMode(); } // 读取阅读偏好 cookie(与 read.blade.php 内联脚本的 setCookie 对应)。注意:该 cookie // 由前端 document.cookie 明文写入,不经过 Laravel 加密,只能在客户端读。 function readCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) { return decodeURIComponent(parts.pop().split(';').shift()); } return null; } // 把「注释」呈现模式(行内 / 段后)从 cookie 应用到正文根节点。 function applyAnnotationMode() { const article = document.querySelector('article.reader-body'); if (!article) { return; } article.dataset.annotationMode = readCookie('annotation_mode') === 'footnote' ? 'footnote' : 'inline'; } // 处理 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'); } }); }); } // 注释编号:按「段」分组,给正文角标 .sidenote-number 与段后 .anno-footnote 分别 // 编号 [1][2]…,二者一一对应(参照 wikipali-mobile 的 numberPara)。段后脚注的编号 // 由 JS 动态补进 label。 function numberAnnotations() { document .querySelectorAll('article.reader-body div[data-para]') .forEach((para) => { para.querySelectorAll('.sidenote-number').forEach((marker, i) => { marker.dataset.idx = String(i + 1); }); para.querySelectorAll('.anno-footnote').forEach((fn, i) => { fn.dataset.idx = String(i + 1); const label = fn.querySelector('.anno-fn-label'); if (label && !label.querySelector('.anno-fn-num')) { const num = document.createElement('span'); num.className = 'anno-fn-num'; num.textContent = `[${i + 1}]`; num.dataset.idx = String(i + 1); label.insertBefore(num, label.firstChild); } }); }); } // 段后模式下的互跳:点正文角标 [N] → 滚到对应脚注;点脚注编号 [N] → 滚回正文角标。 // 行内模式不挂此监听,保留 label 的原生 checkbox 展开行为。 function initAnnotationMode() { const article = document.querySelector('article.reader-body'); if (!article || article.dataset.annotationMode !== 'footnote') { return; } const flashAnno = (el) => { if (!el) { return; } el.classList.add('anno-highlight'); setTimeout(() => el.classList.remove('anno-highlight'), 2200); }; article.addEventListener('click', (e) => { const marker = e.target.closest('.sidenote-number'); if (marker) { const para = marker.closest('div[data-para]'); const fn = para && para.querySelector( `.anno-footnote[data-idx="${marker.dataset.idx}"]` ); if (fn) { e.preventDefault(); fn.scrollIntoView({ block: 'center', behavior: 'smooth' }); flashAnno(fn); } return; } const fnNum = e.target.closest('.anno-fn-num'); if (fnNum) { const fn = fnNum.closest('.anno-footnote'); const para = fn && fn.closest('div[data-para]'); const marker = para && para.querySelector( `.sidenote-number[data-idx="${fnNum.dataset.idx}"]` ); if (marker) { e.preventDefault(); marker.scrollIntoView({ block: 'center', behavior: 'smooth', }); flashAnno(marker); } } }); } 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 = '加载失败'; } }