refactor: TOC scroll position logic

pull/18314/head
ZeroZ_JQ 1 year ago
parent dc9c5a4bc7
commit 81a492fc23

@ -12,57 +12,134 @@ import { LanguagesSupported } from '@/i18n/language'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app' import { Theme } from '@/types/app'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { throttle } from 'lodash-es'
type TocItem = {
href: string;
text: string;
index: number;
}
type DocProps = { type DocProps = {
apiBaseUrl: string apiBaseUrl: string
} }
const Doc = ({ apiBaseUrl }: DocProps) => { const useToc = (apiBaseUrl: string, locale: string) => {
const { locale } = useContext(I18n) const [toc, setToc] = useState<TocItem[]>([])
const { t } = useTranslation() const [headingElements, setHeadingElements] = useState<HTMLElement[]>([])
const [toc, setToc] = useState<Array<{ href: string; text: string }>>([])
const [isTocExpanded, setIsTocExpanded] = useState(false)
const { theme } = useTheme()
// Set initial TOC expanded state based on screen width
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
}, [])
// Extract TOC from article content
useEffect(() => { useEffect(() => {
const extractTOC = () => { const extractTOC = () => {
const article = document.querySelector('article') const article = document.querySelector('article')
if (article) { if (article) {
const headings = article.querySelectorAll('h2') const headings = article.querySelectorAll('h2')
const tocItems = Array.from(headings).map((heading) => { const headingElementsArray = Array.from(headings) as HTMLElement[]
const anchor = heading.querySelector('a') setHeadingElements(headingElementsArray)
if (anchor) {
return { const tocItems: TocItem[] = headingElementsArray.map((heading, index) => ({
href: anchor.getAttribute('href') || '', href: `#section-${index}`,
text: anchor.textContent || '', text: (heading.textContent || `章节 ${index + 1}`).trim(),
} index,
} }))
return null
}).filter((item): item is { href: string; text: string } => item !== null)
setToc(tocItems) setToc(tocItems)
} }
} }
setTimeout(extractTOC, 0) const timeoutId = setTimeout(extractTOC, 500)
}, [locale]) return () => clearTimeout(timeoutId)
}, [locale, apiBaseUrl])
return { toc, headingElements }
}
const useScrollPosition = (headingElements: HTMLElement[]) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
useEffect(() => {
const scrollContainer = document.querySelector('.scroll-container')
if (!scrollContainer || headingElements.length === 0) return
const handleScroll = () => {
const scrollContainerTop = scrollContainer.scrollTop
const scrollContainerHeight = scrollContainer.clientHeight
const scrollContainerBottom = scrollContainerTop + scrollContainerHeight
const totalScrollHeight = scrollContainer.scrollHeight
const offset = 110
let currentActiveIndex: number | null = null
for (let i = headingElements.length - 1; i >= 0; i--) {
const heading = headingElements[i]
if (heading.offsetTop <= scrollContainerTop + offset) {
currentActiveIndex = i
break
}
}
if (scrollContainerBottom >= totalScrollHeight - 20) {
currentActiveIndex = headingElements.length - 1
}
else if (currentActiveIndex === null && headingElements.length > 0) {
const firstHeadingTop = headingElements[0].offsetTop
if (firstHeadingTop >= scrollContainerTop && firstHeadingTop < scrollContainerBottom)
currentActiveIndex = 0
}
if (currentActiveIndex !== activeIndex)
setActiveIndex(currentActiveIndex)
}
const throttledScrollHandler = throttle(handleScroll, 100)
scrollContainer.addEventListener('scroll', throttledScrollHandler)
handleScroll()
return () => {
scrollContainer.removeEventListener('scroll', throttledScrollHandler)
throttledScrollHandler.cancel()
}
}, [headingElements, activeIndex])
return activeIndex
}
const useResponsiveToc = () => {
const [isTocExpanded, setIsTocExpanded] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => {
setIsTocExpanded(e.matches)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return { isTocExpanded, setIsTocExpanded }
}
const Doc = ({ apiBaseUrl }: DocProps) => {
const { locale } = useContext(I18n)
const { t } = useTranslation()
const { toc, headingElements } = useToc(apiBaseUrl, locale)
const { isTocExpanded, setIsTocExpanded } = useResponsiveToc()
const activeIndex = useScrollPosition(headingElements)
const { theme } = useTheme()
// Handle TOC item click // Handle TOC item click
const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string; text: string }) => { const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: TocItem) => {
e.preventDefault() e.preventDefault()
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId) const targetElement = headingElements[item.index]
if (element) {
if (targetElement) {
const scrollContainer = document.querySelector('.scroll-container') const scrollContainer = document.querySelector('.scroll-container')
if (scrollContainer) { if (scrollContainer) {
const headerOffset = -40 const headerOffset = 110
const elementTop = element.offsetTop - headerOffset const elementTop = targetElement.offsetTop - headerOffset
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: elementTop, top: elementTop,
behavior: 'smooth', behavior: 'smooth',
@ -98,11 +175,16 @@ const Doc = ({ apiBaseUrl }: DocProps) => {
</button> </button>
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{toc.map((item, index) => ( {toc.map(item => (
<li key={index}> <li key={item.index}>
<a <a
href={item.href} href={item.href}
className="text-text-secondary transition-colors duration-200 hover:text-text-primary hover:underline" className={cn(
'block rounded px-2 py-1 text-sm transition-colors duration-200',
item.index === activeIndex
? 'bg-primary-50 font-semibold text-primary-600 dark:bg-primary-900/[.15] dark:text-primary-400'
: 'text-text-secondary hover:bg-gray-100 hover:text-text-primary dark:hover:bg-gray-800',
)}
onClick={e => handleTocClick(e, item)} onClick={e => handleTocClick(e, item)}
> >
{item.text} {item.text}

@ -1,5 +1,5 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiListUnordered } from '@remixicon/react' import { RiListUnordered } from '@remixicon/react'
@ -20,74 +20,200 @@ import { LanguagesSupported } from '@/i18n/language'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app' import { Theme } from '@/types/app'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { throttle } from 'lodash-es'
type TocItem = {
href: string;
text: string;
index: number;
}
type IDocProps = { type IDocProps = {
appDetail: any appDetail: any
} }
const Doc = ({ appDetail }: IDocProps) => { const useToc = (appDetail: any, locale: string) => {
const { locale } = useContext(I18n) const [toc, setToc] = useState<TocItem[]>([])
const { t } = useTranslation() const [headingElements, setHeadingElements] = useState<HTMLElement[]>([])
const [toc, setToc] = useState<Array<{ href: string; text: string }>>([])
const [isTocExpanded, setIsTocExpanded] = useState(false)
const { theme } = useTheme()
const variables = appDetail?.model_config?.configs?.prompt_variables || []
const inputs = variables.reduce((res: any, variable: any) => {
res[variable.key] = variable.name || ''
return res
}, {})
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
}, [])
useEffect(() => { useEffect(() => {
const extractTOC = () => { const extractTOC = () => {
const article = document.querySelector('article') const article = document.querySelector('article')
if (article) { if (article) {
const headings = article.querySelectorAll('h2') const headings = article.querySelectorAll('h2')
const tocItems = Array.from(headings).map((heading) => { const headingElementsArray = Array.from(headings) as HTMLElement[]
const anchor = heading.querySelector('a') setHeadingElements(headingElementsArray)
if (anchor) {
return { const tocItems: TocItem[] = headingElementsArray.map((heading, index) => ({
href: anchor.getAttribute('href') || '', href: `#section-${index}`,
text: anchor.textContent || '', text: (heading.textContent || `章节 ${index + 1}`).trim(),
} index,
} }))
return null
}).filter((item): item is { href: string; text: string } => item !== null)
setToc(tocItems) setToc(tocItems)
} }
} }
// Run after component has rendered const timeoutId = setTimeout(extractTOC, 500)
setTimeout(extractTOC, 0) return () => clearTimeout(timeoutId)
}, [appDetail, locale]) }, [appDetail, locale])
const handleTocClick = (e: React.MouseEvent<HTMLAnchorElement>, item: { href: string; text: string }) => { return { toc, headingElements }
}
const useScrollPosition = (headingElements: HTMLElement[]) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
useEffect(() => {
const scrollContainer = document.querySelector('.overflow-auto')
if (!scrollContainer || headingElements.length === 0) return
const handleScroll = () => {
const scrollContainerTop = scrollContainer.scrollTop
const scrollContainerHeight = scrollContainer.clientHeight
const scrollContainerBottom = scrollContainerTop + scrollContainerHeight
const totalScrollHeight = scrollContainer.scrollHeight
const offset = 80
let currentActiveIndex: number | null = null
for (let i = headingElements.length - 1; i >= 0; i--) {
const heading = headingElements[i]
const headingTop = heading.offsetTop
const headingBottom = headingTop + heading.offsetHeight
if (headingTop <= scrollContainerTop + offset) {
currentActiveIndex = i
break
}
}
if (scrollContainerBottom >= totalScrollHeight - 20) {
currentActiveIndex = headingElements.length - 1
}
else if (currentActiveIndex === null && headingElements.length > 0) {
const firstHeadingTop = headingElements[0].offsetTop
if (firstHeadingTop >= scrollContainerTop && firstHeadingTop < scrollContainerBottom)
currentActiveIndex = 0
}
if (currentActiveIndex !== activeIndex)
setActiveIndex(currentActiveIndex)
}
const throttledScrollHandler = throttle(handleScroll, 100)
scrollContainer.addEventListener('scroll', throttledScrollHandler)
handleScroll()
return () => {
scrollContainer.removeEventListener('scroll', throttledScrollHandler)
throttledScrollHandler.cancel()
}
}, [headingElements, activeIndex])
return activeIndex
}
const useResponsiveToc = () => {
const [isTocExpanded, setIsTocExpanded] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1280px)')
setIsTocExpanded(mediaQuery.matches)
const handleChange = (e: MediaQueryListEvent) => {
setIsTocExpanded(e.matches)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return { isTocExpanded, setIsTocExpanded }
}
const Doc = ({ appDetail }: IDocProps) => {
const { locale } = useContext(I18n)
const { t } = useTranslation()
const { toc, headingElements } = useToc(appDetail, locale)
const { isTocExpanded, setIsTocExpanded } = useResponsiveToc()
const activeIndex = useScrollPosition(headingElements)
const { theme } = useTheme()
const variables = appDetail?.model_config?.configs?.prompt_variables || []
const inputs = variables.reduce((res: any, variable: any) => {
res[variable.key] = variable.name || ''
return res
}, {})
const handleTocClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: TocItem) => {
e.preventDefault() e.preventDefault()
const targetId = item.href.replace('#', '')
const element = document.getElementById(targetId) const targetElement = headingElements[item.index]
if (element) {
if (targetElement) {
const scrollContainer = document.querySelector('.overflow-auto') const scrollContainer = document.querySelector('.overflow-auto')
if (scrollContainer) { if (scrollContainer) {
const headerOffset = 80 const headerOffset = 80
const elementTop = element.offsetTop - headerOffset const elementTop = targetElement.offsetTop - headerOffset
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: elementTop, top: elementTop,
behavior: 'smooth', behavior: 'smooth',
}) })
} }
} }
} }, [headingElements])
const Template = useMemo(() => {
if (appDetail?.mode === 'chat' || appDetail?.mode === 'agent-chat') {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === 'advanced-chat') {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === 'workflow') {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateWorkflowZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateWorkflowJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateWorkflowEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
if (appDetail?.mode === 'completion') {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
}
return null
}, [appDetail, locale, variables, inputs])
return ( return (
<div className="flex"> <div className="flex">
<div className={`fixed right-8 top-32 z-10 transition-all ${isTocExpanded ? 'w-64' : 'w-10'}`}> <div className={`fixed right-8 top-32 z-10 transition-all ${isTocExpanded ? 'w-64' : 'w-10'}`}>
{isTocExpanded {isTocExpanded
? ( ? (
<nav className="toc w-full rounded-lg bg-components-panel-bg p-4 shadow-md"> <nav className="toc max-h-[calc(100vh-150px)] w-full overflow-y-auto rounded-lg bg-components-panel-bg p-4 shadow-md">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-text-primary">{t('appApi.develop.toc')}</h3> <h3 className="text-lg font-semibold text-text-primary">{t('appApi.develop.toc')}</h3>
<button <button
@ -98,11 +224,16 @@ const Doc = ({ appDetail }: IDocProps) => {
</button> </button>
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{toc.map((item, index) => ( {toc.map(item => (
<li key={index}> <li key={item.index}>
<a <a
href={item.href} href={item.href}
className="text-text-secondary transition-colors duration-200 hover:text-text-primary hover:underline" className={cn(
'block rounded px-2 py-1 text-sm transition-colors duration-200',
item.index === activeIndex
? 'bg-primary-50 font-semibold text-primary-600 dark:bg-primary-900/[.15] dark:text-primary-400'
: 'text-text-secondary hover:bg-gray-100 hover:text-text-primary dark:hover:bg-gray-800',
)}
onClick={e => handleTocClick(e, item)} onClick={e => handleTocClick(e, item)}
> >
{item.text} {item.text}
@ -121,55 +252,8 @@ const Doc = ({ appDetail }: IDocProps) => {
</button> </button>
)} )}
</div> </div>
<article className={cn('prose-xl prose', theme === Theme.dark && 'dark:prose-invert')} > <article className={cn('prose-xl prose mx-1 overflow-auto rounded-t-xl bg-background-default px-4 pt-16 sm:mx-12', theme === Theme.dark && 'dark:prose-invert')}>
{(appDetail?.mode === 'chat' || appDetail?.mode === 'agent-chat') && ( {Template}
(() => {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
})()
)}
{appDetail?.mode === 'advanced-chat' && (
(() => {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
})()
)}
{appDetail?.mode === 'workflow' && (
(() => {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateWorkflowZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateWorkflowJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateWorkflowEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
})()
)}
{appDetail?.mode === 'completion' && (
(() => {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateZh appDetail={appDetail} variables={variables} inputs={inputs} />
case LanguagesSupported[7]:
return <TemplateJa appDetail={appDetail} variables={variables} inputs={inputs} />
default:
return <TemplateEn appDetail={appDetail} variables={variables} inputs={inputs} />
}
})()
)}
</article> </article>
</div> </div>
) )

Loading…
Cancel
Save