| @@ -3,7 +3,7 @@ | |||
| import { useEffect, useMemo, useState } from 'react' | |||
| import { useContext } from 'use-context-selector' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiListUnordered } from '@remixicon/react' | |||
| import { RiCloseLine, RiListUnordered } from '@remixicon/react' | |||
| import TemplateEn from './template/template.en.mdx' | |||
| import TemplateZh from './template/template.zh.mdx' | |||
| import TemplateJa from './template/template.ja.mdx' | |||
| @@ -22,6 +22,7 @@ const Doc = ({ apiBaseUrl }: DocProps) => { | |||
| const { t } = useTranslation() | |||
| const [toc, setToc] = useState<Array<{ href: string; text: string }>>([]) | |||
| const [isTocExpanded, setIsTocExpanded] = useState(false) | |||
| const [activeSection, setActiveSection] = useState<string>('') | |||
| const { theme } = useTheme() | |||
| // Set initial TOC expanded state based on screen width | |||
| @@ -47,12 +48,47 @@ const Doc = ({ apiBaseUrl }: DocProps) => { | |||
| return null | |||
| }).filter((item): item is { href: string; text: string } => item !== null) | |||
| setToc(tocItems) | |||
| // Set initial active section | |||
| if (tocItems.length > 0) | |||
| setActiveSection(tocItems[0].href.replace('#', '')) | |||
| } | |||
| } | |||
| setTimeout(extractTOC, 0) | |||
| }, [locale]) | |||
| // Track scroll position for active section highlighting | |||
| useEffect(() => { | |||
| const handleScroll = () => { | |||
| const scrollContainer = document.querySelector('.scroll-container') | |||
| if (!scrollContainer || toc.length === 0) | |||
| return | |||
| // Find active section based on scroll position | |||
| let currentSection = '' | |||
| toc.forEach((item) => { | |||
| const targetId = item.href.replace('#', '') | |||
| const element = document.getElementById(targetId) | |||
| if (element) { | |||
| const rect = element.getBoundingClientRect() | |||
| // Consider section active if its top is above the middle of viewport | |||
| if (rect.top <= window.innerHeight / 2) | |||
| currentSection = targetId | |||
| } | |||
| }) | |||
| if (currentSection && currentSection !== activeSection) | |||
| setActiveSection(currentSection) | |||
| } | |||
| const scrollContainer = document.querySelector('.scroll-container') | |||
| if (scrollContainer) { | |||
| scrollContainer.addEventListener('scroll', handleScroll) | |||
| handleScroll() // Initial check | |||
| return () => scrollContainer.removeEventListener('scroll', handleScroll) | |||
| } | |||
| }, [toc, activeSection]) | |||
| // Handle TOC item click | |||
| const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string; text: string }) => { | |||
| e.preventDefault() | |||
| @@ -84,49 +120,80 @@ const Doc = ({ apiBaseUrl }: DocProps) => { | |||
| return ( | |||
| <div className='flex'> | |||
| <div className={`fixed right-20 top-32 z-10 transition-all ${isTocExpanded ? 'w-64' : 'w-10'}`}> | |||
| <div className={`fixed right-20 top-32 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`}> | |||
| {isTocExpanded | |||
| ? ( | |||
| <nav className='toc max-h-[calc(100vh-150px)] w-full overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-md'> | |||
| <div className='mb-4 flex items-center justify-between'> | |||
| <h3 className='text-lg font-semibold text-text-primary'>{t('appApi.develop.toc')}</h3> | |||
| <nav className='toc flex max-h-[calc(100vh-150px)] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-background-default-hover shadow-xl'> | |||
| <div className='relative z-10 flex items-center justify-between border-b border-components-panel-border-subtle bg-background-default-hover px-4 py-2.5'> | |||
| <span className='text-xs font-medium uppercase tracking-wide text-text-tertiary'> | |||
| {t('appApi.develop.toc')} | |||
| </span> | |||
| <button | |||
| onClick={() => setIsTocExpanded(false)} | |||
| className='text-text-tertiary hover:text-text-secondary' | |||
| className='group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover' | |||
| aria-label='Close' | |||
| > | |||
| ✕ | |||
| <RiCloseLine className='h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary' /> | |||
| </button> | |||
| </div> | |||
| <ul className='space-y-2'> | |||
| {toc.map((item, index) => ( | |||
| <li key={index}> | |||
| <a | |||
| href={item.href} | |||
| className='text-text-secondary transition-colors duration-200 hover:text-text-primary hover:underline' | |||
| onClick={e => handleTocClick(e, item)} | |||
| > | |||
| {item.text} | |||
| </a> | |||
| </li> | |||
| ))} | |||
| </ul> | |||
| <div className='from-components-panel-border-subtle/20 pointer-events-none absolute left-0 right-0 top-[41px] z-10 h-2 bg-gradient-to-b to-transparent'></div> | |||
| <div className='pointer-events-none absolute left-0 right-0 top-[43px] z-10 h-3 bg-gradient-to-b from-background-default-hover to-transparent'></div> | |||
| <div className='relative flex-1 overflow-y-auto px-3 py-3 pt-1'> | |||
| {toc.length === 0 ? ( | |||
| <div className='px-2 py-8 text-center text-xs text-text-quaternary'> | |||
| {t('appApi.develop.noContent')} | |||
| </div> | |||
| ) : ( | |||
| <ul className='space-y-0.5'> | |||
| {toc.map((item, index) => { | |||
| const isActive = activeSection === item.href.replace('#', '') | |||
| return ( | |||
| <li key={index}> | |||
| <a | |||
| href={item.href} | |||
| onClick={e => handleTocClick(e, item)} | |||
| className={cn( | |||
| 'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200', | |||
| isActive | |||
| ? 'bg-state-base-hover font-medium text-text-primary' | |||
| : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', | |||
| )} | |||
| > | |||
| <span | |||
| className={cn( | |||
| 'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200', | |||
| isActive | |||
| ? 'scale-100 bg-text-accent' | |||
| : 'scale-75 bg-components-panel-border', | |||
| )} | |||
| /> | |||
| <span className='flex-1 truncate'> | |||
| {item.text} | |||
| </span> | |||
| </a> | |||
| </li> | |||
| ) | |||
| })} | |||
| </ul> | |||
| )} | |||
| </div> | |||
| <div className='pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-4 rounded-b-xl bg-gradient-to-t from-background-default-hover to-transparent'></div> | |||
| </nav> | |||
| ) | |||
| : ( | |||
| <button | |||
| onClick={() => setIsTocExpanded(true)} | |||
| className='flex h-10 w-10 items-center justify-center rounded-full border border-components-panel-border bg-components-button-secondary-bg shadow-md transition-colors duration-200 hover:bg-components-button-secondary-bg-hover' | |||
| className='group flex h-11 w-11 items-center justify-center rounded-full border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-all duration-150 hover:bg-background-default-hover hover:shadow-xl' | |||
| aria-label='Open table of contents' | |||
| > | |||
| <RiListUnordered className='h-6 w-6 text-components-button-secondary-text' /> | |||
| <RiListUnordered className='h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary' /> | |||
| </button> | |||
| )} | |||
| </div> | |||
| <article | |||
| className={cn( | |||
| 'prose-xl prose mx-1 rounded-t-xl bg-background-default px-4 pt-16 sm:mx-12', | |||
| theme === Theme.dark && 'prose-invert', | |||
| )} | |||
| > | |||
| <article className={cn('prose-xl prose', theme === Theme.dark && 'prose-invert')}> | |||
| {Template} | |||
| </article> | |||
| </div> | |||