use-content-auto-scroll.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { useEffect, useRef } from 'react';
  2. import {
  3. GROWI_IS_CONTENT_RENDERING_ATTR,
  4. GROWI_IS_CONTENT_RENDERING_SELECTOR,
  5. } from '@growi/core/dist/consts';
  6. const RENDERING_POLL_INTERVAL_MS = 5000;
  7. const WATCH_TIMEOUT_MS = 10000;
  8. /**
  9. * Watch for elements with in-progress rendering status in the container.
  10. * Periodically calls scrollToTarget while rendering elements remain.
  11. * Returns a cleanup function that stops observation and clears timers.
  12. */
  13. export const watchRenderingAndReScroll = (
  14. contentContainer: HTMLElement,
  15. scrollToTarget: () => boolean,
  16. ): (() => void) => {
  17. let timerId: number | undefined;
  18. let stopped = false;
  19. let wasRendering = false;
  20. const cleanup = () => {
  21. stopped = true;
  22. observer.disconnect();
  23. if (timerId != null) {
  24. window.clearTimeout(timerId);
  25. timerId = undefined;
  26. }
  27. window.clearTimeout(watchTimeoutId);
  28. };
  29. const checkAndSchedule = () => {
  30. if (stopped) return;
  31. const hasRendering =
  32. contentContainer.querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR) !=
  33. null;
  34. if (!hasRendering) {
  35. if (timerId != null) {
  36. window.clearTimeout(timerId);
  37. timerId = undefined;
  38. }
  39. // Final re-scroll to compensate for the layout shift from the last completed render
  40. if (wasRendering) {
  41. wasRendering = false;
  42. scrollToTarget();
  43. }
  44. return;
  45. }
  46. wasRendering = true;
  47. // If a timer is already ticking, let it fire — don't reset
  48. if (timerId != null) return;
  49. timerId = window.setTimeout(() => {
  50. if (stopped) return;
  51. timerId = undefined;
  52. // Reset before checkAndSchedule so the wasRendering guard does not
  53. // trigger an extra re-scroll if rendering is already done by now.
  54. wasRendering = false;
  55. scrollToTarget();
  56. checkAndSchedule();
  57. }, RENDERING_POLL_INTERVAL_MS);
  58. };
  59. const observer = new MutationObserver(checkAndSchedule);
  60. observer.observe(contentContainer, {
  61. childList: true,
  62. subtree: true,
  63. attributes: true,
  64. attributeFilter: [GROWI_IS_CONTENT_RENDERING_ATTR],
  65. });
  66. // Initial check
  67. checkAndSchedule();
  68. // Stop watching after timeout regardless of rendering state
  69. const watchTimeoutId = window.setTimeout(cleanup, WATCH_TIMEOUT_MS);
  70. return cleanup;
  71. };
  72. /** Configuration for the auto-scroll hook */
  73. export interface UseContentAutoScrollOptions {
  74. /**
  75. * Unique key that triggers re-execution when changed.
  76. * When null/undefined, all scroll processing is skipped.
  77. */
  78. key: string | undefined | null;
  79. /** DOM id of the content container element to observe */
  80. contentContainerId: string;
  81. /**
  82. * Optional function to resolve the scroll target element.
  83. * Receives the decoded hash string (without '#').
  84. * Defaults to: (hash) => document.getElementById(hash)
  85. */
  86. resolveTarget?: (decodedHash: string) => HTMLElement | null;
  87. /**
  88. * Optional function to scroll to the target element.
  89. * Defaults to: (el) => el.scrollIntoView()
  90. */
  91. scrollTo?: (target: HTMLElement) => void;
  92. }
  93. /**
  94. * Auto-scroll to the URL hash target when a content view loads.
  95. * Handles lazy-rendered content by polling for rendering-status
  96. * attributes and re-scrolling after they finish.
  97. */
  98. export const useContentAutoScroll = (
  99. options: UseContentAutoScrollOptions,
  100. ): void => {
  101. const { key, contentContainerId } = options;
  102. const resolveTargetRef = useRef(options.resolveTarget);
  103. resolveTargetRef.current = options.resolveTarget;
  104. const scrollToRef = useRef(options.scrollTo);
  105. scrollToRef.current = options.scrollTo;
  106. useEffect(() => {
  107. if (key == null) return;
  108. const { hash } = window.location;
  109. if (hash.length === 0) return;
  110. const contentContainer = document.getElementById(contentContainerId);
  111. if (contentContainer == null) return;
  112. const targetId = decodeURIComponent(hash.slice(1));
  113. const scrollToTarget = (): boolean => {
  114. const resolve =
  115. resolveTargetRef.current ??
  116. ((id: string) => document.getElementById(id));
  117. const target = resolve(targetId);
  118. if (target == null) return false;
  119. const scroll =
  120. scrollToRef.current ?? ((el: HTMLElement) => el.scrollIntoView());
  121. scroll(target);
  122. return true;
  123. };
  124. const hasRenderingElements = (): boolean => {
  125. return (
  126. contentContainer.querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR) !=
  127. null
  128. );
  129. };
  130. const startRenderingWatchIfNeeded = (): (() => void) | undefined => {
  131. if (hasRenderingElements()) {
  132. return watchRenderingAndReScroll(contentContainer, scrollToTarget);
  133. }
  134. return undefined;
  135. };
  136. // Target already in DOM — scroll and optionally watch rendering
  137. if (scrollToTarget()) {
  138. const renderingCleanup = startRenderingWatchIfNeeded();
  139. return () => {
  140. renderingCleanup?.();
  141. };
  142. }
  143. // Target not in DOM yet — wait for it, then optionally watch rendering
  144. let renderingCleanup: (() => void) | undefined;
  145. const observer = new MutationObserver(() => {
  146. if (scrollToTarget()) {
  147. observer.disconnect();
  148. window.clearTimeout(timeoutId);
  149. renderingCleanup = startRenderingWatchIfNeeded();
  150. }
  151. });
  152. observer.observe(contentContainer, { childList: true, subtree: true });
  153. const timeoutId = window.setTimeout(
  154. () => observer.disconnect(),
  155. WATCH_TIMEOUT_MS,
  156. );
  157. return () => {
  158. observer.disconnect();
  159. window.clearTimeout(timeoutId);
  160. renderingCleanup?.();
  161. };
  162. }, [key, contentContainerId]);
  163. };