import type { FC, JSX } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import dynamic from 'next/dynamic'; import type { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '@growi/core'; import { getIdStringForRef } from '@growi/core'; import { useTranslation } from 'next-i18next'; import { DropdownItem } from 'reactstrap'; import { debounce } from 'throttle-debounce'; import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems, } from '~/client/components/Common/Dropdown/PageItemControl'; import type { RevisionLoaderProps } from '~/client/components/Page/RevisionLoader'; import { watchRenderingAndReScroll } from '~/client/hooks/use-content-auto-scroll/watch-rendering-and-rescroll'; import { exportAsMarkdown } from '~/client/services/page-operation'; import { scrollWithinContainer } from '~/client/util/smooth-scroll'; import { toastSuccess } from '~/client/util/toastr'; import { PagePathNav } from '~/components/Common/PagePathNav'; import type { IPageWithSearchMeta } from '~/interfaces/search'; import type { OnDeletedFunction, OnDuplicatedFunction, OnRenamedFunction, } from '~/interfaces/ui'; import { useShouldExpandContent } from '~/services/layout/use-should-expand-content'; import { useCurrentUser } from '~/states/global'; import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete'; import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate'; import { usePageRenameModalActions } from '~/states/ui/modal/page-rename'; import { mutatePageList, mutatePageTree, mutateRecentlyUpdated, } from '~/stores/page-listing'; import { useSearchResultOptions } from '~/stores/renderer'; import { mutateSearching } from '~/stores/search'; import styles from './SearchResultContent.module.scss'; const moduleClass = styles['search-result-content']; const _fluidLayoutClass = styles['fluid-layout']; const PageControls = dynamic( () => import('~/client/components/PageControls').then((mod) => mod.PageControls), { ssr: false }, ); const RevisionLoader = dynamic( () => import('~/client/components/Page/RevisionLoader').then( (mod) => mod.RevisionLoader, ), { ssr: false }, ); const PageComment = dynamic( () => import('~/client/components/PageComment').then((mod) => mod.PageComment), { ssr: false }, ); const PageContentFooter = dynamic( () => import('~/components/PageView/PageContentFooter').then( (mod) => mod.PageContentFooter, ), { ssr: false }, ); type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & { pageId: string; revisionId: string; }; const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => { const { t } = useTranslation(); const { pageId, revisionId } = props; return ( // Export markdown exportAsMarkdown(pageId, revisionId, 'md')} className="grw-page-control-dropdown-item" > cloud_download {t('page_export.export_page_markdown')} ); }; const SCROLL_OFFSET_TOP = 30; const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true }; // omit 'subtree: true' type Props = { pageWithMeta: IPageWithSearchMeta; highlightKeywords?: string[]; showPageControlDropdown?: boolean; forceHideMenuItems?: ForceHideMenuItems; }; const scrollToTargetWithinContainer = ( target: HTMLElement, container: HTMLElement, ): void => { const distance = target.getBoundingClientRect().top - container.getBoundingClientRect().top - SCROLL_OFFSET_TOP; scrollWithinContainer(container, distance); }; const scrollToFirstHighlightedKeyword = (scrollElement: HTMLElement): void => { // use querySelector to intentionally get the first element found const toElem = scrollElement.querySelector( '.highlighted-keyword', ) as HTMLElement | null; if (toElem == null) return; scrollToTargetWithinContainer(toElem, scrollElement); }; const scrollToFirstHighlightedKeywordDebounced = debounce( 500, scrollToFirstHighlightedKeyword, ); export const SearchResultContent: FC = (props: Props) => { const scrollElementRef = useRef(null); const { pageWithMeta } = props; const page = pageWithMeta.data; // *************************** Keyword Scroll *************************** // biome-ignore lint/correctness/useExhaustiveDependencies: page._id is a trigger dep: re-run this effect when the selected page changes useEffect(() => { const scrollElement = scrollElementRef.current; if (scrollElement == null) return; const scrollToKeyword = (): boolean => { const toElem = scrollElement.querySelector( '.highlighted-keyword', ) as HTMLElement | null; if (toElem == null) return false; scrollToTargetWithinContainer(toElem, scrollElement); return true; }; const observer = new MutationObserver(() => { scrollToFirstHighlightedKeywordDebounced(scrollElement); }); observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG); // Re-scroll to keyword after async renderers (drawio/mermaid) cause layout shifts const cleanupWatch = watchRenderingAndReScroll( scrollElement, scrollToKeyword, ); return cleanupWatch; }, [page._id]); // ******************************* end ******************************* const { highlightKeywords, showPageControlDropdown, forceHideMenuItems } = props; const { t } = useTranslation(); const { open: openDuplicateModal } = usePageDuplicateModalActions(); const { open: openRenameModal } = usePageRenameModalActions(); const { open: openDeleteModal } = usePageDeleteModalActions(); const { data: rendererOptions } = useSearchResultOptions( pageWithMeta.data.path, highlightKeywords, ); const currentUser = useCurrentUser(); const shouldExpandContent = useShouldExpandContent(page); const duplicateItemClickedHandler = useCallback( async (pageToDuplicate) => { const duplicatedHandler: OnDuplicatedFunction = (fromPath, _toPath) => { toastSuccess(t('duplicated_pages', { fromPath })); mutatePageTree(); mutateRecentlyUpdated(); mutateSearching(); mutatePageList(); }; openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler }); }, [openDuplicateModal, t], ); const renameItemClickedHandler = useCallback( (pageToRename: IPageToRenameWithMeta) => { const renamedHandler: OnRenamedFunction = (path) => { toastSuccess(t('renamed_pages', { path })); mutatePageTree(); mutateRecentlyUpdated(); mutateSearching(); mutatePageList(); }; openRenameModal(pageToRename, { onRenamed: renamedHandler }); }, [openRenameModal, t], ); const onDeletedHandler: OnDeletedFunction = useCallback( (pathOrPathsToDelete, isRecursively, isCompletely) => { if (typeof pathOrPathsToDelete !== 'string') { return; } const path = pathOrPathsToDelete; if (isCompletely) { toastSuccess(t('deleted_pages_completely', { path })); } else { toastSuccess(t('deleted_pages', { path })); } mutatePageTree(); mutateRecentlyUpdated(); mutateSearching(); mutatePageList(); }, [t], ); const deleteItemClickedHandler = useCallback( (pageToDelete: IPageToDeleteWithMeta) => { openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler }); }, [onDeletedHandler, openDeleteModal], ); const RightComponent = useCallback(() => { if (page == null) { return <>; } const revisionId = page.revision != null ? getIdStringForRef(page.revision) : null; const additionalMenuItemRenderer = revisionId != null ? (props) => ( ) : undefined; return (
); }, [ page, shouldExpandContent, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, ]); const fluidLayoutClass = shouldExpandContent ? _fluidLayoutClass : ''; return (
{page.revision != null && rendererOptions != null && ( )} {page.revision != null && ( )}
); };