ui.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import { RefObject } from 'react';
  2. import {
  3. isClient, isServer, pagePathUtils, Nullable,
  4. } from '@growi/core';
  5. import { withUtils, SWRResponseWithUtils } from '@growi/core/src/utils/with-utils';
  6. import { Breakpoint, addBreakpointListener } from '@growi/ui';
  7. import SimpleBar from 'simplebar-react';
  8. import {
  9. useSWRConfig, SWRResponse, Key, Fetcher,
  10. } from 'swr';
  11. import useSWRImmutable from 'swr/immutable';
  12. import { IFocusable } from '~/client/interfaces/focusable';
  13. import { useUserUISettings } from '~/client/services/user-ui-settings';
  14. import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
  15. import { ISidebarConfig } from '~/interfaces/sidebar-config';
  16. import { SidebarContentsType } from '~/interfaces/ui';
  17. import { UpdateDescCountData } from '~/interfaces/websocket';
  18. import loggerFactory from '~/utils/logger';
  19. import {
  20. useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
  21. useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
  22. } from './context';
  23. import { localStorageMiddleware } from './middlewares/sync-to-storage';
  24. import { useStaticSWR } from './use-static-swr';
  25. const { isTrashTopPage } = pagePathUtils;
  26. const logger = loggerFactory('growi:stores:ui');
  27. /** **********************************************************
  28. * Unions
  29. *********************************************************** */
  30. export const EditorMode = {
  31. View: 'view',
  32. Editor: 'editor',
  33. HackMD: 'hackmd',
  34. } as const;
  35. export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  36. /** **********************************************************
  37. * Storing RefObjects
  38. *********************************************************** */
  39. export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
  40. return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
  41. };
  42. /** **********************************************************
  43. * SWR Hooks
  44. * for switching UI
  45. *********************************************************** */
  46. export const useIsMobile = (): SWRResponse<boolean, Error> => {
  47. const key = isClient() ? 'isMobile' : null;
  48. let configuration;
  49. if (isClient()) {
  50. const userAgent = window.navigator.userAgent.toLowerCase();
  51. configuration = {
  52. fallbackData: /iphone|ipad|android/.test(userAgent),
  53. };
  54. }
  55. return useStaticSWR<boolean, Error>(key, undefined, configuration);
  56. };
  57. const updateBodyClassesByEditorMode = (newEditorMode: EditorMode, isSidebar = false) => {
  58. const bodyElement = document.getElementsByTagName('body')[0];
  59. if (bodyElement == null) {
  60. logger.warn('The body tag was not successfully obtained');
  61. return;
  62. }
  63. switch (newEditorMode) {
  64. case EditorMode.View:
  65. bodyElement.classList.remove('on-edit', 'builtin-editor', 'hackmd', 'editing-sidebar');
  66. break;
  67. case EditorMode.Editor:
  68. bodyElement.classList.add('on-edit', 'builtin-editor');
  69. bodyElement.classList.remove('hackmd');
  70. // editing /Sidebar
  71. if (isSidebar) {
  72. bodyElement.classList.add('editing-sidebar');
  73. }
  74. break;
  75. case EditorMode.HackMD:
  76. bodyElement.classList.add('on-edit', 'hackmd');
  77. bodyElement.classList.remove('builtin-editor', 'editing-sidebar');
  78. break;
  79. }
  80. };
  81. const updateHashByEditorMode = (newEditorMode: EditorMode) => {
  82. const { pathname } = window.location;
  83. switch (newEditorMode) {
  84. case EditorMode.View:
  85. window.history.replaceState(null, '', pathname);
  86. break;
  87. case EditorMode.Editor:
  88. window.history.replaceState(null, '', `${pathname}#edit`);
  89. break;
  90. case EditorMode.HackMD:
  91. window.history.replaceState(null, '', `${pathname}#hackmd`);
  92. break;
  93. }
  94. };
  95. export const determineEditorModeByHash = (): EditorMode => {
  96. if (isServer()) {
  97. return EditorMode.View;
  98. }
  99. const { hash } = window.location;
  100. switch (hash) {
  101. case '#edit':
  102. return EditorMode.Editor;
  103. case '#hackmd':
  104. return EditorMode.HackMD;
  105. default:
  106. return EditorMode.View;
  107. }
  108. };
  109. let isEditorModeLoaded = false;
  110. export const useEditorMode = (): SWRResponse<EditorMode, Error> => {
  111. const { data: _isEditable } = useIsEditable();
  112. const editorModeByHash = determineEditorModeByHash();
  113. const isLoading = _isEditable === undefined;
  114. const isEditable = !isLoading && _isEditable;
  115. const initialData = isEditable ? editorModeByHash : EditorMode.View;
  116. const { data: currentPagePath } = useCurrentPagePath();
  117. const isSidebar = currentPagePath === '/Sidebar';
  118. const swrResponse = useSWRImmutable(
  119. isLoading ? null : ['editorMode', isEditable],
  120. null,
  121. { fallbackData: initialData },
  122. );
  123. // initial updating
  124. if (!isEditorModeLoaded && !isLoading && swrResponse.data != null) {
  125. if (isEditable) {
  126. updateBodyClassesByEditorMode(swrResponse.data, isSidebar);
  127. }
  128. isEditorModeLoaded = true;
  129. }
  130. return {
  131. ...swrResponse,
  132. // overwrite mutate
  133. mutate: (editorMode: EditorMode, shouldRevalidate?: boolean) => {
  134. if (!isEditable) {
  135. return Promise.resolve(EditorMode.View); // fixed if not editable
  136. }
  137. updateBodyClassesByEditorMode(editorMode, isSidebar);
  138. updateHashByEditorMode(editorMode);
  139. return swrResponse.mutate(editorMode, shouldRevalidate);
  140. },
  141. };
  142. };
  143. export const useIsDeviceSmallerThanMd = (): SWRResponse<boolean, Error> => {
  144. const key: Key = isClient() ? 'isDeviceSmallerThanMd' : null;
  145. const { cache, mutate } = useSWRConfig();
  146. if (isClient()) {
  147. const mdOrAvobeHandler = function(this: MediaQueryList): void {
  148. // sm -> md: matches will be true
  149. // md -> sm: matches will be false
  150. mutate(key, !this.matches);
  151. };
  152. const mql = addBreakpointListener(Breakpoint.MD, mdOrAvobeHandler);
  153. // initialize
  154. if (cache.get(key) == null) {
  155. document.addEventListener('DOMContentLoaded', () => {
  156. mutate(key, !mql.matches);
  157. });
  158. }
  159. }
  160. return useStaticSWR(key);
  161. };
  162. export const useIsDeviceSmallerThanLg = (): SWRResponse<boolean, Error> => {
  163. const key: Key = isClient() ? 'isDeviceSmallerThanLg' : null;
  164. const { cache, mutate } = useSWRConfig();
  165. if (isClient()) {
  166. const lgOrAvobeHandler = function(this: MediaQueryList): void {
  167. // md -> lg: matches will be true
  168. // lg -> md: matches will be false
  169. mutate(key, !this.matches);
  170. };
  171. const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler);
  172. // initialize
  173. if (cache.get(key) == null) {
  174. document.addEventListener('DOMContentLoaded', () => {
  175. mutate(key, !mql.matches);
  176. });
  177. }
  178. }
  179. return useStaticSWR(key);
  180. };
  181. type PreferDrawerModeByUserUtils = {
  182. update: (preferDrawerMode: boolean) => void
  183. }
  184. export const usePreferDrawerModeByUser = (initialData?: boolean): SWRResponseWithUtils<SWRResponse, PreferDrawerModeByUserUtils> => {
  185. const { data: isGuestUser } = useIsGuestUser();
  186. const { scheduleToPut } = useUserUISettings();
  187. const swrResponse: SWRResponse<boolean, Error> = useStaticSWR('preferDrawerModeByUser', initialData, { use: isGuestUser ? [localStorageMiddleware] : [] });
  188. const utils: PreferDrawerModeByUserUtils = {
  189. update: (preferDrawerMode: boolean) => {
  190. swrResponse.mutate(preferDrawerMode);
  191. if (!isGuestUser) {
  192. scheduleToPut({ preferDrawerModeByUser: preferDrawerMode });
  193. }
  194. },
  195. };
  196. return withUtils(swrResponse, utils);
  197. };
  198. export const usePreferDrawerModeOnEditByUser = (initialData?: boolean): SWRResponse<boolean, Error> => {
  199. return useStaticSWR('preferDrawerModeOnEditByUser', initialData, { fallbackData: true });
  200. };
  201. export const useSidebarCollapsed = (initialData?: boolean): SWRResponse<boolean, Error> => {
  202. return useStaticSWR('isSidebarCollapsed', initialData, { fallbackData: false });
  203. };
  204. export const useCurrentSidebarContents = (initialData?: SidebarContentsType): SWRResponse<SidebarContentsType, Error> => {
  205. return useStaticSWR('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE });
  206. };
  207. export const useCurrentProductNavWidth = (initialData?: number): SWRResponse<number, Error> => {
  208. return useStaticSWR('productNavWidth', initialData, { fallbackData: 320 });
  209. };
  210. export const useDrawerMode = (): SWRResponse<boolean, Error> => {
  211. const { data: preferDrawerModeByUser } = usePreferDrawerModeByUser();
  212. const { data: preferDrawerModeOnEditByUser } = usePreferDrawerModeOnEditByUser();
  213. const { data: editorMode } = useEditorMode();
  214. const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
  215. const condition = editorMode != null || preferDrawerModeByUser != null || preferDrawerModeOnEditByUser != null || isDeviceSmallerThanMd != null;
  216. const calcDrawerMode: Fetcher<boolean> = (
  217. key: Key, editorMode: EditorMode, preferDrawerModeByUser: boolean, preferDrawerModeOnEditByUser: boolean, isDeviceSmallerThanMd: boolean,
  218. ): boolean => {
  219. // get preference on view or edit
  220. const preferDrawerMode = editorMode !== EditorMode.View ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
  221. return isDeviceSmallerThanMd || preferDrawerMode;
  222. };
  223. const isViewModeWithPreferDrawerMode = editorMode === EditorMode.View && preferDrawerModeByUser;
  224. const isEditModeWithPreferDrawerMode = editorMode === EditorMode.Editor && preferDrawerModeOnEditByUser;
  225. const useFallbackData = isViewModeWithPreferDrawerMode || isEditModeWithPreferDrawerMode;
  226. const fallbackOption = useFallbackData
  227. ? { fallbackData: true }
  228. : { fallback: calcDrawerMode };
  229. return useSWRImmutable(
  230. condition ? ['isDrawerMode', editorMode, preferDrawerModeByUser, preferDrawerModeOnEditByUser, isDeviceSmallerThanMd] : null,
  231. calcDrawerMode,
  232. fallbackOption,
  233. );
  234. };
  235. type SidebarConfigOption = {
  236. update: () => Promise<void>,
  237. isSidebarDrawerMode: boolean|undefined,
  238. isSidebarClosedAtDockMode: boolean|undefined,
  239. setIsSidebarDrawerMode: (isSidebarDrawerMode: boolean) => void,
  240. setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode: boolean) => void
  241. }
  242. export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> & SidebarConfigOption => {
  243. const swrResponse = useSWRImmutable<ISidebarConfig>(
  244. '/customize-setting/sidebar',
  245. endpoint => apiv3Get(endpoint).then(result => result.data),
  246. );
  247. return {
  248. ...swrResponse,
  249. update: async() => {
  250. const { data } = swrResponse;
  251. if (data == null) {
  252. return;
  253. }
  254. const { isSidebarDrawerMode, isSidebarClosedAtDockMode } = data;
  255. const updateData = {
  256. isSidebarDrawerMode,
  257. isSidebarClosedAtDockMode,
  258. };
  259. // invoke API
  260. await apiv3Put('/customize-setting/sidebar', updateData);
  261. },
  262. isSidebarDrawerMode: swrResponse.data?.isSidebarDrawerMode,
  263. isSidebarClosedAtDockMode: swrResponse.data?.isSidebarClosedAtDockMode,
  264. setIsSidebarDrawerMode: (isSidebarDrawerMode) => {
  265. const { data, mutate } = swrResponse;
  266. if (data == null) {
  267. return;
  268. }
  269. const updateData = {
  270. isSidebarDrawerMode,
  271. };
  272. // update isSidebarDrawerMode in cache, not revalidate
  273. mutate({ ...data, ...updateData }, false);
  274. },
  275. setIsSidebarClosedAtDockMode: (isSidebarClosedAtDockMode) => {
  276. const { data, mutate } = swrResponse;
  277. if (data == null) {
  278. return;
  279. }
  280. const updateData = {
  281. isSidebarClosedAtDockMode,
  282. };
  283. // update isSidebarClosedAtDockMode in cache, not revalidate
  284. mutate({ ...data, ...updateData }, false);
  285. },
  286. };
  287. };
  288. export const useDrawerOpened = (isOpened?: boolean): SWRResponse<boolean, Error> => {
  289. return useStaticSWR('isDrawerOpened', isOpened, { fallbackData: false });
  290. };
  291. export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<boolean, Error> => {
  292. return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
  293. };
  294. export const useSelectedGrant = (initialData?: number): SWRResponse<number, Error> => {
  295. return useStaticSWR<number, Error>('grant', initialData);
  296. };
  297. export const useSelectedGrantGroupId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
  298. return useStaticSWR<Nullable<string>, Error>('grantGroupId', initialData);
  299. };
  300. export const useSelectedGrantGroupName = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
  301. return useStaticSWR<Nullable<string>, Error>('grantGroupName', initialData);
  302. };
  303. export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
  304. return useStaticSWR('globalSearchTypeahead', initialData);
  305. };
  306. type PageTreeDescCountMapUtils = {
  307. update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
  308. getDescCount(pageId?: string): number | null | undefined
  309. }
  310. export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse<UpdateDescCountData, Error> & PageTreeDescCountMapUtils => {
  311. const key = 'pageTreeDescCountMap';
  312. const swrResponse = useStaticSWR<UpdateDescCountData, Error>(key, initialData, { fallbackData: new Map() });
  313. return {
  314. ...swrResponse,
  315. getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null),
  316. update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])),
  317. };
  318. };
  319. /** **********************************************************
  320. * SWR Hooks
  321. * Determined value by context
  322. *********************************************************** */
  323. export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean, Error> => {
  324. const { data: currentUser } = useCurrentUser();
  325. const { data: isTrashPage } = useIsTrashPage();
  326. return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null);
  327. };
  328. export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
  329. const key = 'isAbleToShowPageManagement';
  330. const { data: currentPageId } = useCurrentPageId();
  331. const { data: isTrashPage } = useIsTrashPage();
  332. const { data: isSharedUser } = useIsSharedUser();
  333. const pageId = currentPageId;
  334. const includesUndefined = [pageId, isTrashPage, isSharedUser].some(v => v === undefined);
  335. const isPageExist = pageId != null;
  336. return useSWRImmutable(
  337. includesUndefined ? null : key,
  338. () => isPageExist && !isTrashPage && !isSharedUser,
  339. );
  340. };
  341. export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
  342. const key = 'isAbleToShowTagLabel';
  343. const { data: isUserPage } = useIsUserPage();
  344. const { data: currentPagePath } = useCurrentPagePath();
  345. const { data: isIdenticalPath } = useIsIdenticalPath();
  346. const { data: isNotFound } = useIsNotFound();
  347. const { data: editorMode } = useEditorMode();
  348. const { data: shareLinkId } = useShareLinkId();
  349. const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
  350. const isViewMode = editorMode === EditorMode.View;
  351. return useSWRImmutable(
  352. includesUndefined ? null : [key, editorMode],
  353. // "/trash" page does not exist on page collection and unable to add tags
  354. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  355. () => !isUserPage && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
  356. );
  357. };
  358. export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Error> => {
  359. const key = 'isAbleToShowPageEditorModeManager';
  360. const { data: isNotCreatable } = useIsNotCreatable();
  361. const { data: isForbidden } = useIsForbidden();
  362. const { data: isTrashPage } = useIsTrashPage();
  363. const { data: isSharedUser } = useIsSharedUser();
  364. const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
  365. return useSWRImmutable(
  366. includesUndefined ? null : key,
  367. () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
  368. );
  369. };
  370. export const useIsAbleToShowPageAuthors = (): SWRResponse<boolean, Error> => {
  371. const key = 'isAbleToShowPageAuthors';
  372. const { data: currentPageId } = useCurrentPageId();
  373. const { data: isUserPage } = useIsUserPage();
  374. const includesUndefined = [currentPageId, isUserPage].some(v => v === undefined);
  375. const isPageExist = currentPageId != null;
  376. return useSWRImmutable(
  377. includesUndefined ? null : key,
  378. () => isPageExist && !isUserPage,
  379. );
  380. };