ui.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import { type RefObject, useCallback, useEffect } from 'react';
  2. import { PageGrant, type Nullable } from '@growi/core';
  3. import { type SWRResponseWithUtils, useSWRStatic } from '@growi/core/dist/swr';
  4. import { pagePathUtils, isClient, isServer } from '@growi/core/dist/utils';
  5. import { Breakpoint } from '@growi/ui/dist/interfaces';
  6. import { addBreakpointListener, cleanupBreakpointListener } from '@growi/ui/dist/utils';
  7. import type { HtmlElementNode } from 'rehype-toc';
  8. import type SimpleBar from 'simplebar-react';
  9. import {
  10. useSWRConfig, type SWRResponse, type Key,
  11. } from 'swr';
  12. import useSWRImmutable from 'swr/immutable';
  13. import type { IFocusable } from '~/client/interfaces/focusable';
  14. import type { IPageGrantData } from '~/interfaces/page';
  15. import { SidebarContentsType } from '~/interfaces/ui';
  16. import type { UpdateDescCountData } from '~/interfaces/websocket';
  17. import {
  18. useIsNotFound, useCurrentPagePath, useIsTrashPage, useCurrentPageId,
  19. } from '~/stores/page';
  20. import loggerFactory from '~/utils/logger';
  21. import {
  22. useIsEditable, useIsReadOnlyUser,
  23. useIsSharedUser, useIsIdenticalPath, useCurrentUser, useShareLinkId,
  24. } from './context';
  25. import { useStaticSWR } from './use-static-swr';
  26. const { isTrashTopPage, isUsersTopPage } = pagePathUtils;
  27. const logger = loggerFactory('growi:stores:ui');
  28. /** **********************************************************
  29. * Unions
  30. *********************************************************** */
  31. export const EditorMode = {
  32. View: 'view',
  33. Editor: 'editor',
  34. } as const;
  35. export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  36. /** **********************************************************
  37. * Storing objects to ref
  38. *********************************************************** */
  39. export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
  40. return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
  41. };
  42. export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
  43. const { data: currentPagePath } = useCurrentPagePath();
  44. return useStaticSWR(['currentPageTocNode', currentPagePath]);
  45. };
  46. /** **********************************************************
  47. * SWR Hooks
  48. * for switching UI
  49. *********************************************************** */
  50. export const useIsMobile = (): SWRResponse<boolean, Error> => {
  51. const key = isClient() ? 'isMobile' : null;
  52. let configuration;
  53. if (isClient()) {
  54. const userAgent = window.navigator.userAgent.toLowerCase();
  55. configuration = {
  56. fallbackData: /iphone|ipad|android/.test(userAgent),
  57. };
  58. }
  59. return useStaticSWR<boolean, Error>(key, undefined, configuration);
  60. };
  61. const getClassNamesByEditorMode = (editorMode: EditorMode | undefined): string[] => {
  62. const classNames: string[] = [];
  63. switch (editorMode) {
  64. case EditorMode.Editor:
  65. classNames.push('editing', 'builtin-editor');
  66. break;
  67. }
  68. return classNames;
  69. };
  70. export const EditorModeHash = {
  71. View: '',
  72. Edit: '#edit',
  73. } as const;
  74. export type EditorModeHash = typeof EditorModeHash[keyof typeof EditorModeHash];
  75. export const isEditorModeHash = (hash: string): hash is EditorModeHash => Object.values<string>(EditorModeHash).includes(hash);
  76. const updateHashByEditorMode = (newEditorMode: EditorMode) => {
  77. const { pathname, search } = window.location;
  78. switch (newEditorMode) {
  79. case EditorMode.View:
  80. window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.View}`);
  81. break;
  82. case EditorMode.Editor:
  83. window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.Edit}`);
  84. break;
  85. }
  86. };
  87. export const determineEditorModeByHash = (): EditorMode => {
  88. if (isServer()) {
  89. return EditorMode.View;
  90. }
  91. const { hash } = window.location;
  92. switch (hash) {
  93. case EditorModeHash.Edit:
  94. return EditorMode.Editor;
  95. default:
  96. return EditorMode.View;
  97. }
  98. };
  99. type EditorModeUtils = {
  100. getClassNamesByEditorMode: () => string[],
  101. }
  102. export const useEditorMode = (): SWRResponseWithUtils<EditorModeUtils, EditorMode> => {
  103. const { data: _isEditable } = useIsEditable();
  104. const editorModeByHash = determineEditorModeByHash();
  105. const isLoading = _isEditable === undefined;
  106. const isEditable = !isLoading && _isEditable;
  107. const initialData = isEditable ? editorModeByHash : EditorMode.View;
  108. const swrResponse = useSWRImmutable(
  109. isLoading ? null : ['editorMode', isEditable],
  110. null,
  111. { fallbackData: initialData },
  112. );
  113. // construct overriding mutate method
  114. const mutateOriginal = swrResponse.mutate;
  115. const mutate = useCallback((editorMode: EditorMode, shouldRevalidate?: boolean) => {
  116. if (!isEditable) {
  117. return Promise.resolve(EditorMode.View); // fixed if not editable
  118. }
  119. updateHashByEditorMode(editorMode);
  120. return mutateOriginal(editorMode, shouldRevalidate);
  121. }, [isEditable, mutateOriginal]);
  122. const getClassNames = useCallback(() => {
  123. return getClassNamesByEditorMode(swrResponse.data);
  124. }, [swrResponse.data]);
  125. return Object.assign(swrResponse, {
  126. mutate,
  127. getClassNamesByEditorMode: getClassNames,
  128. });
  129. };
  130. export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
  131. const key: Key = isClient() ? 'isDeviceSmallerThanMd' : null;
  132. const { cache, mutate } = useSWRConfig();
  133. useEffect(() => {
  134. if (key != null) {
  135. const mdOrAvobeHandler = function(this: MediaQueryList): void {
  136. // sm -> md: matches will be true
  137. // md -> sm: matches will be false
  138. mutate(key, !this.matches);
  139. };
  140. const mql = addBreakpointListener(Breakpoint.MD, mdOrAvobeHandler);
  141. // initialize
  142. if (cache.get(key)?.data == null) {
  143. cache.set(key, { ...cache.get(key), data: !mql.matches });
  144. }
  145. return () => {
  146. cleanupBreakpointListener(mql, mdOrAvobeHandler);
  147. };
  148. }
  149. }, [cache, key, mutate]);
  150. return useStaticSWR(key);
  151. };
  152. export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
  153. const key: Key = isClient() ? 'isDeviceSmallerThanLg' : null;
  154. const { cache, mutate } = useSWRConfig();
  155. useEffect(() => {
  156. if (key != null) {
  157. const lgOrAvobeHandler = function(this: MediaQueryList): void {
  158. // md -> lg: matches will be true
  159. // lg -> md: matches will be false
  160. mutate(key, !this.matches);
  161. };
  162. const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler);
  163. // initialize
  164. if (cache.get(key)?.data == null) {
  165. cache.set(key, { ...cache.get(key), data: !mql.matches });
  166. }
  167. return () => {
  168. cleanupBreakpointListener(mql, lgOrAvobeHandler);
  169. };
  170. }
  171. }, [cache, key, mutate]);
  172. return useStaticSWR(key);
  173. };
  174. export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
  175. return useSWRStatic('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
  176. };
  177. export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
  178. return useSWRStatic('productNavWidth', initialData, { fallbackData: 320 });
  179. };
  180. export const useDrawerMode = (): SWRResponse<boolean, Error> => {
  181. const { data: editorMode } = useEditorMode();
  182. const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
  183. const condition = editorMode != null && isDeviceSmallerThanMd != null;
  184. const calcDrawerMode = (
  185. _keyString: string,
  186. editorMode: EditorMode,
  187. isDeviceSmallerThanMd: boolean,
  188. ): boolean => {
  189. return isDeviceSmallerThanMd
  190. ? true
  191. : editorMode === EditorMode.Editor;
  192. };
  193. return useSWRImmutable(
  194. condition ? ['isDrawerMode', editorMode, isDeviceSmallerThanMd] : null,
  195. // calcDrawerMode,
  196. key => calcDrawerMode(...key),
  197. condition
  198. ? {
  199. fallbackData: calcDrawerMode('isDrawerMode', editorMode, isDeviceSmallerThanMd),
  200. }
  201. : undefined,
  202. );
  203. };
  204. export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
  205. return useSWRStatic('isDrawerOpened', isOpened, { fallbackData: false });
  206. };
  207. export const useCollapsedMode = (initialData?: boolean): SWRResponse<boolean, Error> => {
  208. return useSWRStatic('isCollapsedMode', initialData, { fallbackData: false });
  209. };
  210. export const useCollapsedContentsOpened = (initialData?: boolean): SWRResponse<boolean, Error> => {
  211. return useSWRStatic('isCollapsedContentsOpened', initialData, { fallbackData: false });
  212. };
  213. export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
  214. return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
  215. };
  216. export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
  217. return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
  218. };
  219. export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
  220. return useStaticSWR('globalSearchTypeahead', initialData);
  221. };
  222. type PageTreeDescCountMapUtils = {
  223. update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
  224. getDescCount(pageId?: string): number | null | undefined
  225. }
  226. export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
  227. const key = 'pageTreeDescCountMap';
  228. const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
  229. return {
  230. ...swrResponse,
  231. getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
  232. update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
  233. };
  234. };
  235. /** **********************************************************
  236. * SWR Hooks
  237. * Determined value by context
  238. *********************************************************** */
  239. export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
  240. const { data: currentUser } = useCurrentUser();
  241. const { data: isReadOnlyUser } = useIsReadOnlyUser();
  242. const { data: isTrashPage } = useIsTrashPage();
  243. return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null && !isReadOnlyUser);
  244. };
  245. export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
  246. const key = 'isAbleToShowPageManagement';
  247. const { data: currentPageId } = useCurrentPageId();
  248. const { data: _isTrashPage } = useIsTrashPage();
  249. const { data: _isSharedUser } = useIsSharedUser();
  250. const { data: isNotFound } = useIsNotFound();
  251. const pageId = currentPageId;
  252. const includesUndefined = [pageId, _isTrashPage, _isSharedUser, isNotFound].some(v => v === undefined);
  253. const isPageExist = (pageId != null) && isNotFound === false;
  254. const isEmptyPage = (pageId != null) && isNotFound === true;
  255. const isTrashPage = isPageExist && _isTrashPage === true;
  256. const isSharedUser = isPageExist && _isSharedUser === true;
  257. return useSWRImmutable(
  258. includesUndefined ? null : [key, pageId, isPageExist, isEmptyPage, isTrashPage, isSharedUser],
  259. ([, , isPageExist, isEmptyPage, isTrashPage, isSharedUser]) => (isPageExist && !isTrashPage && !isSharedUser) || isEmptyPage,
  260. );
  261. };
  262. export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
  263. const key = 'isAbleToShowTagLabel';
  264. const { data: pageId } = useCurrentPageId();
  265. const { data: currentPagePath } = useCurrentPagePath();
  266. const { data: isIdenticalPath } = useIsIdenticalPath();
  267. const { data: isNotFound } = useIsNotFound();
  268. const { data: editorMode } = useEditorMode();
  269. const { data: shareLinkId } = useShareLinkId();
  270. const includesUndefined = [currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
  271. const isViewMode = editorMode === EditorMode.View;
  272. return useSWRImmutable(
  273. includesUndefined ? null : [key, pageId, currentPagePath, isIdenticalPath, isNotFound, editorMode, shareLinkId],
  274. // "/trash" page does not exist on page collection and unable to add tags
  275. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  276. () => !isUsersTopPage(currentPagePath!) && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
  277. );
  278. };
  279. export const useIsAbleToChangeEditorMode = (): SWRResponse<boolean, Error> => {
  280. const key = 'isAbleToChangeEditorMode';
  281. const { data: isEditable } = useIsEditable();
  282. const { data: isSharedUser } = useIsSharedUser();
  283. const includesUndefined = [isEditable, isSharedUser].some(v => v === undefined);
  284. return useSWRImmutable(
  285. includesUndefined ? null : [key, isEditable, isSharedUser],
  286. () => !!isEditable && !isSharedUser,
  287. );
  288. };
  289. export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
  290. const key = 'isAbleToShowPageAuthors';
  291. const { data: pageId } = useCurrentPageId();
  292. const { data: pagePath } = useCurrentPagePath();
  293. const { data: isNotFound } = useIsNotFound();
  294. const includesUndefined = [pageId, pagePath, isNotFound].some(v => v === undefined);
  295. const isPageExist = (pageId != null) && !isNotFound;
  296. const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
  297. return useSWRImmutable(
  298. includesUndefined ? null : [key, pageId, pagePath, isNotFound],
  299. () => isPageExist && !isUsersTopPagePath,
  300. );
  301. };