feat(theme): 新增自定义有侧边栏层级配置

This commit is contained in:
pengzhanbo 2024-06-03 00:52:25 +08:00
parent 131be4424f
commit 90f5409642
10 changed files with 368 additions and 210 deletions

View File

@ -54,7 +54,7 @@ permalink: /config/frontmatter/basic/
- 类型: `boolean`
- 默认值: `true`
当前文章内的 外部链接是否显示 外部链接图标 即 “ <ExternalLinkIcon /> ” 图标
当前文章内的 外部链接是否显示 外部链接图标
### backToTop
@ -77,6 +77,20 @@ permalink: /config/frontmatter/basic/
当前文章是否 显示 右侧边栏。
### outline
- 类型: `false | number | [number, number] | 'deep'`
- 默认值: `[2, 3]`
- 详情:
要显示的标题级别。
单个数字表示只显示该级别的标题。
如果传递的是一个元组,第一个数字是最小级别,第二个数字是最大级别。
`'deep'``[2, 6]` 相同,将显示从 `<h2>``<h6>` 的所有标题。
### prev
- 类型: `string | { text: string, link: string, icon?: string }`

View File

@ -16,6 +16,79 @@ permalink: /config/basic/
主题使用的插件默认已进行了配置,大多数情况下您不需要进行任何修改,如果需要使用到细致的定制化,请查阅
[此文档](/config/plugins/)
### hostname
- 类型: `string`
- 默认值: `''`
- 详情:
部署站点域名。
`hostname` 配置为有效域名时,主题将会生成 `sitemap``seo` 相关的内容。
### blog
- 类型: `false | BlogOptions`
- 默认值: `{ link: '/blog/', include: ['**/*.md'], exclude: [] }`
- 详情:
博客配置。
```ts
interface BlogOptions {
/**
* blog list link
*
* @default '/blog/'
*/
link?: string
/**
* 在 `blog.dir` 目录中,通过 glob string 配置包含文件
*
* @default - ['**\*.md']
*/
include?: string[]
/**
* 在 `blog.dir` 目录中,通过 glob string 配置排除的文件
*
* README.md 文件一般作为主页或者某个目录下的主页,不应该被读取为 blog文章
*
* @default - ['.vuepress/', 'node_modules/', '{README,index}.md']
*/
exclude?: string[]
/**
* 分页配置
*/
pagination?: false | {
/**
* 每页显示的文章数量
* @default 10
*/
perPage?: number
}
/**
* 是否启用标签页
* @default true
*/
tags?: boolean
/**
* 是否启用归档页
* @default true
*/
archives?: boolean
}
```
### article
- 类型: `string`
- 默认值: `/article/`
- 详情: 文章链接前缀
### locales
- 类型: `Record<string, PlumeThemeLocaleConfig>`
@ -69,16 +142,6 @@ permalink: /config/basic/
- 默认值: `'Appearance'`
- 详情: 导航栏中的主题切换按钮的文本。
### hostname
- 类型: `string`
- 默认值: `''`
- 详情:
部署站点域名。
`hostname` 配置为有效域名时,主题将会生成 `sitemap``seo` 相关的内容。
### avatar
- 类型: `PlumeThemeAvatar`
@ -162,79 +225,6 @@ export default {
允许显示在导航栏的社交链接。
该配置仅在 PC 端下有效。
### blog
- 类型: `false | BlogOptions`
- 默认值: `{ link: '/blog/', include: ['**/*.md'], exclude: [] }`
- 详情:
博客配置。
```ts
interface BlogOptions {
/**
* blog list link
*
* @default '/blog/'
*/
link?: string
/**
* 在 `blog.dir` 目录中,通过 glob string 配置包含文件
*
* @default - ['**\*.md']
*/
include?: string[]
/**
* 在 `blog.dir` 目录中,通过 glob string 配置排除的文件
*
* README.md 文件一般作为主页或者某个目录下的主页,不应该被读取为 blog文章
*
* @default - ['.vuepress/', 'node_modules/', '{README,index}.md']
*/
exclude?: string[]
/**
* 分页配置
*/
pagination?: false | {
/**
* 每页显示的文章数量
* @default 10
*/
perPage?: number
/**
* 前一页的文本
* @default 'Prev'
*/
prevPageText?: string
/**
* 后一页的文本
* @default 'Next'
*/
nextPageText?: string
}
/**
* 是否启用标签页
* @default true
*/
tags?: boolean
/**
* 是否启用归档页
* @default true
*/
archives?: boolean
}
```
### article
- 类型: `string`
- 默认值: `/article/`
- 详情: 文章链接前缀
### navbar
- 类型: `NavItem[]`
@ -332,12 +322,30 @@ type NavItem = string | {
- 类型: `false | PlumeThemeNotesOptions`
- 默认值: `{ link: '/note', dir: 'notes', notes: [] }`
- 详情: 笔记配置, 笔记中的文章默认不会出现在首页文章列表
- 详情:
笔记配置, 笔记中的文章默认不会出现在首页文章列表
你可以将配置的notes 配置到 navbar中以便浏览查看
详细配置请查看 [此文档](/config/notes/)
### outline
- 类型: `false | number | [number, number] | 'deep'`
- 默认值: `[2, 3]`
- 详情:
要显示的标题级别。
单个数字表示只显示该级别的标题。
如果传递的是一个元组,第一个数字是最小级别,第二个数字是最大级别。
`'deep'``[2, 6]` 相同,将显示从 `<h2>``<h6>` 的所有标题。
每个页面可以通过 [frontmatter outline](./frontmatter/basic.md#outline) 覆盖层级配置。
### selectLanguageName
- 类型: `string`

View File

@ -1,16 +1,16 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { onContentUpdated } from '@vuepress-plume/plugin-content-update/client'
import { useActiveAnchor, useData } from '../composables/index.js'
import { type MenuItem, getHeaders, useActiveAnchor, useData } from '../composables/index.js'
import PageAsideItem from './PageAsideItem.vue'
const { page, theme } = useData()
const { theme, frontmatter } = useData()
const headers = ref(page.value.headers)
const headers = ref<MenuItem[]>([])
const hasOutline = computed(() => headers.value.length > 0)
onContentUpdated(() => {
headers.value = page.value.headers
headers.value = getHeaders(frontmatter.value.outline ?? theme.value.outline)
})
const container = ref()
@ -84,6 +84,7 @@ function handlePrint() {
width: 2px;
height: 18px;
background-color: var(--vp-c-brand-1);
border-radius: 2px;
opacity: 0;
transition:
top 0.25s cubic-bezier(0, 1, 0.5, 1),
@ -94,9 +95,9 @@ function handlePrint() {
.outline-title {
display: flex;
align-items: center;
font-size: 13px;
font-size: 14px;
font-weight: 600;
line-height: 28px;
line-height: 32px;
letter-spacing: 0.4px;
}

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { PageHeader } from 'vuepress/client'
import type { MenuItem } from '../composables/index.js'
defineProps<{
headers: PageHeader[]
headers: MenuItem[]
root?: boolean
}>()
@ -39,7 +39,9 @@ function handleClick({ target: el }: Event) {
.outline-link {
display: block;
overflow: hidden;
line-height: 28px;
font-size: 14px;
font-weight: 400;
line-height: 32px;
color: var(--vp-c-text-2);
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -1,11 +1,7 @@
import { useMediaQuery } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, onUpdated } from 'vue'
import { throttleAndDebounce } from '../utils/index.js'
import { computed } from 'vue'
import { useSidebar } from './sidebar.js'
const PAGE_OFFSET = 71
export function useAside() {
const { hasSidebar } = useSidebar()
const is960 = useMediaQuery('(min-width: 960px)')
@ -22,114 +18,3 @@ export function useAside() {
isAsideEnabled,
}
}
export function useActiveAnchor(
container: Ref<HTMLElement>,
marker: Ref<HTMLElement>,
) {
const { isAsideEnabled } = useAside()
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() {
if (!isAsideEnabled.value)
return
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 = document.body.offsetHeight
const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1
// page bottom - highlight last one
if (anchors.length && isBottom) {
activateLink(anchors[anchors.length - 1].hash)
return
}
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) {
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)}"]`,
)
}
const activeLink = prevActiveLink
if (activeLink) {
activeLink.classList.add('active')
marker.value.style.top = `${activeLink.offsetTop + 33}px`
marker.value.style.opacity = '1'
}
else {
marker.value.style.top = '33px'
marker.value.style.opacity = '0'
}
}
}
function getAnchorTop(anchor: HTMLAnchorElement): number {
return anchor.parentElement!.offsetTop - PAGE_OFFSET
}
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

@ -10,3 +10,4 @@ export * from './locale.js'
export * from './useRouteQuery.js'
export * from './watermark.js'
export * from './data.js'
export * from './outline.js'

View File

@ -0,0 +1,243 @@
import { onMounted, onUnmounted, onUpdated } from 'vue'
import type { Ref } from 'vue'
import type { PlumeThemeLocaleData } from '../../shared/index.js'
import { throttleAndDebounce } from '../utils/index.js'
import { useAside } from './aside.js'
export interface Header {
/**
* The level of the header
*
* `1` to `6` for `<h1>` to `<h6>`
*/
level: number
/**
* The title of the header
*/
title: string
/**
* The slug of the header
*
* Typically the `id` attr of the header anchor
*/
slug: string
/**
* Link of the header
*
* Typically using `#${slug}` as the anchor hash
*/
link: string
/**
* The children of the header
*/
children: Header[]
}
// cached list of anchor elements from resolveHeaders
const resolvedHeaders: { element: HTMLHeadElement, link: string }[] = []
export type MenuItem = Omit<Header, 'slug' | 'children'> & {
element: HTMLHeadElement
children?: MenuItem[]
}
export function getHeaders(range: PlumeThemeLocaleData['outline']): MenuItem[] {
const headers = Array.from(
document.querySelectorAll('.plume-content :where(h1,h2,h3,h4,h5,h6)'),
)
.filter(el => el.id && el.hasChildNodes())
.map((el) => {
const level = Number(el.tagName[1])
return {
element: el as HTMLHeadElement,
title: serializeHeader(el),
link: `#${el.id}`,
level,
}
})
return resolveHeaders(headers, range)
}
function serializeHeader(h: Element): string {
// <hx><a href="#"><span>title</span></a></hx>
const anchor = h.firstChild
const el = anchor?.firstChild
let ret = ''
for (const node of Array.from(el?.childNodes ?? [])) {
if (node.nodeType === 1) {
if (
(node as Element).classList.contains('badge-view')
|| (node as Element).classList.contains('ignore-header')
)
continue
ret += node.textContent
}
else if (node.nodeType === 3) {
ret += node.textContent
}
}
// maybe `<hx><a href="#"></a><a href="xxx"></a</hx>` or more
let next = anchor?.nextSibling
while (next) {
if (next.nodeType === 1 || next.nodeType === 3)
ret += next.textContent
next = next.nextSibling
}
return ret.trim()
}
export function resolveHeaders(headers: MenuItem[], range?: PlumeThemeLocaleData['outline']): MenuItem[] {
if (range === false)
return []
const levelsRange = range || 2
const [high, low]: [number, number]
= typeof levelsRange === 'number'
? [levelsRange, levelsRange]
: levelsRange === 'deep'
? [2, 6]
: levelsRange
headers = headers.filter(h => h.level >= high && h.level <= low)
// clear previous caches
resolvedHeaders.length = 0
// update global header list for active link rendering
for (const { element, link } of headers)
resolvedHeaders.push({ element, link })
const ret: MenuItem[] = []
// eslint-disable-next-line no-labels, no-restricted-syntax
outer: for (let i = 0; i < headers.length; i++) {
const cur = headers[i]
if (i === 0) {
ret.push(cur)
}
else {
for (let j = i - 1; j >= 0; j--) {
const prev = headers[j]
if (prev.level < cur.level) {
;(prev.children || (prev.children = [])).push(cur)
// eslint-disable-next-line no-labels
continue outer
}
}
ret.push(cur)
}
}
return ret
}
export function useActiveAnchor(container: Ref<HTMLElement>, marker: Ref<HTMLElement>): void {
const { isAsideEnabled } = useAside()
let prevActiveLink: HTMLAnchorElement | null = null
const setActiveLink = (): void => {
if (!isAsideEnabled.value)
return
const scrollY = window.scrollY
const innerHeight = window.innerHeight
const offsetHeight = document.body.offsetHeight
const isBottom = Math.abs(scrollY + innerHeight - offsetHeight) < 1
// resolvedHeaders may be repositioned, hidden or fix positioned
const headers = resolvedHeaders
.map(({ element, link }) => ({
link,
top: getAbsoluteTop(element),
}))
.filter(({ top }) => !Number.isNaN(top))
.sort((a, b) => a.top - b.top)
// no headers available for active link
if (!headers.length) {
activateLink(null)
return
}
// page top
if (scrollY < 1) {
activateLink(null)
return
}
// page bottom - highlight last link
if (isBottom) {
activateLink(headers[headers.length - 1].link)
return
}
// find the last header above the top of viewport
let activeLink: string | null = null
for (const { link, top } of headers) {
if (top > scrollY + 144)
break
activeLink = link
}
activateLink(activeLink)
}
function activateLink(hash: string | null): void {
if (prevActiveLink)
prevActiveLink.classList.remove('active')
if (hash == null) {
prevActiveLink = null
}
else {
prevActiveLink = container.value.querySelector(
`a[href="${decodeURIComponent(hash)}"]`,
)
}
const activeLink = prevActiveLink
if (activeLink) {
activeLink.classList.add('active')
marker.value.style.top = `${activeLink.offsetTop + 39}px`
marker.value.style.opacity = '1'
}
else {
marker.value.style.top = '33px'
marker.value.style.opacity = '0'
}
}
const onScroll = throttleAndDebounce(setActiveLink, 100)
onMounted(() => {
requestAnimationFrame(setActiveLink)
window.addEventListener('scroll', onScroll)
})
onUpdated(() => {
// sidebar update means a route change
activateLink(location.hash)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
}
function getAbsoluteTop(element: HTMLElement): number {
let offsetTop = 0
while (element !== document.body) {
if (element === null) {
// child element is:
// - not attached to the DOM (display: none)
// - set to fixed position (not scrollable)
// - body or html element (null offsetParent)
return Number.NaN
}
offsetTop += element.offsetTop
element = element.offsetParent as HTMLElement
}
return offsetTop
}

View File

@ -11,6 +11,7 @@ const FALLBACK_OPTIONS: PlumeThemeLocaleData = {
article: '/article/',
notes: { link: '/', dir: '/notes/', notes: [] },
navbarSocialInclude: ['github', 'twitter', 'discord', 'facebook'],
outline: [2, 3],
// page meta
editLink: true,

View File

@ -119,6 +119,7 @@ export interface PlumeThemePageFrontmatter {
contributors?: boolean
prev?: string | NavItemWithLink
next?: string | NavItemWithLink
outline?: false | number | [number, number] | 'deep'
backToTop?: boolean
externalLink?: boolean
readingTime?: boolean

View File

@ -74,6 +74,8 @@ export interface PlumeThemeLocaleData extends LocaleData {
*/
notes?: false | NotesDataOptions
outline?: false | number | [number, number] | 'deep'
/**
* language text
*/