ui.tsx 16 KB

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