ui.tsx 16 KB

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