| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- import { useEffect, useRef } from 'react';
- import {
- GROWI_IS_CONTENT_RENDERING_ATTR,
- GROWI_IS_CONTENT_RENDERING_SELECTOR,
- } from '@growi/core/dist/consts';
- const RENDERING_POLL_INTERVAL_MS = 5000;
- const WATCH_TIMEOUT_MS = 10000;
- /**
- * Watch for elements with in-progress rendering status in the container.
- * Periodically calls scrollToTarget while rendering elements remain.
- * Returns a cleanup function that stops observation and clears timers.
- */
- export const watchRenderingAndReScroll = (
- contentContainer: HTMLElement,
- scrollToTarget: () => boolean,
- ): (() => void) => {
- let timerId: number | undefined;
- let stopped = false;
- let wasRendering = false;
- const cleanup = () => {
- stopped = true;
- observer.disconnect();
- if (timerId != null) {
- window.clearTimeout(timerId);
- timerId = undefined;
- }
- window.clearTimeout(watchTimeoutId);
- };
- const checkAndSchedule = () => {
- if (stopped) return;
- const hasRendering =
- contentContainer.querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR) !=
- null;
- if (!hasRendering) {
- if (timerId != null) {
- window.clearTimeout(timerId);
- timerId = undefined;
- }
- // Final re-scroll to compensate for the layout shift from the last completed render
- if (wasRendering) {
- wasRendering = false;
- scrollToTarget();
- }
- return;
- }
- wasRendering = true;
- // If a timer is already ticking, let it fire — don't reset
- if (timerId != null) return;
- timerId = window.setTimeout(() => {
- if (stopped) return;
- timerId = undefined;
- // Reset before checkAndSchedule so the wasRendering guard does not
- // trigger an extra re-scroll if rendering is already done by now.
- wasRendering = false;
- scrollToTarget();
- checkAndSchedule();
- }, RENDERING_POLL_INTERVAL_MS);
- };
- const observer = new MutationObserver(checkAndSchedule);
- observer.observe(contentContainer, {
- childList: true,
- subtree: true,
- attributes: true,
- attributeFilter: [GROWI_IS_CONTENT_RENDERING_ATTR],
- });
- // Initial check
- checkAndSchedule();
- // Stop watching after timeout regardless of rendering state
- const watchTimeoutId = window.setTimeout(cleanup, WATCH_TIMEOUT_MS);
- return cleanup;
- };
- /** Configuration for the auto-scroll hook */
- export interface UseContentAutoScrollOptions {
- /**
- * Unique key that triggers re-execution when changed.
- * When null/undefined, all scroll processing is skipped.
- */
- key: string | undefined | null;
- /** DOM id of the content container element to observe */
- contentContainerId: string;
- /**
- * Optional function to resolve the scroll target element.
- * Receives the decoded hash string (without '#').
- * Defaults to: (hash) => document.getElementById(hash)
- */
- resolveTarget?: (decodedHash: string) => HTMLElement | null;
- /**
- * Optional function to scroll to the target element.
- * Defaults to: (el) => el.scrollIntoView()
- */
- scrollTo?: (target: HTMLElement) => void;
- }
- /**
- * Auto-scroll to the URL hash target when a content view loads.
- * Handles lazy-rendered content by polling for rendering-status
- * attributes and re-scrolling after they finish.
- */
- export const useContentAutoScroll = (
- options: UseContentAutoScrollOptions,
- ): void => {
- const { key, contentContainerId } = options;
- const resolveTargetRef = useRef(options.resolveTarget);
- resolveTargetRef.current = options.resolveTarget;
- const scrollToRef = useRef(options.scrollTo);
- scrollToRef.current = options.scrollTo;
- useEffect(() => {
- if (key == null) return;
- const { hash } = window.location;
- if (hash.length === 0) return;
- const contentContainer = document.getElementById(contentContainerId);
- if (contentContainer == null) return;
- const targetId = decodeURIComponent(hash.slice(1));
- const scrollToTarget = (): boolean => {
- const resolve =
- resolveTargetRef.current ??
- ((id: string) => document.getElementById(id));
- const target = resolve(targetId);
- if (target == null) return false;
- const scroll =
- scrollToRef.current ?? ((el: HTMLElement) => el.scrollIntoView());
- scroll(target);
- return true;
- };
- const hasRenderingElements = (): boolean => {
- return (
- contentContainer.querySelector(GROWI_IS_CONTENT_RENDERING_SELECTOR) !=
- null
- );
- };
- const startRenderingWatchIfNeeded = (): (() => void) | undefined => {
- if (hasRenderingElements()) {
- return watchRenderingAndReScroll(contentContainer, scrollToTarget);
- }
- return undefined;
- };
- // Target already in DOM — scroll and optionally watch rendering
- if (scrollToTarget()) {
- const renderingCleanup = startRenderingWatchIfNeeded();
- return () => {
- renderingCleanup?.();
- };
- }
- // Target not in DOM yet — wait for it, then optionally watch rendering
- let renderingCleanup: (() => void) | undefined;
- const observer = new MutationObserver(() => {
- if (scrollToTarget()) {
- observer.disconnect();
- window.clearTimeout(timeoutId);
- renderingCleanup = startRenderingWatchIfNeeded();
- }
- });
- observer.observe(contentContainer, { childList: true, subtree: true });
- const timeoutId = window.setTimeout(
- () => observer.disconnect(),
- WATCH_TIMEOUT_MS,
- );
- return () => {
- observer.disconnect();
- window.clearTimeout(timeoutId);
- renderingCleanup?.();
- };
- }, [key, contentContainerId]);
- };
|