term-tooltip.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /**
  2. * resources/js/term-tooltip.js
  3. * WikiPāli 术语 tooltip(桌面 Popover)+ 抽屉(移动端 Offcanvas)
  4. *
  5. * 依赖:Bootstrap 5(bootstrap.Popover / bootstrap.Offcanvas)
  6. * 已由 Tabler 全局引入,无需重复 import
  7. */
  8. (function () {
  9. "use strict";
  10. // ── 缓存层 ────────────────────────────────────────────────────────
  11. const cache = {};
  12. async function fetchTerm(id) {
  13. if (cache[id]) return cache[id];
  14. const res = await fetch(`/api/v2/terms/${id}`);
  15. if (!res.ok) throw new Error(`fetchTerm ${id} failed: ${res.status}`);
  16. const json = await res.json();
  17. cache[id] = json.data;
  18. return json.data;
  19. }
  20. // ── 设备判断 ──────────────────────────────────────────────────────
  21. const isMobile = () => window.innerWidth < 768;
  22. // ── Popover 内容模板 ──────────────────────────────────────────────
  23. function buildPopoverContent(data) {
  24. const meaning = (data.meaning || "").trim();
  25. const summary = (data.summary || "").trim();
  26. const showSummary = summary && summary !== meaning;
  27. return `
  28. <div class="wiki-term-card-word">${data.word || ""}</div>
  29. <div class="wiki-term-card-body" style="padding: 10px 14px 12px;">
  30. ${
  31. meaning
  32. ? `<div class="wiki-term-card-meaning">${meaning}</div>`
  33. : ""
  34. }
  35. ${
  36. showSummary
  37. ? `<div class="wiki-term-card-summary">${summary}</div>`
  38. : ""
  39. }
  40. </div>
  41. <div><a>查看完整条目</a></div>
  42. `;
  43. }
  44. // ── 桌面端:Bootstrap Popover ─────────────────────────────────────
  45. function initDesktopPopover(el, data) {
  46. // 防止重复初始化
  47. if (el._wikiPopover) return el._wikiPopover;
  48. const popover = new bootstrap.Popover(el, {
  49. trigger: "manual",
  50. html: true,
  51. placement: "bottom", // flip 会自动在空间不足时翻转为 top
  52. fallbackPlacements: ["top", "bottom"],
  53. customClass: "wiki-term-popover",
  54. content: buildPopoverContent(data),
  55. // 让 popover 自身也可以接收鼠标事件(解决移入气泡消失问题)
  56. // Bootstrap 5.2+ 支持 sanitize: false 保留自定义 html
  57. sanitize: false,
  58. });
  59. el._wikiPopover = popover;
  60. let hideTimer = null;
  61. function scheduleHide() {
  62. hideTimer = setTimeout(() => {
  63. // 如果鼠标此刻在 popover 内,不关闭
  64. const tipEl = document.querySelector(".wiki-term-popover.show");
  65. if (tipEl && tipEl.matches(":hover")) return;
  66. popover.hide();
  67. }, 120);
  68. }
  69. function cancelHide() {
  70. clearTimeout(hideTimer);
  71. }
  72. // 触发元素
  73. el.addEventListener("mouseenter", () => {
  74. cancelHide();
  75. popover.show();
  76. });
  77. el.addEventListener("mouseleave", scheduleHide);
  78. // Popover 显示后,给气泡本身绑定 mouseenter / mouseleave
  79. el.addEventListener("shown.bs.popover", () => {
  80. const tipEl = document.querySelector(".wiki-term-popover.show");
  81. if (!tipEl) return;
  82. tipEl.addEventListener("mouseenter", cancelHide);
  83. tipEl.addEventListener("mouseleave", scheduleHide);
  84. });
  85. return popover;
  86. }
  87. // ── 移动端:Bootstrap Offcanvas(全局共享一个) ────────────────────
  88. let offcanvasInstance = null;
  89. function getOffcanvas() {
  90. if (offcanvasInstance) return offcanvasInstance;
  91. const el = document.getElementById("wikiTermDrawer");
  92. if (!el) return null;
  93. offcanvasInstance = new bootstrap.Offcanvas(el, { scroll: false });
  94. return offcanvasInstance;
  95. }
  96. function showMobileDrawer(data) {
  97. const oc = getOffcanvas();
  98. if (!oc) return;
  99. const meaning = (data.meaning || "").trim();
  100. const summary = (data.summary || "").trim();
  101. const showSummary = summary && summary !== meaning;
  102. document.getElementById("wikiTermCardSlot").innerHTML = `
  103. <div class="wiki-term-card">
  104. <div class="wiki-term-card-word">${data.word || ""}</div>
  105. <div class="wiki-term-card-body">
  106. ${
  107. meaning
  108. ? `<div class="wiki-term-card-meaning">${meaning}</div>`
  109. : ""
  110. }
  111. ${
  112. showSummary
  113. ? `<div class="wiki-term-card-summary">${summary}</div>`
  114. : ""
  115. }
  116. </div>
  117. </div>
  118. `;
  119. document.getElementById("wikiTermDrawerLink").style.display = "none";
  120. oc.show();
  121. }
  122. // ── 入口:扫描所有 .term-ref ──────────────────────────────────────
  123. function init() {
  124. const refs = document.querySelectorAll(".term-ref[data-id]");
  125. if (!refs.length) return;
  126. refs.forEach((el) => {
  127. // 桌面:mouseenter 时懒加载并初始化 Popover
  128. el.addEventListener("mouseenter", async function onFirstEnter() {
  129. if (isMobile()) return;
  130. try {
  131. const data = await fetchTerm(el.dataset.id);
  132. const popover = initDesktopPopover(el, data);
  133. // 首次加载后直接 show(mouseenter 已触发)
  134. popover.show();
  135. } catch (e) {
  136. console.warn(
  137. "[WikiPāli] term fetch failed",
  138. el.dataset.id,
  139. e
  140. );
  141. }
  142. // 移除首次监听,后续由 Popover 自身管理
  143. el.removeEventListener("mouseenter", onFirstEnter);
  144. });
  145. // 移动端:点击触发抽屉
  146. el.addEventListener("click", async (e) => {
  147. if (!isMobile()) return;
  148. e.preventDefault();
  149. try {
  150. const data = await fetchTerm(el.dataset.id);
  151. showMobileDrawer(data);
  152. } catch (err) {
  153. console.warn(
  154. "[WikiPāli] term fetch failed",
  155. el.dataset.id,
  156. err
  157. );
  158. }
  159. });
  160. });
  161. }
  162. // DOM 就绪后执行
  163. if (document.readyState === "loading") {
  164. document.addEventListener("DOMContentLoaded", init);
  165. } else {
  166. init();
  167. }
  168. })();