term-tooltip.js 8.7 KB

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