reader.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. // resources/js/modules/reader.js
  2. export function initReader() {
  3. injectCommentaryMarkers();
  4. injectEvaluateMarkers();
  5. initTocToggle();
  6. initHashScroll();
  7. }
  8. // 处理 URL hash(#354 / #94-354 / #para-354):滚动到对应段落并加高亮底色
  9. function initHashScroll() {
  10. const scrollToParagraph = (paragraph, smooth = false) => {
  11. const el = document.getElementById(`para-${paragraph}`);
  12. if (!el) {
  13. return;
  14. }
  15. document
  16. .querySelectorAll('.reader-para-anchor')
  17. .forEach((node) => node.classList.remove('reader-para-anchor'));
  18. el.classList.add('reader-para-anchor');
  19. el.scrollIntoView({ block: 'start', behavior: smooth ? 'smooth' : 'auto' });
  20. };
  21. // 统一解析为段落号:取最后一个 '-' 之后的数字,兼容 #354 与 #94-354
  22. const parseHash = (hash) => {
  23. if (!hash || hash === '#') {
  24. return null;
  25. }
  26. const paragraph = parseInt(hash.replace(/^#/, '').split('-').pop(), 10);
  27. return Number.isInteger(paragraph) && paragraph > 0 ? paragraph : null;
  28. };
  29. window.addEventListener('hashchange', () => {
  30. const paragraph = parseHash(window.location.hash);
  31. if (paragraph !== null) {
  32. scrollToParagraph(paragraph, true);
  33. }
  34. });
  35. const paragraph = parseHash(window.location.hash);
  36. if (paragraph !== null) {
  37. scrollToParagraph(paragraph);
  38. }
  39. }
  40. // TOC 折叠/展开:点击按钮切换所在 .toc-tree 的 .toc-expanded 类
  41. // offcanvas 与侧边栏各有一份 toc,故对每个按钮分别绑定
  42. function initTocToggle() {
  43. document.querySelectorAll('[data-toc-toggle]').forEach((btn) => {
  44. btn.addEventListener('click', () => {
  45. const tree = btn.closest('[data-toc-tree]');
  46. if (tree) {
  47. tree.classList.toggle('toc-expanded');
  48. }
  49. });
  50. });
  51. }
  52. function injectCommentaryMarkers() {
  53. let counter = 0;
  54. document
  55. .querySelectorAll('div.sentence[data-note-id]')
  56. .forEach((sentenceEl) => {
  57. const uid = sentenceEl.dataset.noteId;
  58. counter++;
  59. const id = `commentary-${counter}`;
  60. // checkbox:控制展开,紧跟在 sentence 后
  61. const checkbox = document.createElement('input');
  62. checkbox.type = 'checkbox';
  63. checkbox.className = 'commentary-toggle';
  64. checkbox.id = id;
  65. // label(icon):行内,插在句子文字末尾
  66. const label = document.createElement('label');
  67. label.htmlFor = id;
  68. label.className = 'commentary-icon';
  69. label.innerHTML = '<i class="ti ti-message-circle"></i>';
  70. // 注释块:紧跟在 checkbox 后(CSS 相邻选择器依赖此顺序)
  71. const note = document.createElement('div');
  72. note.className = 'commentary-note';
  73. note.dataset.uuid = uid;
  74. note.dataset.loaded = 'false';
  75. // label 插入句子内文字末尾(行内不打断文字流)
  76. const innerSpan = sentenceEl.querySelector(':scope > span');
  77. (innerSpan ?? sentenceEl).appendChild(label);
  78. // sentence 后:先插 note,再插 checkbox(after 逆序)
  79. // 最终顺序:div.sentence → input.commentary-toggle → div.commentary-note
  80. sentenceEl.after(note);
  81. sentenceEl.after(checkbox);
  82. // 点击时懒加载
  83. checkbox.addEventListener('change', async () => {
  84. if (!checkbox.checked) {
  85. return;
  86. }
  87. if (note.dataset.loaded === 'true') {
  88. return;
  89. }
  90. note.innerHTML =
  91. '<span class="text-muted small">加载中…</span>';
  92. await fetchCommentary(note);
  93. });
  94. });
  95. }
  96. // 译文质量评估标注:把 <span class="evaluate evaluate-级别" title="…"> 就地
  97. // 升级为 Tufte 旁注。保留原 span(含背景色高亮)不动,在其后注入
  98. // span.evaluate → label.margin-toggle → input.margin-toggle → span.marginnote
  99. // 复用既有旁注样式:桌面端 marginnote 自动浮到右栏,手机端点击 label 展开。
  100. // 用 marginnote(而非 sidenote)以避免 ::before 计数器的 [N] 编号。
  101. function injectEvaluateMarkers() {
  102. let counter = 0;
  103. document
  104. .querySelectorAll('article.reader-body span.evaluate[title]')
  105. .forEach((evalEl) => {
  106. const info = evalEl.getAttribute('title').trim();
  107. if (info === '') {
  108. return;
  109. }
  110. counter++;
  111. const id = `evaluate-${counter}`;
  112. // label:手机端点击触发(桌面隐藏,marginnote 自动浮动)
  113. const label = document.createElement('label');
  114. label.htmlFor = id;
  115. label.className = 'margin-toggle';
  116. label.innerHTML = '<i class="ti ti-alert-triangle"></i>';
  117. // checkbox:控制手机端展开
  118. const checkbox = document.createElement('input');
  119. checkbox.type = 'checkbox';
  120. checkbox.className = 'margin-toggle';
  121. checkbox.id = id;
  122. // 旁注内容:取自 title,| 分隔的「问题简述/建议」分行显示
  123. const note = document.createElement('span');
  124. note.className = 'marginnote evaluate-note';
  125. info.split('|').forEach((part, index) => {
  126. if (index > 0) {
  127. note.appendChild(document.createElement('br'));
  128. }
  129. note.appendChild(document.createTextNode(part.trim()));
  130. });
  131. // 顺序:span.evaluate → label → input → span.marginnote
  132. // (input 与 marginnote 相邻,CSS :checked + .marginnote 依赖此顺序)
  133. evalEl.after(label, checkbox, note);
  134. });
  135. }
  136. async function fetchCommentary(noteEl) {
  137. const uuid = noteEl.dataset.uuid;
  138. try {
  139. const res = await fetch(`/api/v2/sentence/${uuid}?format=html`);
  140. if (!res.ok) {
  141. throw new Error(res.status);
  142. }
  143. const json = await res.json();
  144. if (!json.ok) {
  145. throw new Error('api error');
  146. }
  147. noteEl.innerHTML = json.data.html;
  148. noteEl.dataset.loaded = 'true';
  149. } catch {
  150. noteEl.innerHTML = '<span class="text-muted small">加载失败</span>';
  151. }
  152. }