From b3f06b9234f101f0556b34dd19993e18d001f0a6 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Sat, 22 Mar 2025 21:38:58 +0800 Subject: [PATCH 01/10] chore: split animation logic to animation feature --- docs/components/demo/reorder-layout/index.vue | 1 - .../src/features/animation/animation.ts | 202 ++++++++++++++++++ .../motion/src/features/animation/types.ts | 19 ++ .../src/features/gestures/hover/index.ts | 2 +- .../src/features/gestures/in-view/index.ts | 4 +- packages/motion/src/state/motion-state.ts | 18 +- packages/motion/src/state/transform.ts | 13 +- packages/motion/src/utils/noop.ts | 1 - .../nuxt/pages/reorder-layout/index.vue | 1 - 9 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 packages/motion/src/features/animation/types.ts delete mode 100644 packages/motion/src/utils/noop.ts diff --git a/docs/components/demo/reorder-layout/index.vue b/docs/components/demo/reorder-layout/index.vue index 577fc23..f9fdca2 100644 --- a/docs/components/demo/reorder-layout/index.vue +++ b/docs/components/demo/reorder-layout/index.vue @@ -41,7 +41,6 @@ function add() { class="tabs" > void constructor(state: MotionState) { super(state) + this.state.animateUpdates = this.animateUpdates } updateAnimationControlsSubscription() { @@ -15,6 +28,195 @@ export class AnimationFeature extends Feature { } } + animateUpdates: AnimateUpdates = ({ + controlActiveState, + directAnimate, + directTransition, + controlDelay = 0, + isFallback, + isExit, + } = {}) => { + const prevTarget = this.state.target + this.state.target = { ...this.state.baseTarget } + let animationOptions: $Transition = {} + const transition = { ...this.state.options.transition } + + animationOptions = this.resolveStateAnimation({ + controlActiveState, + directAnimate, + directTransition, + }) + + const factories = this.createAnimationFactories(prevTarget, animationOptions, controlDelay) + const { getChildAnimations, childAnimations } = this.setupChildAnimations(animationOptions, controlActiveState, isFallback) + + return this.executeAnimations({ + factories, + getChildAnimations, + childAnimations, + transition, + controlActiveState, + isExit, + }) + } + + executeAnimations( + { + factories, + getChildAnimations, + transition, + controlActiveState, + isExit = false, + }: { + factories: AnimationFactory[] + getChildAnimations: () => Promise + childAnimations: (() => Promise)[] + transition: $Transition | undefined + controlActiveState: Partial> | undefined + isExit: boolean + }, + ) { + const getAnimation = () => Promise.all(factories.map(factory => factory()).filter(Boolean)) + + const animationTarget = { ...this.state.target } + const element = this.state.element + + /** + * Finish the animation and dispatch events + */ + const finishAnimation = (animationPromise: Promise) => { + element.dispatchEvent(motionEvent('motionstart', animationTarget)) + animationPromise + .then(() => { + element.dispatchEvent(motionEvent('motioncomplete', animationTarget, isExit)) + }) + .catch(noop) + } + + /** + * Get the animation promise + */ + const getAnimationPromise = () => { + const animationPromise = transition?.when + ? (transition.when === 'beforeChildren' ? getAnimation() : getChildAnimations()) + .then(() => (transition.when === 'beforeChildren' ? getChildAnimations() : getAnimation())) + : Promise.all([getAnimation(), getChildAnimations()]) + + finishAnimation(animationPromise) + return animationPromise + } + + return controlActiveState ? getAnimationPromise : getAnimationPromise() + } + + /** + * Setup child animations + */ + setupChildAnimations( + transition: $Transition | undefined, + controlActiveState: Partial> | undefined, + isFallback: boolean, + ) { + if (!this.state.visualElement.variantChildren?.size || !controlActiveState) + return { getChildAnimations: () => Promise.resolve(), childAnimations: [] } + + const { staggerChildren = 0, staggerDirection = 1, delayChildren = 0 } = transition || {} + const maxStaggerDuration = (this.state.visualElement.variantChildren.size - 1) * staggerChildren + const generateStaggerDuration = staggerDirection === 1 + ? (i = 0) => i * staggerChildren + : (i = 0) => maxStaggerDuration - i * staggerChildren + + const childAnimations = Array.from(this.state.visualElement.variantChildren) + .map((child: VisualElement & { state: MotionState }, index) => { + const childDelay = delayChildren + generateStaggerDuration(index) + return child.state.animateUpdates({ + controlActiveState, + controlDelay: isFallback ? 0 : childDelay, + }) + }) + .filter(Boolean) as (() => Promise)[] + + return { + getChildAnimations: () => Promise.all(childAnimations.map((animation) => { + return animation?.() + })), + childAnimations, + } + } + + createAnimationFactories( + prevTarget: Record, + animationOptions: $Transition, + controlDelay: number, + ) { + const factories: AnimationFactory[] = [] + + Object.keys(this.state.target).forEach((key: any) => { + if (!hasChanged(prevTarget[key], this.state.target[key])) + return + this.state.baseTarget[key] ??= style.get(this.state.element, key) as string + const keyValue = (this.state.target[key] === 'none' && isDef(transformResetValue[key])) ? transformResetValue[key] : this.state.target[key] + factories.push(() => animate( + this.state.element, + { [key]: keyValue }, + { + ...(animationOptions?.[key] || animationOptions), + delay: (animationOptions?.[key]?.delay || animationOptions?.delay || 0) + controlDelay, + } as any, + )) + }) + return factories + } + + resolveStateAnimation( + { + controlActiveState, + directAnimate, + directTransition, + }: { + controlActiveState: Partial> | undefined + directAnimate: Options['animate'] + directTransition: $Transition | undefined + }, + ) { + let variantTransition = this.state.options.transition + let variant: Variant = {} + const { variants, custom, transition, animatePresenceContext } = this.state.options + const customValue = isDef(custom) ? custom : animatePresenceContext?.custom + if (controlActiveState) { + this.state.activeStates = { ...this.state.activeStates, ...controlActiveState } + STATE_TYPES.forEach((name) => { + if (!this.state.activeStates[name] || isAnimationControls(this.state.options[name])) + return + + const definition = this.state.options[name] + let resolvedVariant = isDef(definition) ? resolveVariant(definition as any, variants, customValue) : undefined + // If current node is a variant node, merge the control node's variant + if (this.state.visualElement.isVariantNode) { + const controlVariant = resolveVariant(this.state.context[name], variants, customValue) + resolvedVariant = controlVariant ? Object.assign(controlVariant || {}, resolvedVariant) : variant + } + if (!resolvedVariant) + return + if (name !== 'initial') + variantTransition = resolvedVariant.transition || this.state.options.transition || {} + variant = Object.assign(variant, resolvedVariant) + }) + } + + if (directAnimate) { + variant = resolveVariant(directAnimate, variants, customValue) + variantTransition = variant.transition || directTransition || transition + } + + Object.entries(variant).forEach(([key, value]) => { + if (key === 'transition') + return + this.state.target[key] = value + }) + return variantTransition + } + /** * Subscribe any provided AnimationControls to the component's VisualElement */ diff --git a/packages/motion/src/features/animation/types.ts b/packages/motion/src/features/animation/types.ts new file mode 100644 index 0000000..41fc0e7 --- /dev/null +++ b/packages/motion/src/features/animation/types.ts @@ -0,0 +1,19 @@ +import type { $Transition, Options } from '@/types' + +/** + * Core animation update function that handles all animation state changes and execution + * @param controlActiveState - Animation states that need to be updated + * @param controlDelay - Animation delay time + * @param directAnimate - Direct animation target value + * @param directTransition - Direct animation transition config + */ +export interface AnimateUpdatesOptions { + controlActiveState?: Partial> + controlDelay?: number + directAnimate?: Options['animate'] + directTransition?: $Transition + isFallback?: boolean + isExit?: boolean +} + +export type AnimateUpdates = (options?: AnimateUpdatesOptions) => Promise | (() => Promise) diff --git a/packages/motion/src/features/gestures/hover/index.ts b/packages/motion/src/features/gestures/hover/index.ts index 6d0319d..b20e0df 100644 --- a/packages/motion/src/features/gestures/hover/index.ts +++ b/packages/motion/src/features/gestures/hover/index.ts @@ -24,7 +24,7 @@ function handleHoverEvent( export class HoverGesture extends Feature { isActive() { - return Boolean(this.state.getOptions().whileHover) + return Boolean(this.state.options.whileHover) } constructor(state: MotionState) { diff --git a/packages/motion/src/features/gestures/in-view/index.ts b/packages/motion/src/features/gestures/in-view/index.ts index cd8c4e0..d1fa13f 100644 --- a/packages/motion/src/features/gestures/in-view/index.ts +++ b/packages/motion/src/features/gestures/in-view/index.ts @@ -25,7 +25,7 @@ function handleHoverEvent( export class InViewGesture extends Feature { isActive() { - return Boolean(this.state.getOptions().whileInView) + return Boolean(this.state.options.whileInView) } constructor(state: MotionState) { @@ -37,7 +37,7 @@ export class InViewGesture extends Feature { if (!element) return this.unmount() - const { once, ...viewOptions } = this.state.getOptions().inViewOptions || {} + const { once, ...viewOptions } = this.state.options.inViewOptions || {} this.unmount = inView( element, (_, entry) => { diff --git a/packages/motion/src/state/motion-state.ts b/packages/motion/src/state/motion-state.ts index 535be2f..fb2d644 100644 --- a/packages/motion/src/state/motion-state.ts +++ b/packages/motion/src/state/motion-state.ts @@ -2,15 +2,15 @@ import type { MotionStateContext, Options } from '@/types' import { invariant } from 'hey-listen' import { visualElementStore } from 'framer-motion/dist/es/render/store.mjs' import type { DOMKeyframesDefinition, VisualElement } from 'framer-motion' -import { cancelFrame, frame } from 'framer-motion/dom' +import { cancelFrame, frame, noop } from 'framer-motion/dom' import { isAnimateChanged, resolveVariant } from '@/state/utils' import { FeatureManager } from '@/features' import { createVisualElement } from '@/state/create-visual-element' import type { PresenceContext } from '@/components/presence' import { doneCallbacks } from '@/components/presence' import type { StateType } from './animate-updates' -import { animateUpdates } from './animate-updates' import { isVariantLabels } from '@/state/utils/is-variant-labels' +import type { AnimateUpdates } from '@/features/animation/types' // Map to track mounted motion states by element export const mountedStates = new WeakMap() @@ -82,9 +82,7 @@ export class MotionState { parent: parent?.visualElement, props: { ...this.options, - whileHover: this.options.whileHover, whileTap: this.options.whilePress, - whileInView: this.options.whileInView, }, visualState: { renderState: { @@ -137,9 +135,7 @@ export class MotionState { updateOptions() { this.visualElement.update({ ...this.options as any, - whileHover: this.options.hover, - whileTap: this.options.press, - whileInView: this.options.inView, + whileTap: this.options.whilePress, reducedMotionConfig: this.options.motionConfig.reduceMotion, }, { isPresent: !doneCallbacks.has(this.element), @@ -175,7 +171,7 @@ export class MotionState { if (this.visualElement.type === 'svg') { (this.visualElement as any).updateDimensions() } - this.startAnimation() + this.startAnimation?.() } if (this.options.layoutId) { mountedLayoutIds.add(this.options.layoutId) @@ -277,16 +273,12 @@ export class MotionState { } // Core animation update logic - animateUpdates = animateUpdates + animateUpdates: AnimateUpdates = noop as any isMounted() { return Boolean(this.element) } - getOptions() { - return this.options - } - // Called before layout updates to prepare for changes willUpdate(label: string) { if (this.options.layout || this.options.layoutId) { diff --git a/packages/motion/src/state/transform.ts b/packages/motion/src/state/transform.ts index 801be9d..480bda8 100644 --- a/packages/motion/src/state/transform.ts +++ b/packages/motion/src/state/transform.ts @@ -1,6 +1,5 @@ -import { isDef } from '@vueuse/core' import type { CssPropertyDefinition, CssPropertyDefinitionMap } from '@/types' -import { noopReturn } from './utils' +import { noopReturn } from '@/state/utils' const rotation: CssPropertyDefinition = { syntax: '', @@ -77,16 +76,6 @@ export function buildTransformTemplate(transforms: [string, any][]): string { .trim() } -export function getFirstAnimateTransform(initialFrame: any, animateFrame: any) { - const first = Array.isArray(initialFrame) ? initialFrame[0] : initialFrame - if (Array.isArray(animateFrame)) { - return isDef(first) ? [first, ...animateFrame] : animateFrame - } - else { - return isDef(first) ? [first, animateFrame] : animateFrame - } -} - export const transformResetValue = { translate: [0, 0], rotate: 0, diff --git a/packages/motion/src/utils/noop.ts b/packages/motion/src/utils/noop.ts deleted file mode 100644 index 42dc7a1..0000000 --- a/packages/motion/src/utils/noop.ts +++ /dev/null @@ -1 +0,0 @@ -export const noop = (any: T): T => any diff --git a/playground/nuxt/pages/reorder-layout/index.vue b/playground/nuxt/pages/reorder-layout/index.vue index 60f098d..dbbb4e9 100644 --- a/playground/nuxt/pages/reorder-layout/index.vue +++ b/playground/nuxt/pages/reorder-layout/index.vue @@ -41,7 +41,6 @@ function add() { class="tabs" > Date: Mon, 24 Mar 2025 20:25:19 +0800 Subject: [PATCH 02/10] feat: add DOM animation feature --- .../src/components/lazy-motion/index.ts | 0 .../motion/src/components/motion/Motion.vue | 2 -- .../motion/src/components/motion/index.ts | 11 ++++++++- packages/motion/src/features/dom-animation.ts | 13 ++++++++++ .../motion/src/features/feature-manager.ts | 18 ++++---------- packages/motion/src/features/feature.ts | 24 +++++-------------- 6 files changed, 33 insertions(+), 35 deletions(-) create mode 100644 packages/motion/src/components/lazy-motion/index.ts create mode 100644 packages/motion/src/features/dom-animation.ts diff --git a/packages/motion/src/components/lazy-motion/index.ts b/packages/motion/src/components/lazy-motion/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/motion/src/components/motion/Motion.vue b/packages/motion/src/components/motion/Motion.vue index 4631a58..b392669 100644 --- a/packages/motion/src/components/motion/Motion.vue +++ b/packages/motion/src/components/motion/Motion.vue @@ -36,7 +36,6 @@ defineOptions({ name: 'Motion', inheritAttrs: false, }) - const props = withDefaults(defineProps>(), { as: 'div' as T, asChild: false, @@ -126,7 +125,6 @@ const state = new MotionState( provideMotion(state) const instance = getCurrentInstance().proxy - onBeforeMount(() => { state.beforeMount() }) diff --git a/packages/motion/src/components/motion/index.ts b/packages/motion/src/components/motion/index.ts index e832faf..17e38e2 100644 --- a/packages/motion/src/components/motion/index.ts +++ b/packages/motion/src/components/motion/index.ts @@ -1,2 +1,11 @@ +import { domAnimation } from '@/features/dom-animation' +import Motion, { type MotionProps } from './Motion.vue' +import { features } from '@/features/feature-manager' +import type { Feature } from '@/features' + +Motion.prototype.features = features +features.push(...(domAnimation as unknown as Feature[])) +console.log('features', features) export { motion } from './NameSpace' -export { default as Motion, type MotionProps } from './Motion.vue' + +export { Motion, type MotionProps } diff --git a/packages/motion/src/features/dom-animation.ts b/packages/motion/src/features/dom-animation.ts new file mode 100644 index 0000000..a4f9fb5 --- /dev/null +++ b/packages/motion/src/features/dom-animation.ts @@ -0,0 +1,13 @@ +import { AnimationFeature } from '@/features/animation/animation' +import { PressGesture } from '@/features/gestures/press' +import { HoverGesture } from '@/features/gestures/hover' +import { InViewGesture } from '@/features/gestures/in-view' +import { FocusGesture } from '@/features/gestures/focus' + +export const domAnimation = [ + AnimationFeature, + PressGesture, + HoverGesture, + InViewGesture, + FocusGesture, +] diff --git a/packages/motion/src/features/feature-manager.ts b/packages/motion/src/features/feature-manager.ts index 74b8b57..c39ea3a 100644 --- a/packages/motion/src/features/feature-manager.ts +++ b/packages/motion/src/features/feature-manager.ts @@ -1,23 +1,13 @@ -import { AnimationFeature, DragGesture, type Feature, HoverGesture, InViewGesture, LayoutFeature, PanGesture, PressGesture } from '@/features' +import type { Feature } from '@/features' import type { MotionState } from '@/state' -import { ProjectionFeature } from './layout/projection' -import { FocusGesture } from '@/features/gestures/focus' +export const features: Feature[] = [] export class FeatureManager { features: Feature[] = [] constructor(state: MotionState) { - this.features = [ - new HoverGesture(state), - new PressGesture(state), - new InViewGesture(state), - new LayoutFeature(state), - new ProjectionFeature(state), - new PanGesture(state), - new DragGesture(state), - new FocusGesture(state), - new AnimationFeature(state), - ] + this.features = features + console.log('this.features', this.features) } mount() { diff --git a/packages/motion/src/features/feature.ts b/packages/motion/src/features/feature.ts index e88884c..56c31d3 100644 --- a/packages/motion/src/features/feature.ts +++ b/packages/motion/src/features/feature.ts @@ -7,27 +7,15 @@ export abstract class Feature { this.state = state } - beforeMount(): void { - // noop - } + beforeMount(): void {} - mount(): void { - // noop - } + mount(): void {} - unmount(): void { - // noop - } + unmount(): void {} - update?(): void { - // noop - } + update?(): void {} - beforeUpdate?(): void { - // noop - } + beforeUpdate?(): void {} - beforeUnmount?(): void { - // noop - } + beforeUnmount?(): void {} } From 2f09ea41ebe5ad6369989c6be456fc8a41513be2 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Wed, 26 Mar 2025 21:30:44 +0800 Subject: [PATCH 03/10] refactor: simplify motion component creation --- .../motion/src/components/motion/NameSpace.ts | 52 +++---------------- .../motion/src/components/motion/index.ts | 6 --- packages/motion/src/components/motion/m.ts | 0 .../motion/src/components/motion/utils.ts | 49 ++++++++++++++--- 4 files changed, 51 insertions(+), 56 deletions(-) create mode 100644 packages/motion/src/components/motion/m.ts diff --git a/packages/motion/src/components/motion/NameSpace.ts b/packages/motion/src/components/motion/NameSpace.ts index 6e818b9..ca89c74 100644 --- a/packages/motion/src/components/motion/NameSpace.ts +++ b/packages/motion/src/components/motion/NameSpace.ts @@ -1,62 +1,26 @@ import type { DefineComponent, IntrinsicElementAttributes } from 'vue' -import { defineComponent, h } from 'vue' import Motion from './Motion.vue' import type { MotionProps } from './Motion.vue' import type { ComponentProps, MotionHTMLAttributes } from '@/types' +import { createMotionComponent } from './utils' type MotionComponentProps = { create: (T, options?: { forwardMotionProps?: boolean }) => DefineComponent & ComponentProps> } -type MotionKeys = keyof MotionComponentProps type MotionNameSpace = { [K in keyof IntrinsicElementAttributes]: DefineComponent & MotionHTMLAttributes> -} & { - create: MotionComponentProps['create'] -} - -const componentCache = new Map() +} & MotionComponentProps -export const motion = new Proxy(Motion, { - get(target, prop: MotionKeys) { +export const motion = new Proxy(Motion as unknown as MotionNameSpace, { + get(target, prop) { if (typeof prop === 'symbol') return target[prop] - let motionComponent = componentCache.get(prop) if (prop === 'create') { - return (component: any, { forwardMotionProps = false }: { forwardMotionProps?: boolean } = {}) => { - return defineComponent({ - inheritAttrs: false, - name: `motion.${component.$name}`, - setup(_, { attrs, slots }) { - return () => { - return h(Motion, { - ...attrs, - forwardMotionProps, - as: component, - asChild: false, - }, slots) - } - }, - }) - } + return (component: any, options?: { forwardMotionProps?: boolean }) => + createMotionComponent(component, options) } - if (!motionComponent) { - motionComponent = defineComponent({ - inheritAttrs: false, - name: `motion.${prop}`, - setup(_, { attrs, slots }) { - return () => { - return h(Motion, { - ...attrs, - as: prop as any, - asChild: false, - }, slots) - } - }, - }) as any - componentCache.set(prop, motionComponent) - } - return motionComponent + return createMotionComponent(prop as string) }, -}) as (unknown) as MotionNameSpace +}) diff --git a/packages/motion/src/components/motion/index.ts b/packages/motion/src/components/motion/index.ts index 17e38e2..39a7dfb 100644 --- a/packages/motion/src/components/motion/index.ts +++ b/packages/motion/src/components/motion/index.ts @@ -1,11 +1,5 @@ -import { domAnimation } from '@/features/dom-animation' import Motion, { type MotionProps } from './Motion.vue' -import { features } from '@/features/feature-manager' -import type { Feature } from '@/features' -Motion.prototype.features = features -features.push(...(domAnimation as unknown as Feature[])) -console.log('features', features) export { motion } from './NameSpace' export { Motion, type MotionProps } diff --git a/packages/motion/src/components/motion/m.ts b/packages/motion/src/components/motion/m.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/motion/src/components/motion/utils.ts b/packages/motion/src/components/motion/utils.ts index e70b508..fea88ca 100644 --- a/packages/motion/src/components/motion/utils.ts +++ b/packages/motion/src/components/motion/utils.ts @@ -1,13 +1,50 @@ import { getMotionElement } from '@/components/hooks/use-motion-elm' -import type { ComponentPublicInstance } from 'vue' +import type { ComponentPublicInstance, DefineComponent } from 'vue' +import { defineComponent, h } from 'vue' +import Motion from './Motion.vue' +import type { AsTag } from '@/types' -/** - * 检查是否是隐藏的 motion 元素 - * @param instance - * @returns - */ export function checkMotionIsHidden(instance: ComponentPublicInstance) { const isHidden = getMotionElement(instance.$el)?.style.display === 'none' const hasTransition = instance.$.vnode.transition return hasTransition && isHidden } + +const componentCache = new Map() + +/** + * Creates a motion component from a base component or HTML tag + * Caches string-based components for reuse + */ +export function createMotionComponent( + component: string | DefineComponent, + options: { forwardMotionProps?: boolean } = {}, +) { + const isString = typeof component === 'string' + const name = isString ? component : component.name || '' + + if (isString && componentCache?.has(component)) { + return componentCache.get(component) + } + + const motionComponent = defineComponent({ + inheritAttrs: false, + name: `motion.${name}`, + setup(_, { attrs, slots }) { + return () => { + return h(Motion, { + ...attrs, + forwardMotionProps: isString ? false : options.forwardMotionProps, + as: component as AsTag, + asChild: false, + }, slots) + } + }, + }) as any + + if (isString) { + componentCache?.set(component, motionComponent) + } + + return motionComponent +} From ac205ff38f494f77b154a78f05fff0c5738c1525 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Wed, 26 Mar 2025 22:18:16 +0800 Subject: [PATCH 04/10] feat: init feature --- .../motion/src/components/motion/NameSpace.ts | 15 +++++-- .../motion/src/components/motion/index.ts | 7 +--- .../motion/src/components/motion/utils.ts | 4 +- .../src/features/animation/animation.ts | 39 +++++++++---------- packages/motion/src/features/dom-animation.ts | 11 +++++- .../motion/src/features/feature-manager.ts | 4 +- 6 files changed, 45 insertions(+), 35 deletions(-) diff --git a/packages/motion/src/components/motion/NameSpace.ts b/packages/motion/src/components/motion/NameSpace.ts index ca89c74..f1045d8 100644 --- a/packages/motion/src/components/motion/NameSpace.ts +++ b/packages/motion/src/components/motion/NameSpace.ts @@ -1,21 +1,26 @@ import type { DefineComponent, IntrinsicElementAttributes } from 'vue' -import Motion from './Motion.vue' +import MotionComponent from './Motion.vue' import type { MotionProps } from './Motion.vue' import type { ComponentProps, MotionHTMLAttributes } from '@/types' import { createMotionComponent } from './utils' +import { features } from '@/features/feature-manager' +import { domAnimation } from '@/features/dom-animation' type MotionComponentProps = { - create: (T, options?: { forwardMotionProps?: boolean }) => DefineComponent & ComponentProps> + create: (T, options?: { forwardMotionProps?: boolean }) => DefineComponent, 'as' | 'asChild'> & ComponentProps> } type MotionNameSpace = { - [K in keyof IntrinsicElementAttributes]: DefineComponent & MotionHTMLAttributes> + [K in keyof IntrinsicElementAttributes]: DefineComponent, 'as' | 'asChild'> & MotionHTMLAttributes, 'create'> } & MotionComponentProps -export const motion = new Proxy(Motion as unknown as MotionNameSpace, { +export const motion = new Proxy(MotionComponent as unknown as MotionNameSpace, { get(target, prop) { if (typeof prop === 'symbol') return target[prop] + if (!features.length) { + features.push(...domAnimation) + } if (prop === 'create') { return (component: any, options?: { forwardMotionProps?: boolean }) => createMotionComponent(component, options) @@ -24,3 +29,5 @@ export const motion = new Proxy(Motion as unknown as MotionNameSpace, { return createMotionComponent(prop as string) }, }) + +export const Motion = motion.div as unknown as typeof MotionComponent diff --git a/packages/motion/src/components/motion/index.ts b/packages/motion/src/components/motion/index.ts index 39a7dfb..90063be 100644 --- a/packages/motion/src/components/motion/index.ts +++ b/packages/motion/src/components/motion/index.ts @@ -1,5 +1,2 @@ -import Motion, { type MotionProps } from './Motion.vue' - -export { motion } from './NameSpace' - -export { Motion, type MotionProps } +export { type MotionProps } from './Motion.vue' +export { motion, Motion } from './NameSpace' diff --git a/packages/motion/src/components/motion/utils.ts b/packages/motion/src/components/motion/utils.ts index fea88ca..d63724a 100644 --- a/packages/motion/src/components/motion/utils.ts +++ b/packages/motion/src/components/motion/utils.ts @@ -35,8 +35,8 @@ export function createMotionComponent( return h(Motion, { ...attrs, forwardMotionProps: isString ? false : options.forwardMotionProps, - as: component as AsTag, - asChild: false, + as: (attrs.as || component) as AsTag, + asChild: (attrs.asChild ?? false) as boolean, }, slots) } }, diff --git a/packages/motion/src/features/animation/animation.ts b/packages/motion/src/features/animation/animation.ts index 2ee69e0..7c2c881 100644 --- a/packages/motion/src/features/animation/animation.ts +++ b/packages/motion/src/features/animation/animation.ts @@ -183,26 +183,25 @@ export class AnimationFeature extends Feature { let variant: Variant = {} const { variants, custom, transition, animatePresenceContext } = this.state.options const customValue = isDef(custom) ? custom : animatePresenceContext?.custom - if (controlActiveState) { - this.state.activeStates = { ...this.state.activeStates, ...controlActiveState } - STATE_TYPES.forEach((name) => { - if (!this.state.activeStates[name] || isAnimationControls(this.state.options[name])) - return - - const definition = this.state.options[name] - let resolvedVariant = isDef(definition) ? resolveVariant(definition as any, variants, customValue) : undefined - // If current node is a variant node, merge the control node's variant - if (this.state.visualElement.isVariantNode) { - const controlVariant = resolveVariant(this.state.context[name], variants, customValue) - resolvedVariant = controlVariant ? Object.assign(controlVariant || {}, resolvedVariant) : variant - } - if (!resolvedVariant) - return - if (name !== 'initial') - variantTransition = resolvedVariant.transition || this.state.options.transition || {} - variant = Object.assign(variant, resolvedVariant) - }) - } + + this.state.activeStates = { ...this.state.activeStates, ...controlActiveState } + STATE_TYPES.forEach((name) => { + if (!this.state.activeStates[name] || isAnimationControls(this.state.options[name])) + return + + const definition = this.state.options[name] + let resolvedVariant = isDef(definition) ? resolveVariant(definition as any, variants, customValue) : undefined + // If current node is a variant node, merge the control node's variant + if (this.state.visualElement.isVariantNode) { + const controlVariant = resolveVariant(this.state.context[name], variants, customValue) + resolvedVariant = controlVariant ? Object.assign(controlVariant || {}, resolvedVariant) : variant + } + if (!resolvedVariant) + return + if (name !== 'initial') + variantTransition = resolvedVariant.transition || this.state.options.transition || {} + variant = Object.assign(variant, resolvedVariant) + }) if (directAnimate) { variant = resolveVariant(directAnimate, variants, customValue) diff --git a/packages/motion/src/features/dom-animation.ts b/packages/motion/src/features/dom-animation.ts index a4f9fb5..4ea2338 100644 --- a/packages/motion/src/features/dom-animation.ts +++ b/packages/motion/src/features/dom-animation.ts @@ -3,6 +3,11 @@ import { PressGesture } from '@/features/gestures/press' import { HoverGesture } from '@/features/gestures/hover' import { InViewGesture } from '@/features/gestures/in-view' import { FocusGesture } from '@/features/gestures/focus' +import { ProjectionFeature } from '@/features/layout/projection' +import { DragGesture } from '@/features/gestures/drag' +import { LayoutFeature } from '@/features/layout/layout' +import { PanGesture } from '@/features/gestures/pan' +import type { Feature } from '@/features/feature' export const domAnimation = [ AnimationFeature, @@ -10,4 +15,8 @@ export const domAnimation = [ HoverGesture, InViewGesture, FocusGesture, -] + ProjectionFeature, + DragGesture, + LayoutFeature, + PanGesture, +] as unknown as Feature[] diff --git a/packages/motion/src/features/feature-manager.ts b/packages/motion/src/features/feature-manager.ts index c39ea3a..c9906c2 100644 --- a/packages/motion/src/features/feature-manager.ts +++ b/packages/motion/src/features/feature-manager.ts @@ -4,10 +4,8 @@ import type { MotionState } from '@/state' export const features: Feature[] = [] export class FeatureManager { features: Feature[] = [] - constructor(state: MotionState) { - this.features = features - console.log('this.features', this.features) + this.features = features.map((Feature: any) => new Feature(state)) } mount() { From db2f63822edd1eab43e386e45e6a313b0409cb50 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Thu, 27 Mar 2025 22:50:39 +0800 Subject: [PATCH 05/10] refactor: consolidate motion component props and state management --- .../motion/src/components/motion/Motion.vue | 190 ++---------------- .../motion/src/components/motion/Primitive.ts | 63 ------ packages/motion/src/components/motion/Slot.ts | 47 ----- .../motion/src/components/motion/props.ts | 90 +++++++++ .../motion/src/components/motion/types.ts | 17 ++ .../src/components/motion/use-motion-state.ts | 126 ++++++++++++ .../motion/src/components/motion/utils.ts | 98 ++++++++- packages/motion/src/features/dom-animation.ts | 16 +- 8 files changed, 348 insertions(+), 299 deletions(-) delete mode 100644 packages/motion/src/components/motion/Primitive.ts delete mode 100644 packages/motion/src/components/motion/Slot.ts create mode 100644 packages/motion/src/components/motion/props.ts create mode 100644 packages/motion/src/components/motion/types.ts create mode 100644 packages/motion/src/components/motion/use-motion-state.ts diff --git a/packages/motion/src/components/motion/Motion.vue b/packages/motion/src/components/motion/Motion.vue index b392669..55d0247 100644 --- a/packages/motion/src/components/motion/Motion.vue +++ b/packages/motion/src/components/motion/Motion.vue @@ -1,19 +1,11 @@ diff --git a/packages/motion/src/components/motion/Primitive.ts b/packages/motion/src/components/motion/Primitive.ts deleted file mode 100644 index d3d0d31..0000000 --- a/packages/motion/src/components/motion/Primitive.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type Component, type PropType, defineComponent, h } from 'vue' -import { Slot } from './Slot' -import type { AsTag } from '@/types' - -export interface PrimitiveProps { - /** - * Change the default rendered element for the one passed as a child, merging their props and behavior. - * - * Read our [Composition](https://www.radix-vue.com/guides/composition.html) guide for more details. - */ - asChild?: boolean - /** - * The element or component this component should render as. Can be overwrite by `asChild` - * @defaultValue "div" - */ - as?: AsTag | Component -} - -export const Primitive = defineComponent({ - name: 'Primitive', - inheritAttrs: false, - props: { - asChild: { - type: Boolean, - default: false, - }, - as: { - type: [String, Object] as PropType, - default: 'div', - }, - getProps: { - type: Function, - default: () => ({}), - }, - getAttrs: { - type: Function, - default: () => ({}), - }, - }, - setup(props, { attrs, slots }) { - const asTag = props.asChild ? 'template' : props.as - // For self closing tags, don't provide default slots because of hydration issue - const SELF_CLOSING_TAGS = ['area', 'img', 'input'] - - return () => { - const motionProps = props.getProps() - const motionAttrs = props.getAttrs() - let allAttrs = { ...motionAttrs, ...attrs } - - if (typeof asTag === 'string' && SELF_CLOSING_TAGS.includes(asTag)) - return h(asTag, allAttrs) - - if (asTag !== 'template') { - if (motionProps.forwardMotionProps) { - allAttrs = { ...motionProps, ...allAttrs } - } - return h(props.as, allAttrs, { default: slots.default }) - } - - return h(Slot, allAttrs, { default: slots.default }) - } - }, -}) diff --git a/packages/motion/src/components/motion/Slot.ts b/packages/motion/src/components/motion/Slot.ts deleted file mode 100644 index 11d18fa..0000000 --- a/packages/motion/src/components/motion/Slot.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Comment, cloneVNode, defineComponent, mergeProps } from 'vue' -import { renderSlotFragments } from './renderSlotFragments' - -export const Slot = defineComponent({ - name: 'PrimitiveSlot', - inheritAttrs: false, - setup(_, { attrs, slots }) { - return () => { - if (!slots.default) - return null - - const childrens = renderSlotFragments(slots.default()) - const firstNonCommentChildrenIndex = childrens.findIndex(child => child.type !== Comment) - if (firstNonCommentChildrenIndex === -1) - return childrens - - const firstNonCommentChildren = childrens[firstNonCommentChildrenIndex] - - // remove props ref from being inferred - delete firstNonCommentChildren.props?.ref - - const mergedProps = firstNonCommentChildren.props - ? mergeProps(attrs, firstNonCommentChildren.props) - : attrs - // remove class to prevent duplicated - if (attrs.class && firstNonCommentChildren.props?.class) - delete firstNonCommentChildren.props.class - const cloned = cloneVNode(firstNonCommentChildren, mergedProps) - - // Explicitly override props starting with `on`. - // It seems cloneVNode from Vue doesn't like overriding `onXXX` props. - // So we have to do it manually. - for (const prop in mergedProps) { - if (prop.startsWith('on')) { - cloned.props ||= {} - cloned.props[prop] = mergedProps[prop] - } - } - - if (childrens.length === 1) - return cloned - - childrens[firstNonCommentChildrenIndex] = cloned - return childrens - } - }, -}) diff --git a/packages/motion/src/components/motion/props.ts b/packages/motion/src/components/motion/props.ts new file mode 100644 index 0000000..22de5dc --- /dev/null +++ b/packages/motion/src/components/motion/props.ts @@ -0,0 +1,90 @@ +import { warning } from 'hey-listen' + +export const MotionComponentProps = { + 'as': { type: [String, Object], default: 'div' }, + 'asChild': { type: Boolean, default: false }, + 'hover': { type: [String, Array, Object] }, + 'press': { type: [String, Array, Object] }, + 'inView': { type: [String, Array, Object] }, + 'focus': { type: [String, Array, Object] }, + 'whileDrag': { type: [String, Array, Object] }, + 'whileHover': { type: [String, Array, Object], default: ({ hover }) => { + if (process.env.NODE_ENV === 'development' && hover) { + warning(true, 'hover is deprecated. Use whileHover instead.') + } + return hover + } }, + 'whilePress': { type: [String, Array, Object], default: ({ press }) => { + if (process.env.NODE_ENV === 'development' && press) { + warning(true, 'press is deprecated. Use whilePress instead.') + } + return press + } }, + 'whileInView': { type: [String, Array, Object], default: ({ inView }) => { + if (process.env.NODE_ENV === 'development' && inView) { + warning(true, 'inView is deprecated. Use whileInView instead.') + } + return inView + } }, + 'whileFocus': { type: [String, Array, Object], default: ({ focus }) => { + if (process.env.NODE_ENV === 'development' && focus) { + warning(true, 'focus is deprecated. Use whileFocus instead.') + } + return focus + } }, + 'custom': { type: [String, Number, Object, Array] }, + 'initial': { type: [String, Array, Object, Boolean], default: undefined }, + 'animate': { type: [String, Array, Object], default: undefined }, + 'exit': { type: [String, Array, Object] }, + 'variants': { type: Object }, + 'inherit': { type: Boolean }, + 'style': { type: Object }, + 'transformTemplate': { type: Function }, + 'transition': { type: Object }, + 'layoutGroup': { type: Object }, + 'motionConfig': { type: Object }, + 'onAnimationComplete': { type: Function }, + 'onUpdate': { type: Function }, + 'layout': { type: [Boolean, String], default: false }, + 'layoutId': { type: String, default: undefined }, + 'layoutScroll': { type: Boolean, default: false }, + 'layoutRoot': { type: Boolean, default: false }, + 'data-framer-portal-id': { type: String }, + 'crossfade': { type: Boolean, default: true }, + 'layoutDependency': { type: [String, Number, Object, Array] }, + 'onBeforeLayoutMeasure': { type: Function }, + 'onLayoutMeasure': { type: Function }, + 'onLayoutAnimationStart': { type: Function }, + 'onLayoutAnimationComplete': { type: Function }, + 'globalPressTarget': { type: Boolean }, + 'onPressStart': { type: Function }, + 'onPress': { type: Function }, + 'onPressCancel': { type: Function }, + 'onHoverStart': { type: Function }, + 'onHoverEnd': { type: Function }, + 'inViewOptions': { type: Object }, + 'onViewportEnter': { type: Function }, + 'onViewportLeave': { type: Function }, + 'drag': { type: [Boolean, String] }, + 'dragSnapToOrigin': { type: Boolean }, + 'dragDirectionLock': { type: Boolean }, + 'dragPropagation': { type: Boolean }, + 'dragConstraints': { type: [Boolean, Object] }, + 'dragElastic': { type: [Boolean, Number, Object], default: 0.5 }, + 'dragMomentum': { type: Boolean, default: true }, + 'dragTransition': { type: Object }, + 'dragListener': { type: Boolean, default: true }, + 'dragControls': { type: Object }, + 'onDragStart': { type: Function }, + 'onDragEnd': { type: Function }, + 'onDrag': { type: Function }, + 'onDirectionLock': { type: Function }, + 'onDragTransitionEnd': { type: Function }, + 'onMeasureDragConstraints': { type: Function }, + 'onPanSessionStart': { type: Function }, + 'onPanStart': { type: Function }, + 'onPan': { type: Function }, + 'onPanEnd': { type: Function }, + 'onFocus': { type: Function }, + 'onBlur': { type: Function }, +} diff --git a/packages/motion/src/components/motion/types.ts b/packages/motion/src/components/motion/types.ts new file mode 100644 index 0000000..ec91565 --- /dev/null +++ b/packages/motion/src/components/motion/types.ts @@ -0,0 +1,17 @@ +import type { MotionProps } from '@/components/motion/Motion.vue' +import type { AsTag, ComponentProps, Options, SVGAttributesWithMotionValues, SetMotionValueType } from '@/types' +import type { IntrinsicElementAttributes } from 'vue' + +type __VLS_PrettifyLocal = { + [K in keyof T]: T[K]; +} & {} + +export type MotionComponent = (__VLS_props: NonNullable>['props'], __VLS_ctx?: __VLS_PrettifyLocal>, 'attrs' | 'emit' | 'slots'>>, __VLS_expose?: NonNullable>['expose'], __VLS_setup?: Promise<{ + props: __VLS_PrettifyLocal & Omit<{} & import('vue').VNodeProps & import('vue').AllowedComponentProps & import('vue').ComponentCustomProps & Readonly>, never>, never> & (Omit[T] : ComponentProps, keyof Options | 'asChild'> & MotionProps)> & import('vue').PublicProps + expose: (exposed: import('vue').ShallowUnwrapRef<{}>) => void + attrs: any + slots: { + default?: (_: {}) => any + } + emit: {} +}>) => import('vue').VNode & { __ctx?: Awaited } diff --git a/packages/motion/src/components/motion/use-motion-state.ts b/packages/motion/src/components/motion/use-motion-state.ts new file mode 100644 index 0000000..48abe52 --- /dev/null +++ b/packages/motion/src/components/motion/use-motion-state.ts @@ -0,0 +1,126 @@ +import { injectLayoutGroup, injectMotion, provideMotion } from '@/components/context' +import { getMotionElement } from '@/components/hooks/use-motion-elm' +import { useMotionConfig } from '@/components/motion-config' +import type { MotionProps } from '@/components/motion/Motion.vue' +import { checkMotionIsHidden } from '@/components/motion/utils' +import { injectAnimatePresence } from '@/components/presence' +import { MotionState } from '@/state' +import { convertSvgStyleToAttributes, createStyles } from '@/state/style' +import { isMotionValue } from '@/utils' +import type { DOMKeyframesDefinition } from 'motion-dom' +import { getCurrentInstance, onBeforeMount, onBeforeUnmount, onBeforeUpdate, onMounted, onUnmounted, onUpdated, useAttrs } from 'vue' + +export function useMotionState(props: MotionProps) { + const parentState = injectMotion(null) + const layoutGroup = injectLayoutGroup({}) + const config = useMotionConfig() + const animatePresenceContext = injectAnimatePresence({}) + const attrs = useAttrs() + + /** + * Get the layout ID for the motion component + * If both layoutGroup.id and props.layoutId exist, combine them with a hyphen + * Otherwise return props.layoutId or undefined + */ + function getLayoutId() { + if (layoutGroup.id && props.layoutId) + return `${layoutGroup.id}-${props.layoutId}` + return props.layoutId || undefined + } + + function getProps() { + return { + ...props, + layoutId: getLayoutId(), + transition: props.transition ?? config.value.transition, + layoutGroup, + motionConfig: config.value, + inViewOptions: props.inViewOptions ?? config.value.inViewOptions, + animatePresenceContext, + initial: animatePresenceContext.initial === false + ? animatePresenceContext.initial + : ( + props.initial === true ? undefined : props.initial + ), + } + } + function getMotionProps() { + return { + ...attrs, + ...getProps(), + } + } + + const state = new MotionState( + getMotionProps(), + parentState!, + ) + provideMotion(state) + + function getAttrs() { + const isSVG = state.visualElement.type === 'svg' + const attrsProps = { ...attrs } + Object.keys(attrs).forEach((key) => { + if (isMotionValue(attrs[key])) + attrsProps[key] = attrs[key].get() + }) + let styleProps: Record = { + ...props.style, + ...(isSVG ? {} : state.visualElement.latestValues), + } + if (isSVG) { + const { attributes, style } = convertSvgStyleToAttributes({ + ...(state.isMounted() ? state.target : state.baseTarget), + ...styleProps, + } as DOMKeyframesDefinition) + Object.assign(attrsProps, attributes) + styleProps = style + } + if (props.drag && props.dragListener !== false) { + Object.assign(styleProps, { + userSelect: 'none', + WebkitUserSelect: 'none', + WebkitTouchCallout: 'none', + touchAction: props.drag === true + ? 'none' + : `pan-${props.drag === 'x' ? 'y' : 'x'}`, + }) + } + + attrsProps.style = createStyles(styleProps) + return attrsProps + } + + const instance = getCurrentInstance().proxy + + onBeforeMount(() => { + state.beforeMount() + }) + + onMounted(() => { + state.mount(getMotionElement(instance.$el), getMotionProps(), checkMotionIsHidden(instance)) + }) + + onBeforeUnmount(() => state.beforeUnmount()) + + onUnmounted(() => { + const el = getMotionElement(instance.$el) + if (!el?.isConnected) { + state.unmount() + } + }) + + onBeforeUpdate(() => { + state.beforeUpdate() + }) + + onUpdated(() => { + state.update(getMotionProps()) + }) + + return { + getProps, + getAttrs, + layoutGroup, + } +} diff --git a/packages/motion/src/components/motion/utils.ts b/packages/motion/src/components/motion/utils.ts index d63724a..2f6e39e 100644 --- a/packages/motion/src/components/motion/utils.ts +++ b/packages/motion/src/components/motion/utils.ts @@ -1,8 +1,9 @@ import { getMotionElement } from '@/components/hooks/use-motion-elm' import type { ComponentPublicInstance, DefineComponent } from 'vue' -import { defineComponent, h } from 'vue' -import Motion from './Motion.vue' -import type { AsTag } from '@/types' +import { Comment, cloneVNode, defineComponent, h, mergeProps } from 'vue' +import type { MotionProps } from './Motion.vue' +import { useMotionState } from './use-motion-state' +import { MotionComponentProps } from './props' export function checkMotionIsHidden(instance: ComponentPublicInstance) { const isHidden = getMotionElement(instance.$el)?.style.display === 'none' @@ -10,7 +11,68 @@ export function checkMotionIsHidden(instance: ComponentPublicInstance) { return hasTransition && isHidden } -const componentCache = new Map() +const componentCache = new Map() + +function renderSlotFragments(fragments: any[]) { + if (!Array.isArray(fragments)) + return [fragments] + const result = [] + for (const item of fragments) { + if (Array.isArray(item)) + result.push(...item) + else + result.push(item) + } + return result +} + +const SELF_CLOSING_TAGS = ['area', 'img', 'input'] +function handlePrimitiveAndSlot(asTag: string | any, allAttrs: any, slots: any) { + // Handle self-closing tags + if (typeof asTag === 'string' && SELF_CLOSING_TAGS.includes(asTag)) { + return h(asTag, allAttrs) + } + + // Handle template/asChild case + if (asTag === 'template') { + if (!slots.default) + return null + + const childrens = renderSlotFragments(slots.default()) + const firstNonCommentChildrenIndex = childrens.findIndex(child => child.type !== Comment) + + if (firstNonCommentChildrenIndex === -1) + return childrens + + const firstNonCommentChildren = childrens[firstNonCommentChildrenIndex] + delete firstNonCommentChildren.props?.ref + + const mergedProps = firstNonCommentChildren.props + ? mergeProps(allAttrs, firstNonCommentChildren.props) + : allAttrs + + if (allAttrs.class && firstNonCommentChildren.props?.class) + delete firstNonCommentChildren.props.class + + const cloned = cloneVNode(firstNonCommentChildren, mergedProps) + + // Handle onXXX event handlers + for (const prop in mergedProps) { + if (prop.startsWith('on')) { + cloned.props ||= {} + cloned.props[prop] = mergedProps[prop] + } + } + + if (childrens.length === 1) + return cloned + + childrens[firstNonCommentChildrenIndex] = cloned + return childrens + } + + return null +} /** * Creates a motion component from a base component or HTML tag @@ -29,14 +91,28 @@ export function createMotionComponent( const motionComponent = defineComponent({ inheritAttrs: false, - name: `motion.${name}`, - setup(_, { attrs, slots }) { + props: { + ...MotionComponentProps, + }, + name: name ? `motion.${name}` : 'Motion', + setup(props, { attrs, slots }) { + const { getProps, getAttrs } = useMotionState(props as MotionProps) + return () => { - return h(Motion, { - ...attrs, - forwardMotionProps: isString ? false : options.forwardMotionProps, - as: (attrs.as || component) as AsTag, - asChild: (attrs.asChild ?? false) as boolean, + console.log(props) + const motionProps = getProps() + const motionAttrs = getAttrs() + const asTag = props.asChild ? 'template' : (attrs.as || component) + const allAttrs = { ...(options.forwardMotionProps ? motionProps : {}), ...motionAttrs } + + // Try to handle as Primitive or Slot first + const primitiveOrSlotResult = handlePrimitiveAndSlot(asTag, allAttrs, slots) + if (primitiveOrSlotResult !== null) { + return primitiveOrSlotResult + } + + return h(asTag, { + ...allAttrs, }, slots) } }, diff --git a/packages/motion/src/features/dom-animation.ts b/packages/motion/src/features/dom-animation.ts index 4ea2338..c3baccf 100644 --- a/packages/motion/src/features/dom-animation.ts +++ b/packages/motion/src/features/dom-animation.ts @@ -3,10 +3,10 @@ import { PressGesture } from '@/features/gestures/press' import { HoverGesture } from '@/features/gestures/hover' import { InViewGesture } from '@/features/gestures/in-view' import { FocusGesture } from '@/features/gestures/focus' -import { ProjectionFeature } from '@/features/layout/projection' -import { DragGesture } from '@/features/gestures/drag' -import { LayoutFeature } from '@/features/layout/layout' -import { PanGesture } from '@/features/gestures/pan' +// import { ProjectionFeature } from '@/features/layout/projection' +// import { DragGesture } from '@/features/gestures/drag' +// import { LayoutFeature } from '@/features/layout/layout' +// import { PanGesture } from '@/features/gestures/pan' import type { Feature } from '@/features/feature' export const domAnimation = [ @@ -15,8 +15,8 @@ export const domAnimation = [ HoverGesture, InViewGesture, FocusGesture, - ProjectionFeature, - DragGesture, - LayoutFeature, - PanGesture, + // ProjectionFeature, + // DragGesture, + // LayoutFeature, + // PanGesture, ] as unknown as Feature[] From a34c3c22f56cd6433850b039c7e68406087d2390 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Fri, 28 Mar 2025 13:53:33 +0800 Subject: [PATCH 06/10] chore: merge Primitive and Slot logic into createMotionComponent --- .../motion/src/components/motion/Motion.vue | 60 --------------- .../motion/src/components/motion/NameSpace.ts | 33 --------- .../motion/src/components/motion/index.ts | 9 ++- packages/motion/src/components/motion/m.ts | 5 ++ .../motion/src/components/motion/props.ts | 2 +- .../motion/src/components/motion/types.ts | 15 +++- .../src/components/motion/use-motion-state.ts | 3 +- .../motion/src/components/motion/utils.ts | 73 ++++++++++++++++--- packages/motion/src/features/dom-max.ts | 22 ++++++ .../drag/VisualElementDragControls.ts | 2 +- playground/nuxt/pages/change-style.vue | 5 +- playground/nuxt/pages/child.vue | 29 +++++--- playground/nuxt/pages/layout.vue | 6 ++ 13 files changed, 142 insertions(+), 122 deletions(-) delete mode 100644 packages/motion/src/components/motion/Motion.vue delete mode 100644 packages/motion/src/components/motion/NameSpace.ts create mode 100644 packages/motion/src/features/dom-max.ts diff --git a/packages/motion/src/components/motion/Motion.vue b/packages/motion/src/components/motion/Motion.vue deleted file mode 100644 index 55d0247..0000000 --- a/packages/motion/src/components/motion/Motion.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - - - diff --git a/packages/motion/src/components/motion/NameSpace.ts b/packages/motion/src/components/motion/NameSpace.ts deleted file mode 100644 index f1045d8..0000000 --- a/packages/motion/src/components/motion/NameSpace.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { DefineComponent, IntrinsicElementAttributes } from 'vue' -import MotionComponent from './Motion.vue' -import type { MotionProps } from './Motion.vue' -import type { ComponentProps, MotionHTMLAttributes } from '@/types' -import { createMotionComponent } from './utils' -import { features } from '@/features/feature-manager' -import { domAnimation } from '@/features/dom-animation' - -type MotionComponentProps = { - create: (T, options?: { forwardMotionProps?: boolean }) => DefineComponent, 'as' | 'asChild'> & ComponentProps> -} - -type MotionNameSpace = { - [K in keyof IntrinsicElementAttributes]: DefineComponent, 'as' | 'asChild'> & MotionHTMLAttributes, 'create'> -} & MotionComponentProps - -export const motion = new Proxy(MotionComponent as unknown as MotionNameSpace, { - get(target, prop) { - if (typeof prop === 'symbol') - return target[prop] - if (!features.length) { - features.push(...domAnimation) - } - if (prop === 'create') { - return (component: any, options?: { forwardMotionProps?: boolean }) => - createMotionComponent(component, options) - } - - return createMotionComponent(prop as string) - }, -}) - -export const Motion = motion.div as unknown as typeof MotionComponent diff --git a/packages/motion/src/components/motion/index.ts b/packages/motion/src/components/motion/index.ts index 90063be..79e3879 100644 --- a/packages/motion/src/components/motion/index.ts +++ b/packages/motion/src/components/motion/index.ts @@ -1,2 +1,7 @@ -export { type MotionProps } from './Motion.vue' -export { motion, Motion } from './NameSpace' +export { type MotionProps } from './types' +import type { MotionComponent } from '@/components/motion/types' +import { createMotionComponentWithFeatures } from './utils' +import { domMax } from '@/features/dom-max' + +export const motion = createMotionComponentWithFeatures(domMax) +export const Motion = motion.create('div') as unknown as MotionComponent diff --git a/packages/motion/src/components/motion/m.ts b/packages/motion/src/components/motion/m.ts index e69de29..badab84 100644 --- a/packages/motion/src/components/motion/m.ts +++ b/packages/motion/src/components/motion/m.ts @@ -0,0 +1,5 @@ +import type { MotionComponent } from '@/components/motion/types' +import { createMotionComponentWithFeatures } from '@/components/motion/utils' + +export const m = createMotionComponentWithFeatures() +export const M = m.create('div') as unknown as MotionComponent diff --git a/packages/motion/src/components/motion/props.ts b/packages/motion/src/components/motion/props.ts index 22de5dc..d440d47 100644 --- a/packages/motion/src/components/motion/props.ts +++ b/packages/motion/src/components/motion/props.ts @@ -1,7 +1,7 @@ import { warning } from 'hey-listen' export const MotionComponentProps = { - 'as': { type: [String, Object], default: 'div' }, + 'forwardMotionProps': { type: Boolean, default: false }, 'asChild': { type: Boolean, default: false }, 'hover': { type: [String, Array, Object] }, 'press': { type: [String, Array, Object] }, diff --git a/packages/motion/src/components/motion/types.ts b/packages/motion/src/components/motion/types.ts index ec91565..858e081 100644 --- a/packages/motion/src/components/motion/types.ts +++ b/packages/motion/src/components/motion/types.ts @@ -1,7 +1,20 @@ -import type { MotionProps } from '@/components/motion/Motion.vue' import type { AsTag, ComponentProps, Options, SVGAttributesWithMotionValues, SetMotionValueType } from '@/types' import type { IntrinsicElementAttributes } from 'vue' +export interface MotionProps extends Options { + as?: T + asChild?: boolean + hover?: Options['hover'] + press?: Options['press'] + inView?: Options['inView'] + focus?: Options['focus'] + whileDrag?: Options['whileDrag'] + whileHover?: Options['whileHover'] + whilePress?: Options['whilePress'] + whileInView?: Options['whileInView'] + whileFocus?: Options['whileFocus'] + forwardMotionProps?: boolean +} type __VLS_PrettifyLocal = { [K in keyof T]: T[K]; } & {} diff --git a/packages/motion/src/components/motion/use-motion-state.ts b/packages/motion/src/components/motion/use-motion-state.ts index 48abe52..cf50dd9 100644 --- a/packages/motion/src/components/motion/use-motion-state.ts +++ b/packages/motion/src/components/motion/use-motion-state.ts @@ -1,7 +1,7 @@ import { injectLayoutGroup, injectMotion, provideMotion } from '@/components/context' import { getMotionElement } from '@/components/hooks/use-motion-elm' import { useMotionConfig } from '@/components/motion-config' -import type { MotionProps } from '@/components/motion/Motion.vue' +import type { MotionProps } from '@/components/motion/types' import { checkMotionIsHidden } from '@/components/motion/utils' import { injectAnimatePresence } from '@/components/presence' import { MotionState } from '@/state' @@ -122,5 +122,6 @@ export function useMotionState(props: MotionProps) { getProps, getAttrs, layoutGroup, + state, } } diff --git a/packages/motion/src/components/motion/utils.ts b/packages/motion/src/components/motion/utils.ts index 2f6e39e..7992643 100644 --- a/packages/motion/src/components/motion/utils.ts +++ b/packages/motion/src/components/motion/utils.ts @@ -1,17 +1,25 @@ import { getMotionElement } from '@/components/hooks/use-motion-elm' -import type { ComponentPublicInstance, DefineComponent } from 'vue' +import type { Component, ComponentPublicInstance, DefineComponent, IntrinsicElementAttributes } from 'vue' import { Comment, cloneVNode, defineComponent, h, mergeProps } from 'vue' -import type { MotionProps } from './Motion.vue' import { useMotionState } from './use-motion-state' import { MotionComponentProps } from './props' +import type { MotionProps } from '@/components/motion/types' +import { type Feature, features } from '@/features' +import type { ComponentProps, MotionHTMLAttributes } from '@/types' +type MotionCompProps = { + create: (T, options?: MotionCreateOptions) => DefineComponent, 'as' | 'asChild'> & ComponentProps> +} +export interface MotionCreateOptions { + forwardMotionProps?: boolean +} export function checkMotionIsHidden(instance: ComponentPublicInstance) { const isHidden = getMotionElement(instance.$el)?.style.display === 'none' const hasTransition = instance.$.vnode.transition return hasTransition && isHidden } -const componentCache = new Map() +const componentCache = new Map() function renderSlotFragments(fragments: any[]) { if (!Array.isArray(fragments)) @@ -80,7 +88,7 @@ function handlePrimitiveAndSlot(asTag: string | any, allAttrs: any, slots: any) */ export function createMotionComponent( component: string | DefineComponent, - options: { forwardMotionProps?: boolean } = {}, + options: MotionCreateOptions = {}, ) { const isString = typeof component === 'string' const name = isString ? component : component.name || '' @@ -93,17 +101,38 @@ export function createMotionComponent( inheritAttrs: false, props: { ...MotionComponentProps, + as: { type: [String, Object], default: component || 'div' }, }, name: name ? `motion.${name}` : 'Motion', - setup(props, { attrs, slots }) { - const { getProps, getAttrs } = useMotionState(props as MotionProps) - + setup(props, { slots }) { + const { getProps, getAttrs, state } = useMotionState(props as MotionProps) + /** + * Vue reapplies all styles every render, include style properties and calculated initially styles get reapplied every render. + * To prevent this, reapply the current motion state styles in vnode updated lifecycle + */ + function onVnodeUpdated() { + const el = state.element + const isComponent = typeof props.as === 'object' + if ((!isComponent || props.asChild) && el) { + const { style } = getAttrs() + for (const [key, val] of Object.entries(style)) { + (el).style[key] = val + } + } + } return () => { - console.log(props) const motionProps = getProps() const motionAttrs = getAttrs() - const asTag = props.asChild ? 'template' : (attrs.as || component) - const allAttrs = { ...(options.forwardMotionProps ? motionProps : {}), ...motionAttrs } + const asTag = props.asChild ? 'template' : props.as + const allAttrs = { + ...(options.forwardMotionProps || props.forwardMotionProps ? motionProps : {}), + ...motionAttrs, + /** + * Vue reapplies all styles every render, include style properties and calculated initially styles get reapplied every render. + * To prevent this, reapply the current motion state styles in vnode updated lifecycle + */ + onVnodeUpdated, + } // Try to handle as Primitive or Slot first const primitiveOrSlotResult = handlePrimitiveAndSlot(asTag, allAttrs, slots) @@ -116,7 +145,7 @@ export function createMotionComponent( }, slots) } }, - }) as any + }) if (isString) { componentCache?.set(component, motionComponent) @@ -124,3 +153,25 @@ export function createMotionComponent( return motionComponent } + +type MotionNameSpace = { + [K in keyof IntrinsicElementAttributes]: DefineComponent, 'as' | 'asChild'> & MotionHTMLAttributes, 'create'> +} & MotionCompProps + +export function createMotionComponentWithFeatures( + feats: Feature[] = [], +) { + return new Proxy({} as unknown as MotionNameSpace, { + get(target, prop) { + if (!features.length) { + features.push(...feats) + } + if (prop === 'create') { + return (component: any, options?: MotionCreateOptions) => + createMotionComponent(component, options) + } + + return createMotionComponent(prop as string) + }, + }) +} diff --git a/packages/motion/src/features/dom-max.ts b/packages/motion/src/features/dom-max.ts new file mode 100644 index 0000000..922f138 --- /dev/null +++ b/packages/motion/src/features/dom-max.ts @@ -0,0 +1,22 @@ +import { AnimationFeature } from '@/features/animation/animation' +import { PressGesture } from '@/features/gestures/press' +import { HoverGesture } from '@/features/gestures/hover' +import { InViewGesture } from '@/features/gestures/in-view' +import { FocusGesture } from '@/features/gestures/focus' +import { ProjectionFeature } from '@/features/layout/projection' +import { DragGesture } from '@/features/gestures/drag' +import { LayoutFeature } from '@/features/layout/layout' +import { PanGesture } from '@/features/gestures/pan' +import type { Feature } from '@/features/feature' + +export const domMax = [ + AnimationFeature, + PressGesture, + HoverGesture, + InViewGesture, + FocusGesture, + ProjectionFeature, + DragGesture, + LayoutFeature, + PanGesture, +] as unknown as Feature[] diff --git a/packages/motion/src/features/gestures/drag/VisualElementDragControls.ts b/packages/motion/src/features/gestures/drag/VisualElementDragControls.ts index 942eac8..0d3c5f0 100644 --- a/packages/motion/src/features/gestures/drag/VisualElementDragControls.ts +++ b/packages/motion/src/features/gestures/drag/VisualElementDragControls.ts @@ -1,4 +1,3 @@ -import type { MotionProps } from '@/components/motion/Motion.vue' import { addDomEvent, addPointerEvent, extractEventInfo } from '@/events' import type { Lock } from '@/features/gestures/drag/lock' import { getGlobalLock } from '@/features/gestures/drag/lock' @@ -24,6 +23,7 @@ import type { LayoutUpdateData } from '@/projection/node/types' import { invariant } from 'hey-listen' import { isPresent } from '@/state/utils/is-present' import type { MotionState } from '@/state' +import type { MotionProps } from '@/components' export const elementDragControls = new WeakMap< VisualElement, diff --git a/playground/nuxt/pages/change-style.vue b/playground/nuxt/pages/change-style.vue index c066a0e..40761cc 100644 --- a/playground/nuxt/pages/change-style.vue +++ b/playground/nuxt/pages/change-style.vue @@ -1,6 +1,7 @@ diff --git a/playground/nuxt/pages/layout.vue b/playground/nuxt/pages/layout.vue index 411e8d7..e6cc6ad 100644 --- a/playground/nuxt/pages/layout.vue +++ b/playground/nuxt/pages/layout.vue @@ -10,6 +10,12 @@ const isExpanded = ref(false) + + 123 + Date: Fri, 28 Mar 2025 15:39:07 +0800 Subject: [PATCH 07/10] feat: add LazyMotion component and integrate feature management --- packages/motion/src/components/index.ts | 2 + .../src/components/lazy-motion/context.ts | 9 + .../src/components/lazy-motion/index.ts | 33 +++ .../motion/src/components/motion/types.ts | 2 + .../src/components/motion/use-motion-state.ts | 13 +- .../motion/src/components/motion/utils.ts | 23 +- packages/motion/src/features/dom-max.ts | 2 +- .../motion/src/features/feature-manager.ts | 30 ++- packages/motion/src/features/index.ts | 2 + packages/motion/src/index.ts | 1 + packages/motion/src/state/animate-updates.ts | 236 ------------------ packages/motion/src/state/motion-state.ts | 4 + playground/nuxt/pages/layout.vue | 30 ++- playground/nuxt/pages/test.ts | 3 + 14 files changed, 133 insertions(+), 257 deletions(-) create mode 100644 packages/motion/src/components/lazy-motion/context.ts delete mode 100644 packages/motion/src/state/animate-updates.ts create mode 100644 playground/nuxt/pages/test.ts diff --git a/packages/motion/src/components/index.ts b/packages/motion/src/components/index.ts index 832abe3..ad3e6f6 100644 --- a/packages/motion/src/components/index.ts +++ b/packages/motion/src/components/index.ts @@ -3,4 +3,6 @@ export * from './animate-presence' export * from './motion-config' export * from './reorder' export { default as RowValue } from './RowValue.vue' +export * from './lazy-motion' +export * from './motion/m' export { mountedStates } from '@/state' diff --git a/packages/motion/src/components/lazy-motion/context.ts b/packages/motion/src/components/lazy-motion/context.ts new file mode 100644 index 0000000..7e4efd6 --- /dev/null +++ b/packages/motion/src/components/lazy-motion/context.ts @@ -0,0 +1,9 @@ +import type { Feature } from '@/features' +import { createContext } from '@/utils' +import type { Ref } from 'vue' + +export type LazyMotionContext = { + features: Ref + strict: boolean +} +export const [useLazyMotionContext, lazyMotionContextProvider] = createContext('LazyMotionContext') diff --git a/packages/motion/src/components/lazy-motion/index.ts b/packages/motion/src/components/lazy-motion/index.ts index e69de29..8d60aa4 100644 --- a/packages/motion/src/components/lazy-motion/index.ts +++ b/packages/motion/src/components/lazy-motion/index.ts @@ -0,0 +1,33 @@ +import { lazyMotionContextProvider } from '@/components/lazy-motion/context' +import type { Feature } from '@/features' +import type { PropType } from 'vue' +import { defineComponent, ref } from 'vue' + +export const LazyMotion = defineComponent({ + name: 'LazyMotion', + props: { + features: { + type: Object as PropType>, + default: () => ([]), + }, + strict: { + type: Boolean, + default: false, + }, + }, + setup(props, { slots }) { + const features = ref(Array.isArray(props.features) ? props.features : []) + if (!Array.isArray(props.features)) { + props.features.then((feats) => { + features.value = feats + }) + } + lazyMotionContextProvider({ + features, + strict: props.strict, + }) + return () => { + return slots.default?.() + } + }, +}) diff --git a/packages/motion/src/components/motion/types.ts b/packages/motion/src/components/motion/types.ts index 858e081..13f919a 100644 --- a/packages/motion/src/components/motion/types.ts +++ b/packages/motion/src/components/motion/types.ts @@ -1,3 +1,4 @@ +import type { Feature } from '@/features' import type { AsTag, ComponentProps, Options, SVGAttributesWithMotionValues, SetMotionValueType } from '@/types' import type { IntrinsicElementAttributes } from 'vue' @@ -14,6 +15,7 @@ export interface MotionProps extends Optio whileInView?: Options['whileInView'] whileFocus?: Options['whileFocus'] forwardMotionProps?: boolean + features?: Feature[] } type __VLS_PrettifyLocal = { [K in keyof T]: T[K]; diff --git a/packages/motion/src/components/motion/use-motion-state.ts b/packages/motion/src/components/motion/use-motion-state.ts index cf50dd9..4375f66 100644 --- a/packages/motion/src/components/motion/use-motion-state.ts +++ b/packages/motion/src/components/motion/use-motion-state.ts @@ -1,5 +1,6 @@ import { injectLayoutGroup, injectMotion, provideMotion } from '@/components/context' import { getMotionElement } from '@/components/hooks/use-motion-elm' +import { useLazyMotionContext } from '@/components/lazy-motion/context' import { useMotionConfig } from '@/components/motion-config' import type { MotionProps } from '@/components/motion/types' import { checkMotionIsHidden } from '@/components/motion/utils' @@ -8,13 +9,22 @@ import { MotionState } from '@/state' import { convertSvgStyleToAttributes, createStyles } from '@/state/style' import { isMotionValue } from '@/utils' import type { DOMKeyframesDefinition } from 'motion-dom' -import { getCurrentInstance, onBeforeMount, onBeforeUnmount, onBeforeUpdate, onMounted, onUnmounted, onUpdated, useAttrs } from 'vue' +import { getCurrentInstance, onBeforeMount, onBeforeUnmount, onBeforeUpdate, onMounted, onUnmounted, onUpdated, ref, useAttrs } from 'vue' export function useMotionState(props: MotionProps) { + // motion context const parentState = injectMotion(null) + // layout group context const layoutGroup = injectLayoutGroup({}) + // motion config context const config = useMotionConfig() + // animate presence context const animatePresenceContext = injectAnimatePresence({}) + // lazy motion context + const lazyMotionContext = useLazyMotionContext({ + features: ref([]), + strict: false, + }) const attrs = useAttrs() /** @@ -31,6 +41,7 @@ export function useMotionState(props: MotionProps) { function getProps() { return { ...props, + lazyMotionContext, layoutId: getLayoutId(), transition: props.transition ?? config.value.transition, layoutGroup, diff --git a/packages/motion/src/components/motion/utils.ts b/packages/motion/src/components/motion/utils.ts index 7992643..f2bc71c 100644 --- a/packages/motion/src/components/motion/utils.ts +++ b/packages/motion/src/components/motion/utils.ts @@ -1,10 +1,10 @@ import { getMotionElement } from '@/components/hooks/use-motion-elm' -import type { Component, ComponentPublicInstance, DefineComponent, IntrinsicElementAttributes } from 'vue' +import type { Component, ComponentPublicInstance, DefineComponent, IntrinsicElementAttributes, PropType } from 'vue' import { Comment, cloneVNode, defineComponent, h, mergeProps } from 'vue' import { useMotionState } from './use-motion-state' import { MotionComponentProps } from './props' import type { MotionProps } from '@/components/motion/types' -import { type Feature, features } from '@/features' +import type { Feature } from '@/features' import type { ComponentProps, MotionHTMLAttributes } from '@/types' type MotionCompProps = { @@ -12,6 +12,7 @@ type MotionCompProps = { } export interface MotionCreateOptions { forwardMotionProps?: boolean + features?: Feature[] } export function checkMotionIsHidden(instance: ComponentPublicInstance) { const isHidden = getMotionElement(instance.$el)?.style.display === 'none' @@ -101,6 +102,10 @@ export function createMotionComponent( inheritAttrs: false, props: { ...MotionComponentProps, + features: { + type: Object as PropType>, + default: () => (options.features || []), + }, as: { type: [String, Object], default: component || 'div' }, }, name: name ? `motion.${name}` : 'Motion', @@ -159,19 +164,21 @@ type MotionNameSpace = { } & MotionCompProps export function createMotionComponentWithFeatures( - feats: Feature[] = [], + features: Feature[] = [], ) { return new Proxy({} as unknown as MotionNameSpace, { get(target, prop) { - if (!features.length) { - features.push(...feats) - } if (prop === 'create') { return (component: any, options?: MotionCreateOptions) => - createMotionComponent(component, options) + createMotionComponent(component, { + ...options, + features, + }) } - return createMotionComponent(prop as string) + return createMotionComponent(prop as string, { + features, + }) }, }) } diff --git a/packages/motion/src/features/dom-max.ts b/packages/motion/src/features/dom-max.ts index 922f138..368620c 100644 --- a/packages/motion/src/features/dom-max.ts +++ b/packages/motion/src/features/dom-max.ts @@ -16,7 +16,7 @@ export const domMax = [ InViewGesture, FocusGesture, ProjectionFeature, + PanGesture, DragGesture, LayoutFeature, - PanGesture, ] as unknown as Feature[] diff --git a/packages/motion/src/features/feature-manager.ts b/packages/motion/src/features/feature-manager.ts index c9906c2..74e5a4d 100644 --- a/packages/motion/src/features/feature-manager.ts +++ b/packages/motion/src/features/feature-manager.ts @@ -1,11 +1,37 @@ import type { Feature } from '@/features' import type { MotionState } from '@/state' +import { watch } from 'vue' -export const features: Feature[] = [] export class FeatureManager { features: Feature[] = [] constructor(state: MotionState) { - this.features = features.map((Feature: any) => new Feature(state)) + const { features = [], lazyMotionContext } = state.options + const allFeatures = features.concat(lazyMotionContext.features.value) + this.features = allFeatures.map((Feature: any) => new Feature(state)) + // watch for lazy motion features + // @eslint-disable-next-line + const featureInstances = this.features + /** + * Watch for lazy motion features + * If the feature is not already in the allFeatures array, we need to add it + * and create a new instance of the feature + */ + watch(lazyMotionContext.features, (features) => { + features.forEach((feature) => { + if (!allFeatures.includes(feature)) { + allFeatures.push(feature) + const featureInstance = new (feature as any)(state) as Feature + featureInstances.push(featureInstance) + /** + * If the Component is already mounted, we need to call the beforeMount and mount methods + */ + if (state.isMounted()) { + featureInstance.beforeMount() + featureInstance.mount() + } + } + }) + }) } mount() { diff --git a/packages/motion/src/features/index.ts b/packages/motion/src/features/index.ts index b89e804..0aafab6 100644 --- a/packages/motion/src/features/index.ts +++ b/packages/motion/src/features/index.ts @@ -4,3 +4,5 @@ export * from './layout/layout' export * from './gestures/pan' export * from './feature-manager' export * from './animation/animation' +export * from './dom-max' +export * from './dom-animation' diff --git a/packages/motion/src/index.ts b/packages/motion/src/index.ts index ac90280..8953c9d 100644 --- a/packages/motion/src/index.ts +++ b/packages/motion/src/index.ts @@ -12,3 +12,4 @@ export * from './animation' export * from './utils' export { useDragControls } from './features/gestures/drag/use-drag-controls' export type { PanInfo } from './features/gestures/pan/PanSession' +export { domAnimation, domMax } from '@/features' diff --git a/packages/motion/src/state/animate-updates.ts b/packages/motion/src/state/animate-updates.ts deleted file mode 100644 index de49988..0000000 --- a/packages/motion/src/state/animate-updates.ts +++ /dev/null @@ -1,236 +0,0 @@ -import type { DOMKeyframesDefinition, VisualElement } from 'framer-motion' -import { animate, noop } from 'framer-motion/dom' -import type { $Transition, AnimationFactory, Options, Variant } from '@/types' -import { hasChanged, resolveVariant } from '@/state/utils' -import { style } from '@/state/style' -import { transformResetValue } from '@/state/transform' -import { motionEvent } from '@/state/event' -import type { MotionState } from './motion-state' -import { isDef } from '@vueuse/core' -import { isAnimationControls } from '@/animation/utils' - -// 定义所有可用的动画状态类型 -const STATE_TYPES = ['initial', 'animate', 'whileInView', 'whileHover', 'whilePress', 'whileDrag', 'whileFocus', 'exit'] as const -export type StateType = typeof STATE_TYPES[number] - -/** - * 核心动画更新函数,处理所有动画状态变化和执行 - * @param controlActiveState - 需要更新的动画状态 - * @param controlDelay - 动画延迟时间 - * @param directAnimate - 直接动画目标值 - * @param directTransition - 直接动画过渡配置 - */ -export function animateUpdates( - this: MotionState, - { - controlActiveState = undefined, - controlDelay = 0, - directAnimate, - directTransition, - isFallback = false, - isExit = false, - }: { - controlActiveState?: Partial> - controlDelay?: number - directAnimate?: Options['animate'] - directTransition?: $Transition - isFallback?: boolean - isExit?: boolean - } = {}, -) { - const prevTarget = this.target - this.target = { ...this.baseTarget } - let animationOptions: Record = {} - const transition = { ...this.options.transition } - // 处理直接动画或状态动画 - if (directAnimate) - animationOptions = resolveDirectAnimation.call(this, directAnimate, directTransition, animationOptions) - else - animationOptions = resolveStateAnimation.call(this, controlActiveState) - const factories = createAnimationFactories.call(this, prevTarget, animationOptions, controlDelay) - const { getChildAnimations, childAnimations } = setupChildAnimations.call(this, animationOptions, this.activeStates, isFallback) - - return executeAnimations.call(this, { - factories, - getChildAnimations, - childAnimations, - transition, - controlActiveState, - isExit, - }) -} - -/** - * 解析直接动画配置 - */ -function resolveDirectAnimation( - this: MotionState, - directAnimate: Options['animate'], - directTransition: $Transition | undefined, -) { - const variant = resolveVariant(directAnimate, this.options.variants, this.options.custom || this.options.animatePresenceContext?.custom) - if (!variant) - return {} - - const transition = variant.transition || directTransition || this.options.transition || {} - Object.entries(variant).forEach(([key, value]) => { - if (key === 'transition') - return - this.target[key] = value - }) - return transition -} - -/** - * 解析状态动画配置 - */ -function resolveStateAnimation( - this: MotionState, - controlActiveState: Partial> | undefined, -) { - if (controlActiveState) - this.activeStates = { ...this.activeStates, ...controlActiveState } - - let variantTransition = this.options.transition - let variant: Variant = {} - STATE_TYPES.forEach((name) => { - if (!this.activeStates[name] || isAnimationControls(this.options[name])) - return - - const definition = this.options[name] - let resolvedVariant = isDef(definition) ? resolveVariant(definition as any, this.options.variants, this.options.custom) : undefined - // If current node is a variant node, merge the control node's variant - if (this.visualElement.isVariantNode) { - const controlVariant = resolveVariant(this.context[name], this.options.variants, this.options.custom) - resolvedVariant = controlVariant ? Object.assign(controlVariant || {}, resolvedVariant) : variant - } - if (!resolvedVariant) - return - if (name !== 'initial') - variantTransition = resolvedVariant.transition || this.options.transition || {} - variant = Object.assign(variant, resolvedVariant) - }) - Object.entries(variant).forEach(([key, value]) => { - if (key === 'transition') - return - this.target[key] = value - }) - return variantTransition -} - -/** - * 创建动画工厂函数 - */ -function createAnimationFactories( - this: MotionState, - prevTarget: DOMKeyframesDefinition, - animationOptions: $Transition, - controlDelay: number, -): AnimationFactory[] { - const factories: AnimationFactory[] = [] - - Object.keys(this.target).forEach((key: any) => { - if (!hasChanged(prevTarget[key], this.target[key])) - return - this.baseTarget[key] ??= style.get(this.element, key) as string - const keyValue = (this.target[key] === 'none' && isDef(transformResetValue[key])) ? transformResetValue[key] : this.target[key] - // console.log(key, keyValue, (animationOptions?.[key]?.delay || animationOptions?.delay || 0) + controlDelay, controlDelay) - factories.push(() => animate( - this.element, - { [key]: keyValue }, - { - ...(animationOptions?.[key] || animationOptions), - delay: (animationOptions?.[key]?.delay || animationOptions?.delay || 0) + controlDelay, - } as any, - )) - }) - - return factories -} - -/** - * 设置子元素动画 - * 处理交错动画和子元素延迟 - */ -function setupChildAnimations( - this: MotionState, - transition: $Transition | undefined, - controlActiveState: Partial> | undefined, - isFallback: boolean, -) { - if (!this.visualElement.variantChildren?.size || !controlActiveState) - return { getChildAnimations: () => Promise.resolve(), childAnimations: [] } - - const { staggerChildren = 0, staggerDirection = 1, delayChildren = 0 } = transition || {} - const maxStaggerDuration = (this.visualElement.variantChildren.size - 1) * staggerChildren - const generateStaggerDuration = staggerDirection === 1 - ? (i = 0) => i * staggerChildren - : (i = 0) => maxStaggerDuration - i * staggerChildren - - const childAnimations = Array.from(this.visualElement.variantChildren) - .map((child: VisualElement & { state: MotionState }, index) => { - const childDelay = delayChildren + generateStaggerDuration(index) - return child.state.animateUpdates({ - controlActiveState, - controlDelay: isFallback ? 0 : childDelay, - }) - }) - .filter(Boolean) as (() => Promise)[] - - return { - getChildAnimations: () => Promise.all(childAnimations.map((animation) => { - return animation?.() - })), - childAnimations, - } -} - -/** - * 执行动画 - * 处理动画顺序、事件分发和完成回调 - */ -function executeAnimations( - this: MotionState, - { - factories, - getChildAnimations, - childAnimations, - transition, - controlActiveState, - isExit = false, - }: { - factories: AnimationFactory[] - getChildAnimations: () => Promise - childAnimations: (() => Promise)[] - transition: $Transition | undefined - controlActiveState: Partial> | undefined - isExit: boolean - }, -) { - const getAnimation = () => Promise.all(factories.map(factory => factory()).filter(Boolean)) - - const animationTarget = { ...this.target } - const element = this.element - - // 完成动画并分发事件 - const finishAnimation = (animationPromise: Promise) => { - element.dispatchEvent(motionEvent('motionstart', animationTarget)) - animationPromise - .then(() => { - element.dispatchEvent(motionEvent('motioncomplete', animationTarget, isExit)) - }) - .catch(noop) - } - // 获取动画Promise - const getAnimationPromise = () => { - const animationPromise = transition?.when - ? (transition.when === 'beforeChildren' ? getAnimation() : getChildAnimations()) - .then(() => (transition.when === 'beforeChildren' ? getChildAnimations() : getAnimation())) - : Promise.all([getAnimation(), getChildAnimations()]) - - finishAnimation(animationPromise) - return animationPromise - } - - return controlActiveState ? getAnimationPromise : getAnimationPromise() -} diff --git a/packages/motion/src/state/motion-state.ts b/packages/motion/src/state/motion-state.ts index fb2d644..762448c 100644 --- a/packages/motion/src/state/motion-state.ts +++ b/packages/motion/src/state/motion-state.ts @@ -4,6 +4,7 @@ import { visualElementStore } from 'framer-motion/dist/es/render/store.mjs' import type { DOMKeyframesDefinition, VisualElement } from 'framer-motion' import { cancelFrame, frame, noop } from 'framer-motion/dom' import { isAnimateChanged, resolveVariant } from '@/state/utils' +import type { Feature } from '@/features' import { FeatureManager } from '@/features' import { createVisualElement } from '@/state/create-visual-element' import type { PresenceContext } from '@/components/presence' @@ -11,6 +12,7 @@ import { doneCallbacks } from '@/components/presence' import type { StateType } from './animate-updates' import { isVariantLabels } from '@/state/utils/is-variant-labels' import type { AnimateUpdates } from '@/features/animation/types' +import type { LazyMotionContext } from '@/components/lazy-motion/context' // Map to track mounted motion states by element export const mountedStates = new WeakMap() @@ -30,6 +32,8 @@ export class MotionState { private parent?: MotionState public options: Options & { animatePresenceContext?: PresenceContext + features?: Feature[] + lazyMotionContext?: LazyMotionContext } public isSafeToRemove = false diff --git a/playground/nuxt/pages/layout.vue b/playground/nuxt/pages/layout.vue index e6cc6ad..7e1e177 100644 --- a/playground/nuxt/pages/layout.vue +++ b/playground/nuxt/pages/layout.vue @@ -1,7 +1,8 @@ @@ -10,13 +11,24 @@ const isExpanded = ref(false) - - 123 - - + + + + diff --git a/playground/nuxt/pages/test.ts b/playground/nuxt/pages/test.ts new file mode 100644 index 0000000..e9a9ac1 --- /dev/null +++ b/playground/nuxt/pages/test.ts @@ -0,0 +1,3 @@ +import { domMax } from 'motion-v' + +export default domMax From de421d779d1f5625711f319a18c0969dbb878c4b Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Fri, 28 Mar 2025 21:17:21 +0800 Subject: [PATCH 08/10] feat: add lazy motion test --- .../lazy-motion/__tests__/lazy.test.tsx | 190 ++++++++++++++++++ .../src/components/lazy-motion/index.ts | 6 +- .../motion/src/components/motion/props.ts | 1 + .../motion/src/components/motion/types.ts | 1 + .../src/components/motion/use-motion-state.ts | 17 ++ .../motion/src/components/motion/utils.ts | 9 +- .../src/features/animation/animation.ts | 3 +- .../motion/src/features/feature-manager.ts | 2 + packages/motion/src/state/motion-state.ts | 6 +- packages/motion/src/types/state.ts | 2 +- playground/nuxt/pages/hover.vue | 1 + playground/nuxt/pages/layout.vue | 16 +- 12 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 packages/motion/src/components/lazy-motion/__tests__/lazy.test.tsx diff --git a/packages/motion/src/components/lazy-motion/__tests__/lazy.test.tsx b/packages/motion/src/components/lazy-motion/__tests__/lazy.test.tsx new file mode 100644 index 0000000..bbb9cda --- /dev/null +++ b/packages/motion/src/components/lazy-motion/__tests__/lazy.test.tsx @@ -0,0 +1,190 @@ +import { motionValue } from 'framer-motion/dom' +import { describe, expect, it } from 'vitest' +import { m } from '../../motion/m' +import { motion } from '../../motion' +import { render } from '@testing-library/vue' +import { defineComponent } from 'vue' +import { LazyMotion } from '@/components/lazy-motion' +import { domAnimation, domMax } from '@/features' + +describe('lazy feature loading', () => { + it('doesn\'t animate without loaded features', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + const Component = defineComponent({ + setup() { + return () => ( + + ) + }, + }) + + const wrapper = render(Component) + setTimeout(() => resolve(x.get()), 50) + }) + return expect(promise).resolves.not.toBe(20) + }) + + it('does animate with synchronously-loaded domAnimation', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + + const Component = defineComponent({ + setup() { + return () => ( + + + + ) + }, + }) + + const wrapper = render(Component) + // setTimeout(() => resolve(x.get()), 50) + }) + + return expect(promise).resolves.toBe(20) + }) + + it('does animate with synchronously-loaded domMax', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + + const Component = defineComponent({ + setup() { + return () => ( + + + + ) + }, + }) + + const wrapper = render(Component) + }) + + return expect(promise).resolves.toBe(20) + }) + + it('supports nested feature sets', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + + const Component = defineComponent({ + setup() { + return () => ( + + + + ) + }, + }) + + const wrapper = render(Component) + }) + + return expect(promise).resolves.toBe(20) + }) + + it('doesn\'t throw without strict mode', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + + const Component = defineComponent({ + setup() { + return () => ( + + + + ) + }, + }) + + const wrapper = render(Component) + }) + + return expect(promise).resolves.toBe(20) + }) + + it('throws in strict mode', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + + const Component = defineComponent({ + setup() { + return () => ( + + + + ) + }, + }) + + const wrapper = render(Component) + }) + + return expect(promise).rejects.toThrowError() + }) + + it('animates after async loading', async () => { + const promise = new Promise((resolve) => { + const x = motionValue(0) + const onComplete = () => resolve(x.get()) + + const Component = defineComponent({ + setup() { + return () => ( + import('@/features/dom-animation').then(mod => mod.domAnimation)}> + + + ) + }, + }) + + const wrapper = render(Component) + }) + + return expect(promise).resolves.toBe(20) + }) +}) diff --git a/packages/motion/src/components/lazy-motion/index.ts b/packages/motion/src/components/lazy-motion/index.ts index 8d60aa4..3be5509 100644 --- a/packages/motion/src/components/lazy-motion/index.ts +++ b/packages/motion/src/components/lazy-motion/index.ts @@ -5,9 +5,10 @@ import { defineComponent, ref } from 'vue' export const LazyMotion = defineComponent({ name: 'LazyMotion', + inheritAttrs: false, props: { features: { - type: Object as PropType>, + type: Object as PropType | (() => Promise)>, default: () => ([]), }, strict: { @@ -18,7 +19,8 @@ export const LazyMotion = defineComponent({ setup(props, { slots }) { const features = ref(Array.isArray(props.features) ? props.features : []) if (!Array.isArray(props.features)) { - props.features.then((feats) => { + const featuresPromise = typeof props.features === 'function' ? props.features() : props.features + featuresPromise.then((feats) => { features.value = feats }) } diff --git a/packages/motion/src/components/motion/props.ts b/packages/motion/src/components/motion/props.ts index d440d47..41ffba7 100644 --- a/packages/motion/src/components/motion/props.ts +++ b/packages/motion/src/components/motion/props.ts @@ -1,6 +1,7 @@ import { warning } from 'hey-listen' export const MotionComponentProps = { + 'ignoreStrict': { type: Boolean }, 'forwardMotionProps': { type: Boolean, default: false }, 'asChild': { type: Boolean, default: false }, 'hover': { type: [String, Array, Object] }, diff --git a/packages/motion/src/components/motion/types.ts b/packages/motion/src/components/motion/types.ts index 13f919a..45077a8 100644 --- a/packages/motion/src/components/motion/types.ts +++ b/packages/motion/src/components/motion/types.ts @@ -16,6 +16,7 @@ export interface MotionProps extends Optio whileFocus?: Options['whileFocus'] forwardMotionProps?: boolean features?: Feature[] + ignoreStrict?: boolean } type __VLS_PrettifyLocal = { [K in keyof T]: T[K]; diff --git a/packages/motion/src/components/motion/use-motion-state.ts b/packages/motion/src/components/motion/use-motion-state.ts index 4375f66..d3e5140 100644 --- a/packages/motion/src/components/motion/use-motion-state.ts +++ b/packages/motion/src/components/motion/use-motion-state.ts @@ -8,6 +8,7 @@ import { injectAnimatePresence } from '@/components/presence' import { MotionState } from '@/state' import { convertSvgStyleToAttributes, createStyles } from '@/state/style' import { isMotionValue } from '@/utils' +import { invariant, warning } from 'hey-listen' import type { DOMKeyframesDefinition } from 'motion-dom' import { getCurrentInstance, onBeforeMount, onBeforeUnmount, onBeforeUpdate, onMounted, onUnmounted, onUpdated, ref, useAttrs } from 'vue' @@ -25,6 +26,22 @@ export function useMotionState(props: MotionProps) { features: ref([]), strict: false, }) + + /** + * If we're in development mode, check to make sure we're not rendering a motion component + * as a child of LazyMotion, as this will break the file-size benefits of using it. + */ + if ( + process.env.NODE_ENV !== 'production' + && props.features?.length + && lazyMotionContext.strict + ) { + const strictMessage + = 'You have rendered a `motion` component within a `LazyMotion` component. This will break tree shaking. Import and render a `m` component instead.' + props.ignoreStrict + ? warning(false, strictMessage) + : invariant(false, strictMessage) + } const attrs = useAttrs() /** diff --git a/packages/motion/src/components/motion/utils.ts b/packages/motion/src/components/motion/utils.ts index f2bc71c..e46dcd2 100644 --- a/packages/motion/src/components/motion/utils.ts +++ b/packages/motion/src/components/motion/utils.ts @@ -20,7 +20,8 @@ export function checkMotionIsHidden(instance: ComponentPublicInstance) { return hasTransition && isHidden } -const componentCache = new Map() +const componentMaxCache = new Map() +const componentMiniCache = new Map() function renderSlotFragments(fragments: any[]) { if (!Array.isArray(fragments)) @@ -93,7 +94,7 @@ export function createMotionComponent( ) { const isString = typeof component === 'string' const name = isString ? component : component.name || '' - + const componentCache = options.features?.length > 0 ? componentMaxCache : componentMiniCache if (isString && componentCache?.has(component)) { return componentCache.get(component) } @@ -132,10 +133,6 @@ export function createMotionComponent( const allAttrs = { ...(options.forwardMotionProps || props.forwardMotionProps ? motionProps : {}), ...motionAttrs, - /** - * Vue reapplies all styles every render, include style properties and calculated initially styles get reapplied every render. - * To prevent this, reapply the current motion state styles in vnode updated lifecycle - */ onVnodeUpdated, } diff --git a/packages/motion/src/features/animation/animation.ts b/packages/motion/src/features/animation/animation.ts index 7c2c881..1d7146b 100644 --- a/packages/motion/src/features/animation/animation.ts +++ b/packages/motion/src/features/animation/animation.ts @@ -49,7 +49,6 @@ export class AnimationFeature extends Feature { const factories = this.createAnimationFactories(prevTarget, animationOptions, controlDelay) const { getChildAnimations, childAnimations } = this.setupChildAnimations(animationOptions, controlActiveState, isFallback) - return this.executeAnimations({ factories, getChildAnimations, @@ -86,9 +85,11 @@ export class AnimationFeature extends Feature { */ const finishAnimation = (animationPromise: Promise) => { element.dispatchEvent(motionEvent('motionstart', animationTarget)) + this.state.options.onAnimationStart?.(animationTarget) animationPromise .then(() => { element.dispatchEvent(motionEvent('motioncomplete', animationTarget, isExit)) + this.state.options.onAnimationComplete?.(animationTarget) }) .catch(noop) } diff --git a/packages/motion/src/features/feature-manager.ts b/packages/motion/src/features/feature-manager.ts index 74e5a4d..a6609f5 100644 --- a/packages/motion/src/features/feature-manager.ts +++ b/packages/motion/src/features/feature-manager.ts @@ -31,6 +31,8 @@ export class FeatureManager { } } }) + }, { + flush: 'pre', }) } diff --git a/packages/motion/src/state/motion-state.ts b/packages/motion/src/state/motion-state.ts index 762448c..6185780 100644 --- a/packages/motion/src/state/motion-state.ts +++ b/packages/motion/src/state/motion-state.ts @@ -4,14 +4,13 @@ import { visualElementStore } from 'framer-motion/dist/es/render/store.mjs' import type { DOMKeyframesDefinition, VisualElement } from 'framer-motion' import { cancelFrame, frame, noop } from 'framer-motion/dom' import { isAnimateChanged, resolveVariant } from '@/state/utils' -import type { Feature } from '@/features' +import type { Feature, StateType } from '@/features' import { FeatureManager } from '@/features' import { createVisualElement } from '@/state/create-visual-element' import type { PresenceContext } from '@/components/presence' import { doneCallbacks } from '@/components/presence' -import type { StateType } from './animate-updates' -import { isVariantLabels } from '@/state/utils/is-variant-labels' import type { AnimateUpdates } from '@/features/animation/types' +import { isVariantLabels } from '@/state/utils/is-variant-labels' import type { LazyMotionContext } from '@/components/lazy-motion/context' // Map to track mounted motion states by element @@ -102,7 +101,6 @@ export class MotionState { }, reducedMotionConfig: options.motionConfig.reduceMotion, }) - this.featureManager = new FeatureManager(this) } diff --git a/packages/motion/src/types/state.ts b/packages/motion/src/types/state.ts index e834da6..2bb413d 100644 --- a/packages/motion/src/types/state.ts +++ b/packages/motion/src/types/state.ts @@ -73,7 +73,7 @@ export interface Options extends motionConfig?: MotionConfigState onAnimationComplete?: (definition: Options['animate']) => void onUpdate?: (latest: ResolvedValues) => void - + onAnimationStart?: (definition: Options['animate']) => void } export interface MotionStateContext { diff --git a/playground/nuxt/pages/hover.vue b/playground/nuxt/pages/hover.vue index 4fc416a..4f96cce 100644 --- a/playground/nuxt/pages/hover.vue +++ b/playground/nuxt/pages/hover.vue @@ -12,6 +12,7 @@ import { Motion } from 'motion-v' transition: { type: 'spring' }, }" class="bg-blue-500 w-32 h-32 rounded-lg cursor-pointer" + @animation-start="console.log('animation start')" @hover-start="console.log('hover start')" @hover-end="console.log('hover end')" /> diff --git a/playground/nuxt/pages/layout.vue b/playground/nuxt/pages/layout.vue index 7e1e177..2f6b1b7 100644 --- a/playground/nuxt/pages/layout.vue +++ b/playground/nuxt/pages/layout.vue @@ -1,9 +1,17 @@ From 9037ebee62c2a82656c4da522efc625953dd7ea0 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Sat, 29 Mar 2025 11:04:53 +0800 Subject: [PATCH 09/10] feat: update LazyMotion props type to accept Function --- packages/motion/src/components/lazy-motion/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion/src/components/lazy-motion/index.ts b/packages/motion/src/components/lazy-motion/index.ts index 3be5509..3f93062 100644 --- a/packages/motion/src/components/lazy-motion/index.ts +++ b/packages/motion/src/components/lazy-motion/index.ts @@ -8,7 +8,7 @@ export const LazyMotion = defineComponent({ inheritAttrs: false, props: { features: { - type: Object as PropType | (() => Promise)>, + type: [Object, Function] as PropType | (() => Promise)>, default: () => ([]), }, strict: { From b94ad332e28923613792acdecc0ce1dd5f210ea2 Mon Sep 17 00:00:00 2001 From: Persephone Flores <34418758+hp0844182@users.noreply.github.com> Date: Mon, 31 Mar 2025 10:42:02 +0800 Subject: [PATCH 10/10] refactor: remove BaseGesture class and update gesture handling in Hover, InView, and Press features --- packages/motion/src/features/gestures/base.ts | 22 ----------- .../src/features/gestures/hover/index.ts | 18 ++++++++- .../src/features/gestures/in-view/index.ts | 2 +- .../motion/src/features/gestures/index.ts | 1 - .../src/features/gestures/press/index.ts | 39 +++++++++---------- 5 files changed, 35 insertions(+), 47 deletions(-) delete mode 100644 packages/motion/src/features/gestures/base.ts diff --git a/packages/motion/src/features/gestures/base.ts b/packages/motion/src/features/gestures/base.ts deleted file mode 100644 index 7d707bb..0000000 --- a/packages/motion/src/features/gestures/base.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Feature } from '@/features/feature' - -export abstract class BaseGesture extends Feature { - abstract isActive(): boolean - removeGestureSubscriptions?: VoidFunction - subscribeEvents?: () => VoidFunction - protected updateGestureSubscriptions( - ) { - const isActive = this.isActive() - if (isActive && !this.removeGestureSubscriptions) { - this.removeGestureSubscriptions = this.subscribeEvents() - } - else if (!isActive && this.removeGestureSubscriptions) { - this.removeGestureSubscriptions() - this.removeGestureSubscriptions = undefined - } - } - - unmount() { - this.removeGestureSubscriptions?.() - } -} diff --git a/packages/motion/src/features/gestures/hover/index.ts b/packages/motion/src/features/gestures/hover/index.ts index b20e0df..ab15be9 100644 --- a/packages/motion/src/features/gestures/hover/index.ts +++ b/packages/motion/src/features/gestures/hover/index.ts @@ -24,7 +24,8 @@ function handleHoverEvent( export class HoverGesture extends Feature { isActive() { - return Boolean(this.state.options.whileHover) + const { whileHover, onHoverStart, onHoverEnd } = this.state.options + return Boolean(whileHover || onHoverStart || onHoverEnd) } constructor(state: MotionState) { @@ -32,9 +33,22 @@ export class HoverGesture extends Feature { } mount() { + this.register() + } + + update() { + const { whileHover, onHoverStart, onHoverEnd } = this.state.visualElement.prevProps + if (!(whileHover || onHoverStart || onHoverEnd)) { + this.register() + } + } + + register() { const element = this.state.element - if (!element) + if (!element || !this.isActive()) return + // Unmount previous hover handler + this.unmount() this.unmount = hover( element, (el, startEvent) => { diff --git a/packages/motion/src/features/gestures/in-view/index.ts b/packages/motion/src/features/gestures/in-view/index.ts index d1fa13f..c0130d7 100644 --- a/packages/motion/src/features/gestures/in-view/index.ts +++ b/packages/motion/src/features/gestures/in-view/index.ts @@ -34,7 +34,7 @@ export class InViewGesture extends Feature { startObserver() { const element = this.state.element - if (!element) + if (!element || !this.isActive()) return this.unmount() const { once, ...viewOptions } = this.state.options.inViewOptions || {} diff --git a/packages/motion/src/features/gestures/index.ts b/packages/motion/src/features/gestures/index.ts index bf39480..ad1926e 100644 --- a/packages/motion/src/features/gestures/index.ts +++ b/packages/motion/src/features/gestures/index.ts @@ -1,4 +1,3 @@ -export * from './base' export * from './hover' export * from './press' export * from './in-view' diff --git a/packages/motion/src/features/gestures/press/index.ts b/packages/motion/src/features/gestures/press/index.ts index 6d01636..9a444d0 100644 --- a/packages/motion/src/features/gestures/press/index.ts +++ b/packages/motion/src/features/gestures/press/index.ts @@ -2,7 +2,6 @@ import type { MotionState } from '@/state/motion-state' import { Feature } from '@/features' import { frame, press } from 'framer-motion/dom' import type { EventInfo } from 'framer-motion' -import type { Options } from '@/types' export function extractEventInfo(event: PointerEvent): EventInfo { return { @@ -49,34 +48,32 @@ export class PressGesture extends Feature { } update() { - const preProps = this.state.visualElement.prevProps as unknown as Options + const { whilePress, onPress, onPressCancel, onPressStart } = this.state.options // Re-register if whilePress changes - if (preProps.whilePress !== this.state.options.whilePress) { + if (!(whilePress || onPress || onPressCancel || onPressStart)) { this.register() } } register() { + const element = this.state.element + if (!element || !this.isActive()) + return // Unmount previous press handler this.unmount() - if (this.isActive()) { - const element = this.state.element - if (!element) - return - this.unmount = press( - element, - (el, startEvent) => { - handlePressEvent(this.state, startEvent, 'Start') + this.unmount = press( + element, + (el, startEvent) => { + handlePressEvent(this.state, startEvent, 'Start') - return (endEvent, { success }) => - handlePressEvent( - this.state, - endEvent, - success ? 'End' : 'Cancel', - ) - }, - { useGlobalTarget: this.state.options.globalPressTarget }, - ) - } + return (endEvent, { success }) => + handlePressEvent( + this.state, + endEvent, + success ? 'End' : 'Cancel', + ) + }, + { useGlobalTarget: this.state.options.globalPressTarget }, + ) } }