import { type RefObject, useCallback, useEffect, } from 'react'; import { PageGrant, type Nullable } from '@growi/core'; import { type SWRResponseWithUtils, useSWRStatic, withUtils } from '@growi/core/dist/swr'; import { pagePathUtils, isClient, isServer } from '@growi/core/dist/utils'; import { Breakpoint } from '@growi/ui/dist/interfaces'; import { addBreakpointListener, cleanupBreakpointListener } from '@growi/ui/dist/utils'; import type { HtmlElementNode } from 'rehype-toc'; import type SimpleBar from 'simplebar-react'; import { useSWRConfig, type SWRResponse, type Key, KeyedMutator, MutatorOptions, } from 'swr'; import useSWRImmutable from 'swr/immutable'; import type { IFocusable } from '~/client/interfaces/focusable'; import { scheduleToPut } from '~/client/services/user-ui-settings'; import type { IPageGrantData } from '~/interfaces/page'; import { SidebarContentsType, SidebarMode } from '~/interfaces/ui'; import type { UpdateDescCountData } from '~/interfaces/websocket'; import { useIsNotFound, useCurrentPagePath, useIsTrashPage, useCurrentPageId, } from '~/stores/page'; import loggerFactory from '~/utils/logger'; import { useIsEditable, useIsReadOnlyUser, useIsSharedUser, useIsIdenticalPath, useCurrentUser, useShareLinkId, } from './context'; import { useStaticSWR } from './use-static-swr'; const { isTrashTopPage, isUsersTopPage } = pagePathUtils; const logger = loggerFactory('growi:stores:ui'); /** ********************************************************** * Unions *********************************************************** */ export const EditorMode = { View: 'view', Editor: 'editor', } as const; export type EditorMode = typeof EditorMode[keyof typeof EditorMode]; /** ********************************************************** * Storing objects to ref *********************************************************** */ export const useSidebarScrollerRef = (initialData?: RefObject): SWRResponse, Error> => { return useStaticSWR, Error>('sidebarScrollerRef', initialData); }; export const useCurrentPageTocNode = (): SWRResponse => { const { data: currentPagePath } = useCurrentPagePath(); return useStaticSWR(['currentPageTocNode', currentPagePath]); }; /** ********************************************************** * SWR Hooks * for switching UI *********************************************************** */ export const useIsMobile = (): SWRResponse => { const key = isClient() ? 'isMobile' : null; let configuration; if (isClient()) { const userAgent = window.navigator.userAgent.toLowerCase(); configuration = { fallbackData: /iphone|ipad|android/.test(userAgent), }; } return useStaticSWR(key, undefined, configuration); }; const getClassNamesByEditorMode = (editorMode: EditorMode | undefined): string[] => { const classNames: string[] = []; switch (editorMode) { case EditorMode.Editor: classNames.push('editing', 'builtin-editor'); break; } return classNames; }; export const EditorModeHash = { View: '', Edit: '#edit', } as const; export type EditorModeHash = typeof EditorModeHash[keyof typeof EditorModeHash]; const updateHashByEditorMode = (newEditorMode: EditorMode) => { const { pathname, search } = window.location; switch (newEditorMode) { case EditorMode.View: window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.View}`); break; case EditorMode.Editor: window.history.replaceState(null, '', `${pathname}${search}${EditorModeHash.Edit}`); break; } }; export const determineEditorModeByHash = (): EditorMode => { if (isServer()) { return EditorMode.View; } const { hash } = window.location; switch (hash) { case EditorModeHash.Edit: return EditorMode.Editor; default: return EditorMode.View; } }; type EditorModeUtils = { getClassNamesByEditorMode: () => string[], } export const useEditorMode = (): SWRResponseWithUtils => { const { data: _isEditable } = useIsEditable(); const { data: isNotFound } = useIsNotFound(); const editorModeByHash = determineEditorModeByHash(); const isLoading = _isEditable === undefined; const isEditable = !isLoading && _isEditable; const preventModeEditor = !isEditable || isNotFound === undefined || isNotFound === true; const initialData = preventModeEditor ? EditorMode.View : editorModeByHash; const swrResponse = useSWRImmutable( isLoading ? null : ['editorMode', isEditable, preventModeEditor], null, { fallbackData: initialData }, ); // construct overriding mutate method const mutateOriginal = swrResponse.mutate; const mutate = useCallback((editorMode: EditorMode, shouldRevalidate?: boolean) => { if (preventModeEditor) { return Promise.resolve(EditorMode.View); // fixed if not editable } updateHashByEditorMode(editorMode); return mutateOriginal(editorMode, shouldRevalidate); }, [preventModeEditor, mutateOriginal]); const getClassNames = useCallback(() => { return getClassNamesByEditorMode(swrResponse.data); }, [swrResponse.data]); return Object.assign(swrResponse, { mutate, getClassNamesByEditorMode: getClassNames, }); }; export const useIsDeviceLargerThanMd = (): SWRResponse => { const key: Key = isClient() ? 'isDeviceLargerThanMd' : null; const { cache, mutate } = useSWRConfig(); useEffect(() => { if (key != null) { const mdOrAvobeHandler = function(this: MediaQueryList): void { // sm -> md: matches will be true // md -> sm: matches will be false mutate(key, this.matches); }; const mql = addBreakpointListener(Breakpoint.MD, mdOrAvobeHandler); // initialize if (cache.get(key)?.data == null) { cache.set(key, { ...cache.get(key), data: mql.matches }); } return () => { cleanupBreakpointListener(mql, mdOrAvobeHandler); }; } }, [cache, key, mutate]); return useSWRStatic(key); }; export const useIsDeviceLargerThanLg = (): SWRResponse => { const key: Key = isClient() ? 'isDeviceLargerThanLg' : null; const { cache, mutate } = useSWRConfig(); useEffect(() => { if (key != null) { const lgOrAvobeHandler = function(this: MediaQueryList): void { // md -> lg: matches will be true // lg -> md: matches will be false mutate(key, this.matches); }; const mql = addBreakpointListener(Breakpoint.LG, lgOrAvobeHandler); // initialize if (cache.get(key)?.data == null) { cache.set(key, { ...cache.get(key), data: mql.matches }); } return () => { cleanupBreakpointListener(mql, lgOrAvobeHandler); }; } }, [cache, key, mutate]); return useSWRStatic(key); }; export const useIsDeviceLargerThanXl = (): SWRResponse => { const key: Key = isClient() ? 'isDeviceLargerThanXl' : null; const { cache, mutate } = useSWRConfig(); useEffect(() => { if (key != null) { const xlOrAvobeHandler = function(this: MediaQueryList): void { // lg -> xl: matches will be true // xl -> lg: matches will be false mutate(key, this.matches); }; const mql = addBreakpointListener(Breakpoint.XL, xlOrAvobeHandler); // initialize if (cache.get(key)?.data == null) { cache.set(key, { ...cache.get(key), data: mql.matches }); } return () => { cleanupBreakpointListener(mql, xlOrAvobeHandler); }; } }, [cache, key, mutate]); return useSWRStatic(key); }; type MutateAndSaveUserUISettings = (data: Data, opts?: boolean | MutatorOptions) => Promise; type MutateAndSaveUserUISettingsUtils = { mutateAndSave: MutateAndSaveUserUISettings; } export const useCurrentSidebarContents = ( initialData?: SidebarContentsType, ): SWRResponseWithUtils, SidebarContentsType> => { const swrResponse = useSWRStatic('sidebarContents', initialData, { fallbackData: SidebarContentsType.TREE }); const { mutate } = swrResponse; const mutateAndSave: MutateAndSaveUserUISettings = useCallback((data, opts?) => { scheduleToPut({ currentSidebarContents: data }); return mutate(data, opts); }, [mutate]); return withUtils(swrResponse, { mutateAndSave }); }; export const useCurrentProductNavWidth = (initialData?: number): SWRResponseWithUtils, number> => { const swrResponse = useSWRStatic('productNavWidth', initialData, { fallbackData: 320 }); const { mutate } = swrResponse; const mutateAndSave: MutateAndSaveUserUISettings = useCallback((data, opts?) => { scheduleToPut({ currentProductNavWidth: data }); return mutate(data, opts); }, [mutate]); return withUtils(swrResponse, { mutateAndSave }); }; export const usePreferCollapsedMode = (initialData?: boolean): SWRResponseWithUtils, boolean> => { const swrResponse = useSWRStatic('isPreferCollapsedMode', initialData, { fallbackData: false }); const { mutate } = swrResponse; const mutateAndSave: MutateAndSaveUserUISettings = useCallback((data, opts?) => { scheduleToPut({ preferCollapsedModeByUser: data }); return mutate(data, opts); }, [mutate]); return withUtils(swrResponse, { mutateAndSave }); }; export const useCollapsedContentsOpened = (initialData?: boolean): SWRResponse => { return useSWRStatic('isCollapsedContentsOpened', initialData, { fallbackData: false }); }; export const useDrawerOpened = (isOpened?: boolean): SWRResponse => { return useSWRStatic('isDrawerOpened', isOpened, { fallbackData: false }); }; type DetectSidebarModeUtils = { isDrawerMode(): boolean isCollapsedMode(): boolean isDockMode(): boolean } export const useSidebarMode = (): SWRResponseWithUtils => { const { data: isDeviceLargerThanXl } = useIsDeviceLargerThanXl(); const { data: editorMode } = useEditorMode(); const { data: isCollapsedModeUnderDockMode } = usePreferCollapsedMode(); const condition = isDeviceLargerThanXl != null && editorMode != null && isCollapsedModeUnderDockMode != null; const isEditorMode = editorMode === EditorMode.Editor; const fetcher = useCallback(( [, isDeviceLargerThanXl, isEditorMode, isCollapsedModeUnderDockMode]: [Key, boolean|undefined, boolean|undefined, boolean|undefined], ) => { if (!isDeviceLargerThanXl) { return SidebarMode.DRAWER; } return isEditorMode || isCollapsedModeUnderDockMode ? SidebarMode.COLLAPSED : SidebarMode.DOCK; }, []); const swrResponse = useSWRImmutable( condition ? ['sidebarMode', isDeviceLargerThanXl, isEditorMode, isCollapsedModeUnderDockMode] : null, // calcDrawerMode, fetcher, { fallbackData: fetcher(['sidebarMode', isDeviceLargerThanXl, isEditorMode, isCollapsedModeUnderDockMode]) }, ); const _isDrawerMode = useCallback(() => swrResponse.data === SidebarMode.DRAWER, [swrResponse.data]); const _isCollapsedMode = useCallback(() => swrResponse.data === SidebarMode.COLLAPSED, [swrResponse.data]); const _isDockMode = useCallback(() => swrResponse.data === SidebarMode.DOCK, [swrResponse.data]); return { ...swrResponse, isDrawerMode: _isDrawerMode, isCollapsedMode: _isCollapsedMode, isDockMode: _isDockMode, }; }; export const useSelectedGrant = (initialData?: Nullable): SWRResponse, Error> => { return useStaticSWR, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } }); }; export const useGlobalSearchFormRef = (initialData?: RefObject): SWRResponse, Error> => { return useStaticSWR('globalSearchTypeahead', initialData); }; type PageTreeDescCountMapUtils = { update(newData?: UpdateDescCountData): Promise getDescCount(pageId?: string): number | null | undefined } export const usePageTreeDescCountMap = (initialData?: UpdateDescCountData): SWRResponse & PageTreeDescCountMapUtils => { const key = 'pageTreeDescCountMap'; const swrResponse = useStaticSWR(key, initialData, { fallbackData: new Map() }); return { ...swrResponse, getDescCount: (pageId?: string) => (pageId != null ? swrResponse.data?.get(pageId) : null), update: (newData: UpdateDescCountData) => swrResponse.mutate(new Map([...(swrResponse.data || new Map()), ...newData])), }; }; /** ********************************************************** * SWR Hooks * Determined value by context *********************************************************** */ export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse => { const { data: currentUser } = useCurrentUser(); const { data: isReadOnlyUser } = useIsReadOnlyUser(); const { data: isTrashPage } = useIsTrashPage(); return useStaticSWR('isAbleToShowTrashPageManagementButtons', isTrashPage && currentUser != null && !isReadOnlyUser); }; export const useIsAbleToShowPageManagement = (): SWRResponse => { const key = 'isAbleToShowPageManagement'; const { data: currentPageId } = useCurrentPageId(); const { data: _isTrashPage } = useIsTrashPage(); const { data: _isSharedUser } = useIsSharedUser(); const { data: isNotFound } = useIsNotFound(); const pageId = currentPageId; const includesUndefined = [pageId, _isTrashPage, _isSharedUser, isNotFound].some(v => v === undefined); const isPageExist = (pageId != null) && isNotFound === false; const isEmptyPage = (pageId != null) && isNotFound === true; const isTrashPage = isPageExist && _isTrashPage === true; const isSharedUser = isPageExist && _isSharedUser === true; return useSWRImmutable( includesUndefined ? null : [key, pageId, isPageExist, isEmptyPage, isTrashPage, isSharedUser], ([, , isPageExist, isEmptyPage, isTrashPage, isSharedUser]) => (isPageExist && !isTrashPage && !isSharedUser) || isEmptyPage, ); }; export const useIsAbleToShowTagLabel = (): SWRResponse => { const key = 'isAbleToShowTagLabel'; const { data: pageId } = useCurrentPageId(); const { data: currentPagePath } = useCurrentPagePath(); const { data: isIdenticalPath } = useIsIdenticalPath(); const { data: isNotFound } = useIsNotFound(); const { data: editorMode } = useEditorMode(); const { data: shareLinkId } = useShareLinkId(); const includesUndefined = [currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined); const isViewMode = editorMode === EditorMode.View; return useSWRImmutable( includesUndefined ? null : [key, pageId, currentPagePath, isIdenticalPath, isNotFound, editorMode, shareLinkId], // "/trash" page does not exist on page collection and unable to add tags // eslint-disable-next-line @typescript-eslint/no-non-null-assertion () => !isUsersTopPage(currentPagePath!) && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound), ); }; export const useIsAbleToChangeEditorMode = (): SWRResponse => { const key = 'isAbleToChangeEditorMode'; const { data: isEditable } = useIsEditable(); const { data: isSharedUser } = useIsSharedUser(); const includesUndefined = [isEditable, isSharedUser].some(v => v === undefined); return useSWRImmutable( includesUndefined ? null : [key, isEditable, isSharedUser], () => !!isEditable && !isSharedUser, ); }; export const useIsAbleToShowPageAuthors = (): SWRResponse => { const key = 'isAbleToShowPageAuthors'; const { data: pageId } = useCurrentPageId(); const { data: pagePath } = useCurrentPagePath(); const { data: isNotFound } = useIsNotFound(); const includesUndefined = [pageId, pagePath, isNotFound].some(v => v === undefined); const isPageExist = (pageId != null) && !isNotFound; const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath); return useSWRImmutable( includesUndefined ? null : [key, pageId, pagePath, isNotFound], () => isPageExist && !isUsersTopPagePath, ); };