issue-popover.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. // resources/js/modules/issue-popover.js
  2. //
  3. // 阅读页 issue 标注的点击查看。正文里的标注由后端注入
  4. // (PaliContentService::injectAnnotationNotes → AnnotationLayout):
  5. // span.anno-issue 有位置的 issue,包住被指出的原文(交叠的已合并成一个 span)
  6. // span.anno-issue-mark 没有位置的 issue,句尾的三角叹号图标
  7. // 两者都带 data-discussion-ids="id1,id2"。点击后逐条取 GET /api/v3/discussions/{id}
  8. // (issue 对匿名公开),桌面端用 Bootstrap Popover,手机端用 #annoIssueDrawer 抽屉。
  9. import * as bootstrap from 'bootstrap';
  10. const isMobile = () => window.innerWidth < 768;
  11. const cache = new Map();
  12. function escapeHtml(text) {
  13. const div = document.createElement('div');
  14. div.textContent = text ?? '';
  15. return div.innerHTML;
  16. }
  17. async function fetchIssue(id) {
  18. if (!cache.has(id)) {
  19. cache.set(
  20. id,
  21. fetch(`/api/v3/discussions/${encodeURIComponent(id)}`, {
  22. headers: { Accept: 'application/json' },
  23. }).then(async (res) => {
  24. if (res.status === 404) {
  25. return null;
  26. }
  27. if (!res.ok) {
  28. throw new Error(String(res.status));
  29. }
  30. return (await res.json()).data;
  31. }),
  32. );
  33. }
  34. try {
  35. return await cache.get(id);
  36. } catch (e) {
  37. cache.delete(id); // 失败的不缓存,下次点击重试
  38. throw e;
  39. }
  40. }
  41. function renderIssue(issue, i18n) {
  42. if (!issue) {
  43. return `<div class="anno-issue-item text-muted">${escapeHtml(i18n.notFound)}</div>`;
  44. }
  45. const severity = issue.severity ?? '';
  46. const severityLabel = i18n.severity?.[severity] ?? '';
  47. const editor = issue.editor?.nickName ?? '';
  48. const date = issue.created_at
  49. ? new Date(issue.created_at).toLocaleDateString()
  50. : '';
  51. const replies = issue.reply_count
  52. ? i18n.replies.replace(':count', issue.reply_count)
  53. : '';
  54. // summary 是后端渲染好的纯文本(模板已展开),比原始 markdown 更适合直接显示
  55. const body = (issue.summary ?? issue.content ?? '').trim();
  56. const meta = [severityLabel, editor, date, replies]
  57. .filter(Boolean)
  58. .join(' · ');
  59. return `
  60. <div class="anno-issue-item anno-issue-${escapeHtml(severity || 'none')}">
  61. <div class="anno-issue-item-head">
  62. <i class="ti ti-alert-triangle"></i>
  63. <span>${escapeHtml(issue.title ?? '')}</span>
  64. </div>
  65. ${body ? `<div class="anno-issue-item-body">${escapeHtml(body)}</div>` : ''}
  66. <div class="anno-issue-item-meta">${escapeHtml(meta)}</div>
  67. </div>`;
  68. }
  69. async function renderList(ids, i18n) {
  70. const results = await Promise.allSettled(ids.map(fetchIssue));
  71. return results
  72. .map((r) =>
  73. r.status === 'fulfilled'
  74. ? renderIssue(r.value, i18n)
  75. : `<div class="anno-issue-item text-danger">${escapeHtml(i18n.failed)}</div>`,
  76. )
  77. .join('');
  78. }
  79. export function initIssueMarkers() {
  80. const article = document.querySelector('article.reader-body');
  81. const drawerEl = document.getElementById('annoIssueDrawer');
  82. if (!article || !article.querySelector('.anno-issue, .anno-issue-mark')) {
  83. return;
  84. }
  85. let i18n = {
  86. loading: '…',
  87. failed: '!',
  88. notFound: '',
  89. replies: ':count',
  90. severity: {},
  91. };
  92. try {
  93. i18n = { ...i18n, ...JSON.parse(drawerEl?.dataset.i18n ?? '{}') };
  94. } catch {
  95. // 文案解析失败就用兜底文案
  96. }
  97. const loadingHtml = `<div class="text-muted small">${escapeHtml(i18n.loading)}</div>`;
  98. let activeEl = null;
  99. let activePopover = null;
  100. const deactivate = () => {
  101. activePopover?.dispose();
  102. activePopover = null;
  103. activeEl?.classList.remove('is-active');
  104. activeEl = null;
  105. };
  106. const open = async (el) => {
  107. const ids = (el.dataset.discussionIds ?? '').split(',').filter(Boolean);
  108. if (ids.length === 0) {
  109. return;
  110. }
  111. const same = activeEl === el;
  112. deactivate();
  113. if (same && !isMobile()) {
  114. return; // 再点一次同一个标注:收起
  115. }
  116. activeEl = el;
  117. el.classList.add('is-active');
  118. if (isMobile() && drawerEl) {
  119. const body = drawerEl.querySelector('.offcanvas-body');
  120. body.innerHTML = loadingHtml;
  121. const drawer = bootstrap.Offcanvas.getOrCreateInstance(drawerEl);
  122. drawerEl.addEventListener('hidden.bs.offcanvas', deactivate, {
  123. once: true,
  124. });
  125. drawer.show();
  126. body.innerHTML = await renderList(ids, i18n);
  127. return;
  128. }
  129. const popover = new bootstrap.Popover(el, {
  130. trigger: 'manual',
  131. html: true,
  132. sanitize: false, // 内容全部经过 escapeHtml
  133. placement: 'bottom',
  134. fallbackPlacements: ['top', 'bottom'],
  135. customClass: 'anno-issue-popover',
  136. title: escapeHtml(i18n.title),
  137. content: `<div class="anno-issue-list">${loadingHtml}</div>`,
  138. });
  139. activePopover = popover;
  140. popover.show();
  141. const html = await renderList(ids, i18n);
  142. if (activePopover !== popover) {
  143. return; // 等待期间已经切到别的标注
  144. }
  145. // 不用 setContent():它在 manual 触发下会重走一遍 show(),完成回调里因 _isHovered === false
  146. // 直接把 popover 收起(Bootstrap 5.3)。与 term-tooltip.js 一样直接改 body,再重新定位。
  147. const body = `<div class="anno-issue-list">${html}</div>`;
  148. popover._config.content = body;
  149. const tip = document.getElementById(
  150. el.getAttribute('aria-describedby') ?? '',
  151. );
  152. tip?.querySelector('.popover-body')?.replaceChildren(
  153. document.createRange().createContextualFragment(body),
  154. );
  155. popover.update();
  156. };
  157. article.addEventListener('click', (e) => {
  158. // 标注里可能嵌着边注(label / input / .sidenote)与术语,它们有自己的点击行为
  159. if (
  160. e.target.closest(
  161. 'label, input, .sidenote, .marginnote, .term-ref, a',
  162. )
  163. ) {
  164. return;
  165. }
  166. const el = e.target.closest('.anno-issue, .anno-issue-mark');
  167. if (!el) {
  168. return;
  169. }
  170. e.preventDefault();
  171. open(el);
  172. });
  173. article.addEventListener('keydown', (e) => {
  174. const el = e.target.closest?.('.anno-issue-mark');
  175. if (el && (e.key === 'Enter' || e.key === ' ')) {
  176. e.preventDefault();
  177. open(el);
  178. }
  179. });
  180. // 点标注与弹层以外的地方收起 popover
  181. document.addEventListener('click', (e) => {
  182. if (
  183. activePopover &&
  184. !e.target.closest(
  185. '.anno-issue, .anno-issue-mark, .anno-issue-popover',
  186. )
  187. ) {
  188. deactivate();
  189. }
  190. });
  191. }