SearchResultContent.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import React, {
  2. FC, useCallback, useEffect, useRef,
  3. } from 'react';
  4. import { useTranslation } from 'react-i18next';
  5. import { DropdownItem } from 'reactstrap';
  6. import { exportAsMarkdown } from '~/client/services/page-operation';
  7. import { toastSuccess } from '~/client/util/apiNotification';
  8. import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
  9. import { IPageToDeleteWithMeta, IPageToRenameWithMeta, IPageWithMeta } from '~/interfaces/page';
  10. import { IPageWithSearchMeta } from '~/interfaces/search';
  11. import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
  12. import {
  13. usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
  14. } from '~/stores/modal';
  15. import { useDescendantsPageListForCurrentPathTermManager } from '~/stores/page';
  16. import { usePageTreeTermManager } from '~/stores/page-listing';
  17. import { useSearchResultRenderer } from '~/stores/renderer';
  18. import { useFullTextSearchTermManager } from '~/stores/search';
  19. import AppContainer from '../../client/services/AppContainer';
  20. import { AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
  21. import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
  22. import { SubNavButtons } from '../Navbar/SubNavButtons';
  23. import RevisionLoader from '../Page/RevisionLoader';
  24. import PageComment from '../PageComment';
  25. import PageContentFooter from '../PageContentFooter';
  26. type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
  27. pageId: string,
  28. revisionId: string,
  29. }
  30. const AdditionalMenuItems = (props: AdditionalMenuItemsProps): JSX.Element => {
  31. const { t } = useTranslation();
  32. const { pageId, revisionId } = props;
  33. return (
  34. // Export markdown
  35. <DropdownItem
  36. onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
  37. className="grw-page-control-dropdown-item"
  38. >
  39. <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
  40. {t('export_bulk.export_page_markdown')}
  41. </DropdownItem>
  42. );
  43. };
  44. const SCROLL_OFFSET_TOP = 175; // approximate height of (navigation + subnavigation)
  45. const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
  46. type Props ={
  47. appContainer: AppContainer,
  48. pageWithMeta : IPageWithSearchMeta,
  49. highlightKeywords?: string[],
  50. showPageControlDropdown?: boolean,
  51. forceHideMenuItems?: ForceHideMenuItems,
  52. }
  53. const scrollTo = (scrollElement:HTMLElement) => {
  54. // use querySelector to intentionally get the first element found
  55. const highlightedKeyword = scrollElement.querySelector('.highlighted-keyword') as HTMLElement | null;
  56. if (highlightedKeyword != null) {
  57. smoothScrollIntoView(highlightedKeyword, SCROLL_OFFSET_TOP, scrollElement);
  58. }
  59. };
  60. const generateObserverCallback = (doScroll: ()=>void) => {
  61. return (mutationRecords:MutationRecord[]) => {
  62. mutationRecords.forEach((record:MutationRecord) => {
  63. const target = record.target as HTMLElement;
  64. const targetId = target.id as string;
  65. if (targetId !== 'wiki') return;
  66. doScroll();
  67. });
  68. };
  69. };
  70. export const SearchResultContent: FC<Props> = (props: Props) => {
  71. const scrollElementRef = useRef(null);
  72. // for mutation
  73. const { advance: advancePt } = usePageTreeTermManager();
  74. const { advance: advanceFts } = useFullTextSearchTermManager();
  75. const { advance: advanceDpl } = useDescendantsPageListForCurrentPathTermManager();
  76. // *************************** Auto Scroll ***************************
  77. useEffect(() => {
  78. const scrollElement = scrollElementRef.current as HTMLElement | null;
  79. if (scrollElement == null) return;
  80. const observerCallback = generateObserverCallback(() => {
  81. scrollTo(scrollElement);
  82. });
  83. const observer = new MutationObserver(observerCallback);
  84. observer.observe(scrollElement, MUTATION_OBSERVER_CONFIG);
  85. return () => {
  86. observer.disconnect();
  87. };
  88. });
  89. // ******************************* end *******************************
  90. const {
  91. appContainer,
  92. pageWithMeta,
  93. highlightKeywords,
  94. showPageControlDropdown,
  95. forceHideMenuItems,
  96. } = props;
  97. const { t } = useTranslation();
  98. const page = pageWithMeta?.data;
  99. const { open: openDuplicateModal } = usePageDuplicateModal();
  100. const { open: openRenameModal } = usePageRenameModal();
  101. const { open: openDeleteModal } = usePageDeleteModal();
  102. const { data: growiRenderer } = useSearchResultRenderer();
  103. const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
  104. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  105. const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
  106. toastSuccess(t('duplicated_pages', { fromPath }));
  107. advancePt();
  108. advanceFts();
  109. advanceDpl();
  110. };
  111. openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
  112. }, [advanceDpl, advanceFts, advancePt, openDuplicateModal, t]);
  113. const renameItemClickedHandler = useCallback((pageToRename: IPageToRenameWithMeta) => {
  114. const renamedHandler: OnRenamedFunction = (path) => {
  115. toastSuccess(t('renamed_pages', { path }));
  116. advancePt();
  117. advanceFts();
  118. advanceDpl();
  119. };
  120. openRenameModal(pageToRename, { onRenamed: renamedHandler });
  121. }, [advanceDpl, advanceFts, advancePt, openRenameModal, t]);
  122. const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
  123. if (typeof pathOrPathsToDelete !== 'string') {
  124. return;
  125. }
  126. const path = pathOrPathsToDelete;
  127. if (isCompletely) {
  128. toastSuccess(t('deleted_pages_completely', { path }));
  129. }
  130. else {
  131. toastSuccess(t('deleted_pages', { path }));
  132. }
  133. advancePt();
  134. advanceFts();
  135. advanceDpl();
  136. }, [advanceDpl, advanceFts, advancePt, t]);
  137. const deleteItemClickedHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
  138. openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
  139. }, [onDeletedHandler, openDeleteModal]);
  140. const ControlComponents = useCallback(() => {
  141. if (page == null) {
  142. return <></>;
  143. }
  144. const revisionId = typeof page.revision === 'string'
  145. ? page.revision
  146. : page.revision._id;
  147. return (
  148. <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
  149. <SubNavButtons
  150. pageId={page._id}
  151. revisionId={revisionId}
  152. path={page.path}
  153. showPageControlDropdown={showPageControlDropdown}
  154. forceHideMenuItems={forceHideMenuItems}
  155. additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
  156. isCompactMode
  157. onClickDuplicateMenuItem={duplicateItemClickedHandler}
  158. onClickRenameMenuItem={renameItemClickedHandler}
  159. onClickDeleteMenuItem={deleteItemClickedHandler}
  160. />
  161. </div>
  162. );
  163. }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
  164. // return if page or growiRenderer is null
  165. if (page == null || growiRenderer == null) return <></>;
  166. return (
  167. <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">
  168. <div className="grw-subnav-append-shadow-container">
  169. <GrowiSubNavigation
  170. page={page}
  171. controls={ControlComponents}
  172. isCompactMode
  173. additionalClasses={['px-4']}
  174. />
  175. </div>
  176. <div className="search-result-content-body-container" ref={scrollElementRef}>
  177. <RevisionLoader
  178. growiRenderer={growiRenderer}
  179. pageId={page._id}
  180. pagePath={page.path}
  181. revisionId={page.revision}
  182. highlightKeywords={highlightKeywords}
  183. />
  184. <PageComment appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
  185. <PageContentFooter
  186. createdAt={new Date(pageWithMeta.data.createdAt)}
  187. updatedAt={new Date(pageWithMeta.data.updatedAt)}
  188. creator={pageWithMeta.data.creator}
  189. revisionAuthor={pageWithMeta.data.lastUpdateUser}
  190. />
  191. </div>
  192. </div>
  193. );
  194. };