feat(theme): 新增自定义有侧边栏层级配置
This commit is contained in:
parent
131be4424f
commit
90f5409642
@ -54,7 +54,7 @@ permalink: /config/frontmatter/basic/
|
|||||||
- 类型: `boolean`
|
- 类型: `boolean`
|
||||||
- 默认值: `true`
|
- 默认值: `true`
|
||||||
|
|
||||||
当前文章内的 外部链接是否显示 外部链接图标, 即 “ <ExternalLinkIcon /> ” 图标
|
当前文章内的 外部链接是否显示 外部链接图标。
|
||||||
|
|
||||||
### backToTop
|
### backToTop
|
||||||
|
|
||||||
@ -77,6 +77,20 @@ permalink: /config/frontmatter/basic/
|
|||||||
|
|
||||||
当前文章是否 显示 右侧边栏。
|
当前文章是否 显示 右侧边栏。
|
||||||
|
|
||||||
|
### outline
|
||||||
|
|
||||||
|
- 类型: `false | number | [number, number] | 'deep'`
|
||||||
|
- 默认值: `[2, 3]`
|
||||||
|
- 详情:
|
||||||
|
|
||||||
|
要显示的标题级别。
|
||||||
|
|
||||||
|
单个数字表示只显示该级别的标题。
|
||||||
|
|
||||||
|
如果传递的是一个元组,第一个数字是最小级别,第二个数字是最大级别。
|
||||||
|
|
||||||
|
`'deep'` 与 `[2, 6]` 相同,将显示从 `<h2>` 到 `<h6>` 的所有标题。
|
||||||
|
|
||||||
### prev
|
### prev
|
||||||
|
|
||||||
- 类型: `string | { text: string, link: string, icon?: string }`
|
- 类型: `string | { text: string, link: string, icon?: string }`
|
||||||
|
|||||||
@ -16,6 +16,79 @@ permalink: /config/basic/
|
|||||||
主题使用的插件默认已进行了配置,大多数情况下您不需要进行任何修改,如果需要使用到细致的定制化,请查阅
|
主题使用的插件默认已进行了配置,大多数情况下您不需要进行任何修改,如果需要使用到细致的定制化,请查阅
|
||||||
[此文档](/config/plugins/)
|
[此文档](/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
|
### locales
|
||||||
|
|
||||||
- 类型: `Record<string, PlumeThemeLocaleConfig>`
|
- 类型: `Record<string, PlumeThemeLocaleConfig>`
|
||||||
@ -69,16 +142,6 @@ permalink: /config/basic/
|
|||||||
- 默认值: `'Appearance'`
|
- 默认值: `'Appearance'`
|
||||||
- 详情: 导航栏中的主题切换按钮的文本。
|
- 详情: 导航栏中的主题切换按钮的文本。
|
||||||
|
|
||||||
### hostname
|
|
||||||
|
|
||||||
- 类型: `string`
|
|
||||||
- 默认值: `''`
|
|
||||||
- 详情:
|
|
||||||
|
|
||||||
部署站点域名。
|
|
||||||
|
|
||||||
当 `hostname` 配置为有效域名时,主题将会生成 `sitemap` 和 `seo` 相关的内容。
|
|
||||||
|
|
||||||
### avatar
|
### avatar
|
||||||
|
|
||||||
- 类型: `PlumeThemeAvatar`
|
- 类型: `PlumeThemeAvatar`
|
||||||
@ -162,79 +225,6 @@ export default {
|
|||||||
允许显示在导航栏的社交链接。
|
允许显示在导航栏的社交链接。
|
||||||
该配置仅在 PC 端下有效。
|
该配置仅在 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
|
### navbar
|
||||||
|
|
||||||
- 类型: `NavItem[]`
|
- 类型: `NavItem[]`
|
||||||
@ -332,12 +322,30 @@ type NavItem = string | {
|
|||||||
|
|
||||||
- 类型: `false | PlumeThemeNotesOptions`
|
- 类型: `false | PlumeThemeNotesOptions`
|
||||||
- 默认值: `{ link: '/note', dir: 'notes', notes: [] }`
|
- 默认值: `{ link: '/note', dir: 'notes', notes: [] }`
|
||||||
- 详情: 笔记配置, 笔记中的文章默认不会出现在首页文章列表
|
- 详情:
|
||||||
|
|
||||||
|
笔记配置, 笔记中的文章默认不会出现在首页文章列表
|
||||||
|
|
||||||
你可以将配置的notes 配置到 navbar中,以便浏览查看
|
你可以将配置的notes 配置到 navbar中,以便浏览查看
|
||||||
|
|
||||||
详细配置请查看 [此文档](/config/notes/)
|
详细配置请查看 [此文档](/config/notes/)
|
||||||
|
|
||||||
|
### outline
|
||||||
|
|
||||||
|
- 类型: `false | number | [number, number] | 'deep'`
|
||||||
|
- 默认值: `[2, 3]`
|
||||||
|
- 详情:
|
||||||
|
|
||||||
|
要显示的标题级别。
|
||||||
|
|
||||||
|
单个数字表示只显示该级别的标题。
|
||||||
|
|
||||||
|
如果传递的是一个元组,第一个数字是最小级别,第二个数字是最大级别。
|
||||||
|
|
||||||
|
`'deep'` 与 `[2, 6]` 相同,将显示从 `<h2>` 到 `<h6>` 的所有标题。
|
||||||
|
|
||||||
|
每个页面可以通过 [frontmatter outline](./frontmatter/basic.md#outline) 覆盖层级配置。
|
||||||
|
|
||||||
### selectLanguageName
|
### selectLanguageName
|
||||||
|
|
||||||
- 类型: `string`
|
- 类型: `string`
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onContentUpdated } from '@vuepress-plume/plugin-content-update/client'
|
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'
|
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)
|
const hasOutline = computed(() => headers.value.length > 0)
|
||||||
|
|
||||||
onContentUpdated(() => {
|
onContentUpdated(() => {
|
||||||
headers.value = page.value.headers
|
headers.value = getHeaders(frontmatter.value.outline ?? theme.value.outline)
|
||||||
})
|
})
|
||||||
|
|
||||||
const container = ref()
|
const container = ref()
|
||||||
@ -84,6 +84,7 @@ function handlePrint() {
|
|||||||
width: 2px;
|
width: 2px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background-color: var(--vp-c-brand-1);
|
background-color: var(--vp-c-brand-1);
|
||||||
|
border-radius: 2px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition:
|
transition:
|
||||||
top 0.25s cubic-bezier(0, 1, 0.5, 1),
|
top 0.25s cubic-bezier(0, 1, 0.5, 1),
|
||||||
@ -94,9 +95,9 @@ function handlePrint() {
|
|||||||
.outline-title {
|
.outline-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 28px;
|
line-height: 32px;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PageHeader } from 'vuepress/client'
|
import type { MenuItem } from '../composables/index.js'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
headers: PageHeader[]
|
headers: MenuItem[]
|
||||||
root?: boolean
|
root?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@ -39,7 +39,9 @@ function handleClick({ target: el }: Event) {
|
|||||||
.outline-link {
|
.outline-link {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 28px;
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 32px;
|
||||||
color: var(--vp-c-text-2);
|
color: var(--vp-c-text-2);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import type { Ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { computed, onMounted, onUnmounted, onUpdated } from 'vue'
|
|
||||||
import { throttleAndDebounce } from '../utils/index.js'
|
|
||||||
import { useSidebar } from './sidebar.js'
|
import { useSidebar } from './sidebar.js'
|
||||||
|
|
||||||
const PAGE_OFFSET = 71
|
|
||||||
|
|
||||||
export function useAside() {
|
export function useAside() {
|
||||||
const { hasSidebar } = useSidebar()
|
const { hasSidebar } = useSidebar()
|
||||||
const is960 = useMediaQuery('(min-width: 960px)')
|
const is960 = useMediaQuery('(min-width: 960px)')
|
||||||
@ -22,114 +18,3 @@ export function useAside() {
|
|||||||
isAsideEnabled,
|
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]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -10,3 +10,4 @@ export * from './locale.js'
|
|||||||
export * from './useRouteQuery.js'
|
export * from './useRouteQuery.js'
|
||||||
export * from './watermark.js'
|
export * from './watermark.js'
|
||||||
export * from './data.js'
|
export * from './data.js'
|
||||||
|
export * from './outline.js'
|
||||||
|
|||||||
243
theme/src/client/composables/outline.ts
Normal file
243
theme/src/client/composables/outline.ts
Normal 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
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ const FALLBACK_OPTIONS: PlumeThemeLocaleData = {
|
|||||||
article: '/article/',
|
article: '/article/',
|
||||||
notes: { link: '/', dir: '/notes/', notes: [] },
|
notes: { link: '/', dir: '/notes/', notes: [] },
|
||||||
navbarSocialInclude: ['github', 'twitter', 'discord', 'facebook'],
|
navbarSocialInclude: ['github', 'twitter', 'discord', 'facebook'],
|
||||||
|
outline: [2, 3],
|
||||||
|
|
||||||
// page meta
|
// page meta
|
||||||
editLink: true,
|
editLink: true,
|
||||||
|
|||||||
@ -119,6 +119,7 @@ export interface PlumeThemePageFrontmatter {
|
|||||||
contributors?: boolean
|
contributors?: boolean
|
||||||
prev?: string | NavItemWithLink
|
prev?: string | NavItemWithLink
|
||||||
next?: string | NavItemWithLink
|
next?: string | NavItemWithLink
|
||||||
|
outline?: false | number | [number, number] | 'deep'
|
||||||
backToTop?: boolean
|
backToTop?: boolean
|
||||||
externalLink?: boolean
|
externalLink?: boolean
|
||||||
readingTime?: boolean
|
readingTime?: boolean
|
||||||
|
|||||||
@ -74,6 +74,8 @@ export interface PlumeThemeLocaleData extends LocaleData {
|
|||||||
*/
|
*/
|
||||||
notes?: false | NotesDataOptions
|
notes?: false | NotesDataOptions
|
||||||
|
|
||||||
|
outline?: false | number | [number, number] | 'deep'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* language text
|
* language text
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user