You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

doc.tsx 8.2KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. 'use client'
  2. import { useEffect, useMemo, useState } from 'react'
  3. import { useContext } from 'use-context-selector'
  4. import { useTranslation } from 'react-i18next'
  5. import { RiCloseLine, RiListUnordered } from '@remixicon/react'
  6. import TemplateEn from './template/template.en.mdx'
  7. import TemplateZh from './template/template.zh.mdx'
  8. import TemplateJa from './template/template.ja.mdx'
  9. import I18n from '@/context/i18n'
  10. import { LanguagesSupported } from '@/i18n-config/language'
  11. import useTheme from '@/hooks/use-theme'
  12. import { Theme } from '@/types/app'
  13. import cn from '@/utils/classnames'
  14. type DocProps = {
  15. apiBaseUrl: string
  16. }
  17. const Doc = ({ apiBaseUrl }: DocProps) => {
  18. const { locale } = useContext(I18n)
  19. const { t } = useTranslation()
  20. const [toc, setToc] = useState<Array<{ href: string; text: string }>>([])
  21. const [isTocExpanded, setIsTocExpanded] = useState(false)
  22. const [activeSection, setActiveSection] = useState<string>('')
  23. const { theme } = useTheme()
  24. // Set initial TOC expanded state based on screen width
  25. useEffect(() => {
  26. const mediaQuery = window.matchMedia('(min-width: 1280px)')
  27. setIsTocExpanded(mediaQuery.matches)
  28. }, [])
  29. // Extract TOC from article content
  30. useEffect(() => {
  31. const extractTOC = () => {
  32. const article = document.querySelector('article')
  33. if (article) {
  34. const headings = article.querySelectorAll('h2')
  35. const tocItems = Array.from(headings).map((heading) => {
  36. const anchor = heading.querySelector('a')
  37. if (anchor) {
  38. return {
  39. href: anchor.getAttribute('href') || '',
  40. text: anchor.textContent || '',
  41. }
  42. }
  43. return null
  44. }).filter((item): item is { href: string; text: string } => item !== null)
  45. setToc(tocItems)
  46. // Set initial active section
  47. if (tocItems.length > 0)
  48. setActiveSection(tocItems[0].href.replace('#', ''))
  49. }
  50. }
  51. setTimeout(extractTOC, 0)
  52. }, [locale])
  53. // Track scroll position for active section highlighting
  54. useEffect(() => {
  55. const handleScroll = () => {
  56. const scrollContainer = document.querySelector('.scroll-container')
  57. if (!scrollContainer || toc.length === 0)
  58. return
  59. // Find active section based on scroll position
  60. let currentSection = ''
  61. toc.forEach((item) => {
  62. const targetId = item.href.replace('#', '')
  63. const element = document.getElementById(targetId)
  64. if (element) {
  65. const rect = element.getBoundingClientRect()
  66. // Consider section active if its top is above the middle of viewport
  67. if (rect.top <= window.innerHeight / 2)
  68. currentSection = targetId
  69. }
  70. })
  71. if (currentSection && currentSection !== activeSection)
  72. setActiveSection(currentSection)
  73. }
  74. const scrollContainer = document.querySelector('.scroll-container')
  75. if (scrollContainer) {
  76. scrollContainer.addEventListener('scroll', handleScroll)
  77. handleScroll() // Initial check
  78. return () => scrollContainer.removeEventListener('scroll', handleScroll)
  79. }
  80. }, [toc, activeSection])
  81. // Handle TOC item click
  82. const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string; text: string }) => {
  83. e.preventDefault()
  84. const targetId = item.href.replace('#', '')
  85. const element = document.getElementById(targetId)
  86. if (element) {
  87. const scrollContainer = document.querySelector('.scroll-container')
  88. if (scrollContainer) {
  89. const headerOffset = -40
  90. const elementTop = element.offsetTop - headerOffset
  91. scrollContainer.scrollTo({
  92. top: elementTop,
  93. behavior: 'smooth',
  94. })
  95. }
  96. }
  97. }
  98. const Template = useMemo(() => {
  99. switch (locale) {
  100. case LanguagesSupported[1]:
  101. return <TemplateZh apiBaseUrl={apiBaseUrl} />
  102. case LanguagesSupported[7]:
  103. return <TemplateJa apiBaseUrl={apiBaseUrl} />
  104. default:
  105. return <TemplateEn apiBaseUrl={apiBaseUrl} />
  106. }
  107. }, [apiBaseUrl, locale])
  108. return (
  109. <div className="flex">
  110. <div className={`fixed right-20 top-32 z-10 transition-all duration-150 ease-out ${isTocExpanded ? 'w-[280px]' : 'w-11'}`}>
  111. {isTocExpanded
  112. ? (
  113. <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">
  114. <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">
  115. <span className="text-xs font-medium uppercase tracking-wide text-text-tertiary">
  116. {t('appApi.develop.toc')}
  117. </span>
  118. <button
  119. onClick={() => setIsTocExpanded(false)}
  120. className="group flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-state-base-hover"
  121. aria-label="Close"
  122. >
  123. <RiCloseLine className="h-3 w-3 text-text-quaternary transition-colors group-hover:text-text-secondary" />
  124. </button>
  125. </div>
  126. <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>
  127. <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>
  128. <div className="relative flex-1 overflow-y-auto px-3 py-3 pt-1">
  129. {toc.length === 0 ? (
  130. <div className="px-2 py-8 text-center text-xs text-text-quaternary">
  131. {t('appApi.develop.noContent')}
  132. </div>
  133. ) : (
  134. <ul className="space-y-0.5">
  135. {toc.map((item, index) => {
  136. const isActive = activeSection === item.href.replace('#', '')
  137. return (
  138. <li key={index}>
  139. <a
  140. href={item.href}
  141. onClick={e => handleTocClick(e, item)}
  142. className={cn(
  143. 'group relative flex items-center rounded-md px-3 py-2 text-[13px] transition-all duration-200',
  144. isActive
  145. ? 'bg-state-base-hover font-medium text-text-primary'
  146. : 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
  147. )}
  148. >
  149. <span
  150. className={cn(
  151. 'mr-2 h-1.5 w-1.5 rounded-full transition-all duration-200',
  152. isActive
  153. ? 'scale-100 bg-text-accent'
  154. : 'scale-75 bg-components-panel-border',
  155. )}
  156. />
  157. <span className="flex-1 truncate">
  158. {item.text}
  159. </span>
  160. </a>
  161. </li>
  162. )
  163. })}
  164. </ul>
  165. )}
  166. </div>
  167. <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>
  168. </nav>
  169. )
  170. : (
  171. <button
  172. onClick={() => setIsTocExpanded(true)}
  173. 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"
  174. aria-label="Open table of contents"
  175. >
  176. <RiListUnordered className="h-5 w-5 text-text-tertiary transition-colors group-hover:text-text-secondary" />
  177. </button>
  178. )}
  179. </div>
  180. <article className={cn('prose-xl prose', theme === Theme.dark && 'prose-invert')}>
  181. {Template}
  182. </article>
  183. </div>
  184. )
  185. }
  186. export default Doc