chore(docs): polish theme toggle, search morph & page transitions (#2205)
--------- Co-authored-by: Neko <neko@ayaka.moe>
This commit is contained in:
@@ -34,12 +34,12 @@ const links = computed(() => [
|
||||
transition-colors duration-200 ease-in-out
|
||||
>
|
||||
<Icon
|
||||
class="text-xl"
|
||||
class="text-lg"
|
||||
:icon="link.icon"
|
||||
/>
|
||||
<span>{{ link.label }}</span>
|
||||
<Icon
|
||||
class="text-base"
|
||||
class="text-sm"
|
||||
icon="lucide:arrow-up-right"
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useScroll } from '@vueuse/core'
|
||||
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
|
||||
import { useData, useRoute, withBase } from 'vitepress'
|
||||
import { computed, ref, toRefs, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DocSidebarItem from '../components/DocSidebarItem.vue'
|
||||
|
||||
@@ -14,7 +13,6 @@ import { flatten } from '../utils/flatten'
|
||||
|
||||
const { path } = toRefs(useRoute())
|
||||
const { page, theme } = useData()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSidebarOpen = ref(false)
|
||||
const sidebar = computed(() => (theme.value.sidebar as (DefaultTheme.SidebarItem & { icon?: string })[]))
|
||||
@@ -24,17 +22,18 @@ const sectionTabs = computed(() => sidebar.value
|
||||
return {
|
||||
label: val.text,
|
||||
link: flatten(val.items ?? [], 'items').filter(i => !!i?.link)?.[0]?.link ?? val.link,
|
||||
// Highlight by the section's own link; tab.link points at the first
|
||||
// child page and would narrow the highlight to it.
|
||||
highlight: val.link,
|
||||
icon: val.icon,
|
||||
}
|
||||
})
|
||||
.filter(i => !!i?.link),
|
||||
)
|
||||
|
||||
function isCharacterPage(link?: string) {
|
||||
if (!link)
|
||||
return false
|
||||
|
||||
return link.includes('/characters') || link.includes('/characters/')
|
||||
function isTabActive(tab: { link?: string, highlight?: string }): boolean {
|
||||
const prefix = (tab.highlight ?? tab.link)?.split('/').slice(0, -1).join('/') ?? ''
|
||||
return withBase(`/${page.value.relativePath}`).includes(prefix)
|
||||
}
|
||||
|
||||
const { arrivedState } = useScroll(globalThis.window)
|
||||
@@ -55,28 +54,10 @@ watch(path, () => {
|
||||
<div />
|
||||
|
||||
<a
|
||||
v-for="tab in sectionTabs.filter(i => !isCharacterPage(i.link))"
|
||||
v-for="tab in sectionTabs"
|
||||
:key="tab.label"
|
||||
:href="tab.link"
|
||||
:class="{ '!after:bg-primary !text-foreground': withBase(`/${page.relativePath}`).includes(tab.link?.split('/').slice(0, -1).join('/') || '') }"
|
||||
class="relative mx-4 h-full inline-flex items-center py-2 text-sm text-muted-foreground font-semibold after:absolute after:bottom-0 after:h-0.5 after:w-full hover:border-b-muted after:rounded-t-full after:bg-transparent hover:text-foreground after:content-['']"
|
||||
transition-colors duration-200 ease-in-out
|
||||
>
|
||||
<Icon
|
||||
v-if="tab.icon"
|
||||
:icon="tab.icon"
|
||||
class="mr-2 text-lg"
|
||||
/>
|
||||
<span>{{ tab.label }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="h-full flex items-center">
|
||||
<a
|
||||
v-for="tab in sectionTabs.filter(i => isCharacterPage(i.link))"
|
||||
:key="tab.label"
|
||||
:href="tab.link"
|
||||
:class="{ '!after:bg-primary !text-foreground': withBase(page.relativePath).includes(tab.label?.toLowerCase() ?? '') }"
|
||||
:class="{ '!after:bg-primary !text-foreground': isTabActive(tab) }"
|
||||
class="relative mx-4 h-full inline-flex items-center py-2 text-sm text-muted-foreground font-semibold after:absolute after:bottom-0 after:h-0.5 after:w-full hover:border-b-muted after:rounded-t-full after:bg-transparent hover:text-foreground after:content-['']"
|
||||
transition-colors duration-200 ease-in-out
|
||||
>
|
||||
@@ -106,7 +87,7 @@ watch(path, () => {
|
||||
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
|
||||
<DialogContent class="data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left fixed inset-y-0 left-0 z-50 h-full w-3/4 gap-4 border-r border-muted bg-background pr-0 shadow-lg transition ease-in-out sm:max-w-sm data-[state=closed]:animate-exitToLeft data-[state=open]:animate-enterFromLeft">
|
||||
<DialogContent class="data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left fixed inset-y-0 left-0 z-50 h-full max-w-xs w-5/8 gap-4 border-r border-muted bg-background pr-0 shadow-lg transition ease-in-out data-[state=closed]:animate-exitToLeft data-[state=open]:animate-enterFromLeft">
|
||||
<DialogTitle class="sr-only">
|
||||
Sidebar menu
|
||||
</DialogTitle>
|
||||
@@ -140,20 +121,6 @@ watch(path, () => {
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<div class="h-full flex items-center">
|
||||
<a
|
||||
href="/characters/"
|
||||
:class="{ '!border-b-primary !font-semibold !text-foreground': withBase(page.relativePath).includes('characters') }"
|
||||
class="mx-4 h-full inline-flex items-center gap-2 border-b border-b-transparent py-2 text-sm text-muted-foreground font-medium hover:border-b-muted hover:text-foreground"
|
||||
>
|
||||
<Icon
|
||||
icon="lucide:scan-face"
|
||||
class="text-lg"
|
||||
/>
|
||||
{{ t('docs.theme.pages.characters.title') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -192,8 +192,12 @@ const buttons = computed(() => theme.value.homepage?.buttons || [])
|
||||
|
||||
<style>
|
||||
/* Infinite scrolling background pattern via mask */
|
||||
/* The SVG only acts as a mask; the pattern color is this background-color.
|
||||
Keep it opaque: the elements already carry `opacity-10`, so an extra
|
||||
translucent alpha would double-dilute it to ~0.8% and become invisible
|
||||
on the light background. `--foreground` is dark in light mode. */
|
||||
.bg-icon-pattern {
|
||||
background-color: white;
|
||||
background-color: hsl(var(--foreground));
|
||||
-webkit-mask-image: var(--bg-mask-icon-pattern);
|
||||
mask-image: var(--bg-mask-icon-pattern);
|
||||
-webkit-mask-repeat: repeat;
|
||||
@@ -205,11 +209,15 @@ const buttons = computed(() => theme.value.homepage?.buttons || [])
|
||||
animation: icon-mask-scroll 8s linear infinite;
|
||||
}
|
||||
|
||||
.dark .bg-icon-pattern {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@keyframes icon-mask-scroll {
|
||||
100% { -webkit-mask-position: 256px 256px; mask-position: 256px 256px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bg-ghost-pattern { animation: none; }
|
||||
.bg-icon-pattern { animation: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { dirname, sep } from 'pathe'
|
||||
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, Separator } from 'reka-ui'
|
||||
import { useData, useRoute } from 'vitepress'
|
||||
@@ -22,6 +23,14 @@ watch(path, () => {
|
||||
isPopoverOpen.value = false
|
||||
})
|
||||
|
||||
// Close the mobile menu when crossing to desktop: its teleported popup loses
|
||||
// its anchor once the trigger is hidden by xl:hidden and sticks to the corner.
|
||||
const isDesktop = useMediaQuery('(min-width: 1280px)')
|
||||
watch(isDesktop, (value) => {
|
||||
if (value)
|
||||
isPopoverOpen.value = false
|
||||
})
|
||||
|
||||
function isNavLinkActive(link: string, path: string) {
|
||||
let normalizedLink = link.toLowerCase()
|
||||
normalizedLink = normalizedLink.split(sep).filter(Boolean).length > (site.value.base !== '' ? 3 : 2) ? `${dirname(normalizedLink)}/` : normalizedLink
|
||||
@@ -33,7 +42,7 @@ function isNavLinkActive(link: string, path: string) {
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable vue/prefer-separate-static-class -->
|
||||
<nav class="hidden items-center lg:flex">
|
||||
<nav class="hidden items-center xl:flex">
|
||||
<template
|
||||
v-for="nav in theme.nav"
|
||||
:key="nav.text"
|
||||
@@ -92,7 +101,7 @@ function isNavLinkActive(link: string, path: string) {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="lg:hidden">
|
||||
<div class="xl:hidden">
|
||||
<DropdownMenuRoot v-model:open="isPopoverOpen">
|
||||
<DropdownMenuTrigger class="rounded-lg p-2">
|
||||
<Icon icon="lucide:ellipsis" class="text-lg" />
|
||||
|
||||
@@ -88,7 +88,7 @@ const maskImageURL = `url(${homeCover})`
|
||||
]"
|
||||
>
|
||||
<img ref="surface" :src="homeCover" alt="Project AIRI Cover Image" class="w-full object-cover">
|
||||
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.8105_0.1267_350.84)]" />
|
||||
<div ref="silhouetteLayer1" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.8105_0.1267_350.84)]" />
|
||||
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.5712_0.2396_278.59)]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,8 +8,6 @@ import { onMounted, shallowRef, useTemplateRef, watchEffect } from 'vue'
|
||||
import homeCover from '../assets/home-cover-2025-12-24.avif'
|
||||
|
||||
const surfaceRef = useTemplateRef<HTMLImageElement>('surface')
|
||||
const silhouetteLayer1Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer1')
|
||||
const silhouetteLayer2Ref = useTemplateRef<HTMLDivElement>('silhouetteLayer2')
|
||||
|
||||
const shouldReduceMotion = useLocalStorage('docs:settings/reduce-motion', false)
|
||||
|
||||
@@ -17,8 +15,6 @@ const DURATION = 1200
|
||||
const EASE = 'outSine'
|
||||
|
||||
const surfaceAnimatable = shallowRef<AnimatableObject>()
|
||||
const silhouetteLayer1Animatable = shallowRef<AnimatableObject>()
|
||||
const silhouetteLayer2Animatable = shallowRef<AnimatableObject>()
|
||||
|
||||
function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
|
||||
const referenceWidth = window.innerWidth
|
||||
@@ -26,14 +22,6 @@ function animateCover(xOffsetRatio: number, yOffsetRatio: number) {
|
||||
surfaceAnimatable.value?.x?.(-xOffsetRatio * 0.02 * referenceWidth)
|
||||
surfaceAnimatable.value?.y?.(-yOffsetRatio * 0.02 * referenceWidth)
|
||||
surfaceAnimatable.value?.z?.(0)
|
||||
|
||||
silhouetteLayer1Animatable.value?.x?.(0.01 * referenceWidth - yOffsetRatio * 0.015 * referenceWidth)
|
||||
silhouetteLayer1Animatable.value?.y?.(0.02 * referenceWidth + xOffsetRatio * 0.015 * referenceWidth)
|
||||
silhouetteLayer1Animatable.value?.z?.(0)
|
||||
|
||||
silhouetteLayer2Animatable.value?.x?.(0.01 * referenceWidth + yOffsetRatio * 0.01 * referenceWidth)
|
||||
silhouetteLayer2Animatable.value?.y?.(-0.01 * referenceWidth - yOffsetRatio * 0.01 * referenceWidth)
|
||||
silhouetteLayer2Animatable.value?.z?.(0)
|
||||
}
|
||||
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
@@ -55,8 +43,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
surfaceAnimatable.value = createAnimatable(surfaceRef.value!, animatableConfig)
|
||||
silhouetteLayer1Animatable.value = createAnimatable(silhouetteLayer1Ref.value!, animatableConfig)
|
||||
silhouetteLayer2Animatable.value = createAnimatable(silhouetteLayer2Ref.value!, animatableConfig)
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
|
||||
@@ -88,7 +88,7 @@ const maskImageURL = `url(${homeCover})`
|
||||
]"
|
||||
>
|
||||
<img ref="surface" :src="homeCover" alt="Project AIRI Cover Image" class="w-full object-cover">
|
||||
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.89_0.08_67.49)]" />
|
||||
<div ref="silhouetteLayer1" class="silhouette absolute left-0 top-0 z--1 h-full w-full bg-[oklch(0.89_0.08_67.49)]" />
|
||||
<div ref="silhouetteLayer2" class="silhouette absolute left-0 top-0 z--2 h-full w-full bg-[oklch(0.69_0.16_295.04)]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -49,10 +49,15 @@ const mark = computedAsync(async () => {
|
||||
return markRaw(new Mark(resultsEl.value))
|
||||
}, null)
|
||||
|
||||
const searchIndex = computedAsync(async () =>
|
||||
markRaw(
|
||||
const searchIndex = computedAsync(async () => {
|
||||
// Index loads asynchronously in onMounted; return undefined until ready.
|
||||
const data = searchIndexData.value
|
||||
if (!data)
|
||||
return undefined
|
||||
|
||||
return markRaw(
|
||||
MiniSearch.loadJSON<Result>(
|
||||
(await searchIndexData.value[localeIndex.value]?.())?.default,
|
||||
(await data[localeIndex.value]?.())?.default,
|
||||
{
|
||||
fields: ['title', 'titles', 'text'],
|
||||
storeFields: ['title', 'titles'],
|
||||
@@ -63,8 +68,8 @@ const searchIndex = computedAsync(async () =>
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const cache = new LRUCache(16) // 16 files
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useMagicKeys, whenever } from '@vueuse/core'
|
||||
import { AnimatePresence, Motion } from 'motion-v'
|
||||
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const SearchCommandBox = defineAsyncComponent(() => import('./SearchCommandBox.vue'))
|
||||
|
||||
const open = ref(false)
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const overlayRef = ref<HTMLElement>()
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const { meta_k } = useMagicKeys()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -22,11 +24,160 @@ function handleClose() {
|
||||
open.value = false
|
||||
})
|
||||
}
|
||||
|
||||
let contentAnim: Animation | undefined
|
||||
let overlayAnim: Animation | undefined
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
/** Unwrap a reka-ui component ref to its DOM element ($el). */
|
||||
function resolveElement(refValue: unknown): HTMLElement | undefined {
|
||||
const plain = (refValue as { $el?: unknown } | null | undefined)?.$el ?? refValue
|
||||
return plain instanceof HTMLElement ? plain : undefined
|
||||
}
|
||||
|
||||
/** Wait for the async content to settle; a near-zero height would blow up the FLIP scale. */
|
||||
async function waitForContent(
|
||||
getContent: () => HTMLElement | undefined,
|
||||
timeoutMs = 1500,
|
||||
): Promise<HTMLElement | undefined> {
|
||||
const start = performance.now()
|
||||
let el: HTMLElement | undefined
|
||||
let prevHeight = -1
|
||||
while (performance.now() - start < timeoutMs) {
|
||||
// Bail out early if the dialog was closed while we were waiting.
|
||||
if (!open.value)
|
||||
return undefined
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
el = getContent()
|
||||
if (!el)
|
||||
continue
|
||||
const height = el.getBoundingClientRect().height
|
||||
if (height > 0 && height === prevHeight)
|
||||
return el
|
||||
prevHeight = height
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* FLIP morph: animate the dialog from the search bar's rect (WAAPI, 0.2s);
|
||||
* closing replays it in reverse via the `data-state=closed` CSS animation.
|
||||
* Stores --morph-* for the close animation and cancels the WAAPI object
|
||||
* afterwards, otherwise it keeps locking transform and suppresses it.
|
||||
*
|
||||
* A run awaiting waitForContent must not apply animations once the dialog
|
||||
* has been closed (or reopened) in the meantime — guarded by `watchRun`.
|
||||
*/
|
||||
let watchRun = 0
|
||||
watch(open, async (isOpen) => {
|
||||
const run = ++watchRun
|
||||
|
||||
if (!isOpen) {
|
||||
// Snapshot the current computed state BEFORE cancelling the opening
|
||||
// animation: cancelling reverts to the class defaults (opacity-0), which
|
||||
// would make the closing animation jump to its `from` keyframe (full
|
||||
// opacity) first. Writing the snapshot into CSS variables lets the
|
||||
// closing keyframes start from the mid-flight state instead.
|
||||
const content = resolveElement(contentRef.value)
|
||||
if (content) {
|
||||
const current = getComputedStyle(content)
|
||||
content.style.setProperty('--morph-from-transform', current.transform)
|
||||
content.style.setProperty('--morph-from-opacity', current.opacity)
|
||||
}
|
||||
const overlay = resolveElement(overlayRef.value)
|
||||
if (overlay)
|
||||
overlay.style.setProperty('--morph-from-opacity', getComputedStyle(overlay).opacity)
|
||||
}
|
||||
|
||||
contentAnim?.cancel()
|
||||
overlayAnim?.cancel()
|
||||
contentAnim = undefined
|
||||
overlayAnim = undefined
|
||||
|
||||
if (!isOpen) {
|
||||
// Clear the open animation's inline styles so the CSS close animation can run.
|
||||
const content = resolveElement(contentRef.value)
|
||||
if (content) {
|
||||
content.style.transform = ''
|
||||
content.style.opacity = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const content = await waitForContent(
|
||||
() => resolveElement(contentRef.value) ?? document.querySelector<HTMLElement>('.search-dialog') ?? undefined,
|
||||
)
|
||||
// Stale guard: the dialog may have been closed (or reopened) while we were
|
||||
// waiting — abort, so the opening animation never applies to a closing dialog.
|
||||
if (run !== watchRun || !open.value)
|
||||
return
|
||||
|
||||
const trigger = resolveElement(triggerRef.value)
|
||||
if (!trigger || !content)
|
||||
return
|
||||
|
||||
const triggerRect = trigger.getBoundingClientRect()
|
||||
const contentRect = content.getBoundingClientRect()
|
||||
|
||||
const scaleX = clamp(triggerRect.width / contentRect.width, 0.1, 1)
|
||||
const scaleY = clamp(triggerRect.height / contentRect.height, 0.1, 1)
|
||||
const translateX = (triggerRect.left + triggerRect.width / 2) - (contentRect.left + contentRect.width / 2)
|
||||
const translateY = (triggerRect.top + triggerRect.height / 2) - (contentRect.top + contentRect.height / 2)
|
||||
|
||||
const { style } = content
|
||||
style.setProperty('--morph-x', `${translateX}px`)
|
||||
style.setProperty('--morph-y', `${translateY}px`)
|
||||
style.setProperty('--morph-sx', `${scaleX}`)
|
||||
style.setProperty('--morph-sy', `${scaleY}`)
|
||||
|
||||
// Start at the search bar position (content is opacity-0, so no flash)
|
||||
style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`
|
||||
content.getBoundingClientRect() // force reflow so the initial state applies first
|
||||
|
||||
const animation = content.animate(
|
||||
[
|
||||
{ transform: `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`, opacity: 0 },
|
||||
{ transform: 'none', opacity: 1 },
|
||||
],
|
||||
// Nonlinear scale (fast-in, slow-out, no overshoot).
|
||||
{ duration: 200, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' },
|
||||
)
|
||||
contentAnim = animation
|
||||
animation.finished
|
||||
.then(() => {
|
||||
animation.cancel()
|
||||
style.transform = ''
|
||||
style.opacity = '1' // cancel reverts to the class's opacity-0; keep it visible explicitly
|
||||
if (contentAnim === animation)
|
||||
contentAnim = undefined
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
const overlay = resolveElement(overlayRef.value)
|
||||
if (overlay) {
|
||||
const overlayAnimation = overlay.animate(
|
||||
[{ opacity: 0 }, { opacity: 1 }],
|
||||
{ duration: 200, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' },
|
||||
)
|
||||
overlayAnim = overlayAnimation
|
||||
overlayAnimation.finished
|
||||
.then(() => {
|
||||
overlayAnimation.cancel()
|
||||
overlay.style.opacity = '1'
|
||||
if (overlayAnim === overlayAnimation)
|
||||
overlayAnim = undefined
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogTrigger class="text-md flex items-center border-muted rounded-lg px-3 py-[7px] text-muted-foreground transition-colors duration-200 ease-in-out space-x-2 md:border hover:bg-muted md:bg-card md:text-sm">
|
||||
<DialogTrigger ref="triggerRef" class="text-md flex items-center border-muted rounded-lg px-3 py-[7px] text-muted-foreground transition-colors duration-200 ease-in-out space-x-2 md:border hover:bg-muted md:bg-card md:text-sm">
|
||||
<Icon icon="lucide:search" />
|
||||
<span class="hidden w-24 text-left lg:w-40 md:inline-flex">{{ t('docs.theme.search.title') }}</span>
|
||||
<span class="hidden text-xs prose md:inline-flex">
|
||||
@@ -35,32 +186,22 @@ function handleClose() {
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal>
|
||||
<AnimatePresence multiple>
|
||||
<DialogOverlay as-child>
|
||||
<Motion
|
||||
class="fixed inset-0 z-30 bg-background/50 backdrop-blur-md"
|
||||
:initial="{ opacity: 0, scale: 0 }"
|
||||
:animate="{ opacity: 1, scale: 1 }"
|
||||
:exit="{ opacity: 0 }"
|
||||
/>
|
||||
</DialogOverlay>
|
||||
<DialogContent as-child>
|
||||
<Motion
|
||||
class="fixed left-[50%] top-[10%] z-[100] max-h-[85vh] max-w-[750px] w-[90vw] translate-x-[-50%] overflow-hidden border border-muted rounded-xl bg-card shadow-xl will-change-transform focus:outline-none"
|
||||
:initial="{ opacity: 0, top: '0%', transition: { duration: 0.2, ease: 'easeInOut' } }"
|
||||
:animate="{ opacity: 1, top: '10%', transition: { duration: 0.2, ease: 'easeInOut' } }"
|
||||
:exit="{ opacity: 0, top: '0%', transition: { duration: 0.2, ease: 'easeInOut' } }"
|
||||
>
|
||||
<DialogTitle class="sr-only">
|
||||
Search documentation
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
Show related results based on search term
|
||||
</DialogDescription>
|
||||
<SearchCommandBox @close="handleClose" />
|
||||
</Motion>
|
||||
</DialogContent>
|
||||
</AnimatePresence>
|
||||
<DialogOverlay
|
||||
ref="overlayRef"
|
||||
class="search-overlay fixed inset-0 z-30 bg-background/50 opacity-0 backdrop-blur-md"
|
||||
/>
|
||||
<DialogContent
|
||||
ref="contentRef"
|
||||
class="search-dialog fixed inset-x-0 top-[10%] z-[100] mx-auto max-h-[85vh] max-w-[750px] w-[90vw] origin-center overflow-hidden border border-muted rounded-xl bg-card opacity-0 shadow-xl focus:outline-none"
|
||||
>
|
||||
<DialogTitle class="sr-only">
|
||||
Search documentation
|
||||
</DialogTitle>
|
||||
<DialogDescription class="sr-only">
|
||||
Show related results based on search term
|
||||
</DialogDescription>
|
||||
<SearchCommandBox @close="handleClose" />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,12 @@ import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
import { useData } from 'vitepress'
|
||||
import { ref, watchPostEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
// DropdownMenuItem as-child passes item attrs here; ClientOnly (fragment)
|
||||
// can't inherit them, so bind $attrs onto SwitchRoot manually.
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const { isDark } = useData()
|
||||
|
||||
const switchTitle = ref('')
|
||||
@@ -13,15 +19,35 @@ watchPostEffect(() => {
|
||||
? 'Switch to light theme'
|
||||
: 'Switch to dark theme'
|
||||
})
|
||||
|
||||
/**
|
||||
* Wraps the theme switch in a View Transition so the browser cross-fades the
|
||||
* whole page snapshots, keeping every element in sync. Falls back to an
|
||||
* instant switch where `startViewTransition` is unsupported (e.g. Firefox).
|
||||
*/
|
||||
function onToggle(value: boolean) {
|
||||
if (value === isDark.value)
|
||||
return
|
||||
if (typeof document !== 'undefined' && 'startViewTransition' in document) {
|
||||
document.startViewTransition(() => {
|
||||
isDark.value = value
|
||||
})
|
||||
}
|
||||
else {
|
||||
isDark.value = value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<SwitchRoot
|
||||
id="theme-toggle"
|
||||
v-model="isDark"
|
||||
v-bind="$attrs"
|
||||
:model-value="isDark"
|
||||
class="relative h-6 w-11 flex flex-shrink-0 border border-muted-foreground/10 rounded-full bg-muted"
|
||||
:aria-label="switchTitle"
|
||||
@update:model-value="onToggle"
|
||||
>
|
||||
<SwitchThumb
|
||||
class="my-auto h-5 w-5 flex translate-x-0.5 items-center justify-center border border-muted rounded-full bg-background text-xs text-muted-foreground will-change-transform data-[state=checked]:translate-x-5 !transition-transform"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Color from 'colorjs.io'
|
||||
|
||||
import { withRetry } from '@moeru/std'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { useData } from 'vitepress'
|
||||
|
||||
export function themeColorFromPropertyOf(colorFromClass: string, property: string): () => Promise<string> {
|
||||
return async () => {
|
||||
@@ -18,15 +18,21 @@ export function themeColorFromPropertyOf(colorFromClass: string, property: strin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a theme color from a static value or per-scheme values.
|
||||
*
|
||||
* `useData()` must run here (factory body, called from `setup()`): inside the
|
||||
* returned async closure the inject context is gone and it would throw.
|
||||
* Reading VitePress' `isDark` also avoids stray `useDark()` instances that
|
||||
* force the theme back to the system preference.
|
||||
*/
|
||||
export function themeColorFromValue(value: string | { light: string, dark: string }): () => Promise<string> {
|
||||
const { isDark } = useData()
|
||||
return async () => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
else {
|
||||
const dark = useDark()
|
||||
return dark.value ? value.dark : value.light
|
||||
}
|
||||
return isDark.value ? value.dark : value.light
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export default defineConfig<ThemeConfig>({
|
||||
{
|
||||
text: 'Manual',
|
||||
icon: 'lucide:book-open',
|
||||
link: withBase('/en/docs/manual/'),
|
||||
items: [
|
||||
{
|
||||
text: 'Quick Start',
|
||||
@@ -194,11 +195,6 @@ export default defineConfig<ThemeConfig>({
|
||||
{ text: 'Before Story v0.0.1', link: withBase('/en/docs/chronicles/version-v0.0.1/') },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Characters',
|
||||
icon: 'lucide:scan-face',
|
||||
link: withBase('/en/characters/'),
|
||||
},
|
||||
] as (DefaultTheme.SidebarItem & { icon?: string })[],
|
||||
|
||||
homepage: {
|
||||
@@ -282,12 +278,12 @@ export default defineConfig<ThemeConfig>({
|
||||
{ text: '先前的故事 v0.0.1', link: withBase('/zh-Hans/docs/chronicles/version-v0.0.1/') },
|
||||
],
|
||||
},
|
||||
{ text: '角色', link: withBase('/zh-Hans/characters/') },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '用户手册',
|
||||
icon: 'lucide:book-open',
|
||||
link: withBase('/zh-Hans/docs/manual/'),
|
||||
items: [
|
||||
{
|
||||
text: '快速开始',
|
||||
@@ -521,6 +517,7 @@ export default defineConfig<ThemeConfig>({
|
||||
{
|
||||
text: 'マニュアル',
|
||||
icon: 'lucide:book-open',
|
||||
link: withBase('/ja/docs/manual/'),
|
||||
items: [
|
||||
{
|
||||
text: 'クイックスタート',
|
||||
@@ -577,11 +574,6 @@ export default defineConfig<ThemeConfig>({
|
||||
{ text: '前日譚 v0.0.1', link: withBase('/ja/docs/chronicles/version-v0.0.1/') },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'キャラクター',
|
||||
icon: 'lucide:scan-face',
|
||||
link: withBase('/ja/characters/'),
|
||||
},
|
||||
] as (DefaultTheme.SidebarItem & { icon?: string })[],
|
||||
|
||||
homepage: {
|
||||
@@ -662,6 +654,7 @@ export default defineConfig<ThemeConfig>({
|
||||
{
|
||||
text: '사용 설명서',
|
||||
icon: 'lucide:book-open',
|
||||
link: withBase('/ko/docs/manual/'),
|
||||
items: [
|
||||
{
|
||||
text: '빠른 시작',
|
||||
|
||||
@@ -4,10 +4,11 @@ import type { DefaultTheme } from 'vitepress/theme'
|
||||
import type { Author } from '../functions/authors.data'
|
||||
|
||||
import { tryCatch } from '@moeru/std'
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { intlFormat } from 'date-fns'
|
||||
import { AvatarFallback, AvatarImage, AvatarRoot } from 'reka-ui'
|
||||
import { Content, useData, useRoute } from 'vitepress'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { computed, onMounted, ref, toRefs, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// import DocCarbonAds from '../components/DocCarbonAds.vue'
|
||||
@@ -107,6 +108,39 @@ const authors = computed(() => {
|
||||
const data = (authorsData as unknown as { data: Array<{ url: string, authors: Author[] }> }).data
|
||||
return data.find(item => item.url === path.value)?.authors || []
|
||||
})
|
||||
|
||||
// Title snapshot keyed by `path`: `frontmatter` updates before the article
|
||||
// key changes, which would swap the leaving page's title to the new one.
|
||||
const pageTitle = ref(frontmatter.value.title || '')
|
||||
|
||||
watch(path, () => {
|
||||
pageTitle.value = frontmatter.value.title || ''
|
||||
})
|
||||
|
||||
// Lock the first paint: hydration replays the transition, so start with an
|
||||
// empty `name` (transition classes match no `.fade-*` CSS → no animation)
|
||||
// and switch to "fade" only after the article height stabilizes.
|
||||
const transitionName = ref('')
|
||||
|
||||
// Also honor the user's reduced-motion preference (system setting); the CSS
|
||||
// media query in theme-animations.css backs this up for live preference changes.
|
||||
const reducedMotion = usePreferredReducedMotion()
|
||||
|
||||
onMounted(async () => {
|
||||
const article = document.querySelector<HTMLElement>('.docs-article')
|
||||
if (article) {
|
||||
let prevHeight = -1
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
const height = article.getBoundingClientRect().height
|
||||
if (height > 0 && height === prevHeight)
|
||||
break
|
||||
prevHeight = height
|
||||
}
|
||||
}
|
||||
if (!reducedMotion.value)
|
||||
transitionName.value = 'fade'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,60 +179,69 @@ const authors = computed(() => {
|
||||
</aside>
|
||||
|
||||
<div class="flex-1 overflow-x-hidden px-6 py-6 md:px-24 md:py-12">
|
||||
<div class="mb-2 text-sm text-primary font-bold">
|
||||
<!-- Section name for mobile (desktop has the sidebar) -->
|
||||
<div class="mb-2 text-sm text-primary font-bold md:hidden">
|
||||
{{ activeSection?.text }}
|
||||
</div>
|
||||
<article class="docs-article max-w-none w-full font-sans prose prose-slate dark:prose-invert">
|
||||
<h1>
|
||||
{{ frontmatter.title || '' }}
|
||||
</h1>
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
mode="out-in"
|
||||
>
|
||||
<article
|
||||
:key="path"
|
||||
class="docs-article max-w-none w-full font-sans prose prose-slate dark:prose-invert"
|
||||
>
|
||||
<h1>
|
||||
{{ pageTitle }}
|
||||
</h1>
|
||||
|
||||
<div v-if="publishedAt || authors && authors.length" class="mb-10 mt-5 flex flex-col gap-3 sm:gap-5">
|
||||
<div v-if="publishedAt" class="text-neutral-400 dark:text-neutral-500">
|
||||
<span>
|
||||
{{ t('docs.theme.doc.published-at', { date: publishedAt }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="publishedAt || authors && authors.length" class="mb-10 mt-5 flex flex-col gap-3 sm:gap-5">
|
||||
<div v-if="publishedAt" class="text-neutral-400 dark:text-neutral-500">
|
||||
<span>
|
||||
{{ t('docs.theme.doc.published-at', { date: publishedAt }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2 sm:gap-4">
|
||||
<!-- Authors -->
|
||||
<div v-for="(author, index) of authors" :key="index" class="flex flex-row items-center gap-2.5">
|
||||
<AvatarRoot class="size-10 inline-flex select-none items-center justify-center overflow-hidden rounded-full bg-neutral-100 align-middle dark:bg-neutral-800">
|
||||
<AvatarImage
|
||||
class="h-full w-full rounded-[inherit] object-cover"
|
||||
:src="author.avatar || author.avatarFallback"
|
||||
:alt="`${author.displayName}'s avatar`"
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="h-full w-full flex items-center justify-center bg-white text-sm text-primary font-medium leading-1 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
:delay-ms="600"
|
||||
as-child
|
||||
>
|
||||
{{
|
||||
[
|
||||
author.displayName.charAt(0).toUpperCase(),
|
||||
author.displayName.charAt(1).toUpperCase(),
|
||||
].join('')
|
||||
}}
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
<div class="flex flex-row gap-2 sm:gap-4">
|
||||
<!-- Authors -->
|
||||
<div v-for="(author, index) of authors" :key="index" class="flex flex-row items-center gap-2.5">
|
||||
<AvatarRoot class="size-10 inline-flex select-none items-center justify-center overflow-hidden rounded-full bg-neutral-100 align-middle dark:bg-neutral-800">
|
||||
<AvatarImage
|
||||
class="h-full w-full rounded-[inherit] object-cover"
|
||||
:src="author.avatar || author.avatarFallback"
|
||||
:alt="`${author.displayName}'s avatar`"
|
||||
/>
|
||||
<AvatarFallback
|
||||
class="h-full w-full flex items-center justify-center bg-white text-sm text-primary font-medium leading-1 dark:bg-neutral-800 dark:text-neutral-300"
|
||||
:delay-ms="600"
|
||||
as-child
|
||||
>
|
||||
{{
|
||||
[
|
||||
author.displayName.charAt(0).toUpperCase(),
|
||||
author.displayName.charAt(1).toUpperCase(),
|
||||
].join('')
|
||||
}}
|
||||
</AvatarFallback>
|
||||
</AvatarRoot>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<span>{{ author.displayName }}</span>
|
||||
</div>
|
||||
<div v-if="author.githubUsername">
|
||||
<a :href="`https://github.com/${author.githubUsername}`" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">
|
||||
<span>{{ author.githubUsername }}</span>
|
||||
</a>
|
||||
<div class="flex flex-col">
|
||||
<div>
|
||||
<span>{{ author.displayName }}</span>
|
||||
</div>
|
||||
<div v-if="author.githubUsername">
|
||||
<a :href="`https://github.com/${author.githubUsername}`" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">
|
||||
<span>{{ author.githubUsername }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Content />
|
||||
</article>
|
||||
<Content />
|
||||
</article>
|
||||
</Transition>
|
||||
|
||||
<DocFooter v-if="!isCharactersPage" />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
/* Search dialog close: reverse of the open FLIP morph (--morph-* set in
|
||||
SearchTrigger.vue); reka-ui's Presence waits for these animations. */
|
||||
.search-overlay[data-state='closed'] {
|
||||
animation: search-fade-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.search-dialog[data-state='closed'] {
|
||||
animation: search-morph-out 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes search-fade-out {
|
||||
from { opacity: var(--morph-from-opacity, 1); }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
/* The `from` frame starts from the snapshot SearchTrigger.vue wrote before
|
||||
cancelling the opening animation, so a mid-flight close does not flash the
|
||||
full visible state first. */
|
||||
@keyframes search-morph-out {
|
||||
from {
|
||||
transform: var(--morph-from-transform, none);
|
||||
opacity: var(--morph-from-opacity, 1);
|
||||
}
|
||||
to {
|
||||
transform: translate(var(--morph-x, 0px), var(--morph-y, 0px))
|
||||
scale(var(--morph-sx, 1), var(--morph-sy, 1));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Honor the user's reduced-motion preference for the route transition
|
||||
(Docs.vue also keeps its transition name empty in that case). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ description: Resources for referencing and taking inspiration from
|
||||
|
||||
One of the greatest CSS stage transition author I know is **[yui540](https://yui540.com/)**, he/she designed so many other stunning looking ACG websites, and the most famous one was [臆病な魔女](https://cowardly-witch.netlify.app/) (source code can be found under [yui540](https://github.com/yui540?tab=repositories)).
|
||||
|
||||
To achieve the similar transition effect like the above, you may reference to this repository [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations), it has a [live demo](https://yui540.github.io/css-animations/2025-02-25/transitions/) you can play around too.
|
||||
To achieve the similar transition effect like the above, you may reference to this repository [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations).
|
||||
|
||||
### [Nihe Works](https://nihe.work/)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: 参照やインスピレーションを得るためのリソース
|
||||
|
||||
私が知る限り、最も素晴らしい CSS ステージ遷移の作者の一人は **[yui540](https://yui540.com/)** です。彼/彼女は他にも多くの素晴らしい見た目の ACG ウェブサイトをデザインしており、最も有名なものは [臆病な魔女](https://cowardly-witch.netlify.app/) でした (ソースコードは [yui540](https://github.com/yui540?tab=repositories) で見つけることができます)。
|
||||
|
||||
上記のような同様の遷移効果を実現するには、このリポジトリ [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) を参照してください。遊べる [ライブデモ](https://yui540.github.io/css-animations/2025-02-25/transitions/) もあります。
|
||||
上記のような同様の遷移効果を実現するには、このリポジトリ [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) を参照してください。
|
||||
|
||||
### [Nihe Works](https://nihe.work/)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: 참고하고 영감을 얻을 수 있는 자료들
|
||||
|
||||
제가 아는 최고의 CSS 화면 전환 제작자 중 한 명은 **[yui540](https://yui540.com/)** 입니다. 눈부신 ACG 웹사이트를 정말 많이 디자인했고, 그중 가장 유명한 작품은 [臆病な魔女](https://cowardly-witch.netlify.app/) 입니다 (소스 코드는 [yui540](https://github.com/yui540?tab=repositories) 에서 찾아볼 수 있습니다).
|
||||
|
||||
위와 비슷한 전환 효과를 구현하고 싶다면 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) 저장소를 참고하세요. 직접 만져볼 수 있는 [라이브 데모](https://yui540.github.io/css-animations/2025-02-25/transitions/) 도 있습니다.
|
||||
위와 비슷한 전환 효과를 구현하고 싶다면 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations) 저장소를 참고하세요.
|
||||
|
||||
### [Nihe Works](https://nihe.work/)
|
||||
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
/* /zh-Hans/:splat 301 Language=zh-CN
|
||||
/* /ko/:splat 301 Language=ko
|
||||
/* /ko/:splat 301 Language=ko-KR
|
||||
/* /ja/:splat 301 Language=ja
|
||||
/* /ja/:splat 301 Language=ja-JP
|
||||
/* /en/:splat 301
|
||||
|
||||
@@ -7,7 +7,7 @@ description: 用于参考和获取灵感的资源
|
||||
|
||||
我所知道的最伟大的 CSS 场景过渡效果作者之一是 **[yui540](https://yui540.com/)**,他/她设计了许多其他外观惊艳的 ACG 网站,其中最著名的是 [臆病な魔女](https://cowardly-witch.netlify.app/)(源代码可以在 [yui540](https://github.com/yui540?tab=repositories) 下找到)。
|
||||
|
||||
要实现类似上述的过渡效果,您可以参考这个仓库 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations),它还有一个 [在线演示](https://yui540.github.io/css-animations/2025-02-25/transitions/) 供您试玩。
|
||||
要实现类似上述的过渡效果,您可以参考这个仓库 [yui540/css-animations: 俺流CSSアニメーション](https://github.com/yui540/css-animations)
|
||||
|
||||
### [Nihe Works](https://nihe.work/)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user