From 764c58693e14adb799ec01aa0c0cc4fd7240b399 Mon Sep 17 00:00:00 2001 From: pengzhanbo Date: Sun, 12 Feb 2023 06:03:52 +0800 Subject: [PATCH] feat(theme): add page components --- packages/theme/package.json | 1 + .../theme/src/client/components/BlogPage.vue | 28 ++ .../src/client/components/LayoutContent.vue | 48 ++ .../src/client/components/Nav/NavBarTitle.vue | 9 +- packages/theme/src/client/components/Page.vue | 159 ++++++- .../theme/src/client/components/PageAside.vue | 94 ++++ .../src/client/components/PageAsideItem.vue | 51 +++ .../theme/src/client/components/Sidebar.vue | 148 ++++++ .../src/client/components/SidebarItem.vue | 223 +++++++++ packages/theme/src/client/components/Toc.ts | 148 ++++++ .../theme/src/client/composables/aside.ts | 140 ++++++ .../theme/src/client/composables/index.ts | 2 + .../theme/src/client/composables/sidebar.ts | 41 +- packages/theme/src/client/layouts/Layout.vue | 34 +- .../theme/src/client/styles/_variables.scss | 5 + packages/theme/src/client/styles/code.scss | 277 ++++++++++++ packages/theme/src/client/styles/content.scss | 424 +++++++++--------- packages/theme/src/client/styles/index.scss | 4 + packages/theme/src/client/styles/toc.scss | 64 +++ packages/theme/src/client/styles/vars.scss | 6 + packages/theme/src/client/utils/animate.ts | 14 + packages/theme/src/client/utils/dom.ts | 65 +++ packages/theme/src/client/utils/index.ts | 1 + packages/theme/src/client/utils/shared.ts | 22 + packages/theme/src/node/plugins.ts | 14 +- packages/theme/src/shared/index.ts | 1 + packages/theme/src/shared/page.ts | 7 + pnpm-lock.yaml | 27 ++ 28 files changed, 1832 insertions(+), 225 deletions(-) create mode 100644 packages/theme/src/client/components/LayoutContent.vue create mode 100644 packages/theme/src/client/components/PageAside.vue create mode 100644 packages/theme/src/client/components/PageAsideItem.vue create mode 100644 packages/theme/src/client/components/Sidebar.vue create mode 100644 packages/theme/src/client/components/SidebarItem.vue create mode 100644 packages/theme/src/client/components/Toc.ts create mode 100644 packages/theme/src/client/composables/aside.ts create mode 100644 packages/theme/src/client/styles/_variables.scss create mode 100644 packages/theme/src/client/styles/code.scss create mode 100644 packages/theme/src/client/styles/toc.scss create mode 100644 packages/theme/src/client/utils/animate.ts create mode 100644 packages/theme/src/client/utils/dom.ts create mode 100644 packages/theme/src/shared/page.ts diff --git a/packages/theme/package.json b/packages/theme/package.json index 521f9aa6..868e2053 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -52,6 +52,7 @@ "@vuepress/plugin-palette": "2.0.0-beta.60", "@vuepress/plugin-prismjs": "2.0.0-beta.60", "@vuepress/plugin-search": "2.0.0-beta.60", + "@vuepress/plugin-shiki": "2.0.0-beta.60", "@vuepress/plugin-theme-data": "2.0.0-beta.60", "@vuepress/plugin-toc": "2.0.0-beta.60", "@vuepress/shared": "2.0.0-beta.60", diff --git a/packages/theme/src/client/components/BlogPage.vue b/packages/theme/src/client/components/BlogPage.vue index e69de29b..de33f312 100644 --- a/packages/theme/src/client/components/BlogPage.vue +++ b/packages/theme/src/client/components/BlogPage.vue @@ -0,0 +1,28 @@ + + + + diff --git a/packages/theme/src/client/components/LayoutContent.vue b/packages/theme/src/client/components/LayoutContent.vue new file mode 100644 index 00000000..ac9d8d61 --- /dev/null +++ b/packages/theme/src/client/components/LayoutContent.vue @@ -0,0 +1,48 @@ + + + + diff --git a/packages/theme/src/client/components/Nav/NavBarTitle.vue b/packages/theme/src/client/components/Nav/NavBarTitle.vue index 24c92803..3f2a80d5 100644 --- a/packages/theme/src/client/components/Nav/NavBarTitle.vue +++ b/packages/theme/src/client/components/Nav/NavBarTitle.vue @@ -1,22 +1,25 @@ diff --git a/packages/theme/src/client/components/Page.vue b/packages/theme/src/client/components/Page.vue index 0a35923b..4326bf5f 100644 --- a/packages/theme/src/client/components/Page.vue +++ b/packages/theme/src/client/components/Page.vue @@ -1,11 +1,164 @@ + - 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 @@ + + + + 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 @@