diff --git a/playgrounds/vite/src/App.vue b/playgrounds/vite/src/App.vue index 613f890c..f2d78495 100644 --- a/playgrounds/vite/src/App.vue +++ b/playgrounds/vite/src/App.vue @@ -3,6 +3,7 @@ import Delay from './demos/Delay.vue' import Editor from './demos/Editor.vue' import Transitions from './demos/Transitions.vue' import Sandbox from './demos/Sandbox.vue' +import SVGPath from './demos/SVGPath.vue' const sandbox = false @@ -33,6 +34,12 @@ const sandbox = false + +

+ SVGPath +

+ + diff --git a/playgrounds/vite/src/demos/SVGPath.vue b/playgrounds/vite/src/demos/SVGPath.vue new file mode 100644 index 00000000..67560dcd --- /dev/null +++ b/playgrounds/vite/src/demos/SVGPath.vue @@ -0,0 +1,133 @@ + + + diff --git a/playgrounds/vite/src/examples/svgpath.ts b/playgrounds/vite/src/examples/svgpath.ts new file mode 100644 index 00000000..119a1169 --- /dev/null +++ b/playgrounds/vite/src/examples/svgpath.ts @@ -0,0 +1,132 @@ +export default () => ` + + + +` diff --git a/src/reactiveStyle.ts b/src/reactiveStyle.ts index 03488a1c..bca1c21c 100644 --- a/src/reactiveStyle.ts +++ b/src/reactiveStyle.ts @@ -1,27 +1,26 @@ import type { Ref } from 'vue' import { reactive, ref, watch } from 'vue' -import type { StyleProperties } from './types' +import type { SVGPathProperties, StyleProperties } from './types' import { getValueAsType, getValueType } from './utils/style' - /** * Reactive style object implementing all native CSS properties. * * @param props */ -export function reactiveStyle(props: StyleProperties = {}) { +export function reactiveStyle(props: StyleProperties | SVGPathProperties = {}) { // Reactive StyleProperties object - const state = reactive({ + const state = reactive({ ...props, }) - const style = ref({}) as Ref + const style = ref({}) as Ref // Reactive DOM Element compatible `style` object bound to state watch( state, () => { // Init result object - const result: StyleProperties = {} + const result: StyleProperties | SVGPathProperties = {} for (const [key, value] of Object.entries(state)) { // Get value type for key diff --git a/src/useElementStyle.ts b/src/useElementStyle.ts index 6168b028..95336ba1 100644 --- a/src/useElementStyle.ts +++ b/src/useElementStyle.ts @@ -1,9 +1,9 @@ import type { MaybeRef } from '@vueuse/core' import { watch } from 'vue' import { reactiveStyle } from './reactiveStyle' -import type { MotionTarget, PermissiveTarget, StyleProperties } from './types' +import type { MotionTarget, PermissiveTarget, SVGPathProperties, StyleProperties } from './types' import { usePermissiveTarget } from './usePermissiveTarget' -import { valueTypes } from './utils/style' +import { getSVGPath, isSVGElement, isSVGPathProp, setSVGPath, valueTypes } from './utils/style' import { isTransformOriginProp, isTransformProp } from './utils/transform' /** @@ -13,7 +13,7 @@ import { isTransformOriginProp, isTransformProp } from './utils/transform' */ export function useElementStyle(target: MaybeRef, onInit?: (initData: Partial) => void) { // Transform cache available before the element is mounted - let _cache: StyleProperties | undefined + let _cache: StyleProperties | SVGPathProperties | undefined // Local target cache as we need to resolve the element from PermissiveTarget let _target: MotionTarget // Create a reactive style object @@ -22,10 +22,19 @@ export function useElementStyle(target: MaybeRef, onInit?: (in usePermissiveTarget(target, (el) => { _target = el + if (isSVGElement(_target)) { + const { pathLength, pathSpacing, pathOffset } = getSVGPath(_target as SVGElement) + if (pathLength !== undefined) { + (state as SVGPathProperties).pathLength = pathLength; + (state as SVGPathProperties).pathSpacing = pathSpacing; + (state as SVGPathProperties).pathOffset = pathOffset + } + } + // Loop on style keys for (const key of Object.keys(valueTypes)) { // @ts-expect-error - Fix errors later for typescript 5 - if (el.style[key] === null || el.style[key] === '' || isTransformProp(key) || isTransformOriginProp(key)) + if (el.style[key] === null || el.style[key] === '' || isTransformProp(key) || isTransformOriginProp(key) || isSVGPathProp(key)) continue // Append a defined key to the local StyleProperties state object @@ -35,6 +44,13 @@ export function useElementStyle(target: MaybeRef, onInit?: (in // If cache is present, init the target with the current cached value if (_cache) { + if (isSVGElement(_target)) { + const { pathLength, pathOffset, pathSpacing } = _cache as SVGPathProperties + if (pathLength !== undefined) { + setSVGPath((_target as SVGElement), pathLength, pathSpacing, pathOffset) + } + } + // @ts-expect-error - Fix errors later for typescript 5 Object.entries(_cache).forEach(([key, value]) => (el.style[key] = value)) } @@ -53,6 +69,13 @@ export function useElementStyle(target: MaybeRef, onInit?: (in return } + if (isSVGElement(_target)) { + const { pathLength, pathOffset, pathSpacing } = newVal as SVGPathProperties + if (pathLength !== undefined) { + setSVGPath((_target as SVGElement), pathLength, pathSpacing, pathOffset) + } + } + // Append the state object to the target style properties // @ts-expect-error - Fix errors later for typescript 5 for (const key in newVal) _target.style[key] = newVal[key] diff --git a/src/utils/component.ts b/src/utils/component.ts index f447bc06..a70f25e7 100644 --- a/src/utils/component.ts +++ b/src/utils/component.ts @@ -15,6 +15,7 @@ import * as presets from '../presets' import type { MotionInstance } from '../types/instance' import type { MotionVariants, + SVGPathProperties, StyleProperties, Variant, } from '../types/variants' @@ -228,7 +229,7 @@ export function setupMotionComponent( } // Set node style and register to `instances` on mount - function setNodeInstance(node: VNode, index: number, style: StyleProperties) { + function setNodeInstance(node: VNode, index: number, style: StyleProperties | SVGPathProperties) { node.props ??= {} node.props.style ??= {} diff --git a/src/utils/style.ts b/src/utils/style.ts index db036b44..20ee0d71 100644 --- a/src/utils/style.ts +++ b/src/utils/style.ts @@ -103,6 +103,11 @@ export const valueTypes: ValueTypeMap = { fillOpacity: alpha, strokeOpacity: alpha, numOctaves: int, + + // custom SVG properties + pathLength: auto, + pathOffset: auto, + pathSpacing: auto, } /** @@ -135,3 +140,63 @@ export function getAnimatableNone(key: string, value: string): any { // If value is not recognised as animatable, ie "none", create an animatable version origin based on the target return defaultValueType.getAnimatableNone ? defaultValueType.getAnimatableNone(value) : undefined } + +/** + * A quick lookup for custom SVG props. + */ +const SVGPathProps = new Set(['pathLength', 'pathOffset', 'pathSpacing']) +export function isSVGPathProp(key: string): boolean { + return SVGPathProps.has(key) +} + +/** + * Determine whether it is an svg element + * @param target + */ +export function isSVGElement(target: HTMLElement | SVGElement): boolean { + return !!((target as SVGElement)?.ownerSVGElement || target.tagName.toLowerCase() === 'svg') +} + +/** + * Build SVG path properties from custom properties + * pathLength always normalize to 1 + * pathOffset to stroke-dashoffset + * pathLength and pathSpacing to stroke-dasharray + * + * pathLength: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/pathLength + * the path is always normalize to 1, so you need deal with other paths by yourself + * base the total length is 1. + * for example stroke-dasharray will assume the start of the path being 0 and + * the end point the value defined in the pathLength attribute as 1. + * + * @param target + * @param length + * @param spacing + * @param offset + */ +export function setSVGPath(target: SVGElement, length: number, spacing = 1, offset = 0) { + target.setAttribute('pathLength', '1') // normalize to 1 + target.setAttribute('stroke-dashoffset', `${offset}`) + target.setAttribute('stroke-dasharray', `${length} ${spacing}`) +} + +/** + * Get pathLength pathSpacing pathOffset from svg element + * Convert stroke-dashoffset stroke-dasharray to custome properties + * @param target + */ +export function getSVGPath(target: SVGElement) { + // pathLength is normalize to 1 + const pathLength = target.getAttribute('pathLength') ? 1 : undefined + const pathOffset = target.getAttribute('stroke-dashoffset') ? Number.parseFloat(target.getAttribute('stroke-dashoffset')!) : undefined + // TODO need to support odd? + // sinle: dashes and gaps are same size + // two: dashes and gaps are different sizes + // odd: dashes and gaps of various sizes with an odd number of values, [4,1,2] is equivalent to [4,1,2,4,1,2] + const pathSpacing = target.getAttribute('stroke-dasharray') ? Number.parseFloat(target.getAttribute('stroke-dasharray')!.split(' ')[1]!) : undefined + return { + pathLength, + pathSpacing, + pathOffset, + } +} diff --git a/tests/components.spec.ts b/tests/components.spec.ts index 1548a169..c0d30493 100644 --- a/tests/components.spec.ts +++ b/tests/components.spec.ts @@ -4,7 +4,12 @@ import { h, nextTick, ref } from 'vue' import { MotionComponent, MotionPlugin } from '../src' import MotionGroup from '../src/components/MotionGroup' import { intersect } from './utils/intersectionObserver' -import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils' +import { + getTestComponent, + getTestComponentSVG, + useCompletionFn, + waitForMockCalls, +} from './utils' // Register plugin config.global.plugins.push([ @@ -236,3 +241,143 @@ describe('`` component', async () => { ).toEqual('0') }) }) + +/** + * tests for svg path + * pathLegnth + * pathSpacing + * pathOffset + */ +describe.each([ + { t: 'dirctive', name: 'v-motion svg element directive' }, + { t: 'component', name: ' svg element directive' }, +])(`$name`, async ({ t }) => { + const TestComponentSVG = getTestComponentSVG(t) + + it('svg variants number', async () => { + const onComplete = useCompletionFn() + + const wrapper = mount(TestComponentSVG, { + props: { + initial: { + pathLength: 1, + pathSpacing: 1, + pathOffset: 2, + }, + enter: { + pathLength: 2, + pathSpacing: 2, + pathOffset: 3, + transition: { onComplete }, + }, + duration: 10, + }, + }) + + const el = wrapper.element as SVGElement + await nextTick() + + // Renders initial variant + expect(el.style.pathLength).toEqual(1) + expect(el.style.pathSpacing).toEqual(1) + expect(el.style.pathOffset).toEqual(2) + expect(el.getAttribute('pathLength')).toBe('1') + expect(el.getAttribute('stroke-dashoffset')).toBe('2') + expect(el.getAttribute('stroke-dasharray')).toBe('1 1') + + await waitForMockCalls(onComplete) + + // Renders enter variant + expect(el.style.pathLength).toEqual(2) + expect(el.style.pathSpacing).toEqual(2) + expect(el.style.pathOffset).toEqual(3) + expect(el.getAttribute('pathLength')).toBe('1') + expect(el.getAttribute('stroke-dashoffset')).toBe('3') + expect(el.getAttribute('stroke-dasharray')).toBe('2 2') + }) + + it('svg variants px', async () => { + const onComplete = useCompletionFn() + + const wrapper = mount(TestComponentSVG, { + props: { + initial: { + pathLength: '1px', + pathSpacing: '1px', + pathOffset: '2px', + }, + enter: { + pathLength: '2px', + pathSpacing: '2px', + pathOffset: '3px', + transition: { onComplete }, + }, + duration: 10, + }, + }) + + const el = wrapper.element as SVGElement + await nextTick() + + // Renders initial variant + expect(el.style.pathLength).toEqual('1px') + expect(el.style.pathSpacing).toEqual('1px') + expect(el.style.pathOffset).toEqual('2px') + expect(el.getAttribute('pathLength')).toBe('1') + expect(el.getAttribute('stroke-dashoffset')).toBe('2px') + expect(el.getAttribute('stroke-dasharray')).toBe('1px 1px') + + await waitForMockCalls(onComplete) + + // Renders enter variant + expect(el.style.pathLength).toEqual('2px') + expect(el.style.pathSpacing).toEqual('2px') + expect(el.style.pathOffset).toEqual('3px') + expect(el.getAttribute('pathLength')).toBe('1') + expect(el.getAttribute('stroke-dashoffset')).toBe('3px') + expect(el.getAttribute('stroke-dasharray')).toBe('2px 2px') + }) + + it('svg event variants', async () => { + const onComplete = useCompletionFn() + + const wrapper = mount(TestComponentSVG, { + props: { + initial: { + pathLength: 1, + pathSpacing: 1, + pathOffset: 2, + }, + hovered: { + pathLength: 2, + pathSpacing: 2, + pathOffset: 4, + transition: { onComplete }, + }, + duration: 10, + }, + }) + + const el = wrapper.element as SVGElement + await nextTick() + + // Renders initial variant + expect(el.style.pathLength).toEqual(1) + expect(el.style.pathSpacing).toEqual(1) + expect(el.style.pathOffset).toEqual(2) + expect(el.getAttribute('pathLength')).toBe('1') + expect(el.getAttribute('stroke-dashoffset')).toBe('2') + expect(el.getAttribute('stroke-dasharray')).toBe('1 1') + + await wrapper.trigger('mouseenter') + await waitForMockCalls(onComplete) + + // Renders enter variant + expect(el.style.pathLength).toEqual(2) + expect(el.style.pathSpacing).toEqual(2) + expect(el.style.pathOffset).toEqual(4) + expect(el.getAttribute('pathLength')).toBe('1') + expect(el.getAttribute('stroke-dashoffset')).toBe('4') + expect(el.getAttribute('stroke-dasharray')).toBe('2 2') + }) +}) diff --git a/tests/useElementStyle.spec.ts b/tests/useElementStyle.spec.ts index 4ae78cd0..244250de 100644 --- a/tests/useElementStyle.spec.ts +++ b/tests/useElementStyle.spec.ts @@ -44,3 +44,109 @@ describe('useElementStyle', () => { expect(element.value.style.backgroundColor).toBe('blue') }) }) + +/** + * tests for svg path + * pathLegnth + * pathSpacing + * pathOffset + */ +const TestComponentSVG = { + template: ` + + +`, +} + +function getSVGElementRef() { + const c = mount(TestComponentSVG) + + return ref(c.element as SVGElement) +} + +describe('useElementStyle Test SVGPath', () => { + it('accepts an svg element', () => { + const element = getSVGElementRef() + + const { style } = useElementStyle(element) + + expect(style).toBeDefined() + }) + + it('mutates pathLength', async () => { + const element = getSVGElementRef() + const { style } = useElementStyle(element) + style.pathLength = 2 + + const line = element.value.querySelector('line') + const { style: lineStyle } = useElementStyle(line) + lineStyle.pathLength = 3 + + await nextTick() + + // svg + expect(element.value.getAttribute('pathLength')).toBe('1') + expect(element.value.getAttribute('stroke-dashoffset')).toBe('0') + expect(element.value.getAttribute('stroke-dasharray')).toBe('2 1') + + // line + expect(line.getAttribute('pathLength')).toBe('1') + expect(line.getAttribute('stroke-dashoffset')).toBe('0') + expect(line.getAttribute('stroke-dasharray')).toBe('3 1') + + // number + lineStyle.pathLength = 3 + lineStyle.pathOffset = 3 + lineStyle.pathSpacing = 3 + + await nextTick() + + expect(line.getAttribute('pathLength')).toBe('1') + expect(line.getAttribute('stroke-dashoffset')).toBe('3') + expect(line.getAttribute('stroke-dasharray')).toBe('3 3') + + // string + lineStyle.pathLength = '3' + lineStyle.pathOffset = '3' + lineStyle.pathSpacing = '2' + + await nextTick() + + expect(line.getAttribute('pathLength')).toBe('1') + expect(line.getAttribute('stroke-dashoffset')).toBe('3') + expect(line.getAttribute('stroke-dasharray')).toBe('3 2') + + // px + lineStyle.pathLength = '3px' + lineStyle.pathOffset = '3px' + lineStyle.pathSpacing = '2px' + + await nextTick() + + expect(line.getAttribute('pathLength')).toBe('1') + expect(line.getAttribute('stroke-dashoffset')).toBe('3px') + expect(line.getAttribute('stroke-dasharray')).toBe('3px 2px') + + // percentage + lineStyle.pathLength = '3%' + lineStyle.pathOffset = '3%' + lineStyle.pathSpacing = '2%' + + await nextTick() + + expect(line.getAttribute('pathLength')).toBe('1') + expect(line.getAttribute('stroke-dashoffset')).toBe('3%') + expect(line.getAttribute('stroke-dasharray')).toBe('3% 2%') + }) +}) diff --git a/tests/utils/index.ts b/tests/utils/index.ts index 5f28b1d3..f6bd9731 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -33,3 +33,31 @@ export async function waitForMockCalls(fn: Mock, calls = 1, options: Parameters< throw err } } + +export function getTestComponentSVG(t: string) { + if (t === 'directive') { + return { + template: ` + + + +`, + } + } + return { + render: () => h(MotionComponent, { + is: 'svg', + }), + } +}