ui.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import { RefObject } from 'react';
  2. import { isClient, pagePathUtils } from '@growi/core';
  3. import { Breakpoint, addBreakpointListener } from '@growi/ui';
  4. import SimpleBar from 'simplebar-react';
  5. import {
  6. useSWRConfig, SWRResponse, Key, Fetcher,
  7. } from 'swr';
  8. import useSWRImmutable from 'swr/immutable';
  9. import { IFocusable } from '~/client/interfaces/focusable';
  10. import { useUserUISettings } from '~/client/services/user-ui-settings';
  11. import { Nullable } from '~/interfaces/common';
  12. import { SidebarContentsType } from '~/interfaces/ui';
  13. import { UpdateDescCountData } from '~/interfaces/websocket';
  14. import loggerFactory from '~/utils/logger';
  15. import {
  16. useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
  17. useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink,
  18. } from './context';
  19. import { localStorageMiddleware } from './middlewares/sync-to-storage';
  20. import { useStaticSWR } from './use-static-swr';
  21. const { isSharedPage } = pagePathUtils;
  22. const logger = loggerFactory('growi:stores:ui');
  23. /** **********************************************************
  24. * Unions
  25. *********************************************************** */
  26. export const EditorMode = {
  27. View: 'view',
  28. Editor: 'editor',
  29. HackMD: 'hackmd',
  30. } as const;
  31. export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  32. /** **********************************************************
  33. * Storing RefObjects
  34. *********************************************************** */
  35. export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
  36. return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
  37. };
  38. /** **********************************************************
  39. * SWR Hooks
  40. * for switching UI
  41. *********************************************************** */
  42. export const useIsMobile = (): SWRResponse<boolean, Error> => {
  43. const key = isClient() ? 'isMobile' : null;
  44. let configuration;
  45. if (isClient()) {
  46. const userAgent = window.navigator.userAgent.toLowerCase();
  47. configuration = {
  48. fallbackData: /iphone|ipad|android/.test(userAgent),
  49. };
  50. }
  51. return useStaticSWR<boolean, Error>(key, undefined, configuration);
  52. };
  53. const updateBodyClassesByEditorMode = (newEditorMode: EditorMode, isSidebar = false) => {
  54. switch (newEditorMode) {
  55. case EditorMode.View:
  56. $('body').removeClass('on-edit');
  57. $('body').removeClass('builtin-editor');
  58. $('body').removeClass('hackmd');
  59. $('body').removeClass('editing-sidebar');
  60. break;
  61. case EditorMode.Editor:
  62. $('body').addClass('on-edit');
  63. $('body').addClass('builtin-editor');
  64. $('body').removeClass('hackmd');
  65. // editing /Sidebar
  66. if (isSidebar) {
  67. $('body').addClass('editing-sidebar');
  68. }
  69. break;
  70. case EditorMode.HackMD:
  71. $('body').addClass('on-edit');
  72. $('body').addClass('hackmd');
  73. $('body').removeClass('builtin-editor');
  74. $('body').removeClass('editing-sidebar');
  75. break;
  76. }
  77. };
  78. const updateHashByEditorMode = (newEditorMode: EditorMode) => {
  79. const { pathname } = window.location;
  80. switch (newEditorMode) {
  81. case EditorMode.View:
  82. window.history.replaceState(null, '', pathname);
  83. break;
  84. case EditorMode.Editor:
  85. window.history.replaceState(null, '', `${pathname}#edit`);
  86. break;
  87. case EditorMode.HackMD:
  88. window.history.replaceState(null, '', `${pathname}#hackmd`);
  89. break;
  90. }
  91. };
  92. export const determineEditorModeByHash = (): EditorMode => {
  93. const { hash } = window.location;
  94. switch (hash) {
  95. case '#edit':
  96. return EditorMode.Editor;
  97. case '#hackmd':
  98. return EditorMode.HackMD;
  99. default:
  100. return EditorMode.View;
  101. }
  102. };
  103. let isEditorModeLoaded = false;
  104. export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
  105. const { data: _isEditable } = useIsEditable();
  106. const editorModeByHash = determineEditorModeByHash();
  107. const isLoading = _isEditable === undefined;
  108. const isEditable = !isLoading && _isEditable;
  109. const initialData = isEditable ? editorModeByHash : EditorMode.View;
  110. const { data: currentPagePath } = useCurrentPagePath();
  111. const isSidebar = currentPagePath === '/Sidebar';
  112. const swrResponse = useSWRImmutable(
  113. isLoading ? null : ['editorMode', isEditable],
  114. null,
  115. { fallbackData: initialData },
  116. );
  117. // initial updating
  118. if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
  119. if (isEditable) {
  120. updateBodyClassesByEditorMode(swrResponse.data, isSidebar);
  121. }
  122. isEditorModeLoaded = true;
  123. }
  124. return {
  125. ...swrResponse,
  126. // overwrite mutate
  127. mutate: (editorMode: EditorMode, shouldRevalidate?: boolean) => {
  128. if (!isEditable) {
  129. return Promise.resolve(EditorMode.View); // fixed if not editable
  130. }
  131. updateBodyClassesByEditorMode(editorMode, isSidebar);
  132. updateHashByEditorMode(editorMode);
  133. return swrResponse.mutate(editorMode, shouldRevalidate);
  134. },
  135. };
  136. };
  137. export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
  138. const key: Key = isClient() ? 'isDeviceSmallerThanMd' : null;
  139. const { cache, mutate } = useSWRConfig();
  140. if (isClient()) {
  141. const mdOrAvobeHandler = function(this: MediaQueryList): void {
  142. // sm -> md: matches will be true
  143. // md -> sm: matches will be false
  144. mutate(key, !this.matches);
  145. };
  146. const mql = addBreakpointListener(Breakpoint.MD, mdOrAvobeHandler);
  147. // initialize
  148. if (cache.get(key) == null) {
  149. document.addEventListener('DOMContentLoaded', () => {
  150. mutate(key, !mql.matches);
  151. });
  152. }
  153. }
  154. return useStaticSWR(key);
  155. };
  156. export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
  157. const key: Key = isClient() ? 'isDeviceSmallerThanLg' : null;
  158. const { cache, mutate } = useSWRConfig();
  159. if (isClient()) {
  160. const lgOrAvobeHandler = function(this: MediaQueryList): void {
  161. // md -> lg: matches will be true
  162. // lg -> md: matches will be false
  163. mutate(key, !this.matches);
  164. };
  165. const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler);
  166. // initialize
  167. if (cache.get(key) == null) {
  168. document.addEventListener('DOMContentLoaded', () => {
  169. mutate(key, !mql.matches);
  170. });
  171. }
  172. }
  173. return useStaticSWR(key);
  174. };
  175. type PreferDrawerModeByUserUtils = {
  176. update: (preferDrawerMode: boolean) => void
  177. }
  178. export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponse<boolean, Error> & PreferDrawerModeByUserUtils => {
  179. const { data: isGuestUser } = useIsGuestUser();
  180. const { scheduleToPut } = useUserUISettings();
  181. const swrResponse: SWRResponse<boolean, Error> = useStaticSWR('preferDrawerModeByUser', initialData, { use: isGuestUser ? [localStorageMiddleware] : [] });
  182. return {
  183. ...swrResponse,
  184. data: swrResponse.data,
  185. update: (preferDrawerMode: boolean) => {
  186. swrResponse.mutate(preferDrawerMode);
  187. if (!isGuestUser) {
  188. scheduleToPut({ preferDrawerModeByUser: preferDrawerMode });
  189. }
  190. },
  191. };
  192. };
  193. export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
  194. return useStaticSWR('preferDrawerModeOnEditByUser', initialData, { fallbackData: true });
  195. };
  196. export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
  197. return useStaticSWR('isSidebarCollapsed', initialData, { fallbackData: false });
  198. };
  199. export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
  200. return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
  201. };
  202. export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
  203. return useStaticSWR('productNavWidth', initialData, { fallbackData: 320 });
  204. };
  205. export const useDrawerMode = (): SWRResponse<boolean, Error> => {
  206. const { data: editorMode } = useEditorMode();
  207. const { data: preferDrawerModeByUser } = usePreferDrawerModeByUser();
  208. const { data: preferDrawerModeOnEditByUser } = usePreferDrawerModeOnEditByUser();
  209. const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
  210. const condition = editorMode != null || preferDrawerModeByUser != null || preferDrawerModeOnEditByUser != null || isDeviceSmallerThanMd != null;
  211. const calcDrawerMode: Fetcher<boolean> = (
  212. key: Key, editorMode: EditorMode, preferDrawerModeByUser: boolean, preferDrawerModeOnEditByUser: boolean, isDeviceSmallerThanMd: boolean,
  213. ): boolean => {
  214. // get preference on view or edit
  215. const preferDrawerMode = editorMode !== EditorMode.View ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
  216. return isDeviceSmallerThanMd || preferDrawerMode;
  217. };
  218. return useSWRImmutable(
  219. condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
  220. calcDrawerMode,
  221. {
  222. fallback: calcDrawerMode,
  223. },
  224. );
  225. };
  226. export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
  227. return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
  228. };
  229. export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
  230. return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
  231. };
  232. export const useSelectedGrant = (initialData?: number): SWRResponse<number, Error> => {
  233. return useStaticSWR<number, Error>('grant', initialData);
  234. };
  235. export const useSelectedGrantGroupId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
  236. return useStaticSWR<Nullable<string>, Error>('grantGroupId', initialData);
  237. };
  238. export const useSelectedGrantGroupName = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
  239. return useStaticSWR<Nullable<string>, Error>('grantGroupName', initialData);
  240. };
  241. export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
  242. return useStaticSWR('globalSearchTypeahead', initialData);
  243. };
  244. export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
  245. const key = 'isAbleToShowPageManagement';
  246. const { data: currentPageId } = useCurrentPageId();
  247. const { data: isTrashPage } = useIsTrashPage();
  248. const { data: isSharedUser } = useIsSharedUser();
  249. const includesUndefined = [currentPageId, isTrashPage, isSharedUser].some(v => v === undefined);
  250. const isPageExist = currentPageId != null;
  251. return useSWRImmutable(
  252. includesUndefined ? null : key,
  253. () => isPageExist && !isTrashPage && !isSharedUser,
  254. );
  255. };
  256. export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
  257. const key = 'isAbleToShowTagLabel';
  258. const { data: isUserPage } = useIsUserPage();
  259. const { data: currentPagePath } = useCurrentPagePath();
  260. const { data: isIdenticalPath } = useIsIdenticalPath();
  261. const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
  262. const { data: editorMode } = useEditorMode();
  263. const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, notFoundTargetPathOrId, editorMode].some(v => v === undefined);
  264. const isViewMode = editorMode === EditorMode.View;
  265. const isNotFoundPage = notFoundTargetPathOrId != null;
  266. return useSWRImmutable(
  267. includesUndefined ? null : [key, editorMode],
  268. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  269. () => !isUserPage && !isSharedPage(currentPagePath!) && !isIdenticalPath && !(isViewMode && isNotFoundPage),
  270. );
  271. };
  272. export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Error> => {
  273. const key = 'isAbleToShowPageEditorModeManager';
  274. const { data: isNotCreatable } = useIsNotCreatable();
  275. const { data: isForbidden } = useIsForbidden();
  276. const { data: isTrashPage } = useIsTrashPage();
  277. const { data: isSharedUser } = useIsSharedUser();
  278. const { data: isNotFoundPermalink } = useIsNotFoundPermalink();
  279. const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser, isNotFoundPermalink].some(v => v === undefined);
  280. return useSWRImmutable(
  281. includesUndefined ? null : key,
  282. () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser && !isNotFoundPermalink,
  283. );
  284. };
  285. export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
  286. const key = 'isAbleToShowPageAuthors';
  287. const { data: currentPageId } = useCurrentPageId();
  288. const { data: isUserPage } = useIsUserPage();
  289. const includesUndefined = [currentPageId, isUserPage].some(v => v === undefined);
  290. const isPageExist = currentPageId != null;
  291. return useSWRImmutable(
  292. includesUndefined ? null : key,
  293. () => isPageExist && !isUserPage,
  294. );
  295. };
  296. type PageTreeDescCountMapUtils = {
  297. update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
  298. getDescCount(pageId?: string): number | null | undefined
  299. }
  300. export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
  301. const key = 'pageTreeDescCountMap';
  302. const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
  303. return {
  304. ...swrResponse,
  305. getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
  306. update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
  307. };
  308. };