diff --git a/.changeset/mighty-apples-design.md b/.changeset/mighty-apples-design.md new file mode 100644 index 000000000..4f1bec842 --- /dev/null +++ b/.changeset/mighty-apples-design.md @@ -0,0 +1,5 @@ +--- +'gitbook': minor +--- + +Persist state of tabs and dynamically sync them based on title diff --git a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx index 5dc14acd7..333218922 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/DynamicTabs.tsx @@ -1,23 +1,107 @@ 'use client'; import React from 'react'; +import { atom, selectorFamily, useRecoilValue, useSetRecoilState } from 'recoil'; +import { useHash, useIsMounted } from '@/components/hooks'; import { ClassValue, tcls } from '@/lib/tailwind'; +// How many titles are remembered: +const TITLES_MAX = 5; + +export interface TabsItem { + id: string; + title: string; +} + +// https://github.com/facebookexperimental/Recoil/issues/629#issuecomment-914273925 +type SelectorMapper = { + [Property in keyof Type]: Type[Property]; +}; +type TabsInput = { + id: string; + tabs: SelectorMapper[]; +}; + +interface TabsState { + activeIds: { + [tabsBlockId: string]: string; + }; + activeTitles: string[]; +} + /** * Client side component for the tabs, taking care of interactions. */ -export function DynamicTabs(props: { - tabs: Array<{ - id: string; - title: string; - children: React.ReactNode; - }>; - style: ClassValue; -}) { - const { tabs, style } = props; +export function DynamicTabs( + props: TabsInput & { + tabsBody: React.ReactNode[]; + style: ClassValue; + }, +) { + const { id, tabs, tabsBody, style } = props; - const [active, setActive] = React.useState(tabs[0].id); + const hash = useHash(); + + const activeState = useRecoilValue(tabsActiveSelector({ id, tabs })); + + // To avoid issue with hydration, we only use the state from recoil (which is loaded from localstorage), + // once the component has been mounted. + // Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time + // and get stuck into an inconsistent state. + const mounted = useIsMounted(); + const active = mounted ? activeState : tabs[0]; + + const setTabsState = useSetRecoilState(tabsAtom); + + /** + * When clicking to select a tab, we: + * - mark this specific ID as selected + * - store the ID to auto-select other tabs with the same title + */ + const onSelectTab = React.useCallback( + (tab: TabsItem) => { + setTabsState((prev) => ({ + activeIds: { + ...prev.activeIds, + [id]: tab.id, + }, + activeTitles: tab.title + ? prev.activeTitles + .filter((t) => t !== tab.title) + .concat([tab.title]) + .slice(-TITLES_MAX) + : prev.activeTitles, + })); + }, + [id, setTabsState], + ); + + /** + * When the hash changes, we try to select the tab containing the targetted element. + */ + React.useEffect(() => { + if (!hash) { + return; + } + + const activeElement = document.getElementById(hash); + if (!activeElement) { + return; + } + + const tabAncestor = activeElement.closest('[role="tabpanel"]'); + if (!tabAncestor) { + return; + } + + const tab = tabs.find((tab) => getTabPanelId(tab.id) === tabAncestor.id); + if (!tab) { + return; + } + + onSelectTab(tab); + }, [hash, tabs, onSelectTab]); return ( - {tabs.map((tab) => ( + {tabs.map((tab, index) => (
- {tab.children} + {tabsBody[index]}
))} ); } + +const tabsAtom = atom({ + key: 'tabsAtom', + default: { + activeIds: {}, + activeTitles: [], + }, + effects: [ + // Persist the state to local storage + ({ trigger, setSelf, onSet }) => { + if (typeof localStorage === 'undefined') { + return; + } + + const localStorageKey = '@gitbook/tabsState'; + if (trigger === 'get') { + const stored = localStorage.getItem(localStorageKey); + if (stored) { + setSelf(JSON.parse(stored)); + } + } + + onSet((newState) => { + localStorage.setItem(localStorageKey, JSON.stringify(newState)); + }); + }, + ], +}); + +const tabsActiveSelector = selectorFamily>({ + key: 'tabsActiveSelector', + get: + (input) => + ({ get }) => { + const state = get(tabsAtom); + return getTabBySelection(input, state) ?? getTabByTitle(input, state) ?? input.tabs[0]; + }, +}); + +/** + * Get the ID for a tab button. + */ +function getTabButtonId(tabId: string) { + return `tab-${tabId}`; +} + +/** + * Get the ID for a tab panel. + */ +function getTabPanelId(tabId: string) { + return `tabpanel-${tabId}`; +} + +/** + * Get explicitly selected tab in a set of tabs. + */ +function getTabBySelection(input: TabsInput, state: TabsState): TabsItem | null { + const activeId = state.activeIds[input.id]; + return activeId ? (input.tabs.find((child) => child.id === activeId) ?? null) : null; +} + +/** + * Get the best selected tab in a set of tabs by taking only title into account. + */ +function getTabByTitle(input: TabsInput, state: TabsState): TabsItem | null { + return ( + input.tabs + .map((item) => { + return { + item, + score: state.activeTitles.indexOf(item.title), + }; + }) + .filter(({ score }) => score >= 0) + // .sortBy(({ score }) => -score) + .sort(({ score: a }, { score: b }) => b - a) + .map(({ item }) => item)[0] ?? null + ); +} diff --git a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx index 87d925797..ad91110e4 100644 --- a/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx +++ b/packages/gitbook/src/components/DocumentView/Tabs/Tabs.tsx @@ -2,17 +2,23 @@ import { DocumentBlockTabs } from '@gitbook/api'; import { tcls } from '@/lib/tailwind'; -import { DynamicTabs } from './DynamicTabs'; +import { DynamicTabs, TabsItem } from './DynamicTabs'; import { BlockProps } from '../Block'; import { Blocks } from '../Blocks'; export function Tabs(props: BlockProps) { const { block, ancestorBlocks, document, style, context } = props; - const tabs = block.nodes.map((tab, index) => ({ - id: tab.key!, - title: tab.data.title ?? '', - children: ( + const tabs: TabsItem[] = []; + const tabsBody: React.ReactNode[] = []; + + block.nodes.forEach((tab, index) => { + tabs.push({ + id: tab.key!, + title: tab.data.title ?? '', + }); + + tabsBody.push( ) { context={context} blockStyle={tcls('flip-heading-hash')} style={tcls('w-full', 'space-y-4')} - /> - ), - })); + />, + ); + }); if (context.mode === 'print') { // When printing, we display the tab, one after the other return ( <> - {tabs.map((tab) => ( - + {tabs.map((tab, index) => ( + ))} ); } - return ; + return ; } diff --git a/packages/gitbook/src/components/hooks/index.ts b/packages/gitbook/src/components/hooks/index.ts index 3e635cd4e..0a0c5c9a0 100644 --- a/packages/gitbook/src/components/hooks/index.ts +++ b/packages/gitbook/src/components/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useScrollActiveId'; export * from './useScrollToHash'; export * from './useHash'; +export * from './useIsMounted'; diff --git a/packages/gitbook/src/components/hooks/useHash.ts b/packages/gitbook/src/components/hooks/useHash.ts index 5774dfc58..1cb29fd74 100644 --- a/packages/gitbook/src/components/hooks/useHash.ts +++ b/packages/gitbook/src/components/hooks/useHash.ts @@ -3,7 +3,7 @@ import React from 'react'; export function useHash() { const params = useParams(); - const [hash, setHash] = React.useState(global.location?.hash?.slice(1)); + const [hash, setHash] = React.useState(global.location?.hash?.slice(1) ?? null); React.useEffect(() => { function updateHash() { setHash(global.location?.hash?.slice(1)); diff --git a/packages/gitbook/src/components/hooks/useIsMounted.ts b/packages/gitbook/src/components/hooks/useIsMounted.ts new file mode 100644 index 000000000..d6cbb5867 --- /dev/null +++ b/packages/gitbook/src/components/hooks/useIsMounted.ts @@ -0,0 +1,14 @@ +import React from 'react'; + +/** + * Hook to check if a component is mounted. + */ +export function useIsMounted() { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + return mounted; +}