Skip to content

Commit

Permalink
feat: toc position calcation
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei committed Aug 28, 2024
1 parent 87dc225 commit a7828d4
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 102 deletions.
4 changes: 2 additions & 2 deletions src/renderer/src/components/common/ShadowDOM.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ export const ShadowDOM: FC<PropsWithChildren<React.HTMLProps<HTMLElement>>> & {
return (
<root.div {...rest}>
<ShadowDOMContext.Provider value={true}>
<html data-theme={dark ? "dark" : "light"}>
<div data-theme={dark ? "dark" : "light"}>
<head>{stylesElements}</head>
{props.children}
</html>
</div>
</ShadowDOMContext.Provider>
</root.div>
)
Expand Down
141 changes: 93 additions & 48 deletions src/renderer/src/components/ui/markdown/components/Toc.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { springScrollToElement } from "@renderer/lib/scroller"
import { cn } from "@renderer/lib/utils"
import {
useGetWrappedElementPosition,
} from "@renderer/providers/wrapped-element-provider"
import { atom, useAtom } from "jotai"
import { throttle } from "lodash-es"
import {
memo,
startTransition,
Expand All @@ -14,6 +18,7 @@ import { useEventCallback } from "usehooks-ts"

import { useScrollViewElement } from "../../scroll-area/hooks"
import { MarkdownRenderContainerRefContext } from "../context"
import type { TocItemProps } from "./TocItem"
import { TocItem } from "./TocItem"

export interface ITocItem {
Expand Down Expand Up @@ -83,20 +88,75 @@ export const Toc: Component = ({ className }) => {
}
},
)

const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0])
const titleBetweenPositionTopRangeMap = useMemo(() => {
// 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 $nextHeading = $headings[i + 1]
const top = Number.parseInt($heading.dataset["containerTop"] || "0")
const nextTop = Number.parseInt(
$nextHeading.dataset["containerTop"] || "0",
)

titleBetweenPositionTopRangeMap.push([top, nextTop])
}
return titleBetweenPositionTopRangeMap
}, [$headings])

const getWrappedElPos = useGetWrappedElementPosition()

useEffect(() => {
if (!scrollContainerElement) return

const handler = throttle(() => {
const top = scrollContainerElement.scrollTop + getWrappedElPos().y

// current top is in which range?
const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex(
([start, end]) => top >= start && top <= end,
)
const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex]

if (currentRange) {
const [start, end] = currentRange

// current top is this range, the precent is ?
const precent = (top - start) / (end - start)

// position , precent
setCurrentScrollRange([currentRangeIndex, precent])
}
}, 100)
scrollContainerElement.addEventListener("scroll", handler)

return () => {
scrollContainerElement.removeEventListener("scroll", handler)
}
}, [
getWrappedElPos,
scrollContainerElement,
titleBetweenPositionTopRangeMap,
])

if (toc.length === 0) return null
return (
<div className="flex grow flex-col scroll-smooth px-2 scrollbar-none">
<ul
ref={setTreeRef}
className={cn("group overflow-auto scrollbar-none", className)}
>
{toc?.map((heading) => (
{toc.map((heading, index) => (
<MemoedItem
heading={heading}
isActive={heading.anchorId === activeId}
active={heading.anchorId === activeId}
key={heading.title}
rootDepth={rootDepth}
onClick={handleScrollTo}
isScrollOut={index < currentScrollRange[0]}
range={index === currentScrollRange[0] ? currentScrollRange[1] : 0}
/>
))}
</ul>
Expand Down Expand Up @@ -131,51 +191,36 @@ function useActiveId($headings: HTMLHeadingElement[]) {
return [activeId, setActiveId] as const
}

const MemoedItem = memo<{
isActive: boolean
heading: ITocItem
rootDepth: number
onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void
}>((props) => {
const {
heading,
isActive,
onClick,
rootDepth,
// containerRef
} = props

const itemRef = useRef<HTMLElement>(null)

useEffect(() => {
if (!isActive) return

const $item = itemRef.current
if (!$item) return
const $container = $item.parentElement
if (!$container) return

const containerHeight = $container.clientHeight
const itemHeight = $item.clientHeight
const itemOffsetTop = $item.offsetTop
const { scrollTop } = $container

const itemTop = itemOffsetTop - scrollTop
const itemBottom = itemTop + itemHeight
if (itemTop < 0 || itemBottom > containerHeight) {
$container.scrollTop =
const MemoedItem = memo<TocItemProps>((props) => {
const {
active,

...rest
} = props

const itemRef = useRef<HTMLElement>(null)

useEffect(() => {
if (!active) return

const $item = itemRef.current
if (!$item) return
const $container = $item.parentElement
if (!$container) return

const containerHeight = $container.clientHeight
const itemHeight = $item.clientHeight
const itemOffsetTop = $item.offsetTop
const { scrollTop } = $container

const itemTop = itemOffsetTop - scrollTop
const itemBottom = itemTop + itemHeight
if (itemTop < 0 || itemBottom > containerHeight) {
$container.scrollTop =
itemOffsetTop - containerHeight / 2 + itemHeight / 2
}
}, [isActive])

return (
<TocItem
heading={heading}
onClick={onClick}
active={isActive}
key={heading.title}
rootDepth={rootDepth}
/>
)
})
}
}, [active])

return <TocItem active={active} {...rest} />
})
MemoedItem.displayName = "MemoedItem"
40 changes: 23 additions & 17 deletions src/renderer/src/components/ui/markdown/components/TocItem.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { cn } from "@renderer/lib/utils"
import type { FC, MouseEvent } from "react"
import {
memo,
useCallback,
useRef,
} from "react"
import { memo, useCallback, useRef } from "react"

export interface ITocItem {
depth: number
Expand All @@ -15,23 +11,22 @@ export interface ITocItem {
$heading: HTMLHeadingElement
}

export const TocItem: FC<{
export interface TocItemProps {
heading: ITocItem

active: boolean
rootDepth: number
onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void
}> = memo((props) => {
const { active, onClick, heading } = props

isScrollOut: boolean
range: number
}

export const TocItem: FC<TocItemProps> = memo((props) => {
const { active, onClick, heading, isScrollOut, range } = props
const { $heading, anchorId, depth, index, title } = heading

const $ref = useRef<HTMLButtonElement>(null)

// useEffect(() => {
// if (active) {
// $ref.current?.scrollIntoView({ behavior: "smooth" })
// }
// }, [])
return (
<button
type="button"
Expand All @@ -55,12 +50,23 @@ export const TocItem: FC<{
}}
data-active={active}
className={cn(
"inline-block h-1.5 rounded-full",
"relative inline-block h-1.5 rounded-full",
"bg-zinc-100 duration-200 hover:!bg-zinc-400 group-hover:bg-zinc-400/50",
isScrollOut && "bg-zinc-400/80",

"dark:bg-zinc-800/80 dark:hover:!bg-zinc-600 dark:group-hover:bg-zinc-600/50",
active && "!bg-zinc-400/50 data-[active=true]:group-hover:!bg-zinc-500 dark:!bg-zinc-600",
isScrollOut && "dark:bg-zinc-700",
active &&
"!bg-zinc-400/50 data-[active=true]:group-hover:!bg-zinc-500 dark:!bg-zinc-600",
)}
/>
>
<span
className="absolute inset-y-0 left-0 z-[1] rounded-full bg-zinc-600 duration-75 ease-linear dark:bg-zinc-400"
style={{
width: `${range * 100}%`,
}}
/>
</span>
</button>
)
})
Expand Down
15 changes: 14 additions & 1 deletion src/renderer/src/components/ui/markdown/renderers/Heading.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { springScrollToElement } from "@renderer/lib/scroller"
import { cn } from "@renderer/lib/utils"
import { useContext, useId } from "react"
import { useContext, useId, useLayoutEffect, useRef, useState } from "react"

import { useScrollViewElement } from "../../scroll-area/hooks"
import { MarkdownRenderContainerRefContext } from "../context"
Expand All @@ -20,8 +20,21 @@ export const createHeadingRenderer =

const scroller = useScrollViewElement()
const renderContainer = useContext(MarkdownRenderContainerRefContext)
const ref = useRef<HTMLHeadingElement>(null)

const [currentTitleTop, setCurrentTitleTop] = useState(0)
useLayoutEffect(() => {
const $heading = ref.current
if (!$heading) return
const { top } = $heading.getBoundingClientRect()
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-layout-effect
setCurrentTitleTop(top | 0)
}, [])

return (
<As
ref={ref}
data-container-top={currentTitleTop}
{...rest}
data-rid={rid}
className={cn(rest.className, "group relative")}
Expand Down
9 changes: 5 additions & 4 deletions src/renderer/src/components/ui/media.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,18 @@ const MediaImpl: FC<MediaProps> = ({
}
case "video": {
return (
<div
<span
className={cn(
hidden && "hidden",
"block",
!(props.width || props.height) && "size-full",
"relative bg-stone-100 object-cover",
mediaContainerClassName,
)}
onClick={handleClick}
>
<VideoPreview src={src!} previewImageUrl={previewImageUrl} />
</div>
</span>
)
}
default: {
Expand Down Expand Up @@ -205,9 +206,9 @@ const MediaImpl: FC<MediaProps> = ({
return <FallbackMedia {...props} />
}
return (
<div className={cn("overflow-hidden rounded", className)} style={style}>
<span className={cn("block overflow-hidden rounded", className)} style={style}>
{InnerContent}
</div>
</span>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Queries } from "@renderer/queries"
import { useEntryReadHistory } from "@renderer/store/entry"
import { useUserById } from "@renderer/store/user"
import { LayoutGroup, m } from "framer-motion"
import { Fragment, useEffect, useState } from "react"
import { memo, useEffect, useState } from "react"

import { usePresentUserProfileModal } from "../../profile/hooks"

Expand Down Expand Up @@ -51,9 +51,7 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({
.slice(0, 10)

.map((userId, i) => (
<Fragment key={userId}>
<EntryUser userId={userId} i={i} />
</Fragment>
<EntryUser userId={userId} i={i} key={userId} />
))}
</LayoutGroup>

Expand Down Expand Up @@ -85,7 +83,7 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({
const EntryUser: Component<{
userId: string
i: number
}> = ({ userId, i }) => {
}> = memo(({ userId, i }) => {
const user = useUserById(userId)
const presentUserProfile = usePresentUserProfileModal("drawer")
if (!user) return null
Expand Down Expand Up @@ -116,4 +114,4 @@ const EntryUser: Component<{
<TooltipContent side="top">Recent reader: {user.name}</TooltipContent>
</Tooltip>
)
}
})
4 changes: 2 additions & 2 deletions src/renderer/src/modules/entry-content/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ export function EntryHeader({
>
<div
className={cn(
"invisible absolute left-5 top-0 z-0 flex h-full items-center gap-2 text-[13px] leading-none text-zinc-500",
isAtTop && "visible z-[11]",
"absolute left-5 top-0 flex h-full items-center gap-2 text-[13px] leading-none text-zinc-500",
isAtTop ? "visible z-[11]" : "invisible z-[-99]",
views[view].wideMode && "static",
)}
>
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/modules/entry-content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,9 @@ export const EntryContentRender: Component<{ entryId: string }> = ({
</div>
</a>

<TitleMetaHandler entryId={entry.entries.id} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[0.94rem]">
<TitleMetaHandler entryId={entry.entries.id} />
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800 dark:text-neutral-400">
Expand Down
Loading

0 comments on commit a7828d4

Please sign in to comment.