docs: how to generate outline by internal hook, close #73

This commit is contained in:
YunYouJun 2022-10-04 00:42:42 +08:00
parent 4ae990fcfc
commit 95ce867354
7 changed files with 284 additions and 246 deletions

View File

@ -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"}

View File

@ -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
### 实现评论

View File

@ -1,7 +1,7 @@
{
"rewrites": [
{
"source": "/(.*)",
"source": "/:path*",
"destination": "/index.html"
}
]

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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)
}

View File

@ -0,0 +1,2 @@
export * from './headers'
export * from './anchor'