term-tooltip.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. /**
  2. * resources/js/term-tooltip.js
  3. */
  4. import * as bootstrap from "bootstrap";
  5. (function () {
  6. "use strict";
  7. // ── 缓存层 ────────────────────────────────────────────────────────
  8. const cache = {};
  9. async function fetchTerm(id) {
  10. if (cache[id]) return cache[id];
  11. const res = await fetch(`/api/v2/terms/${id}`);
  12. if (!res.ok) throw new Error(`fetchTerm ${id} failed: ${res.status}`);
  13. const json = await res.json();
  14. cache[id] = json.data;
  15. return json.data;
  16. }
  17. // ── 设备判断 ──────────────────────────────────────────────────────
  18. const isMobile = () => window.innerWidth < 768;
  19. // ── Skeleton 模板 ─────────────────────────────────────────────────
  20. function buildSkeletonContent() {
  21. return `
  22. <div class="wiki-term-skeleton">
  23. <div class="wiki-term-skeleton-word"></div>
  24. <div class="wiki-term-skeleton-line"></div>
  25. <div class="wiki-term-skeleton-line short"></div>
  26. </div>
  27. `;
  28. }
  29. // ── Popover 内容模板 ──────────────────────────────────────────────
  30. function buildPopoverContent(data) {
  31. const meaning = (data.meaning || "").trim();
  32. const summary = (data.summary || "").trim();
  33. const showSummary = summary && summary !== meaning;
  34. return `
  35. <div class="wiki-term-card-word">${data.word || ""}</div>
  36. <div class="wiki-term-card-body" style="padding: 10px 14px 12px;">
  37. ${
  38. meaning
  39. ? `<div class="wiki-term-card-meaning">${meaning}</div>`
  40. : ""
  41. }
  42. ${
  43. showSummary
  44. ? `<div class="wiki-term-card-summary">${summary}</div>`
  45. : ""
  46. }
  47. </div>
  48. <div><a>查看完整条目</a></div>
  49. `;
  50. }
  51. // ── 桌面端:Bootstrap Popover ─────────────────────────────────────
  52. function initDesktopPopover(el, content) {
  53. if (el._wikiPopover) return el._wikiPopover;
  54. const popover = new bootstrap.Popover(el, {
  55. trigger: "manual",
  56. html: true,
  57. placement: "bottom",
  58. fallbackPlacements: ["top", "bottom"],
  59. customClass: "wiki-term-popover",
  60. content: content,
  61. sanitize: false,
  62. });
  63. el._wikiPopover = popover;
  64. let hideTimer = null;
  65. function scheduleHide() {
  66. hideTimer = setTimeout(() => {
  67. const tipEl = document.querySelector(".wiki-term-popover.show");
  68. if (tipEl && tipEl.matches(":hover")) return;
  69. popover.hide();
  70. }, 120);
  71. }
  72. function cancelHide() {
  73. clearTimeout(hideTimer);
  74. }
  75. el.addEventListener("mouseenter", () => {
  76. cancelHide();
  77. popover.show();
  78. });
  79. el.addEventListener("mouseleave", scheduleHide);
  80. el.addEventListener("shown.bs.popover", () => {
  81. const tipEl = document.querySelector(".wiki-term-popover.show");
  82. if (!tipEl) return;
  83. tipEl.addEventListener("mouseenter", cancelHide);
  84. tipEl.addEventListener("mouseleave", scheduleHide);
  85. });
  86. return popover;
  87. }
  88. function updateDesktopPopover(el, data) {
  89. const popover = el._wikiPopover;
  90. if (!popover) return;
  91. // 更新内容
  92. const tip = document.querySelector(".wiki-term-popover.show");
  93. if (tip) {
  94. tip.querySelector(".popover-body").innerHTML =
  95. buildPopoverContent(data);
  96. }
  97. // 同步 popover 内部 config,供下次 show 使用
  98. popover._config.content = buildPopoverContent(data);
  99. }
  100. // ── 移动端:Bootstrap Offcanvas ───────────────────────────────────
  101. let offcanvasInstance = null;
  102. function getOffcanvas() {
  103. if (offcanvasInstance) return offcanvasInstance;
  104. const el = document.getElementById("wikiTermDrawer");
  105. if (!el) return null;
  106. offcanvasInstance = new bootstrap.Offcanvas(el, { scroll: false });
  107. el.addEventListener("hidden.bs.offcanvas", () => {
  108. document
  109. .querySelectorAll(".offcanvas-backdrop")
  110. .forEach((b) => b.remove());
  111. document.body.classList.remove("modal-open");
  112. document.body.style.removeProperty("overflow");
  113. document.body.style.removeProperty("padding-right");
  114. });
  115. return offcanvasInstance;
  116. }
  117. function showMobileDrawerSkeleton() {
  118. const oc = getOffcanvas();
  119. if (!oc) return;
  120. document.getElementById("wikiTermDrawerWord").innerHTML =
  121. '<div class="wiki-term-skeleton-word"></div>';
  122. document.getElementById("wikiTermDrawerMeaning").innerHTML =
  123. '<div class="wiki-term-skeleton-line short"></div>';
  124. document.getElementById(
  125. "wikiTermCardSlot"
  126. ).innerHTML = `<div class="wiki-term-skeleton">
  127. <div class="wiki-term-skeleton-line"></div>
  128. <div class="wiki-term-skeleton-line short"></div>
  129. </div>`;
  130. document.getElementById("wikiTermDrawerLink").style.display = "none";
  131. oc.show();
  132. }
  133. function fillMobileDrawer(data) {
  134. const meaning = (data.meaning || "").trim();
  135. const summary = (data.summary || "").trim();
  136. const showSummary = summary && summary !== meaning;
  137. document.getElementById("wikiTermDrawerWord").textContent =
  138. data.word || "";
  139. document.getElementById("wikiTermDrawerMeaning").textContent = meaning;
  140. document.getElementById("wikiTermCardSlot").innerHTML = showSummary
  141. ? `<div class="wiki-term-card-summary">${summary}</div>`
  142. : "";
  143. }
  144. // ── 入口:扫描所有 .term-ref ──────────────────────────────────────
  145. function init() {
  146. const refs = document.querySelectorAll(".term-ref[data-id]");
  147. if (!refs.length) return;
  148. refs.forEach((el) => {
  149. // 桌面:mouseenter 时先显示 skeleton,数据回来后更新
  150. el.addEventListener("mouseenter", async function onFirstEnter() {
  151. if (isMobile()) return;
  152. // 立即显示 skeleton popover
  153. const popover = initDesktopPopover(el, buildSkeletonContent());
  154. popover.show();
  155. try {
  156. const data = await fetchTerm(el.dataset.id);
  157. updateDesktopPopover(el, data);
  158. } catch (e) {
  159. console.warn(
  160. "[WikiPāli] term fetch failed",
  161. el.dataset.id,
  162. e
  163. );
  164. popover.hide();
  165. }
  166. el.removeEventListener("mouseenter", onFirstEnter);
  167. });
  168. // 移动端:点击立即弹出 skeleton,数据回来后填充
  169. el.addEventListener("click", async (e) => {
  170. if (!isMobile()) return;
  171. e.preventDefault();
  172. showMobileDrawerSkeleton();
  173. try {
  174. const data = await fetchTerm(el.dataset.id);
  175. fillMobileDrawer(data);
  176. } catch (err) {
  177. console.warn(
  178. "[WikiPāli] term fetch failed",
  179. el.dataset.id,
  180. err
  181. );
  182. }
  183. });
  184. });
  185. }
  186. if (document.readyState === "loading") {
  187. document.addEventListener("DOMContentLoaded", init);
  188. } else {
  189. init();
  190. }
  191. })();