From baae2701e4562bc39c8fca48e789207fb5644b40 Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 5 Oct 2024 22:19:01 +0800 Subject: [PATCH] refactor: toc codebase Signed-off-by: Innei --- .../components/ui/markdown/components/Toc.tsx | 397 ++++++++++-------- 1 file changed, 221 insertions(+), 176 deletions(-) diff --git a/apps/renderer/src/components/ui/markdown/components/Toc.tsx b/apps/renderer/src/components/ui/markdown/components/Toc.tsx index 4ee4ee682..4c944b6a0 100644 --- a/apps/renderer/src/components/ui/markdown/components/Toc.tsx +++ b/apps/renderer/src/components/ui/markdown/components/Toc.tsx @@ -43,158 +43,8 @@ export interface ITocItem { export const Toc: Component = ({ className }) => { const markdownElement = useContext(MarkdownRenderContainerRefContext) - - const $headings = useMemo( - () => - (markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []) as HTMLHeadingElement[], - [markdownElement], - ) - - const toc: ITocItem[] = useMemo( - () => - Array.from($headings).map((el, idx) => { - const depth = +el.tagName.slice(1) - const elClone = el.cloneNode(true) as HTMLElement - - const title = elClone.textContent || "" - - const index = idx - - return { - depth, - index: Number.isNaN(index) ? -1 : index, - title, - anchorId: el.dataset.rid || "", - $heading: el, - } - }), - [$headings], - ) - - const rootDepth = useMemo( - () => - toc?.length - ? (toc.reduce( - (d: number, cur) => Math.min(d, cur.depth), - toc[0]?.depth || 0, - ) as any as number) - : 0, - [toc], - ) - - const [_, setTreeRef] = useState() - - const scrollContainerElement = useScrollViewElement() - - const scrollDelayHandlerRef = useRef(null) - - const handleScrollTo = useEventCallback( - (i: number, $el: HTMLElement | null, _anchorId: string) => { - if ($el) { - const handle = () => { - scrollDelayHandlerRef.current && clearTimeout(scrollDelayHandlerRef.current) - springScrollToElement($el, -100, scrollContainerElement!).then(() => { - throttleCallerRef.current?.cancel() - scrollDelayHandlerRef.current = setTimeout(() => { - setCurrentScrollRange([i, 1]) - }, 36) - }) - } - handle() - } - }, - ) - - const { h } = useWrappedElementSize() - const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0]) - - const headingRangeParser = () => { - // calculate the range of data-container-top between each two headings - const titleBetweenPositionTopRangeMap = [] as [number, number][] - for (let i = 0; i < $headings.length - 1; i++) { - const $heading = $headings[i] - - const headingTop = - Number.parseInt($heading.dataset["containerTop"] || "0") || getElementTop($heading) - if (!$heading.dataset) { - // @ts-expect-error - $heading.dataset["containerTop"] = headingTop.toString() - } - - const $nextHeading = $headings[i + 1] - - const nextTop = getElementTop($nextHeading) - if (!$nextHeading.dataset) { - // @ts-expect-error - $nextHeading.dataset["containerTop"] = nextTop.toString() - } - - titleBetweenPositionTopRangeMap.push([headingTop, nextTop]) - } - return titleBetweenPositionTopRangeMap - } - - const [titleBetweenPositionTopRangeMap, setTitleBetweenPositionTopRangeMap] = - useState(headingRangeParser) - - useLayoutEffect(() => { - startTransition(() => { - setTitleBetweenPositionTopRangeMap(headingRangeParser) - }) - }, [$headings, h]) - - const throttleCallerRef = useRef void>>() - const getWrappedElPos = useGetWrappedElementPosition() - - useEffect(() => { - if (!scrollContainerElement) return - - const handler = throttle(() => { - const { y } = getWrappedElPos() - const top = scrollContainerElement.scrollTop + y - const winHeight = getViewport().h - const deltaHeight = top >= winHeight ? winHeight : (top / winHeight) * winHeight - - const actualTop = Math.floor(Math.max(0, top - y + deltaHeight)) || 0 - - // current top is in which range? - const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex( - ([start, end]) => actualTop >= start && actualTop <= end, - ) - const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex] - - if (currentRange) { - const [start, end] = currentRange - - // current top is this range, the precent is ? - const precent = (actualTop - start) / (end - start) - - // position , precent - setCurrentScrollRange([currentRangeIndex, precent]) - } else { - const last = titleBetweenPositionTopRangeMap.at(-1) || [0, 0] - - if (top + winHeight > last[1]) { - setCurrentScrollRange([ - titleBetweenPositionTopRangeMap.length, - 1 - (last[1] - top) / winHeight, - ]) - } else { - setCurrentScrollRange([-1, 1]) - } - } - }, 100) - - throttleCallerRef.current = handler - scrollContainerElement.addEventListener("scroll", handler) - - return () => { - scrollContainerElement.removeEventListener("scroll", handler) - handler.cancel() - } - }, [getWrappedElPos, scrollContainerElement, titleBetweenPositionTopRangeMap]) - - const [hoverShow, setHoverShow] = useState(false) + const { toc, rootDepth } = useTocItems(markdownElement) + const { currentScrollRange, handleScrollTo } = useScrollTracking(toc) const renderContentElementPosition = useWrappedElementPosition() const renderContentElementSize = useWrappedElementSize() @@ -206,35 +56,69 @@ export const Toc: Component = ({ className }) => { if (toc.length === 0) return null - if (shouldShowTitle) - return ( -
- {toc.map((heading, index) => ( - - ))} -
- ) + return shouldShowTitle ? ( + + ) : ( + + ) +} + +const TocContainer: React.FC = ({ + className, + toc, + rootDepth, + currentScrollRange, + handleScrollTo, +}) => { + return ( +
+ {toc.map((heading, index) => ( + + ))} +
+ ) +} + +const TocHoverCard: React.FC = ({ + className, + toc, + rootDepth, + currentScrollRange, + handleScrollTo, +}) => { + const [hoverShow, setHoverShow] = useState(false) + return (
((props) => { return }) MemoedItem.displayName = "MemoedItem" + +// Hooks +const useTocItems = (markdownElement: HTMLElement | null) => { + const $headings = useMemo( + () => + (markdownElement?.querySelectorAll("h1, h2, h3, h4, h5, h6") || []) as HTMLHeadingElement[], + [markdownElement], + ) + + const toc: ITocItem[] = useMemo( + () => + Array.from($headings).map((el, idx) => { + const depth = +el.tagName.slice(1) + const elClone = el.cloneNode(true) as HTMLElement + const title = elClone.textContent || "" + const index = idx + + return { + depth, + index: Number.isNaN(index) ? -1 : index, + title, + anchorId: el.dataset.rid || "", + $heading: el, + } + }), + [$headings], + ) + + const rootDepth = useMemo( + () => + toc?.length + ? (toc.reduce( + (d: number, cur) => Math.min(d, cur.depth), + toc[0]?.depth || 0, + ) as any as number) + : 0, + [toc], + ) + + return { toc, rootDepth } +} + +const useScrollTracking = (toc: ITocItem[]) => { + const scrollContainerElement = useScrollViewElement() + const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0] as [number, number]) + const { h } = useWrappedElementSize() + const getWrappedElPos = useGetWrappedElementPosition() + + const headingRangeParser = () => { + // calculate the range of data-container-top between each two headings + const titleBetweenPositionTopRangeMap = [] as [number, number][] + for (let i = 0; i < toc.length - 1; i++) { + const { $heading } = toc[i] + const $nextHeading = toc[i + 1].$heading + + const headingTop = + Number.parseInt($heading.dataset["containerTop"] || "0") || getElementTop($heading) + if (!$heading.dataset) { + // @ts-expect-error + $heading.dataset["containerTop"] = headingTop.toString() + } + + const nextTop = getElementTop($nextHeading) + if (!$nextHeading.dataset) { + // @ts-expect-error + $nextHeading.dataset["containerTop"] = nextTop.toString() + } + + titleBetweenPositionTopRangeMap.push([headingTop, nextTop]) + } + return titleBetweenPositionTopRangeMap + } + + const [titleBetweenPositionTopRangeMap, setTitleBetweenPositionTopRangeMap] = + useState(headingRangeParser) + + useLayoutEffect(() => { + startTransition(() => { + setTitleBetweenPositionTopRangeMap(headingRangeParser) + }) + }, [toc, h]) + + const throttleCallerRef = useRef void>>() + + useEffect(() => { + if (!scrollContainerElement) return + + const handler = throttle(() => { + const { y } = getWrappedElPos() + const top = scrollContainerElement.scrollTop + y + const winHeight = getViewport().h + const deltaHeight = top >= winHeight ? winHeight : (top / winHeight) * winHeight + + const actualTop = Math.floor(Math.max(0, top - y + deltaHeight)) || 0 + + // current top is in which range? + const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex( + ([start, end]) => actualTop >= start && actualTop <= end, + ) + const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex] + + if (currentRange) { + const [start, end] = currentRange + + // current top is this range, the precent is ? + const precent = (actualTop - start) / (end - start) + + // position , precent + setCurrentScrollRange([currentRangeIndex, precent]) + } else { + const last = titleBetweenPositionTopRangeMap.at(-1) || [0, 0] + + if (top + winHeight > last[1]) { + setCurrentScrollRange([ + titleBetweenPositionTopRangeMap.length, + 1 - (last[1] - top) / winHeight, + ]) + } else { + setCurrentScrollRange([-1, 1]) + } + } + }, 100) + + throttleCallerRef.current = handler + scrollContainerElement.addEventListener("scroll", handler) + + return () => { + scrollContainerElement.removeEventListener("scroll", handler) + handler.cancel() + } + }, [getWrappedElPos, scrollContainerElement, titleBetweenPositionTopRangeMap]) + + const handleScrollTo = useEventCallback( + (i: number, $el: HTMLElement | null, _anchorId: string) => { + if ($el) { + const handle = () => { + springScrollToElement($el, -100, scrollContainerElement!).then(() => { + throttleCallerRef.current?.cancel() + setTimeout(() => { + setCurrentScrollRange([i, 1]) + }, 36) + }) + } + handle() + } + }, + ) + + return { currentScrollRange, handleScrollTo } +} + +// Types +interface TocContainerProps { + className?: string + toc: ITocItem[] + rootDepth: number + currentScrollRange: [number, number] + handleScrollTo: (i: number, $el: HTMLElement | null, anchorId: string) => void +} + +interface TocHoverCardProps extends TocContainerProps {}