Skip to content

Commit 68ff332

Browse files
committed
feat: search details block preview and anchor automatic expansion
1 parent be1b775 commit 68ff332

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { getScrollOffset } from 'vitepress'
2+
3+
const DETAILS_SELECTOR = 'details.custom-block.details'
4+
const CUSTOM_BLOCK_SELECTOR = '.custom-block'
5+
const CUSTOM_BLOCK_TITLE_SELECTOR = ':scope > .custom-block-title'
6+
const DETAILS_SUMMARY_SELECTOR = ':scope > summary'
7+
8+
function isSamePageLink(link: HTMLAnchorElement) {
9+
const href = link.getAttribute('href')
10+
if (!href) return false
11+
12+
const targetUrl = new URL(href, window.location.href)
13+
const currentUrl = new URL(window.location.href)
14+
15+
return (
16+
targetUrl.origin === currentUrl.origin &&
17+
targetUrl.pathname === currentUrl.pathname &&
18+
targetUrl.search === currentUrl.search &&
19+
!!targetUrl.hash
20+
)
21+
}
22+
23+
function normalizeHash(hash: string) {
24+
if (!hash.startsWith('#') || hash.length <= 1) return null
25+
26+
try {
27+
return decodeURIComponent(hash.slice(1))
28+
} catch {
29+
return hash.slice(1)
30+
}
31+
}
32+
33+
function getHashTarget() {
34+
const normalizedHash = normalizeHash(window.location.hash)
35+
if (!normalizedHash) return null
36+
37+
return document.getElementById(normalizedHash)
38+
}
39+
40+
function getAncestorDetails(target: HTMLElement) {
41+
const ancestors: HTMLDetailsElement[] = []
42+
let current = target.closest<HTMLDetailsElement>(DETAILS_SELECTOR)
43+
44+
while (current) {
45+
ancestors.unshift(current)
46+
current = current.parentElement?.closest<HTMLDetailsElement>(DETAILS_SELECTOR) ?? null
47+
}
48+
49+
return ancestors
50+
}
51+
52+
function openAncestorDetails(target: HTMLElement) {
53+
const ancestors = getAncestorDetails(target)
54+
let hasOpened = false
55+
56+
for (const details of ancestors) {
57+
if (!details.open) {
58+
details.open = true
59+
hasOpened = true
60+
}
61+
}
62+
63+
return hasOpened
64+
}
65+
66+
function getStickyTitleElement(container: HTMLElement) {
67+
if (container.matches(DETAILS_SELECTOR)) {
68+
return container.querySelector<HTMLElement>(DETAILS_SUMMARY_SELECTOR)
69+
}
70+
71+
return container.querySelector<HTMLElement>(CUSTOM_BLOCK_TITLE_SELECTOR)
72+
}
73+
74+
function getStickyTitleCoverOffset(target: HTMLElement) {
75+
const containers: HTMLElement[] = []
76+
let current = target.closest<HTMLElement>(CUSTOM_BLOCK_SELECTOR)
77+
78+
while (current) {
79+
containers.unshift(current)
80+
current = current.parentElement?.closest<HTMLElement>(CUSTOM_BLOCK_SELECTOR) ?? null
81+
}
82+
83+
return containers.reduce((sum, container) => {
84+
const title = getStickyTitleElement(container)
85+
if (!title) return sum
86+
87+
return sum + (title.getBoundingClientRect().height || title.offsetHeight || 0)
88+
}, 0)
89+
}
90+
91+
function scrollToTarget(target: HTMLElement) {
92+
const stickyTitleCoverOffset = getStickyTitleCoverOffset(target)
93+
const top =
94+
target.getBoundingClientRect().top +
95+
window.scrollY -
96+
getScrollOffset() -
97+
stickyTitleCoverOffset
98+
99+
window.scrollTo({
100+
top: Math.max(0, top),
101+
behavior: 'auto'
102+
})
103+
}
104+
105+
function expandDetailsForHashTarget() {
106+
const target = getHashTarget()
107+
if (!target) return
108+
109+
openAncestorDetails(target)
110+
111+
requestAnimationFrame(() => {
112+
requestAnimationFrame(() => {
113+
scrollToTarget(target)
114+
})
115+
})
116+
}
117+
118+
export function enableDetailsAutoExpandByHash() {
119+
if (typeof window === 'undefined') return
120+
121+
let rafId: number | null = null
122+
123+
const scheduleExpand = () => {
124+
if (rafId !== null) {
125+
cancelAnimationFrame(rafId)
126+
}
127+
128+
rafId = requestAnimationFrame(() => {
129+
rafId = null
130+
expandDetailsForHashTarget()
131+
})
132+
}
133+
134+
const observer = new MutationObserver(() => {
135+
if (window.location.hash) {
136+
scheduleExpand()
137+
}
138+
})
139+
140+
const setup = () => {
141+
scheduleExpand()
142+
143+
observer.observe(document.body, {
144+
subtree: true,
145+
childList: true
146+
})
147+
148+
window.addEventListener('hashchange', scheduleExpand, { passive: true })
149+
document.addEventListener(
150+
'click',
151+
(event) => {
152+
if (!(event.target instanceof Element)) return
153+
154+
const link = event.target.closest<HTMLAnchorElement>('a')
155+
if (!link || !isSamePageLink(link)) return
156+
157+
requestAnimationFrame(() => {
158+
requestAnimationFrame(() => {
159+
scheduleExpand()
160+
})
161+
})
162+
},
163+
{ capture: true, passive: true }
164+
)
165+
}
166+
167+
if (document.readyState === 'loading') {
168+
document.addEventListener('DOMContentLoaded', setup, { once: true })
169+
return
170+
}
171+
172+
setup()
173+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
const SEARCH_DETAILS_SELECTOR = '.VPLocalSearchBox .excerpt .vp-doc details.custom-block.details'
2+
3+
function openSearchPreviewDetails() {
4+
const detailsList = document.querySelectorAll<HTMLDetailsElement>(SEARCH_DETAILS_SELECTOR)
5+
6+
for (const details of detailsList) {
7+
details.open = true
8+
}
9+
}
10+
11+
export function enableSearchDetailsPreview() {
12+
if (typeof window === 'undefined') return
13+
14+
let rafId: number | null = null
15+
16+
const scheduleOpen = () => {
17+
if (rafId !== null) {
18+
cancelAnimationFrame(rafId)
19+
}
20+
21+
rafId = requestAnimationFrame(() => {
22+
rafId = null
23+
openSearchPreviewDetails()
24+
})
25+
}
26+
27+
const observer = new MutationObserver(() => {
28+
scheduleOpen()
29+
})
30+
31+
const setup = () => {
32+
scheduleOpen()
33+
34+
observer.observe(document.body, {
35+
subtree: true,
36+
childList: true,
37+
attributes: true,
38+
attributeFilter: ['open']
39+
})
40+
}
41+
42+
if (document.readyState === 'loading') {
43+
document.addEventListener('DOMContentLoaded', setup, { once: true })
44+
return
45+
}
46+
47+
setup()
48+
}

.vitepress/theme/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import HomeLayout from './layouts/HomeLayout.vue'
55
import CourseLink from './components/global/CourseLink.vue'
66
import InlineMath from './components/global/InlineMath.vue'
77
import './styles/index.css'
8+
import { enableDetailsAutoExpandByHash } from './behaviors/enableDetailsAutoExpandByHash'
89
import { enableCustomBlockStickyTitles } from './behaviors/enableCustomBlockStickyTitles'
910
import { enableDetailsAnimation } from './behaviors/enableDetailsAnimation'
1011
import { enableLastUpdatedEasterEgg } from './behaviors/enableLastUpdatedEasterEgg'
1112
import { enableLastUpdatedPunctuation } from './behaviors/enableLastUpdatedPunctuation'
1213
import { enableNotoSansScFallback } from './behaviors/enableNotoSansScFallback'
1314
import { enableOutlineAutoScroll } from './behaviors/enableOutlineAutoScroll'
15+
import { enableSearchDetailsPreview } from './behaviors/enableSearchDetailsPreview'
1416

1517
export default {
1618
extends: DefaultTheme,
@@ -19,6 +21,8 @@ export default {
1921
app.component('CourseLink', CourseLink)
2022
app.component('InlineMath', InlineMath)
2123
enableNotoSansScFallback()
24+
enableDetailsAutoExpandByHash()
25+
enableSearchDetailsPreview()
2226
enableCustomBlockStickyTitles()
2327
enableDetailsAnimation()
2428
enableLastUpdatedEasterEgg()

.vitepress/theme/styles/features/search.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,15 @@
77
padding: 0 0.25em;
88
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--vp-local-search-highlight-text) 12%, transparent);
99
}
10+
11+
.VPLocalSearchBox .excerpt .vp-doc details.custom-block.details > :not(summary) {
12+
display: block !important;
13+
}
14+
15+
.VPLocalSearchBox .excerpt .vp-doc details.custom-block.details > summary {
16+
cursor: default;
17+
}
18+
19+
.VPLocalSearchBox .excerpt .vp-doc details.custom-block.details > summary + * {
20+
margin-top: 12px;
21+
}

0 commit comments

Comments
 (0)