gitea源码

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import tippy, {followCursor} from 'tippy.js';
  2. import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
  3. import {formatDatetime} from '../utils/time.ts';
  4. import type {Content, Instance, Placement, Props} from 'tippy.js';
  5. import {html} from '../utils/html.ts';
  6. type TippyOpts = {
  7. role?: string,
  8. theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
  9. } & Partial<Props>;
  10. const visibleInstances = new Set<Instance>();
  11. const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
  12. export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
  13. // the callback functions should be destructured from opts,
  14. // because we should use our own wrapper functions to handle them, do not let the user override them
  15. const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
  16. const instance: Instance = tippy(target, {
  17. appendTo: document.body,
  18. animation: false,
  19. allowHTML: false,
  20. hideOnClick: false,
  21. interactiveBorder: 20,
  22. ignoreAttributes: true,
  23. maxWidth: 500, // increase over default 350px
  24. onHide: (instance: Instance) => {
  25. visibleInstances.delete(instance);
  26. return onHide?.(instance);
  27. },
  28. onDestroy: (instance: Instance) => {
  29. visibleInstances.delete(instance);
  30. return onDestroy?.(instance);
  31. },
  32. onShow: (instance: Instance) => {
  33. // hide other tooltip instances so only one tooltip shows at a time
  34. for (const visibleInstance of visibleInstances) {
  35. if (visibleInstance.props.role === 'tooltip') {
  36. visibleInstance.hide();
  37. }
  38. }
  39. visibleInstances.add(instance);
  40. target.setAttribute('aria-controls', instance.popper.id);
  41. return onShow?.(instance);
  42. },
  43. arrow: arrow ?? (theme === 'bare' ? false : arrowSvg),
  44. // HTML role attribute, ideally the default role would be "popover" but it does not exist
  45. role: role || 'menu',
  46. // CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
  47. theme: theme || role || 'default',
  48. offset: [0, arrow ? 10 : 6],
  49. plugins: [followCursor],
  50. ...other,
  51. } satisfies Partial<Props>);
  52. if (instance.props.role === 'menu') {
  53. target.setAttribute('aria-haspopup', 'true');
  54. }
  55. return instance;
  56. }
  57. /**
  58. * Attach a tooltip tippy to the given target element.
  59. * If the target element already has a tooltip tippy attached, the tooltip will be updated with the new content.
  60. * If the target element has no content, then no tooltip will be attached, and it returns null.
  61. *
  62. * Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
  63. */
  64. function attachTooltip(target: Element, content: Content = null): Instance {
  65. switchTitleToTooltip(target);
  66. content = content ?? target.getAttribute('data-tooltip-content');
  67. if (!content) return null;
  68. // when element has a clipboard target, we update the tooltip after copy
  69. // in which case it is undesirable to automatically hide it on click as
  70. // it would momentarily flash the tooltip out and in.
  71. const hasClipboardTarget = target.hasAttribute('data-clipboard-target');
  72. const hideOnClick = !hasClipboardTarget;
  73. const props: TippyOpts = {
  74. content,
  75. delay: 100,
  76. role: 'tooltip',
  77. theme: 'tooltip',
  78. hideOnClick,
  79. placement: target.getAttribute('data-tooltip-placement') as Placement || 'top-start',
  80. followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false,
  81. ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
  82. };
  83. if (!target._tippy) {
  84. createTippy(target, props);
  85. } else {
  86. target._tippy.setProps(props);
  87. }
  88. return target._tippy;
  89. }
  90. function switchTitleToTooltip(target: Element): void {
  91. let title = target.getAttribute('title');
  92. if (title) {
  93. // apply custom formatting to relative-time's tooltips
  94. if (target.tagName.toLowerCase() === 'relative-time') {
  95. const datetime = target.getAttribute('datetime');
  96. if (datetime) {
  97. title = formatDatetime(new Date(datetime));
  98. }
  99. }
  100. target.setAttribute('data-tooltip-content', title);
  101. target.setAttribute('aria-label', title);
  102. // keep the attribute, in case there are some other "[title]" selectors
  103. // and to prevent infinite loop with <relative-time> which will re-add
  104. // title if it is absent
  105. target.setAttribute('title', '');
  106. }
  107. }
  108. /**
  109. * Creating tooltip tippy instance is expensive, so we only create it when the user hovers over the element
  110. * According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event
  111. * Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
  112. * The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
  113. */
  114. function lazyTooltipOnMouseHover(this: HTMLElement, e: Event): void {
  115. e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
  116. attachTooltip(this);
  117. }
  118. // Activate the tooltip for current element.
  119. // If the element has no aria-label, use the tooltip content as aria-label.
  120. function attachLazyTooltip(el: HTMLElement): void {
  121. el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});
  122. // meanwhile, if the element has no aria-label, use the tooltip content as aria-label
  123. if (!el.hasAttribute('aria-label')) {
  124. const content = el.getAttribute('data-tooltip-content');
  125. if (content) {
  126. el.setAttribute('aria-label', content);
  127. }
  128. }
  129. }
  130. // Activate the tooltip for all children elements.
  131. function attachChildrenLazyTooltip(target: HTMLElement): void {
  132. for (const el of target.querySelectorAll<HTMLElement>('[data-tooltip-content]')) {
  133. attachLazyTooltip(el);
  134. }
  135. }
  136. export function initGlobalTooltips(): void {
  137. // use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
  138. const observerConnect = (observer: MutationObserver) => observer.observe(document, {
  139. subtree: true,
  140. childList: true,
  141. attributeFilter: ['data-tooltip-content', 'title'],
  142. });
  143. const observer = new MutationObserver((mutationList, observer) => {
  144. const pending = observer.takeRecords();
  145. observer.disconnect();
  146. for (const mutation of [...mutationList, ...pending]) {
  147. if (mutation.type === 'childList') {
  148. // mainly for Vue components and AJAX rendered elements
  149. for (const el of mutation.addedNodes as NodeListOf<HTMLElement>) {
  150. if (!isDocumentFragmentOrElementNode(el)) continue;
  151. attachChildrenLazyTooltip(el);
  152. if (el.hasAttribute('data-tooltip-content')) {
  153. attachLazyTooltip(el);
  154. }
  155. }
  156. } else if (mutation.type === 'attributes') {
  157. attachTooltip(mutation.target as Element);
  158. }
  159. }
  160. observerConnect(observer);
  161. });
  162. observerConnect(observer);
  163. attachChildrenLazyTooltip(document.documentElement);
  164. }
  165. export function showTemporaryTooltip(target: Element, content: Content): void {
  166. // if the target is inside a dropdown or tippy popup, the menu will be hidden soon
  167. // so display the tooltip on the "aria-controls" element or dropdown instead
  168. let refClientRect: DOMRect;
  169. const popupTippyId = target.closest(`[data-tippy-root]`)?.id;
  170. if (popupTippyId) {
  171. // for example, the "Copy Permalink" button in the "File View" page for the selected lines
  172. target = document.body;
  173. refClientRect = document.querySelector(`[aria-controls="${CSS.escape(popupTippyId)}"]`)?.getBoundingClientRect();
  174. refClientRect = refClientRect ?? new DOMRect(0, 0, 0, 0); // fallback to empty rect if not found, tippy doesn't accept null
  175. } else {
  176. // for example, the "Copy Link" button in the issue header dropdown menu
  177. target = target.closest('.ui.dropdown') ?? target;
  178. refClientRect = target.getBoundingClientRect();
  179. }
  180. const tooltipTippy = target._tippy ?? attachTooltip(target, content);
  181. tooltipTippy.setContent(content);
  182. tooltipTippy.setProps({getReferenceClientRect: () => refClientRect});
  183. if (!tooltipTippy.state.isShown) tooltipTippy.show();
  184. tooltipTippy.setProps({
  185. onHidden: (tippy) => {
  186. // reset the default tooltip content, if no default, then this temporary tooltip could be destroyed
  187. if (!attachTooltip(target)) {
  188. tippy.destroy();
  189. }
  190. },
  191. });
  192. }