ui.tsx 16 KB

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