ui.tsx 13 KB

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