-
+
-
diff --git a/packages/theme/src/client/components/PageAside.vue b/packages/theme/src/client/components/PageAside.vue
new file mode 100644
index 00000000..8e7d4d0a
--- /dev/null
+++ b/packages/theme/src/client/components/PageAside.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
On this page
+
+
+
+
+
+
+
+
diff --git a/packages/theme/src/client/components/PageAsideItem.vue b/packages/theme/src/client/components/PageAsideItem.vue
new file mode 100644
index 00000000..b9257d11
--- /dev/null
+++ b/packages/theme/src/client/components/PageAsideItem.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
diff --git a/packages/theme/src/client/components/Sidebar.vue b/packages/theme/src/client/components/Sidebar.vue
new file mode 100644
index 00000000..a0eef293
--- /dev/null
+++ b/packages/theme/src/client/components/Sidebar.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
diff --git a/packages/theme/src/client/components/SidebarItem.vue b/packages/theme/src/client/components/SidebarItem.vue
new file mode 100644
index 00000000..75c17662
--- /dev/null
+++ b/packages/theme/src/client/components/SidebarItem.vue
@@ -0,0 +1,223 @@
+
+
+
+
+
+
+
diff --git a/packages/theme/src/client/components/Toc.ts b/packages/theme/src/client/components/Toc.ts
new file mode 100644
index 00000000..56c64365
--- /dev/null
+++ b/packages/theme/src/client/components/Toc.ts
@@ -0,0 +1,148 @@
+import { usePageData } from '@vuepress/client'
+import type { PageHeader } from '@vuepress/client'
+import type { PropType, VNode } from 'vue'
+import { computed, defineComponent, h, toRefs } from 'vue'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { useRoute } from 'vue-router'
+import { scrollTo } from '../utils/index.js'
+
+export type TocPropsHeaders = PageHeader[]
+
+export interface TocPropsOptions {
+ containerTag: string
+ containerClass: string
+ listClass: string
+ itemClass: string
+ linkClass: string
+ linkActiveClass: string
+ linkChildrenActiveClass: string
+}
+
+export interface TocProps {
+ headers: TocPropsHeaders
+ options: TocPropsOptions
+}
+
+const renderLink = (
+ header: PageHeader,
+ options: TocPropsOptions,
+ route: RouteLocationNormalizedLoaded
+): VNode => {
+ const hash = `#${header.slug}`
+ const linkClass = [options.linkClass]
+
+ if (options.linkActiveClass && route.hash === hash) {
+ linkClass.push(options.linkActiveClass)
+ }
+
+ if (
+ options.linkChildrenActiveClass &&
+ header.children.some((item) => `#${item.slug}` === route.hash)
+ ) {
+ linkClass.push(options.linkChildrenActiveClass)
+ }
+
+ const setActiveRouteHash = (): void => {
+ const headerAnchors: HTMLAnchorElement[] = Array.from(
+ document.querySelectorAll('.header-anchor')
+ )
+ const anchor = headerAnchors.find(
+ (anchor) => decodeURI(anchor.hash) === hash
+ )
+ if (!anchor) return
+ const el = document.documentElement
+ const top = anchor.getBoundingClientRect().top - 80 + el.scrollTop
+ scrollTo(document, top)
+ }
+
+ return h(
+ 'a',
+ {
+ href: hash,
+ class: linkClass,
+ ariaLabel: header.title,
+ onClick: (e: MouseEvent) => {
+ e.preventDefault()
+ setActiveRouteHash()
+ },
+ },
+ header.title
+ )
+}
+
+const renderHeaders = (
+ headers: PageHeader[],
+ options: TocPropsOptions,
+ route: RouteLocationNormalizedLoaded
+): VNode[] => {
+ if (headers.length === 0) {
+ return []
+ }
+ return [
+ h(
+ 'ul',
+ { class: options.listClass },
+ headers.map((header) =>
+ h('li', { class: options.itemClass }, [
+ renderLink(header, options, route),
+ renderHeaders(header.children, options, route),
+ ])
+ )
+ ),
+ ]
+}
+
+const Toc = defineComponent({
+ name: 'Toc',
+ props: {
+ headers: {
+ type: Array as PropType
,
+ required: false,
+ default: null,
+ },
+ options: {
+ type: Object as PropType,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ setup(props) {
+ const { headers: propsHeaders, options: propsOptions } = toRefs(props)
+
+ const defaultOptions: TocPropsOptions = {
+ containerTag: 'nav',
+ containerClass: 'theme-plume-toc',
+ listClass: 'theme-plume-toc-list',
+ itemClass: 'theme-plume-toc-item',
+ linkClass: 'theme-plume-toc-link',
+ linkActiveClass: 'active',
+ linkChildrenActiveClass: 'active',
+ }
+
+ const route = useRoute()
+ const page = usePageData()
+ const headers = computed(() => {
+ const headerToUse = propsHeaders.value || page.value.headers
+
+ return headerToUse[0]?.level === 1 ? headerToUse[0].children : headerToUse
+ })
+ const options = computed(() => ({
+ ...defaultOptions,
+ ...propsOptions.value,
+ }))
+
+ return () => {
+ const renderedHeaders = renderHeaders(headers.value, options.value, route)
+ if (options.value.containerTag) {
+ return h(
+ options.value.containerTag,
+ { class: options.value.containerClass },
+ renderedHeaders
+ )
+ }
+ return renderedHeaders
+ }
+ },
+})
+
+export default Toc
diff --git a/packages/theme/src/client/composables/aside.ts b/packages/theme/src/client/composables/aside.ts
new file mode 100644
index 00000000..9e8ea367
--- /dev/null
+++ b/packages/theme/src/client/composables/aside.ts
@@ -0,0 +1,140 @@
+import { useMediaQuery } from '@vueuse/core'
+import type { Ref } from 'vue'
+import { computed, onMounted, onUnmounted, onUpdated } from 'vue'
+import { throttleAndDebounce } from '../utils/index.js'
+import { useSidebar } from './sidebar.js'
+
+const PAGE_OFFSET = 71
+
+export function useAside() {
+ const { hasSidebar } = useSidebar()
+ const is960 = useMediaQuery('(min-width: 960px)')
+ const is1280 = useMediaQuery('(min-width: 1280px)')
+
+ const isAsideEnabled = computed(() => {
+ if (!is1280.value && !is960.value) {
+ return false
+ }
+
+ return hasSidebar.value ? is1280.value : is960.value
+ })
+
+ return {
+ isAsideEnabled,
+ }
+}
+
+export function useActiveAnchor(
+ container: Ref,
+ marker: Ref
+) {
+ 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]
+}
diff --git a/packages/theme/src/client/composables/index.ts b/packages/theme/src/client/composables/index.ts
index 4dfbe520..124f8dbe 100644
--- a/packages/theme/src/client/composables/index.ts
+++ b/packages/theme/src/client/composables/index.ts
@@ -2,3 +2,5 @@ export * from './darkMode.js'
export * from './useScrollPromise.js'
export * from './themeData.js'
export * from './useResolveRouteWithRedirect.js'
+export * from './sidebar.js'
+export * from './aside.js'
diff --git a/packages/theme/src/client/composables/sidebar.ts b/packages/theme/src/client/composables/sidebar.ts
index 85738c51..12dbc467 100644
--- a/packages/theme/src/client/composables/sidebar.ts
+++ b/packages/theme/src/client/composables/sidebar.ts
@@ -9,6 +9,7 @@ import { useMediaQuery } from '@vueuse/core'
import type { ComputedRef, Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
+import type { PlumeThemePageData } from '../../shared/index.js'
import { isActive } from '../utils/index.js'
import { useThemeLocaleData } from './themeData.js'
@@ -24,6 +25,7 @@ export function useSidebar() {
const notesData = useNotesData()
const theme = useThemeLocaleData()
const frontmatter = usePageFrontmatter()
+ const page = usePageData()
const is960 = useMediaQuery('(min-width: 960px)')
@@ -33,7 +35,11 @@ export function useSidebar() {
return theme.value.notes ? getSidebarList(route.path, notesData.value) : []
})
const hasSidebar = computed(() => {
- return !frontmatter.value.home && sidebar.value.length > 0
+ return (
+ !frontmatter.value.home &&
+ !page.value.isBlogPost &&
+ sidebar.value.length > 0
+ )
})
const hasAside = computed(() => {
@@ -42,6 +48,10 @@ export function useSidebar() {
const isSidebarEnabled = computed(() => hasSidebar.value && is960.value)
+ const sidebarGroups = computed(() => {
+ return hasSidebar.value ? getSidebarGroups(sidebar.value) : []
+ })
+
function open() {
isOpen.value = true
}
@@ -60,6 +70,7 @@ export function useSidebar() {
hasSidebar,
hasAside,
isSidebarEnabled,
+ sidebarGroups,
open,
close,
toggle,
@@ -167,3 +178,31 @@ export function containsActiveLink(
? containsActiveLink(path, items.items as NotesSidebarItem[])
: false
}
+
+/**
+ * Get or generate sidebar group from the given sidebar items.
+ */
+export function getSidebarGroups(
+ sidebar: NotesSidebarItem[]
+): NotesSidebarItem[] {
+ const groups: NotesSidebarItem[] = []
+
+ let lastGroupIndex = 0
+
+ for (const index in sidebar) {
+ const item = sidebar[index]
+
+ if (item.items) {
+ lastGroupIndex = groups.push(item)
+ continue
+ }
+
+ if (!groups[lastGroupIndex]) {
+ groups.push({ items: [] })
+ }
+
+ groups[lastGroupIndex]!.items!.push(item)
+ }
+
+ return groups
+}
diff --git a/packages/theme/src/client/layouts/Layout.vue b/packages/theme/src/client/layouts/Layout.vue
index 8d3f278e..709f37a1 100644
--- a/packages/theme/src/client/layouts/Layout.vue
+++ b/packages/theme/src/client/layouts/Layout.vue
@@ -1,7 +1,34 @@