reader.js 5.1 KB

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