Skip to content

Commit a7828d4

Browse files
committed
feat: toc position calcation
Signed-off-by: Innei <i@innei.in>
1 parent 87dc225 commit a7828d4

File tree

9 files changed

+188
-102
lines changed

9 files changed

+188
-102
lines changed

src/renderer/src/components/common/ShadowDOM.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ export const ShadowDOM: FC<PropsWithChildren<React.HTMLProps<HTMLElement>>> & {
2929
return (
3030
<root.div {...rest}>
3131
<ShadowDOMContext.Provider value={true}>
32-
<html data-theme={dark ? "dark" : "light"}>
32+
<div data-theme={dark ? "dark" : "light"}>
3333
<head>{stylesElements}</head>
3434
{props.children}
35-
</html>
35+
</div>
3636
</ShadowDOMContext.Provider>
3737
</root.div>
3838
)

src/renderer/src/components/ui/markdown/components/Toc.tsx

Lines changed: 93 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { springScrollToElement } from "@renderer/lib/scroller"
22
import { cn } from "@renderer/lib/utils"
3+
import {
4+
useGetWrappedElementPosition,
5+
} from "@renderer/providers/wrapped-element-provider"
36
import { atom, useAtom } from "jotai"
7+
import { throttle } from "lodash-es"
48
import {
59
memo,
610
startTransition,
@@ -14,6 +18,7 @@ import { useEventCallback } from "usehooks-ts"
1418

1519
import { useScrollViewElement } from "../../scroll-area/hooks"
1620
import { MarkdownRenderContainerRefContext } from "../context"
21+
import type { TocItemProps } from "./TocItem"
1722
import { TocItem } from "./TocItem"
1823

1924
export interface ITocItem {
@@ -83,20 +88,75 @@ export const Toc: Component = ({ className }) => {
8388
}
8489
},
8590
)
91+
92+
const [currentScrollRange, setCurrentScrollRange] = useState([-1, 0])
93+
const titleBetweenPositionTopRangeMap = useMemo(() => {
94+
// calculate the range of data-container-top between each two headings
95+
const titleBetweenPositionTopRangeMap = [] as [number, number][]
96+
for (let i = 0; i < $headings.length - 1; i++) {
97+
const $heading = $headings[i]
98+
const $nextHeading = $headings[i + 1]
99+
const top = Number.parseInt($heading.dataset["containerTop"] || "0")
100+
const nextTop = Number.parseInt(
101+
$nextHeading.dataset["containerTop"] || "0",
102+
)
103+
104+
titleBetweenPositionTopRangeMap.push([top, nextTop])
105+
}
106+
return titleBetweenPositionTopRangeMap
107+
}, [$headings])
108+
109+
const getWrappedElPos = useGetWrappedElementPosition()
110+
111+
useEffect(() => {
112+
if (!scrollContainerElement) return
113+
114+
const handler = throttle(() => {
115+
const top = scrollContainerElement.scrollTop + getWrappedElPos().y
116+
117+
// current top is in which range?
118+
const currentRangeIndex = titleBetweenPositionTopRangeMap.findIndex(
119+
([start, end]) => top >= start && top <= end,
120+
)
121+
const currentRange = titleBetweenPositionTopRangeMap[currentRangeIndex]
122+
123+
if (currentRange) {
124+
const [start, end] = currentRange
125+
126+
// current top is this range, the precent is ?
127+
const precent = (top - start) / (end - start)
128+
129+
// position , precent
130+
setCurrentScrollRange([currentRangeIndex, precent])
131+
}
132+
}, 100)
133+
scrollContainerElement.addEventListener("scroll", handler)
134+
135+
return () => {
136+
scrollContainerElement.removeEventListener("scroll", handler)
137+
}
138+
}, [
139+
getWrappedElPos,
140+
scrollContainerElement,
141+
titleBetweenPositionTopRangeMap,
142+
])
143+
86144
if (toc.length === 0) return null
87145
return (
88146
<div className="flex grow flex-col scroll-smooth px-2 scrollbar-none">
89147
<ul
90148
ref={setTreeRef}
91149
className={cn("group overflow-auto scrollbar-none", className)}
92150
>
93-
{toc?.map((heading) => (
151+
{toc.map((heading, index) => (
94152
<MemoedItem
95153
heading={heading}
96-
isActive={heading.anchorId === activeId}
154+
active={heading.anchorId === activeId}
97155
key={heading.title}
98156
rootDepth={rootDepth}
99157
onClick={handleScrollTo}
158+
isScrollOut={index < currentScrollRange[0]}
159+
range={index === currentScrollRange[0] ? currentScrollRange[1] : 0}
100160
/>
101161
))}
102162
</ul>
@@ -131,51 +191,36 @@ function useActiveId($headings: HTMLHeadingElement[]) {
131191
return [activeId, setActiveId] as const
132192
}
133193

134-
const MemoedItem = memo<{
135-
isActive: boolean
136-
heading: ITocItem
137-
rootDepth: number
138-
onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void
139-
}>((props) => {
140-
const {
141-
heading,
142-
isActive,
143-
onClick,
144-
rootDepth,
145-
// containerRef
146-
} = props
147-
148-
const itemRef = useRef<HTMLElement>(null)
149-
150-
useEffect(() => {
151-
if (!isActive) return
152-
153-
const $item = itemRef.current
154-
if (!$item) return
155-
const $container = $item.parentElement
156-
if (!$container) return
157-
158-
const containerHeight = $container.clientHeight
159-
const itemHeight = $item.clientHeight
160-
const itemOffsetTop = $item.offsetTop
161-
const { scrollTop } = $container
162-
163-
const itemTop = itemOffsetTop - scrollTop
164-
const itemBottom = itemTop + itemHeight
165-
if (itemTop < 0 || itemBottom > containerHeight) {
166-
$container.scrollTop =
194+
const MemoedItem = memo<TocItemProps>((props) => {
195+
const {
196+
active,
197+
198+
...rest
199+
} = props
200+
201+
const itemRef = useRef<HTMLElement>(null)
202+
203+
useEffect(() => {
204+
if (!active) return
205+
206+
const $item = itemRef.current
207+
if (!$item) return
208+
const $container = $item.parentElement
209+
if (!$container) return
210+
211+
const containerHeight = $container.clientHeight
212+
const itemHeight = $item.clientHeight
213+
const itemOffsetTop = $item.offsetTop
214+
const { scrollTop } = $container
215+
216+
const itemTop = itemOffsetTop - scrollTop
217+
const itemBottom = itemTop + itemHeight
218+
if (itemTop < 0 || itemBottom > containerHeight) {
219+
$container.scrollTop =
167220
itemOffsetTop - containerHeight / 2 + itemHeight / 2
168-
}
169-
}, [isActive])
170-
171-
return (
172-
<TocItem
173-
heading={heading}
174-
onClick={onClick}
175-
active={isActive}
176-
key={heading.title}
177-
rootDepth={rootDepth}
178-
/>
179-
)
180-
})
221+
}
222+
}, [active])
223+
224+
return <TocItem active={active} {...rest} />
225+
})
181226
MemoedItem.displayName = "MemoedItem"

src/renderer/src/components/ui/markdown/components/TocItem.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { cn } from "@renderer/lib/utils"
22
import type { FC, MouseEvent } from "react"
3-
import {
4-
memo,
5-
useCallback,
6-
useRef,
7-
} from "react"
3+
import { memo, useCallback, useRef } from "react"
84

95
export interface ITocItem {
106
depth: number
@@ -15,23 +11,22 @@ export interface ITocItem {
1511
$heading: HTMLHeadingElement
1612
}
1713

18-
export const TocItem: FC<{
14+
export interface TocItemProps {
1915
heading: ITocItem
20-
2116
active: boolean
2217
rootDepth: number
2318
onClick?: (i: number, $el: HTMLElement | null, anchorId: string) => void
24-
}> = memo((props) => {
25-
const { active, onClick, heading } = props
19+
20+
isScrollOut: boolean
21+
range: number
22+
}
23+
24+
export const TocItem: FC<TocItemProps> = memo((props) => {
25+
const { active, onClick, heading, isScrollOut, range } = props
2626
const { $heading, anchorId, depth, index, title } = heading
2727

2828
const $ref = useRef<HTMLButtonElement>(null)
2929

30-
// useEffect(() => {
31-
// if (active) {
32-
// $ref.current?.scrollIntoView({ behavior: "smooth" })
33-
// }
34-
// }, [])
3530
return (
3631
<button
3732
type="button"
@@ -55,12 +50,23 @@ export const TocItem: FC<{
5550
}}
5651
data-active={active}
5752
className={cn(
58-
"inline-block h-1.5 rounded-full",
53+
"relative inline-block h-1.5 rounded-full",
5954
"bg-zinc-100 duration-200 hover:!bg-zinc-400 group-hover:bg-zinc-400/50",
55+
isScrollOut && "bg-zinc-400/80",
56+
6057
"dark:bg-zinc-800/80 dark:hover:!bg-zinc-600 dark:group-hover:bg-zinc-600/50",
61-
active && "!bg-zinc-400/50 data-[active=true]:group-hover:!bg-zinc-500 dark:!bg-zinc-600",
58+
isScrollOut && "dark:bg-zinc-700",
59+
active &&
60+
"!bg-zinc-400/50 data-[active=true]:group-hover:!bg-zinc-500 dark:!bg-zinc-600",
6261
)}
63-
/>
62+
>
63+
<span
64+
className="absolute inset-y-0 left-0 z-[1] rounded-full bg-zinc-600 duration-75 ease-linear dark:bg-zinc-400"
65+
style={{
66+
width: `${range * 100}%`,
67+
}}
68+
/>
69+
</span>
6470
</button>
6571
)
6672
})

src/renderer/src/components/ui/markdown/renderers/Heading.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { springScrollToElement } from "@renderer/lib/scroller"
22
import { cn } from "@renderer/lib/utils"
3-
import { useContext, useId } from "react"
3+
import { useContext, useId, useLayoutEffect, useRef, useState } from "react"
44

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

2121
const scroller = useScrollViewElement()
2222
const renderContainer = useContext(MarkdownRenderContainerRefContext)
23+
const ref = useRef<HTMLHeadingElement>(null)
24+
25+
const [currentTitleTop, setCurrentTitleTop] = useState(0)
26+
useLayoutEffect(() => {
27+
const $heading = ref.current
28+
if (!$heading) return
29+
const { top } = $heading.getBoundingClientRect()
30+
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-layout-effect
31+
setCurrentTitleTop(top | 0)
32+
}, [])
33+
2334
return (
2435
<As
36+
ref={ref}
37+
data-container-top={currentTitleTop}
2538
{...rest}
2639
data-rid={rid}
2740
className={cn(rest.className, "group relative")}

src/renderer/src/components/ui/media.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,18 @@ const MediaImpl: FC<MediaProps> = ({
166166
}
167167
case "video": {
168168
return (
169-
<div
169+
<span
170170
className={cn(
171171
hidden && "hidden",
172+
"block",
172173
!(props.width || props.height) && "size-full",
173174
"relative bg-stone-100 object-cover",
174175
mediaContainerClassName,
175176
)}
176177
onClick={handleClick}
177178
>
178179
<VideoPreview src={src!} previewImageUrl={previewImageUrl} />
179-
</div>
180+
</span>
180181
)
181182
}
182183
default: {
@@ -205,9 +206,9 @@ const MediaImpl: FC<MediaProps> = ({
205206
return <FallbackMedia {...props} />
206207
}
207208
return (
208-
<div className={cn("overflow-hidden rounded", className)} style={style}>
209+
<span className={cn("block overflow-hidden rounded", className)} style={style}>
209210
{InnerContent}
210-
</div>
211+
</span>
211212
)
212213
}
213214

src/renderer/src/modules/entry-content/components/EntryReadHistory.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Queries } from "@renderer/queries"
1414
import { useEntryReadHistory } from "@renderer/store/entry"
1515
import { useUserById } from "@renderer/store/user"
1616
import { LayoutGroup, m } from "framer-motion"
17-
import { Fragment, useEffect, useState } from "react"
17+
import { memo, useEffect, useState } from "react"
1818

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

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

5353
.map((userId, i) => (
54-
<Fragment key={userId}>
55-
<EntryUser userId={userId} i={i} />
56-
</Fragment>
54+
<EntryUser userId={userId} i={i} key={userId} />
5755
))}
5856
</LayoutGroup>
5957

@@ -85,7 +83,7 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({
8583
const EntryUser: Component<{
8684
userId: string
8785
i: number
88-
}> = ({ userId, i }) => {
86+
}> = memo(({ userId, i }) => {
8987
const user = useUserById(userId)
9088
const presentUserProfile = usePresentUserProfileModal("drawer")
9189
if (!user) return null
@@ -116,4 +114,4 @@ const EntryUser: Component<{
116114
<TooltipContent side="top">Recent reader: {user.name}</TooltipContent>
117115
</Tooltip>
118116
)
119-
}
117+
})

src/renderer/src/modules/entry-content/header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ export function EntryHeader({
6262
>
6363
<div
6464
className={cn(
65-
"invisible absolute left-5 top-0 z-0 flex h-full items-center gap-2 text-[13px] leading-none text-zinc-500",
66-
isAtTop && "visible z-[11]",
65+
"absolute left-5 top-0 flex h-full items-center gap-2 text-[13px] leading-none text-zinc-500",
66+
isAtTop ? "visible z-[11]" : "invisible z-[-99]",
6767
views[view].wideMode && "static",
6868
)}
6969
>

src/renderer/src/modules/entry-content/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,9 @@ export const EntryContentRender: Component<{ entryId: string }> = ({
210210
</div>
211211
</a>
212212

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

0 commit comments

Comments
 (0)