mirror of https://github.com/YunYouJun/valaxy
docs: how to generate outline by internal hook, close #73
This commit is contained in:
parent
4ae990fcfc
commit
95ce867354
|
@ -253,7 +253,7 @@ If you want to develop a theme and released, you can refer to [valaxy-theme-star
|
|||
::: zh-CN
|
||||
如果您希望自己开发一个主题并发布,您可以参考 [valaxy-theme-starter](https://github.com/YunYouJun/valaxy-theme-starter)。
|
||||
|
||||
更多内容请参见 [如何创建一个 Valaxy 主题](/themes/index.md).
|
||||
更多内容请参见 [如何编写一个 Valaxy 主题](/themes/write).
|
||||
:::
|
||||
|
||||
## Community {lang="en"}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: How to create a theme?
|
||||
title_zh: 如何创建一个 Valaxy 主题
|
||||
title: How to write a theme?
|
||||
title_zh: 如何编写一个 Valaxy 主题
|
||||
categories:
|
||||
- Theme
|
||||
end: false
|
||||
|
@ -108,6 +108,38 @@ Markdown 样式是主题呈现文章样式的部分,需要由主题自定义
|
|||
> 如果你想先使用常见的默认样式(后续再进行定制),你可以直接使用 [star-markdown-css](https://github.com/YunYouJun/star-markdown-css)。
|
||||
> 使用方式可参见 [valaxy-theme-yun/styles](https://github.com/YunYouJun/valaxy/blob/main/packages/valaxy-theme-yun/styles/index.scss)
|
||||
|
||||
## 功能
|
||||
|
||||
### 目录
|
||||
|
||||
如果你想要快速实现一个目录,Valaxy 提供了一个内置钩子函数 `useOutline`。
|
||||
|
||||
你可以用它快速获取文章页的目录信息 `headers` 与对应点击事件 `handleClick`,如:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useOutline } from 'valaxy'
|
||||
const { headers, handleClick } = useOutline()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-labelledby="doc-outline-aria-label">
|
||||
<span id="doc-outline-aria-label" class="visually-hidden">
|
||||
Table of Contents
|
||||
</span>
|
||||
|
||||
<PressOutlineItem
|
||||
class="va-toc relative z-1"
|
||||
:headers="headers"
|
||||
:on-click="handleClick"
|
||||
root
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
```
|
||||
|
||||
> 更多可参见 [PressOutline | valaxy-theme-press](https://github.com/YunYouJun/valaxy/blob/main/packages/valaxy-theme-press/components/PressOutline.vue)。
|
||||
|
||||
## Third Plugin
|
||||
|
||||
### 实现评论
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"source": "/:path*",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,242 +0,0 @@
|
|||
import type { Ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, onUpdated, ref } from 'vue'
|
||||
import { useFrontmatter } from '../composables'
|
||||
import { useThemeConfig } from '../../client'
|
||||
import { throttleAndDebounce } from '../utils'
|
||||
import type { DefaultThemeConfig, Header } from '../../types'
|
||||
|
||||
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
export function resolveHeaders(
|
||||
headers: MenuItem[],
|
||||
levelsRange: Exclude<DefaultThemeConfig['outline'], false> = [2, 4],
|
||||
) {
|
||||
const levels: [number, number]
|
||||
= typeof levelsRange === 'number'
|
||||
? [levelsRange, levelsRange]
|
||||
: levelsRange === 'deep'
|
||||
? [2, 6]
|
||||
: levelsRange
|
||||
|
||||
return groupHeaders(headers, levels)
|
||||
}
|
||||
|
||||
function groupHeaders(headers: MenuItem[], levelsRange: [number, number]) {
|
||||
const result: MenuItem[] = []
|
||||
|
||||
headers = headers.map(h => ({ ...h }))
|
||||
headers.forEach((h, index) => {
|
||||
if (h.level >= levelsRange[0] && h.level <= levelsRange[1]) {
|
||||
if (addToParent(index, headers, levelsRange))
|
||||
result.push(h)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// function mapHeaders(
|
||||
// headers: HeaderWithChildren[],
|
||||
// ): MenuItemWithLinkAndChildren[] {
|
||||
// return headers.map(header => ({
|
||||
// text: header.title,
|
||||
// link: `#${header.slug}`,
|
||||
// children: header.children ? mapHeaders(header.children) : undefined,
|
||||
// hidden: header.hidden,
|
||||
// lang: header.lang,
|
||||
// }))
|
||||
// }
|
||||
|
||||
function addToParent(
|
||||
currIndex: number,
|
||||
headers: MenuItem[],
|
||||
levelsRange: [number, number],
|
||||
) {
|
||||
if (currIndex === 0)
|
||||
return true
|
||||
|
||||
const currentHeader = headers[currIndex]
|
||||
for (let index = currIndex - 1; index >= 0; index--) {
|
||||
const header = headers[index]
|
||||
|
||||
if (
|
||||
header.level < currentHeader.level
|
||||
&& header.level >= levelsRange[0]
|
||||
&& header.level <= levelsRange[1]
|
||||
) {
|
||||
if (header.children == null)
|
||||
header.children = []
|
||||
header.children.push(currentHeader)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// magic number to avoid repeated retrieval
|
||||
const PAGE_OFFSET = 56
|
||||
|
||||
export function useActiveAnchor(
|
||||
container: Ref<HTMLElement>,
|
||||
marker: Ref<HTMLElement>,
|
||||
) {
|
||||
const onScroll = throttleAndDebounce(setActiveLink, 100)
|
||||
|
||||
let prevActiveLink: HTMLAnchorElement | null = null
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(setActiveLink)
|
||||
window.addEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
// sidebar update means a route change
|
||||
activateLink(location.hash)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
function setActiveLink() {
|
||||
const links = [].slice.call(
|
||||
container.value.querySelectorAll('.outline-link'),
|
||||
) as HTMLAnchorElement[]
|
||||
|
||||
const anchors = [].slice
|
||||
.call(document.querySelectorAll('.content .header-anchor'))
|
||||
.filter((anchor: HTMLAnchorElement) => {
|
||||
return links.some((link) => {
|
||||
return link.hash === anchor.hash && anchor.offsetParent !== null
|
||||
})
|
||||
}) as HTMLAnchorElement[]
|
||||
|
||||
const scrollY = window.scrollY
|
||||
const innerHeight = window.innerHeight
|
||||
const offsetHeight = container.value.offsetHeight
|
||||
const isBottom = (scrollY + innerHeight) === offsetHeight
|
||||
|
||||
// console.log(scrollY, innerHeight, offsetHeight)
|
||||
// console.log(isBottom)
|
||||
|
||||
// page bottom - highlight last one
|
||||
if (anchors.length && isBottom) {
|
||||
activateLink(null)
|
||||
return
|
||||
}
|
||||
|
||||
// isTop
|
||||
if (anchors.length && scrollY === 0)
|
||||
activateLink('#')
|
||||
|
||||
for (let i = 0; i < anchors.length; i++) {
|
||||
const anchor = anchors[i]
|
||||
const nextAnchor = anchors[i + 1]
|
||||
|
||||
const [isActive, hash] = isAnchorActive(i, anchor, nextAnchor)
|
||||
|
||||
if (isActive) {
|
||||
history.replaceState(null, document.title, hash || ' ')
|
||||
activateLink(hash)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function activateLink(hash: string | null) {
|
||||
if (prevActiveLink)
|
||||
prevActiveLink.classList.remove('active')
|
||||
|
||||
if (hash !== null) {
|
||||
prevActiveLink = container.value.querySelector(
|
||||
`a[href="${decodeURIComponent(hash)}"]`,
|
||||
) as HTMLAnchorElement
|
||||
}
|
||||
|
||||
const activeLink = prevActiveLink
|
||||
|
||||
const topOffset = 33
|
||||
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active')
|
||||
marker.value.style.top = `${activeLink.offsetTop + topOffset}px`
|
||||
marker.value.style.opacity = '1'
|
||||
}
|
||||
else {
|
||||
marker.value.style.top = `${topOffset}px`
|
||||
marker.value.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchorTop(anchor: HTMLAnchorElement): number {
|
||||
return anchor.parentElement!.offsetTop - PAGE_OFFSET - 15
|
||||
}
|
||||
|
||||
function isAnchorActive(
|
||||
index: number,
|
||||
anchor: HTMLAnchorElement,
|
||||
nextAnchor: HTMLAnchorElement | undefined,
|
||||
): [boolean, string | null] {
|
||||
const scrollTop = window.scrollY
|
||||
|
||||
if (index === 0 && scrollTop === 0)
|
||||
return [true, null]
|
||||
|
||||
if (scrollTop < getAnchorTop(anchor))
|
||||
return [false, null]
|
||||
|
||||
if (!nextAnchor || scrollTop < getAnchorTop(nextAnchor))
|
||||
return [true, anchor.hash]
|
||||
|
||||
return [false, null]
|
||||
}
|
||||
|
||||
export const useOutline = () => {
|
||||
const frontmatter = useFrontmatter()
|
||||
const themeConfig = useThemeConfig()
|
||||
const headers = ref<MenuItem[]>([])
|
||||
const pageOutline = computed<DefaultThemeConfig['outline']>(
|
||||
() => frontmatter.value.outline ?? themeConfig.value.outline,
|
||||
)
|
||||
|
||||
const onContentUpdated = inject('onContentUpdated') as Ref<() => void>
|
||||
onContentUpdated.value = () => {
|
||||
headers.value = getHeaders(pageOutline.value)
|
||||
}
|
||||
|
||||
const handleClick = ({ target: el }: Event) => {
|
||||
const id = `#${(el as HTMLAnchorElement).href!.split('#')[1]}`
|
||||
const heading = document.querySelector(
|
||||
decodeURIComponent(id),
|
||||
) as HTMLAnchorElement
|
||||
heading?.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
headers,
|
||||
handleClick,
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeaders(pageOutline: DefaultThemeConfig['outline']) {
|
||||
if (pageOutline === false)
|
||||
return []
|
||||
const updatedHeaders: MenuItem[] = []
|
||||
document
|
||||
.querySelectorAll<HTMLHeadingElement>('h2, h3, h4, h5, h6')
|
||||
.forEach((el) => {
|
||||
if (el.textContent && el.id) {
|
||||
updatedHeaders.push({
|
||||
level: Number(el.tagName[1]),
|
||||
title: el.innerText.replace(/\s+#\s*$/, ''),
|
||||
link: `#${el.id}`,
|
||||
lang: el.lang,
|
||||
})
|
||||
}
|
||||
})
|
||||
return resolveHeaders(updatedHeaders, pageOutline)
|
||||
}
|
|
@ -0,0 +1,122 @@
|
|||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, onUpdated } from 'vue'
|
||||
import { throttleAndDebounce } from '../../utils'
|
||||
|
||||
// magic number to avoid repeated retrieval
|
||||
const PAGE_OFFSET = 56
|
||||
|
||||
export function useActiveAnchor(
|
||||
container: Ref<HTMLElement>,
|
||||
marker: Ref<HTMLElement>,
|
||||
) {
|
||||
const onScroll = throttleAndDebounce(setActiveLink, 100)
|
||||
|
||||
let prevActiveLink: HTMLAnchorElement | null = null
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(setActiveLink)
|
||||
window.addEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
onUpdated(() => {
|
||||
// sidebar update means a route change
|
||||
activateLink(location.hash)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
function setActiveLink() {
|
||||
const links = [].slice.call(
|
||||
container.value.querySelectorAll('.outline-link'),
|
||||
) as HTMLAnchorElement[]
|
||||
|
||||
const anchors = [].slice
|
||||
.call(document.querySelectorAll('.content .header-anchor'))
|
||||
.filter((anchor: HTMLAnchorElement) => {
|
||||
return links.some((link) => {
|
||||
return link.hash === anchor.hash && anchor.offsetParent !== null
|
||||
})
|
||||
}) as HTMLAnchorElement[]
|
||||
|
||||
const scrollY = window.scrollY
|
||||
const innerHeight = window.innerHeight
|
||||
const offsetHeight = container.value.offsetHeight
|
||||
const isBottom = scrollY + innerHeight === offsetHeight
|
||||
|
||||
// console.log(scrollY, innerHeight, offsetHeight)
|
||||
// console.log(isBottom)
|
||||
|
||||
// page bottom - highlight last one
|
||||
if (anchors.length && isBottom) {
|
||||
activateLink(null)
|
||||
return
|
||||
}
|
||||
|
||||
// isTop
|
||||
if (anchors.length && scrollY === 0)
|
||||
activateLink('#')
|
||||
|
||||
for (let i = 0; i < anchors.length; i++) {
|
||||
const anchor = anchors[i]
|
||||
const nextAnchor = anchors[i + 1]
|
||||
|
||||
const [isActive, hash] = isAnchorActive(i, anchor, nextAnchor)
|
||||
|
||||
if (isActive) {
|
||||
history.replaceState(null, document.title, hash || ' ')
|
||||
activateLink(hash)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function activateLink(hash: string | null) {
|
||||
if (prevActiveLink)
|
||||
prevActiveLink.classList.remove('active')
|
||||
|
||||
if (hash !== null) {
|
||||
prevActiveLink = container.value.querySelector(
|
||||
`a[href="${decodeURIComponent(hash)}"]`,
|
||||
) as HTMLAnchorElement
|
||||
}
|
||||
|
||||
const activeLink = prevActiveLink
|
||||
|
||||
const topOffset = 33
|
||||
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active')
|
||||
marker.value.style.top = `${activeLink.offsetTop + topOffset}px`
|
||||
marker.value.style.opacity = '1'
|
||||
}
|
||||
else {
|
||||
marker.value.style.top = `${topOffset}px`
|
||||
marker.value.style.opacity = '0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAnchorTop(anchor: HTMLAnchorElement): number {
|
||||
return anchor.parentElement!.offsetTop - PAGE_OFFSET - 15
|
||||
}
|
||||
|
||||
function isAnchorActive(
|
||||
index: number,
|
||||
anchor: HTMLAnchorElement,
|
||||
nextAnchor: HTMLAnchorElement | undefined,
|
||||
): [boolean, string | null] {
|
||||
const scrollTop = window.scrollY
|
||||
|
||||
if (index === 0 && scrollTop === 0)
|
||||
return [true, null]
|
||||
|
||||
if (scrollTop < getAnchorTop(anchor))
|
||||
return [false, null]
|
||||
|
||||
if (!nextAnchor || scrollTop < getAnchorTop(nextAnchor))
|
||||
return [true, anchor.hash]
|
||||
|
||||
return [false, null]
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
import type { Ref } from 'vue'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { useFrontmatter, useThemeConfig } from '../..'
|
||||
import type { DefaultThemeConfig, Header } from '../../../types'
|
||||
|
||||
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
export function resolveHeaders(
|
||||
headers: MenuItem[],
|
||||
levelsRange: Exclude<DefaultThemeConfig['outline'], false> = [2, 4],
|
||||
) {
|
||||
const levels: [number, number]
|
||||
= typeof levelsRange === 'number'
|
||||
? [levelsRange, levelsRange]
|
||||
: levelsRange === 'deep'
|
||||
? [2, 6]
|
||||
: levelsRange
|
||||
|
||||
return groupHeaders(headers, levels)
|
||||
}
|
||||
|
||||
function groupHeaders(headers: MenuItem[], levelsRange: [number, number]) {
|
||||
const result: MenuItem[] = []
|
||||
|
||||
headers = headers.map(h => ({ ...h }))
|
||||
headers.forEach((h, index) => {
|
||||
if (h.level >= levelsRange[0] && h.level <= levelsRange[1]) {
|
||||
if (addToParent(index, headers, levelsRange))
|
||||
result.push(h)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function addToParent(
|
||||
currIndex: number,
|
||||
headers: MenuItem[],
|
||||
levelsRange: [number, number],
|
||||
) {
|
||||
if (currIndex === 0)
|
||||
return true
|
||||
|
||||
const currentHeader = headers[currIndex]
|
||||
for (let index = currIndex - 1; index >= 0; index--) {
|
||||
const header = headers[index]
|
||||
|
||||
if (
|
||||
header.level < currentHeader.level
|
||||
&& header.level >= levelsRange[0]
|
||||
&& header.level <= levelsRange[1]
|
||||
) {
|
||||
if (header.children == null)
|
||||
header.children = []
|
||||
header.children.push(currentHeader)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* export headers & handleClick to generate outline
|
||||
* @returns
|
||||
*/
|
||||
export const useOutline = () => {
|
||||
const frontmatter = useFrontmatter()
|
||||
const themeConfig = useThemeConfig()
|
||||
const headers = ref<MenuItem[]>([])
|
||||
const pageOutline = computed<DefaultThemeConfig['outline']>(
|
||||
() => frontmatter.value.outline ?? themeConfig.value.outline,
|
||||
)
|
||||
|
||||
const onContentUpdated = inject('onContentUpdated') as Ref<() => void>
|
||||
onContentUpdated.value = () => {
|
||||
headers.value = getHeaders(pageOutline.value)
|
||||
}
|
||||
|
||||
const handleClick = ({ target: el }: Event) => {
|
||||
const id = `#${(el as HTMLAnchorElement).href!.split('#')[1]}`
|
||||
const heading = document.querySelector(
|
||||
decodeURIComponent(id),
|
||||
) as HTMLAnchorElement
|
||||
heading?.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* headers for toc
|
||||
*/
|
||||
headers,
|
||||
/**
|
||||
* click hash heading
|
||||
*/
|
||||
handleClick,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get headers from document directly
|
||||
* @param pageOutline
|
||||
* @returns
|
||||
*/
|
||||
export function getHeaders(pageOutline: DefaultThemeConfig['outline']) {
|
||||
if (pageOutline === false)
|
||||
return []
|
||||
const updatedHeaders: MenuItem[] = []
|
||||
document
|
||||
.querySelectorAll<HTMLHeadingElement>('h2, h3, h4, h5, h6')
|
||||
.forEach((el) => {
|
||||
if (el.textContent && el.id) {
|
||||
updatedHeaders.push({
|
||||
level: Number(el.tagName[1]),
|
||||
title: el.innerText.replace(/\s+#\s*$/, ''),
|
||||
link: `#${el.id}`,
|
||||
lang: el.lang,
|
||||
})
|
||||
}
|
||||
})
|
||||
return resolveHeaders(updatedHeaders, pageOutline)
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export * from './headers'
|
||||
export * from './anchor'
|
Loading…
Reference in New Issue