ui.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. import { type RefObject, useCallback, useEffect } from 'react';
  2. import { PageGrant, type Nullable } from '@growi/core';
  3. import { type SWRResponseWithUtils, withUtils } 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 { useUserUISettings } from '~/client/services/user-ui-settings';
  15. import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
  16. import type { IPageGrantData } from '~/interfaces/page';
  17. import type { ISidebarConfig } from '~/interfaces/sidebar-config';
  18. import { SidebarContentsType } 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. HackMD: 'hackmd',
  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. case EditorMode.HackMD:
  72. classNames.push('editing', 'hackmd');
  73. break;
  74. }
  75. return classNames;
  76. };
  77. export const EditorModeHash = {
  78. View: '',
  79. Edit: '#edit',
  80. HackMD: '#hackmd',
  81. } as const;
  82. export type EditorModeHash = typeof EditorModeHash[keyof typeof EditorModeHash];
  83. export const isEditorModeHash = (hash: string): hash is EditorModeHash => Object.values<string>(EditorModeHash).includes(hash);
  84. const updateHashByEditorMode = (newEditorMode: EditorMode) => {
  85. const { pathname, search } = window.location;
  86. switch (newEditorMode) {
  87. case EditorMode.View:
  88. window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.View}`);
  89. break;
  90. case EditorMode.Editor:
  91. window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.Edit}`);
  92. break;
  93. case EditorMode.HackMD:
  94. window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.HackMD}`);
  95. break;
  96. }
  97. };
  98. export const determineEditorModeByHash = (): EditorMode => {
  99. if (isServer()) {
  100. return EditorMode.View;
  101. }
  102. const { hash } = window.location;
  103. switch (hash) {
  104. case EditorModeHash.Edit:
  105. return EditorMode.Editor;
  106. case EditorModeHash.HackMD:
  107. return EditorMode.HackMD;
  108. default:
  109. return EditorMode.View;
  110. }
  111. };
  112. type EditorModeUtils = {
  113. getClassNamesByEditorMode: () => string[],
  114. }
  115. export const useEditorMode = (): SWRResponseWithUtils<EditorModeUtils, EditorMode> => {
  116. const { data: _isEditable } = useIsEditable();
  117. const editorModeByHash = determineEditorModeByHash();
  118. const isLoading = _isEditable === undefined;
  119. const isEditable = !isLoading && _isEditable;
  120. const initialData = isEditable ? editorModeByHash : EditorMode.View;
  121. const swrResponse = useSWRImmutable(
  122. isLoading ? null : ['editorMode', isEditable],
  123. null,
  124. { fallbackData: initialData },
  125. );
  126. // construct overriding mutate method
  127. const mutateOriginal = swrResponse.mutate;
  128. const mutate = useCallback((editorMode: EditorMode, shouldRevalidate?: boolean) => {
  129. if (!isEditable) {
  130. return Promise.resolve(EditorMode.View); // fixed if not editable
  131. }
  132. updateHashByEditorMode(editorMode);
  133. return mutateOriginal(editorMode, shouldRevalidate);
  134. }, [isEditable, mutateOriginal]);
  135. const getClassNames = useCallback(() => {
  136. return getClassNamesByEditorMode(swrResponse.data);
  137. }, [swrResponse.data]);
  138. return Object.assign(swrResponse, {
  139. mutate,
  140. getClassNamesByEditorMode: getClassNames,
  141. });
  142. };
  143. export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
  144. const key: Key = isClient() ? 'isDeviceSmallerThanMd' : null;
  145. const { cache, mutate } = useSWRConfig();
  146. useEffect(() => {
  147. if (isClient()) {
  148. const mdOrAvobeHandler = function(this: MediaQueryList): void {
  149. // sm -> md: matches will be true
  150. // md -> sm: matches will be false
  151. mutate(key, !this.matches);
  152. };
  153. const mql = addBreakpointListener(Breakpoint.MD, mdOrAvobeHandler);
  154. // initialize
  155. if (cache.get(key)?.data == null) {
  156. cache.set(key, { ...cache.get(key), data: !mql.matches });
  157. }
  158. return () => {
  159. cleanupBreakpointListener(mql, mdOrAvobeHandler);
  160. };
  161. }
  162. }, [cache, key, mutate]);
  163. return useStaticSWR(key);
  164. };
  165. export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
  166. const key: Key = isClient() ? 'isDeviceSmallerThanLg' : null;
  167. const { cache, mutate } = useSWRConfig();
  168. useEffect(() => {
  169. if (isClient()) {
  170. const lgOrAvobeHandler = function(this: MediaQueryList): void {
  171. // md -> lg: matches will be true
  172. // lg -> md: matches will be false
  173. mutate(key, !this.matches);
  174. };
  175. const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler);
  176. // initialize
  177. if (cache.get(key)?.data == null) {
  178. cache.set(key, { ...cache.get(key), data: !mql.matches });
  179. }
  180. return () => {
  181. cleanupBreakpointListener(mql, lgOrAvobeHandler);
  182. };
  183. }
  184. }, [cache, key, mutate]);
  185. return useStaticSWR(key);
  186. };
  187. type PreferDrawerModeByUserUtils = {
  188. update: (preferDrawerMode: boolean) => void
  189. }
  190. export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponseWithUtils<PreferDrawerModeByUserUtils, boolean> => {
  191. const { scheduleToPut } = useUserUISettings();
  192. const swrResponse: SWRResponse<boolean, Error> = useStaticSWR('preferDrawerModeByUser', initialData);
  193. const utils: PreferDrawerModeByUserUtils = {
  194. update: (preferDrawerMode: boolean) => {
  195. swrResponse.mutate(preferDrawerMode);
  196. scheduleToPut({ preferDrawerModeByUser: preferDrawerMode });
  197. },
  198. };
  199. return withUtils<PreferDrawerModeByUserUtils>(swrResponse, utils);
  200. };
  201. export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
  202. return useStaticSWR('preferDrawerModeOnEditByUser', initialData, { fallbackData: true });
  203. };
  204. export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
  205. return useStaticSWR('isSidebarCollapsed', initialData, { fallbackData: false });
  206. };
  207. export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
  208. return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
  209. };
  210. export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
  211. return useStaticSWR('productNavWidth', initialData, { fallbackData: 320 });
  212. };
  213. export const useDrawerMode = (): SWRResponse<boolean, Error> => {
  214. const { data: preferDrawerModeByUser } = usePreferDrawerModeByUser();
  215. const { data: preferDrawerModeOnEditByUser } = usePreferDrawerModeOnEditByUser();
  216. const { data: editorMode } = useEditorMode();
  217. const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
  218. const condition = editorMode != null && preferDrawerModeByUser != null && preferDrawerModeOnEditByUser != null && isDeviceSmallerThanMd != null;
  219. const calcDrawerMode = (
  220. endpoint: string,
  221. editorMode: EditorMode,
  222. preferDrawerModeByUser: boolean,
  223. preferDrawerModeOnEditByUser: boolean,
  224. isDeviceSmallerThanMd: boolean,
  225. ): boolean => {
  226. // get preference on view or edit
  227. const preferDrawerMode = editorMode !== EditorMode.View ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
  228. return isDeviceSmallerThanMd ?? preferDrawerMode ?? false;
  229. };
  230. const isViewModeWithPreferDrawerMode = editorMode === EditorMode.View && preferDrawerModeByUser;
  231. const isEditModeWithPreferDrawerMode = editorMode !== EditorMode.View && preferDrawerModeOnEditByUser;
  232. const isDrawerModeFixed = isViewModeWithPreferDrawerMode || isEditModeWithPreferDrawerMode;
  233. return useSWRImmutable(
  234. condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
  235. // calcDrawerMode,
  236. key => calcDrawerMode(...key),
  237. condition
  238. ? {
  239. fallbackData: isDrawerModeFixed
  240. ? true
  241. : calcDrawerMode('isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd),
  242. }
  243. : undefined,
  244. );
  245. };
  246. type SidebarConfigOption = {
  247. update: () => Promise<void>,
  248. isSidebarDrawerMode: boolean|undefined,
  249. isSidebarClosedAtDockMode: boolean|undefined,
  250. setIsSidebarDrawerMode: (isSidebarDrawerMode: boolean) => void,
  251. setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode: boolean) => void
  252. }
  253. export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> & SidebarConfigOption => {
  254. const swrResponse = useSWRImmutable(
  255. '/customize-setting/sidebar',
  256. endpoint => apiv3Get(endpoint).then(result => result.data),
  257. );
  258. return {
  259. ...swrResponse,
  260. update: async() => {
  261. const { data } = swrResponse;
  262. if (data == null) {
  263. return;
  264. }
  265. const { isSidebarDrawerMode, isSidebarClosedAtDockMode } = data;
  266. const updateData = {
  267. isSidebarDrawerMode,
  268. isSidebarClosedAtDockMode,
  269. };
  270. // invoke API
  271. await apiv3Put('/customize-setting/sidebar', updateData);
  272. },
  273. isSidebarDrawerMode: swrResponse.data?.isSidebarDrawerMode,
  274. isSidebarClosedAtDockMode: swrResponse.data?.isSidebarClosedAtDockMode,
  275. setIsSidebarDrawerMode: (isSidebarDrawerMode) => {
  276. const { data, mutate } = swrResponse;
  277. if (data == null) {
  278. return;
  279. }
  280. const updateData = {
  281. isSidebarDrawerMode,
  282. };
  283. // update isSidebarDrawerMode in cache, not revalidate
  284. mutate({ ...data, ...updateData }, false);
  285. },
  286. setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode) => {
  287. const { data, mutate } = swrResponse;
  288. if (data == null) {
  289. return;
  290. }
  291. const updateData = {
  292. isSidebarClosedAtDockMode,
  293. };
  294. // update isSidebarClosedAtDockMode in cache, not revalidate
  295. mutate({ ...data, ...updateData }, false);
  296. },
  297. };
  298. };
  299. export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
  300. return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
  301. };
  302. export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
  303. return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
  304. };
  305. export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
  306. return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
  307. };
  308. export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
  309. return useStaticSWR('globalSearchTypeahead', initialData);
  310. };
  311. type PageTreeDescCountMapUtils = {
  312. update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
  313. getDescCount(pageId?: string): number | null | undefined
  314. }
  315. export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
  316. const key = 'pageTreeDescCountMap';
  317. const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
  318. return {
  319. ...swrResponse,
  320. getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
  321. update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
  322. };
  323. };
  324. /** **********************************************************
  325. * SWR Hooks
  326. * Determined value by context
  327. *********************************************************** */
  328. export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
  329. const { data: currentUser } = useCurrentUser();
  330. const { data: isReadOnlyUser } = useIsReadOnlyUser();
  331. const { data: isTrashPage } = useIsTrashPage();
  332. return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null && !isReadOnlyUser);
  333. };
  334. export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
  335. const key = 'isAbleToShowPageManagement';
  336. const { data: currentPageId } = useCurrentPageId();
  337. const { data: _isTrashPage } = useIsTrashPage();
  338. const { data: _isSharedUser } = useIsSharedUser();
  339. const { data: isNotFound } = useIsNotFound();
  340. const pageId = currentPageId;
  341. const includesUndefined = [pageId, _isTrashPage, _isSharedUser, isNotFound].some(v => v === undefined);
  342. const isPageExist = (pageId != null) && isNotFound === false;
  343. const isEmptyPage = (pageId != null) && isNotFound === true;
  344. const isTrashPage = isPageExist && _isTrashPage === true;
  345. const isSharedUser = isPageExist && _isSharedUser === true;
  346. return useSWRImmutable(
  347. includesUndefined ? null : [key, pageId, isPageExist, isEmptyPage, isTrashPage, isSharedUser],
  348. ([, , isPageExist, isEmptyPage, isTrashPage, isSharedUser]) => (isPageExist && !isTrashPage && !isSharedUser) || isEmptyPage,
  349. );
  350. };
  351. export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
  352. const key = 'isAbleToShowTagLabel';
  353. const { data: pageId } = useCurrentPageId();
  354. const { data: currentPagePath } = useCurrentPagePath();
  355. const { data: isIdenticalPath } = useIsIdenticalPath();
  356. const { data: isNotFound } = useIsNotFound();
  357. const { data: editorMode } = useEditorMode();
  358. const { data: shareLinkId } = useShareLinkId();
  359. const includesUndefined = [currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
  360. const isViewMode = editorMode === EditorMode.View;
  361. return useSWRImmutable(
  362. includesUndefined ? null : [key, pageId, currentPagePath, isIdenticalPath, isNotFound, editorMode, shareLinkId],
  363. // "/trash" page does not exist on page collection and unable to add tags
  364. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  365. () => !isUsersTopPage(currentPagePath!) && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
  366. );
  367. };
  368. export const useIsAbleToChangeEditorMode = (): SWRResponse<boolean, Error> => {
  369. const key = 'isAbleToChangeEditorMode';
  370. const { data: isEditable } = useIsEditable();
  371. const { data: isSharedUser } = useIsSharedUser();
  372. const includesUndefined = [isEditable, isSharedUser].some(v => v === undefined);
  373. return useSWRImmutable(
  374. includesUndefined ? null : [key, isEditable, isSharedUser],
  375. () => !!isEditable && !isSharedUser,
  376. );
  377. };
  378. export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
  379. const key = 'isAbleToShowPageAuthors';
  380. const { data: pageId } = useCurrentPageId();
  381. const { data: pagePath } = useCurrentPagePath();
  382. const { data: isNotFound } = useIsNotFound();
  383. const includesUndefined = [pageId, pagePath, isNotFound].some(v => v === undefined);
  384. const isPageExist = (pageId != null) && !isNotFound;
  385. const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
  386. return useSWRImmutable(
  387. includesUndefined ? null : [key, pageId, pagePath, isNotFound],
  388. () => isPageExist && !isUsersTopPagePath,
  389. );
  390. };