@luanlu/mk-motion 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -2
- package/src/a11y/focus-trap.ts +64 -0
- package/src/a11y/keyboard.ts +43 -0
- package/src/components/alert/alert.css +111 -0
- package/src/components/alert/alert.ts +107 -0
- package/src/components/avatar/avatar.css +112 -0
- package/src/components/avatar/avatar.ts +175 -0
- package/src/components/breadcrumb/breadcrumb.css +31 -0
- package/src/components/breadcrumb/breadcrumb.ts +71 -0
- package/src/components/button/button.css +108 -0
- package/src/components/button/button.ts +140 -0
- package/src/components/card/card.css +52 -0
- package/src/components/card/card.ts +87 -0
- package/src/components/collapse/collapse.css +76 -0
- package/src/components/collapse/collapse.ts +168 -0
- package/src/components/dialog/dialog.css +78 -0
- package/src/components/dialog/dialog.ts +164 -0
- package/src/components/drawer/drawer.css +73 -0
- package/src/components/drawer/drawer.ts +131 -0
- package/src/components/empty/empty.css +36 -0
- package/src/components/empty/empty.ts +85 -0
- package/src/components/form/checkbox.css +56 -0
- package/src/components/form/checkbox.ts +119 -0
- package/src/components/form/radio.css +57 -0
- package/src/components/form/radio.ts +153 -0
- package/src/components/form/select.css +91 -0
- package/src/components/form/select.ts +174 -0
- package/src/components/form/slider.css +56 -0
- package/src/components/form/slider.ts +148 -0
- package/src/components/input/input.css +92 -0
- package/src/components/input/input.ts +162 -0
- package/src/components/layout/divider.css +32 -0
- package/src/components/layout/divider.ts +42 -0
- package/src/components/layout/row.css +64 -0
- package/src/components/layout/row.ts +57 -0
- package/src/components/layout/space.css +14 -0
- package/src/components/layout/space.ts +48 -0
- package/src/components/loading/loading.css +37 -0
- package/src/components/loading/loading.ts +46 -0
- package/src/components/menu/menu.css +121 -0
- package/src/components/menu/menu.ts +187 -0
- package/src/components/message/message.css +64 -0
- package/src/components/message/message.ts +96 -0
- package/src/components/popover/popover.css +73 -0
- package/src/components/popover/popover.ts +279 -0
- package/src/components/progress/progress.css +112 -0
- package/src/components/progress/progress.ts +171 -0
- package/src/components/steps/steps.css +127 -0
- package/src/components/steps/steps.ts +102 -0
- package/src/components/styles/components.css +28 -0
- package/src/components/styles/reset.css +24 -0
- package/src/components/styles/tokens.css +248 -0
- package/src/components/styles/variables.css +24 -0
- package/src/components/switch/switch.css +53 -0
- package/src/components/switch/switch.ts +103 -0
- package/src/components/table/table.css +192 -0
- package/src/components/table/table.ts +370 -0
- package/src/components/tabs/tabs.css +138 -0
- package/src/components/tabs/tabs.ts +211 -0
- package/src/components/tag/tag.css +123 -0
- package/src/components/tag/tag.ts +112 -0
- package/src/components/tooltip/tooltip.css +66 -0
- package/src/components/tooltip/tooltip.ts +185 -0
- package/src/core/animator.ts +124 -0
- package/src/core/timeline.ts +128 -0
- package/src/core/utils.ts +47 -0
- package/src/effects/glitch.ts +99 -0
- package/src/effects/particle.ts +134 -0
- package/src/effects/text-split.ts +95 -0
- package/src/effects/wave-text.ts +88 -0
- package/src/gesture/draggable.ts +130 -0
- package/src/gesture/spring.ts +152 -0
- package/src/index.ts +162 -0
- package/src/interactive/coverflow.ts +100 -0
- package/src/interactive/cursor-trail.ts +113 -0
- package/src/interactive/flip-card.ts +114 -0
- package/src/interactive/magnetic.ts +121 -0
- package/src/micro/hover-lift.ts +94 -0
- package/src/micro/ripple.ts +130 -0
- package/src/motion/component-motion.ts +177 -0
- package/src/nuxt/module.ts +46 -0
- package/src/presets/index.ts +69 -0
- package/src/scroll/scroll-trigger.ts +104 -0
- package/src/styles/animations.css +135 -0
- package/src/styles/element-plus.css +174 -0
- package/src/text/count-up.ts +108 -0
- package/src/text/typewriter.ts +109 -0
- package/src/theme/dark.css +19 -0
- package/src/theme/light.css +19 -0
- package/src/theme/theme.ts +65 -0
- package/src/transitions/blur-reveal.ts +92 -0
- package/src/transitions/collapse.ts +112 -0
- package/src/transitions/lazy-image.ts +87 -0
- package/src/transitions/list.ts +75 -0
- package/src/transitions/loading.ts +95 -0
- package/src/transitions/parallax.ts +60 -0
- package/src/transitions/shimmer.ts +105 -0
- package/src/transitions/toast.ts +151 -0
- package/src/types.d.ts +4 -0
- package/src/vite/plugin.ts +45 -0
- package/src/vue/button.ts +28 -9
- package/src/vue/card.ts +28 -8
- package/src/vue/composables/index.ts +4 -0
- package/src/vue/composables/useLoading.ts +12 -0
- package/src/vue/composables/useMessage.ts +16 -0
- package/src/vue/composables/useMotion.ts +19 -0
- package/src/vue/composables/useTheme.ts +12 -0
- package/src/vue/dialog.ts +69 -17
- package/src/vue/index.ts +4 -21
- package/src/vue/input.ts +35 -11
- package/src/vue/slider.ts +22 -4
- package/src/vue/switch.ts +16 -9
- package/src/vue/alert.ts +0 -32
- package/src/vue/avatar.ts +0 -34
- package/src/vue/breadcrumb.ts +0 -32
- package/src/vue/checkbox.ts +0 -32
- package/src/vue/collapse.ts +0 -33
- package/src/vue/divider.ts +0 -32
- package/src/vue/drawer.ts +0 -33
- package/src/vue/empty.ts +0 -33
- package/src/vue/menu.ts +0 -33
- package/src/vue/popover.ts +0 -34
- package/src/vue/progress.ts +0 -33
- package/src/vue/row.ts +0 -32
- package/src/vue/select.ts +0 -33
- package/src/vue/space.ts +0 -32
- package/src/vue/steps.ts +0 -33
- package/src/vue/table.ts +0 -33
- package/src/vue/tabs.ts +0 -33
- package/src/vue/tag.ts +0 -33
- package/src/vue/tooltip.ts +0 -34
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { AnimationName, AnimationOptions } from './utils.ts'
|
|
2
|
+
import { toCssTime, setCSSVariables, removeCSSVariables } from './utils.ts'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_OPTIONS: Required<AnimationOptions> = {
|
|
5
|
+
duration: 500,
|
|
6
|
+
easing: 'ease',
|
|
7
|
+
delay: 0,
|
|
8
|
+
iterations: 1,
|
|
9
|
+
direction: 'normal',
|
|
10
|
+
fill: 'both',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Animator {
|
|
14
|
+
private element: HTMLElement
|
|
15
|
+
private currentAnimation: Animation | null = null
|
|
16
|
+
|
|
17
|
+
constructor(element: HTMLElement | string) {
|
|
18
|
+
this.element =
|
|
19
|
+
typeof element === 'string'
|
|
20
|
+
? document.querySelector(element)!
|
|
21
|
+
: element
|
|
22
|
+
|
|
23
|
+
if (!this.element) {
|
|
24
|
+
throw new Error(`Animator: element not found`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 播放 CSS 类动画
|
|
30
|
+
*/
|
|
31
|
+
animate(
|
|
32
|
+
name: AnimationName,
|
|
33
|
+
options: AnimationOptions = {}
|
|
34
|
+
): Promise<Animator> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
37
|
+
|
|
38
|
+
setCSSVariables(this.element, {
|
|
39
|
+
duration: toCssTime(opts.duration),
|
|
40
|
+
easing: opts.easing,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const className = `mk-animated mk-${name}`
|
|
44
|
+
this.element.classList.add(...className.split(' '))
|
|
45
|
+
|
|
46
|
+
const cleanup = () => {
|
|
47
|
+
this.element.classList.remove(...className.split(' '))
|
|
48
|
+
removeCSSVariables(this.element, ['duration', 'easing'])
|
|
49
|
+
resolve(this)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const onEnd = (e: AnimationEvent) => {
|
|
53
|
+
if (e.animationName.startsWith('mk-')) {
|
|
54
|
+
this.element.removeEventListener('animationend', onEnd)
|
|
55
|
+
cleanup()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (opts.iterations === Infinity || opts.iterations <= 0) {
|
|
60
|
+
this.element.classList.add('mk-infinite')
|
|
61
|
+
resolve(this)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.element.addEventListener('animationend', onEnd)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 使用 Web Animations API 播放动画
|
|
71
|
+
*/
|
|
72
|
+
waa(
|
|
73
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes,
|
|
74
|
+
options: AnimationOptions & { duration?: number } = {}
|
|
75
|
+
): Promise<Animator> {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
78
|
+
|
|
79
|
+
this.currentAnimation = this.element.animate(keyframes, {
|
|
80
|
+
duration: opts.duration,
|
|
81
|
+
easing: opts.easing,
|
|
82
|
+
delay: opts.delay,
|
|
83
|
+
iterations: opts.iterations === Infinity ? Infinity : opts.iterations,
|
|
84
|
+
direction: opts.direction,
|
|
85
|
+
fill: opts.fill,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
this.currentAnimation.onfinish = () => resolve(this)
|
|
89
|
+
|
|
90
|
+
if (opts.iterations === Infinity || opts.iterations <= 0) {
|
|
91
|
+
resolve(this)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 停止当前动画
|
|
98
|
+
*/
|
|
99
|
+
stop(): this {
|
|
100
|
+
if (this.currentAnimation) {
|
|
101
|
+
this.currentAnimation.cancel()
|
|
102
|
+
this.currentAnimation = null
|
|
103
|
+
}
|
|
104
|
+
return this
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 移除所有动效类
|
|
109
|
+
*/
|
|
110
|
+
reset(): this {
|
|
111
|
+
const classes = Array.from(this.element.classList)
|
|
112
|
+
const toRemove = classes.filter((c) => c.startsWith('mk-'))
|
|
113
|
+
this.element.classList.remove(...toRemove)
|
|
114
|
+
return this
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 设置元素样式变量
|
|
119
|
+
*/
|
|
120
|
+
vars(variables: Record<string, string | number>): this {
|
|
121
|
+
setCSSVariables(this.element, variables)
|
|
122
|
+
return this
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AnimationName, AnimationOptions } from './utils.ts'
|
|
2
|
+
|
|
3
|
+
interface TimelineItem {
|
|
4
|
+
name: AnimationName
|
|
5
|
+
target: HTMLElement | string
|
|
6
|
+
options?: AnimationOptions
|
|
7
|
+
at?: number // 相对时间线起点的毫秒数
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Timeline {
|
|
11
|
+
private items: TimelineItem[] = []
|
|
12
|
+
private running = false
|
|
13
|
+
private abortController: AbortController | null = null
|
|
14
|
+
|
|
15
|
+
add(
|
|
16
|
+
name: AnimationName,
|
|
17
|
+
target: HTMLElement | string,
|
|
18
|
+
options?: AnimationOptions & { at?: number }
|
|
19
|
+
): this {
|
|
20
|
+
this.items.push({
|
|
21
|
+
name,
|
|
22
|
+
target,
|
|
23
|
+
options,
|
|
24
|
+
at: options?.at ?? this.estimateEndTime(),
|
|
25
|
+
})
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private estimateEndTime(): number {
|
|
30
|
+
if (this.items.length === 0) return 0
|
|
31
|
+
const last = this.items[this.items.length - 1]
|
|
32
|
+
const duration = last.options?.duration ?? 500
|
|
33
|
+
return (last.at ?? 0) + duration
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async play(): Promise<void> {
|
|
37
|
+
if (this.running) return
|
|
38
|
+
this.running = true
|
|
39
|
+
this.abortController = new AbortController()
|
|
40
|
+
const signal = this.abortController.signal
|
|
41
|
+
|
|
42
|
+
const promises = this.items.map((item) => {
|
|
43
|
+
return new Promise<void>((resolve) => {
|
|
44
|
+
const el =
|
|
45
|
+
typeof item.target === 'string'
|
|
46
|
+
? document.querySelector(item.target)
|
|
47
|
+
: item.target
|
|
48
|
+
|
|
49
|
+
if (!el || signal.aborted) {
|
|
50
|
+
resolve()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const delay = item.at ?? 0
|
|
55
|
+
const duration = item.options?.duration ?? 500
|
|
56
|
+
const easing = item.options?.easing ?? 'ease'
|
|
57
|
+
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
if (signal.aborted) {
|
|
60
|
+
resolve()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const className = `mk-animated mk-${item.name}`
|
|
65
|
+
const htmlEl = el as HTMLElement
|
|
66
|
+
htmlEl.classList.add(...className.split(' '))
|
|
67
|
+
htmlEl.style.setProperty('--mk-duration', `${duration}ms`)
|
|
68
|
+
htmlEl.style.setProperty('--mk-easing', easing)
|
|
69
|
+
|
|
70
|
+
const onEnd = (e: AnimationEvent) => {
|
|
71
|
+
if (e.animationName.startsWith('mk-')) {
|
|
72
|
+
htmlEl.removeEventListener('animationend', onEnd)
|
|
73
|
+
htmlEl.classList.remove(...className.split(' '))
|
|
74
|
+
htmlEl.style.removeProperty('--mk-duration')
|
|
75
|
+
htmlEl.style.removeProperty('--mk-easing')
|
|
76
|
+
resolve()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
htmlEl.addEventListener('animationend', onEnd)
|
|
81
|
+
|
|
82
|
+
// 安全兜底:如果动画没触发,超时 resolve
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
htmlEl.removeEventListener('animationend', onEnd)
|
|
85
|
+
htmlEl.classList.remove(...className.split(' '))
|
|
86
|
+
htmlEl.style.removeProperty('--mk-duration')
|
|
87
|
+
htmlEl.style.removeProperty('--mk-easing')
|
|
88
|
+
resolve()
|
|
89
|
+
}, duration + 100)
|
|
90
|
+
}, delay)
|
|
91
|
+
|
|
92
|
+
signal.addEventListener('abort', () => {
|
|
93
|
+
clearTimeout(timer)
|
|
94
|
+
resolve()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await Promise.all(promises)
|
|
100
|
+
this.running = false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stop(): void {
|
|
104
|
+
this.abortController?.abort()
|
|
105
|
+
this.running = false
|
|
106
|
+
|
|
107
|
+
// 清理所有残留的动画类
|
|
108
|
+
this.items.forEach((item) => {
|
|
109
|
+
const el =
|
|
110
|
+
typeof item.target === 'string'
|
|
111
|
+
? document.querySelector(item.target)
|
|
112
|
+
: item.target
|
|
113
|
+
if (el) {
|
|
114
|
+
const htmlEl = el as HTMLElement
|
|
115
|
+
const className = `mk-animated mk-${item.name}`
|
|
116
|
+
htmlEl.classList.remove(...className.split(' '))
|
|
117
|
+
htmlEl.style.removeProperty('--mk-duration')
|
|
118
|
+
htmlEl.style.removeProperty('--mk-easing')
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
clear(): this {
|
|
124
|
+
this.stop()
|
|
125
|
+
this.items = []
|
|
126
|
+
return this
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type AnimationName =
|
|
2
|
+
| 'fadeIn' | 'fadeOut'
|
|
3
|
+
| 'slideInUp' | 'slideInDown' | 'slideInLeft' | 'slideInRight'
|
|
4
|
+
| 'slideOutUp' | 'slideOutDown'
|
|
5
|
+
| 'zoomIn' | 'zoomOut'
|
|
6
|
+
| 'bounceIn' | 'bounceOut'
|
|
7
|
+
| 'flipInX' | 'flipInY'
|
|
8
|
+
| 'shake' | 'pulse' | 'rotateIn'
|
|
9
|
+
|
|
10
|
+
export interface AnimationOptions {
|
|
11
|
+
duration?: number
|
|
12
|
+
easing?: string
|
|
13
|
+
delay?: number
|
|
14
|
+
iterations?: number
|
|
15
|
+
direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'
|
|
16
|
+
fill?: 'none' | 'forwards' | 'backwards' | 'both'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseTime(value: number | string): number {
|
|
20
|
+
if (typeof value === 'number') return value
|
|
21
|
+
const trimmed = value.trim().toLowerCase()
|
|
22
|
+
if (trimmed.endsWith('ms')) return parseFloat(trimmed)
|
|
23
|
+
if (trimmed.endsWith('s')) return parseFloat(trimmed) * 1000
|
|
24
|
+
return parseFloat(trimmed)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function toCssTime(ms: number): string {
|
|
28
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2).replace(/\.?0+$/, '')}s`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setCSSVariables(
|
|
32
|
+
el: HTMLElement,
|
|
33
|
+
vars: Record<string, string | number>
|
|
34
|
+
): void {
|
|
35
|
+
Object.entries(vars).forEach(([key, value]) => {
|
|
36
|
+
el.style.setProperty(`--mk-${key}`, String(value))
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function removeCSSVariables(
|
|
41
|
+
el: HTMLElement,
|
|
42
|
+
keys: string[]
|
|
43
|
+
): void {
|
|
44
|
+
keys.forEach((key) => {
|
|
45
|
+
el.style.removeProperty(`--mk-${key}`)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export interface GlitchOptions {
|
|
2
|
+
duration?: number // 单次故障时长
|
|
3
|
+
intensity?: number // 1~10 故障强度
|
|
4
|
+
color1?: string // 故障色 1
|
|
5
|
+
color2?: string // 故障色 2
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_GLITCH: Required<GlitchOptions> = {
|
|
9
|
+
duration: 300,
|
|
10
|
+
intensity: 5,
|
|
11
|
+
color1: '#ff0040',
|
|
12
|
+
color2: '#00ffff',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 给元素添加赛博朋克故障风效果
|
|
17
|
+
*/
|
|
18
|
+
export function glitch(
|
|
19
|
+
element: HTMLElement | string,
|
|
20
|
+
options: GlitchOptions = {}
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const el =
|
|
23
|
+
typeof element === 'string'
|
|
24
|
+
? document.querySelector<HTMLElement>(element)!
|
|
25
|
+
: element
|
|
26
|
+
|
|
27
|
+
if (!el) throw new Error('glitch: element not found')
|
|
28
|
+
|
|
29
|
+
const opts = { ...DEFAULT_GLITCH, ...options }
|
|
30
|
+
const intensity = Math.min(Math.max(opts.intensity, 1), 10)
|
|
31
|
+
|
|
32
|
+
const original = el.style.cssText
|
|
33
|
+
const steps = Math.floor(intensity * 2)
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
let current = 0
|
|
37
|
+
const interval = opts.duration / steps
|
|
38
|
+
|
|
39
|
+
const run = () => {
|
|
40
|
+
if (current >= steps) {
|
|
41
|
+
el.style.cssText = original
|
|
42
|
+
el.style.textShadow = ''
|
|
43
|
+
resolve()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const x = (Math.random() - 0.5) * intensity * 4
|
|
48
|
+
const clip = `inset(${Math.random() * 80}% 0 ${Math.random() * 80}% 0)`
|
|
49
|
+
const shadow1 = `${opts.color1} ${x}px 0`
|
|
50
|
+
const shadow2 = `${opts.color2} ${-x}px 0`
|
|
51
|
+
|
|
52
|
+
el.style.transform = `translateX(${x}px)`
|
|
53
|
+
el.style.clipPath = clip
|
|
54
|
+
el.style.textShadow = `${shadow1}, ${shadow2}`
|
|
55
|
+
|
|
56
|
+
current++
|
|
57
|
+
setTimeout(run, interval)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
run()
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 持续故障效果(可停止)
|
|
66
|
+
*/
|
|
67
|
+
export function glitchLoop(
|
|
68
|
+
element: HTMLElement | string,
|
|
69
|
+
options: GlitchOptions = {}
|
|
70
|
+
): () => void {
|
|
71
|
+
const el =
|
|
72
|
+
typeof element === 'string'
|
|
73
|
+
? document.querySelector<HTMLElement>(element)!
|
|
74
|
+
: element
|
|
75
|
+
|
|
76
|
+
if (!el) throw new Error('glitchLoop: element not found')
|
|
77
|
+
|
|
78
|
+
const opts = { ...DEFAULT_GLITCH, ...options }
|
|
79
|
+
const original = el.style.cssText
|
|
80
|
+
let running = true
|
|
81
|
+
|
|
82
|
+
const loop = async () => {
|
|
83
|
+
if (!running) return
|
|
84
|
+
await glitch(el, { ...opts, duration: 100 + Math.random() * 200 })
|
|
85
|
+
if (running) {
|
|
86
|
+
setTimeout(loop, 200 + Math.random() * 800)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
loop()
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
running = false
|
|
94
|
+
el.style.cssText = original
|
|
95
|
+
el.style.textShadow = ''
|
|
96
|
+
el.style.clipPath = ''
|
|
97
|
+
el.style.transform = ''
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export interface ParticleOptions {
|
|
2
|
+
count?: number // 粒子数量
|
|
3
|
+
color?: string | string[] // 颜色
|
|
4
|
+
speed?: number // 扩散速度
|
|
5
|
+
size?: number // 粒子大小
|
|
6
|
+
gravity?: number // 重力
|
|
7
|
+
fadeOut?: boolean // 是否淡出
|
|
8
|
+
duration?: number // 动画总时长
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_PARTICLE: Required<Omit<ParticleOptions, 'color'>> = {
|
|
12
|
+
count: 30,
|
|
13
|
+
speed: 6,
|
|
14
|
+
size: 6,
|
|
15
|
+
gravity: 0.3,
|
|
16
|
+
fadeOut: true,
|
|
17
|
+
duration: 1000,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Particle {
|
|
21
|
+
el: HTMLElement
|
|
22
|
+
vx: number
|
|
23
|
+
vy: number
|
|
24
|
+
x: number
|
|
25
|
+
y: number
|
|
26
|
+
life: number
|
|
27
|
+
maxLife: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 在元素位置触发粒子爆炸效果
|
|
32
|
+
*/
|
|
33
|
+
export function particleBurst(
|
|
34
|
+
element: HTMLElement | string,
|
|
35
|
+
options: ParticleOptions = {}
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const el =
|
|
38
|
+
typeof element === 'string'
|
|
39
|
+
? document.querySelector<HTMLElement>(element)!
|
|
40
|
+
: element
|
|
41
|
+
|
|
42
|
+
if (!el) throw new Error('particleBurst: element not found')
|
|
43
|
+
|
|
44
|
+
const opts = { ...DEFAULT_PARTICLE, ...options }
|
|
45
|
+
const colors = Array.isArray(opts.color)
|
|
46
|
+
? opts.color
|
|
47
|
+
: [opts.color ?? '#38bdf8']
|
|
48
|
+
|
|
49
|
+
const rect = el.getBoundingClientRect()
|
|
50
|
+
const centerX = rect.left + rect.width / 2
|
|
51
|
+
const centerY = rect.top + rect.height / 2
|
|
52
|
+
|
|
53
|
+
const particles: Particle[] = []
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < opts.count; i++) {
|
|
56
|
+
const angle = (Math.PI * 2 * i) / opts.count + Math.random() * 0.5
|
|
57
|
+
const velocity = opts.speed * (0.5 + Math.random() * 0.8)
|
|
58
|
+
|
|
59
|
+
const pEl = document.createElement('div')
|
|
60
|
+
pEl.style.cssText = `
|
|
61
|
+
position: fixed;
|
|
62
|
+
left: ${centerX}px;
|
|
63
|
+
top: ${centerY}px;
|
|
64
|
+
width: ${opts.size}px;
|
|
65
|
+
height: ${opts.size}px;
|
|
66
|
+
border-radius: 50%;
|
|
67
|
+
background: ${colors[i % colors.length]};
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
z-index: 9999;
|
|
70
|
+
`
|
|
71
|
+
document.body.appendChild(pEl)
|
|
72
|
+
|
|
73
|
+
particles.push({
|
|
74
|
+
el: pEl,
|
|
75
|
+
vx: Math.cos(angle) * velocity,
|
|
76
|
+
vy: Math.sin(angle) * velocity,
|
|
77
|
+
x: centerX,
|
|
78
|
+
y: centerY,
|
|
79
|
+
life: 0,
|
|
80
|
+
maxLife: opts.duration!,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const startTime = performance.now()
|
|
86
|
+
|
|
87
|
+
const tick = (now: number) => {
|
|
88
|
+
const elapsed = now - startTime
|
|
89
|
+
let alive = 0
|
|
90
|
+
|
|
91
|
+
particles.forEach((p) => {
|
|
92
|
+
p.life = elapsed
|
|
93
|
+
p.vy += opts.gravity!
|
|
94
|
+
p.x += p.vx
|
|
95
|
+
p.y += p.vy
|
|
96
|
+
|
|
97
|
+
const progress = p.life / p.maxLife
|
|
98
|
+
const opacity = opts.fadeOut ? 1 - progress : 1
|
|
99
|
+
|
|
100
|
+
if (progress < 1) {
|
|
101
|
+
alive++
|
|
102
|
+
p.el.style.transform = `translate(${p.x - centerX}px, ${p.y - centerY}px)`
|
|
103
|
+
p.el.style.opacity = String(Math.max(0, opacity))
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (alive > 0) {
|
|
108
|
+
requestAnimationFrame(tick)
|
|
109
|
+
} else {
|
|
110
|
+
particles.forEach((p) => p.el.remove())
|
|
111
|
+
resolve()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
requestAnimationFrame(tick)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 在点击位置触发粒子(不绑定元素)
|
|
121
|
+
*/
|
|
122
|
+
export function particleAt(
|
|
123
|
+
x: number,
|
|
124
|
+
y: number,
|
|
125
|
+
options: ParticleOptions = {}
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const dummy = document.createElement('div')
|
|
128
|
+
dummy.style.position = 'fixed'
|
|
129
|
+
dummy.style.left = x + 'px'
|
|
130
|
+
dummy.style.top = y + 'px'
|
|
131
|
+
document.body.appendChild(dummy)
|
|
132
|
+
|
|
133
|
+
return particleBurst(dummy, options).finally(() => dummy.remove())
|
|
134
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export interface TextSplitOptions {
|
|
2
|
+
type?: 'char' | 'word' // 按字符拆分还是按词
|
|
3
|
+
stagger?: number // 每个元素间隔毫秒
|
|
4
|
+
duration?: number // 单个动画时长
|
|
5
|
+
animation?: 'fadeUp' | 'fadeDown' | 'zoomIn' | 'rotateIn' | 'slideLeft'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SPLIT: Required<Omit<TextSplitOptions, 'type'>> & { type: 'char' } = {
|
|
9
|
+
type: 'char',
|
|
10
|
+
stagger: 40,
|
|
11
|
+
duration: 500,
|
|
12
|
+
animation: 'fadeUp',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ANIMATION_PRESETS: Record<string, { from: string; to: string }> = {
|
|
16
|
+
fadeUp: { from: 'opacity:0;transform:translateY(20px)', to: 'opacity:1;transform:translateY(0)' },
|
|
17
|
+
fadeDown: { from: 'opacity:0;transform:translateY(-20px)', to: 'opacity:1;transform:translateY(0)' },
|
|
18
|
+
zoomIn: { from: 'opacity:0;transform:scale(0.5)', to: 'opacity:1;transform:scale(1)' },
|
|
19
|
+
rotateIn: { from: 'opacity:0;transform:rotate(-180deg)', to: 'opacity:1;transform:rotate(0)' },
|
|
20
|
+
slideLeft: { from: 'opacity:0;transform:translateX(-30px)', to: 'opacity:1;transform:translateX(0)' },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class TextSplit {
|
|
24
|
+
private element: HTMLElement
|
|
25
|
+
private originalHTML: string
|
|
26
|
+
|
|
27
|
+
constructor(element: HTMLElement | string) {
|
|
28
|
+
this.element =
|
|
29
|
+
typeof element === 'string'
|
|
30
|
+
? document.querySelector(element)!
|
|
31
|
+
: element
|
|
32
|
+
|
|
33
|
+
if (!this.element) {
|
|
34
|
+
throw new Error('TextSplit: element not found')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.originalHTML = this.element.innerHTML
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async animate(options: TextSplitOptions = {}): Promise<void> {
|
|
41
|
+
const opts = { ...DEFAULT_SPLIT, ...options }
|
|
42
|
+
const preset = ANIMATION_PRESETS[opts.animation!]
|
|
43
|
+
const text = this.element.textContent ?? ''
|
|
44
|
+
|
|
45
|
+
// 清空并拆分
|
|
46
|
+
this.element.innerHTML = ''
|
|
47
|
+
this.element.style.display = 'inline-block'
|
|
48
|
+
|
|
49
|
+
const parts = opts.type === 'char' ? text.split('') : text.split(/(\s+)/)
|
|
50
|
+
const spans: HTMLSpanElement[] = []
|
|
51
|
+
|
|
52
|
+
parts.forEach((part) => {
|
|
53
|
+
const span = document.createElement('span')
|
|
54
|
+
span.textContent = part
|
|
55
|
+
span.style.display = 'inline-block'
|
|
56
|
+
span.style.cssText += preset.from
|
|
57
|
+
span.style.transition = `all ${opts.duration}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
|
|
58
|
+
this.element.appendChild(span)
|
|
59
|
+
spans.push(span)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// 强制重排
|
|
63
|
+
this.element.offsetHeight
|
|
64
|
+
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
let completed = 0
|
|
67
|
+
const total = spans.length
|
|
68
|
+
|
|
69
|
+
spans.forEach((span, i) => {
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
span.style.cssText += preset.to
|
|
72
|
+
|
|
73
|
+
setTimeout(() => {
|
|
74
|
+
completed++
|
|
75
|
+
if (completed >= total) resolve()
|
|
76
|
+
}, opts.duration)
|
|
77
|
+
}, i * opts.stagger)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reset(): void {
|
|
83
|
+
this.element.innerHTML = this.originalHTML
|
|
84
|
+
this.element.style.display = ''
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function splitText(
|
|
89
|
+
element: HTMLElement | string,
|
|
90
|
+
options?: TextSplitOptions
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const ts = new TextSplit(element)
|
|
93
|
+
ts.reset()
|
|
94
|
+
return ts.animate(options)
|
|
95
|
+
}
|