diff --git a/demo/starter/slides.md b/demo/starter/slides.md index 27bafb7e8e..1f389375ba 100644 --- a/demo/starter/slides.md +++ b/demo/starter/slides.md @@ -373,8 +373,6 @@ also allows you to add ---- -preload: false --- # Motions @@ -385,18 +383,21 @@ Motion animations are powered by [@vueuse/motion](https://motion.vueuse.org/), t
+ :enter="{ x: 0 }" + :click-3="{ x: 80 }" + :leave="{ x: 1000 }" +> Slidev
``` -
+
@@ -404,7 +405,7 @@ Motion animations are powered by [@vueuse/motion](https://motion.vueuse.org/), t v-motion :initial="{ y: 500, x: -100, scale: 2 }" :enter="final" - class="absolute top-0 left-0 right-0 bottom-0" + class="absolute inset-0" src="https://sli.dev/logo-circle.png" alt="" /> @@ -412,7 +413,7 @@ Motion animations are powered by [@vueuse/motion](https://motion.vueuse.org/), t v-motion :initial="{ x: 600, y: 400, scale: 2, rotate: 100 }" :enter="final" - class="absolute top-0 left-0 right-0 bottom-0" + class="absolute inset-0" src="https://sli.dev/logo-triangle.png" alt="" /> @@ -445,7 +446,7 @@ const final = {
[Learn More](https://sli.dev/guide/animations.html#motion) diff --git a/docs/guide/animations.md b/docs/guide/animations.md index cff29b3953..6cf7977897 100644 --- a/docs/guide/animations.md +++ b/docs/guide/animations.md @@ -338,34 +338,64 @@ Slidev has [@vueuse/motion](https://motion.vueuse.org/) built-in. You can use th
+ :enter="{ x: 0 }" + :leave="{ x: 80 }" +> Slidev
``` -The text `Slidev` will move from `-80px` to its original position on initialization. +The text `Slidev` will move from `-80px` to its original position when entering the slide. When leaving, it will move to `80px`. -> Note: Slidev preloads the next slide for performance, which means the animations might start before you navigate to the page. To get it works properly, you can disable the preloading for the particular slide -> -> ```md -> --- -> preload: false -> --- -> ``` +> Before v0.48.9, you need to add `preload: false` to the slide's frontmatter to enable motion. + +### Motion with Clicks + +> Available since v0.48.9 + +You can also trigger the motion by clicks. For example + +```html +
-> Or control the element life-cycle with `v-if` to have fine-grained controls + Slidev +
+``` + +Or combine `v-click` with `v-motion`: + +```html +
-> ```html ->
v-if="$slidev.nav.currentPage === 7" -> v-motion -> :initial="{ x: -80 }" -> :enter="{ x: 0 }"> -> Slidev ->
-> ``` - -Learn mode: [Demo](https://sli.dev/demo/starter/7) | [@vueuse/motion](https://motion.vueuse.org/) | [v-motion](https://motion.vueuse.org/features/directive-usage) | [Presets](https://motion.vueuse.org/features/presets) + Shown at click 2 and hidden at click 4. +
+``` + +The meanings of variants: + +- `initial`: When `currentPage < thisPage`, or `v-click` hides the current element because `$clicks` is too small. +- `enter`: When `currentPage === thisPage`, and `v-click` shows the element. _Priority: lowest_ +- `click-x`: `x` is a number representing the **absolute** click num. The variant will take effect if `$clicks >= x`. _Priority: `x`_ +- `click-x-y`: The variant will take effect if `x <= $clicks < y`. _Priority: `x`_ +- `leave`: `currentPage > thisPage`, or `v-click` hides the current element because `$clicks` is too large. + +The variants will be combined according to the priority defined above. + +::: warning +Due to a Vue internal [bug](https://github.com/vuejs/core/issues/10295), currently **only** `v-click` to the same element of `v-motion` can control the motion animation. As a workaround, you can use something like `v-if="3 < $clicks"` to achieve the same effect. +::: + +Learn mode: [Demo](https://sli.dev/demo/starter/10) | [@vueuse/motion](https://motion.vueuse.org/) | [v-motion](https://motion.vueuse.org/features/directive-usage) | [Presets](https://motion.vueuse.org/features/presets) ## Slide Transitions diff --git a/packages/client/constants.ts b/packages/client/constants.ts index dda6d5b943..742e79b296 100644 --- a/packages/client/constants.ts +++ b/packages/client/constants.ts @@ -13,6 +13,7 @@ export const injectionRenderContext = '$$slidev-render-context' as unknown as In export const injectionActive = '$$slidev-active' as unknown as InjectionKey> export const injectionFrontmatter = '$$slidev-fontmatter' as unknown as InjectionKey> export const injectionSlideZoom = '$$slidev-slide-zoom' as unknown as InjectionKey> +export const injectionClickVisibility = '$$slidev-click-visibility' as unknown as InjectionKey> export const CLASS_VCLICK_TARGET = 'slidev-vclick-target' export const CLASS_VCLICK_HIDDEN = 'slidev-vclick-hidden' diff --git a/packages/client/modules/v-click.ts b/packages/client/modules/v-click.ts index 53301e15d6..6310d6d706 100644 --- a/packages/client/modules/v-click.ts +++ b/packages/client/modules/v-click.ts @@ -1,5 +1,5 @@ import type { ResolvedClicksInfo } from '@slidev/types' -import type { App, DirectiveBinding, InjectionKey } from 'vue' +import type { App, DirectiveBinding } from 'vue' import { computed, watchEffect } from 'vue' import { CLASS_VCLICK_CURRENT, @@ -8,14 +8,12 @@ import { CLASS_VCLICK_HIDDEN_EXP, CLASS_VCLICK_PRIOR, CLASS_VCLICK_TARGET, + injectionClickVisibility, injectionClicksContext, } from '../constants' +import { directiveInject, directiveProvide } from '../utils' -export type VClickValue = string | [string | number, string | number] | boolean - -export function dirInject(dir: DirectiveBinding, key: InjectionKey | string, defaultValue?: T): T | undefined { - return (dir.instance?.$ as any).provides[key as any] ?? defaultValue -} +export type VClickValue = undefined | string | number | [string | number, string | number] | boolean export function createVClickDirectives() { return { @@ -25,7 +23,7 @@ export function createVClickDirectives() { name: 'v-click', mounted(el, dir) { - const resolved = resolveClick(el, dir, dir.value) + const resolved = resolveClick(el, dir, dir.value, true) if (resolved == null) return @@ -37,7 +35,8 @@ export function createVClickDirectives() { if (clicks[1] != null) el.dataset.slidevClicksEnd = String(clicks[1]) - watchEffect(() => { + // @ts-expect-error extra prop + el.watchStopHandle = watchEffect(() => { const active = resolved.isActive.value const current = resolved.isCurrent.value const prior = active && !current @@ -62,13 +61,14 @@ export function createVClickDirectives() { name: 'v-after', mounted(el, dir) { - const resolved = resolveClick(el, dir, dir.value, true) + const resolved = resolveClick(el, dir, dir.value, true, true) if (resolved == null) return el.classList.toggle(CLASS_VCLICK_TARGET, true) - watchEffect(() => { + // @ts-expect-error extra prop + el.watchStopHandle = watchEffect(() => { const active = resolved.isActive.value const current = resolved.isCurrent.value const prior = active && !current @@ -93,13 +93,14 @@ export function createVClickDirectives() { name: 'v-click-hide', mounted(el, dir) { - const resolved = resolveClick(el, dir, dir.value, false, true) + const resolved = resolveClick(el, dir, dir.value, true, false, true) if (resolved == null) return el.classList.toggle(CLASS_VCLICK_TARGET, true) - watchEffect(() => { + // @ts-expect-error extra prop + el.watchStopHandle = watchEffect(() => { const active = resolved.isActive.value const current = resolved.isCurrent.value const prior = active && !current @@ -117,20 +118,20 @@ export function createVClickDirectives() { } } -function isActive(thisClick: number | [number, number], clicks: number) { +function isClickActive(thisClick: number | [number, number], clicks: number) { return Array.isArray(thisClick) ? thisClick[0] <= clicks && clicks < thisClick[1] : thisClick <= clicks } -function isCurrent(thisClick: number | [number, number], clicks: number) { +function isClickCurrent(thisClick: number | [number, number], clicks: number) { return Array.isArray(thisClick) ? thisClick[0] === clicks : thisClick === clicks } -export function resolveClick(el: Element, dir: DirectiveBinding, value: VClickValue, clickAfter = false, flagHide = false): ResolvedClicksInfo | null { - const ctx = dirInject(dir, injectionClicksContext)?.value +export function resolveClick(el: Element | string, dir: DirectiveBinding, value: VClickValue, provideVisibility = false, clickAfter = false, flagHide = false): ResolvedClicksInfo | null { + const ctx = directiveInject(dir, injectionClicksContext)?.value if (!el || !ctx) return null @@ -152,29 +153,47 @@ export function resolveClick(el: Element, dir: DirectiveBinding, value: VCl if (Array.isArray(value)) { // range (absolute) delta = 0 - thisClick = value as [number, number] + thisClick = [+value[0], +value[1]] maxClick = +value[1] } else { ({ start: thisClick, end: maxClick, delta } = ctx.resolve(value)) } + const isActive = computed(() => isClickActive(thisClick, ctx.current)) + const isCurrent = computed(() => isClickCurrent(thisClick, ctx.current)) + const isShown = computed(() => flagHide ? !isActive.value : isActive.value) + const resolved: ResolvedClicksInfo = { max: maxClick, clicks: thisClick, delta, - isActive: computed(() => isActive(thisClick, ctx.current)), - isCurrent: computed(() => isCurrent(thisClick, ctx.current)), - isShown: computed(() => flagHide ? !isActive(thisClick, ctx.current) : isActive(thisClick, ctx.current)), + isActive, + isCurrent, + isShown, flagFade, flagHide, } ctx.register(el, resolved) + + if (provideVisibility) { + directiveProvide(dir, injectionClickVisibility, computed(() => { + if (isShown.value) + return true + if (Array.isArray(thisClick)) + return ctx.current < thisClick[0] ? 'before' : 'after' + else + return flagHide ? 'after' : 'before' + })) + } + return resolved } function unmounted(el: HTMLElement, dir: DirectiveBinding) { el.classList.toggle(CLASS_VCLICK_TARGET, false) - const ctx = dirInject(dir, injectionClicksContext)?.value + const ctx = directiveInject(dir, injectionClicksContext)?.value ctx?.unregister(el) + // @ts-expect-error extra prop + el.watchStopHandle?.() } diff --git a/packages/client/modules/v-mark.ts b/packages/client/modules/v-mark.ts index 830c747d18..bd32e0d801 100644 --- a/packages/client/modules/v-mark.ts +++ b/packages/client/modules/v-mark.ts @@ -121,7 +121,8 @@ export function createVMarkDirective() { return } - watchEffect(() => { + // @ts-expect-error extra prop + el.watchStopHandle = watchEffect(() => { let shouldShow: boolean | undefined if (options.value.class) @@ -147,6 +148,11 @@ export function createVMarkDirective() { annotation.hide() }) }, + + unmounted: (el) => { + // @ts-expect-error extra prop + el.watchStopHandle?.() + }, }) }, } diff --git a/packages/client/modules/v-motion.ts b/packages/client/modules/v-motion.ts new file mode 100644 index 0000000000..efb601f0aa --- /dev/null +++ b/packages/client/modules/v-motion.ts @@ -0,0 +1,120 @@ +import type { App, ObjectDirective } from 'vue' +import { watch } from 'vue' +import { MotionDirective } from '@vueuse/motion' +import type { ResolvedClicksInfo } from '@slidev/types' +import { injectionClickVisibility, injectionClicksContext, injectionCurrentPage, injectionRenderContext } from '../constants' +import { useNav } from '../composables/useNav' +import { makeId } from '../logic/utils' +import { directiveInject } from '../utils' +import type { VClickValue } from './v-click' +import { resolveClick } from './v-click' + +export type MotionDirectiveValue = undefined | VClickValue | { + key?: string + at?: VClickValue +} + +export function createVMotionDirectives() { + return { + install(app: App) { + const original = MotionDirective() as ObjectDirective + app.directive('motion', { + // @ts-expect-error extra prop + name: 'v-motion', + mounted(el, binding, node, prevNode) { + const props = node.props = { ...node.props } + + const variantInitial = { ...props.initial, ...props.variants?.['slidev-initial'] } + const variantEnter = { ...props.enter, ...props.variants?.['slidev-enter'] } + const variantLeave = { ...props.leave, ...props.variants?.['slidev-leave'] } + delete props.initial + delete props.enter + delete props.leave + + const idPrefix = `${makeId()}-` + const clicks: { + id: string + at: number | [number, number] + variant: Record + resolved: ResolvedClicksInfo | null + }[] = [] + + for (const k of Object.keys(props)) { + if (k.startsWith('click-')) { + const s = k.slice(6) + const at = s.includes('-') ? s.split('-').map(Number) as [number, number] : +s + const id = idPrefix + s + clicks.push({ + id, + at, + variant: { ...props[k] }, + resolved: resolveClick(id, binding, at), + }) + delete props[k] + } + } + + clicks.sort((a, b) => (Array.isArray(a.at) ? a.at[0] : a.at) - (Array.isArray(b.at) ? b.at[0] : b.at)) + + original.created!(el, binding, node, prevNode) + original.mounted!(el, binding, node, prevNode) + + const thisPage = directiveInject(binding, injectionCurrentPage) + const renderContext = directiveInject(binding, injectionRenderContext) + const clickVisibility = directiveInject(binding, injectionClickVisibility) + const clicksContext = directiveInject(binding, injectionClicksContext) + const { currentPage, clicks: currentClicks, isPrintMode } = useNav() + // @ts-expect-error extra prop + const motion = el.motionInstance + motion.clickIds = clicks.map(i => i.id) + motion.set(variantInitial) + motion.watchStopHandle = watch( + [thisPage, currentPage, currentClicks].filter(Boolean), + () => { + const visibility = clickVisibility?.value ?? true + if (!clicksContext?.value || !['slide', 'presenter'].includes(renderContext?.value ?? '')) { + const mixedVariant: Record = { ...variantInitial, ...variantEnter } + for (const { variant } of clicks) + Object.assign(mixedVariant, variant) + + motion.set(mixedVariant) + } + else if (isPrintMode.value || thisPage?.value === currentPage.value) { + if (visibility === true) { + const mixedVariant: Record = { ...variantInitial, ...variantEnter } + for (const { variant, resolved: resolvedClick } of clicks) { + if (!resolvedClick || resolvedClick.isActive.value) + Object.assign(mixedVariant, variant) + } + if (isPrintMode.value) + motion.set(mixedVariant) // print with clicks + else + motion.apply(mixedVariant) + } + else { + motion.apply(visibility === 'before' ? variantInitial : variantLeave) + } + } + else { + motion.apply((thisPage?.value ?? -1) > currentPage.value ? variantInitial : variantLeave) + } + }, + { + immediate: true, + }, + ) + }, + unmounted(el, dir) { + if (!directiveInject(dir, injectionClicksContext)?.value) + return + + const ctx = directiveInject(dir, injectionClicksContext)?.value + // @ts-expect-error extra prop + const motion = el.motionInstance + motion.clickIds.map((id: string) => ctx?.unregister(id)) + motion.watchStopHandle() + }, + }) + }, + } +} diff --git a/packages/client/setup/main.ts b/packages/client/setup/main.ts index 5002e49180..0a7f465b8f 100644 --- a/packages/client/setup/main.ts +++ b/packages/client/setup/main.ts @@ -1,5 +1,4 @@ import type { AppContext } from '@slidev/types' -import { MotionPlugin } from '@vueuse/motion' import TwoSlashFloatingVue from '@shikijs/vitepress-twoslash/client' import type { App } from 'vue' import { nextTick } from 'vue' @@ -8,6 +7,7 @@ import { createHead } from '@unhead/vue' import { routeForceRefresh } from '../logic/route' import { createVClickDirectives } from '../modules/v-click' import { createVMarkDirective } from '../modules/v-mark' +import { createVMotionDirectives } from '../modules/v-motion' import { routes } from '../routes' import setups from '#slidev/setups/main' @@ -34,7 +34,7 @@ export default async function setupMain(app: App) { app.use(createHead()) app.use(createVClickDirectives()) app.use(createVMarkDirective()) - app.use(MotionPlugin) + app.use(createVMotionDirectives()) app.use(TwoSlashFloatingVue as any, { container: '#twoslash-container' }) const context: AppContext = { diff --git a/packages/client/utils.ts b/packages/client/utils.ts index bcd8fa7db0..dd5f8e5010 100644 --- a/packages/client/utils.ts +++ b/packages/client/utils.ts @@ -1,4 +1,5 @@ import type { SlideRoute } from '@slidev/types' +import type { DirectiveBinding, InjectionKey } from 'vue' import { configs } from './env' export function getSlideClass(route?: SlideRoute, extra = '') { @@ -22,3 +23,18 @@ export async function downloadPDF() { `${configs.title}.pdf`, ) } + +export function directiveInject(dir: DirectiveBinding, key: InjectionKey | string, defaultValue?: T): T | undefined { + return (dir.instance?.$ as any).provides[key as any] ?? defaultValue +} + +export function directiveProvide(dir: DirectiveBinding, key: InjectionKey | string, value?: T) { + const instance = dir.instance?.$ as any + if (instance) { + let provides = instance.provides + const parentProvides = instance.parent?.provides + if (provides === parentProvides) + provides = instance.provides = Object.create(parentProvides) + provides[key as any] = value + } +}