issue-popover.js 9.1 KB

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