@luanlu/mk-motion 1.2.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.
Files changed (98) hide show
  1. package/package.json +2 -4
  2. package/src/a11y/focus-trap.ts +64 -0
  3. package/src/a11y/keyboard.ts +43 -0
  4. package/src/components/alert/alert.css +111 -0
  5. package/src/components/alert/alert.ts +107 -0
  6. package/src/components/avatar/avatar.css +112 -0
  7. package/src/components/avatar/avatar.ts +175 -0
  8. package/src/components/breadcrumb/breadcrumb.css +31 -0
  9. package/src/components/breadcrumb/breadcrumb.ts +71 -0
  10. package/src/components/button/button.css +108 -0
  11. package/src/components/button/button.ts +140 -0
  12. package/src/components/card/card.css +52 -0
  13. package/src/components/card/card.ts +87 -0
  14. package/src/components/collapse/collapse.css +76 -0
  15. package/src/components/collapse/collapse.ts +168 -0
  16. package/src/components/dialog/dialog.css +78 -0
  17. package/src/components/dialog/dialog.ts +164 -0
  18. package/src/components/drawer/drawer.css +73 -0
  19. package/src/components/drawer/drawer.ts +131 -0
  20. package/src/components/empty/empty.css +36 -0
  21. package/src/components/empty/empty.ts +85 -0
  22. package/src/components/form/checkbox.css +56 -0
  23. package/src/components/form/checkbox.ts +119 -0
  24. package/src/components/form/radio.css +57 -0
  25. package/src/components/form/radio.ts +153 -0
  26. package/src/components/form/select.css +91 -0
  27. package/src/components/form/select.ts +174 -0
  28. package/src/components/form/slider.css +56 -0
  29. package/src/components/form/slider.ts +148 -0
  30. package/src/components/input/input.css +92 -0
  31. package/src/components/input/input.ts +162 -0
  32. package/src/components/layout/divider.css +32 -0
  33. package/src/components/layout/divider.ts +42 -0
  34. package/src/components/layout/row.css +64 -0
  35. package/src/components/layout/row.ts +57 -0
  36. package/src/components/layout/space.css +14 -0
  37. package/src/components/layout/space.ts +48 -0
  38. package/src/components/loading/loading.css +37 -0
  39. package/src/components/loading/loading.ts +46 -0
  40. package/src/components/menu/menu.css +121 -0
  41. package/src/components/menu/menu.ts +187 -0
  42. package/src/components/message/message.css +64 -0
  43. package/src/components/message/message.ts +96 -0
  44. package/src/components/popover/popover.css +73 -0
  45. package/src/components/popover/popover.ts +279 -0
  46. package/src/components/progress/progress.css +112 -0
  47. package/src/components/progress/progress.ts +171 -0
  48. package/src/components/steps/steps.css +127 -0
  49. package/src/components/steps/steps.ts +102 -0
  50. package/src/components/styles/components.css +28 -0
  51. package/src/components/styles/reset.css +24 -0
  52. package/src/components/styles/tokens.css +248 -0
  53. package/src/components/styles/variables.css +24 -0
  54. package/src/components/switch/switch.css +53 -0
  55. package/src/components/switch/switch.ts +103 -0
  56. package/src/components/table/table.css +192 -0
  57. package/src/components/table/table.ts +370 -0
  58. package/src/components/tabs/tabs.css +138 -0
  59. package/src/components/tabs/tabs.ts +211 -0
  60. package/src/components/tag/tag.css +123 -0
  61. package/src/components/tag/tag.ts +112 -0
  62. package/src/components/tooltip/tooltip.css +66 -0
  63. package/src/components/tooltip/tooltip.ts +185 -0
  64. package/src/core/animator.ts +124 -0
  65. package/src/core/timeline.ts +128 -0
  66. package/src/core/utils.ts +47 -0
  67. package/src/effects/glitch.ts +99 -0
  68. package/src/effects/particle.ts +134 -0
  69. package/src/effects/text-split.ts +95 -0
  70. package/src/effects/wave-text.ts +88 -0
  71. package/src/gesture/draggable.ts +130 -0
  72. package/src/gesture/spring.ts +152 -0
  73. package/src/index.ts +162 -0
  74. package/src/interactive/coverflow.ts +100 -0
  75. package/src/interactive/cursor-trail.ts +113 -0
  76. package/src/interactive/flip-card.ts +114 -0
  77. package/src/interactive/magnetic.ts +121 -0
  78. package/src/micro/hover-lift.ts +94 -0
  79. package/src/micro/ripple.ts +130 -0
  80. package/src/motion/component-motion.ts +177 -0
  81. package/src/presets/index.ts +69 -0
  82. package/src/scroll/scroll-trigger.ts +104 -0
  83. package/src/styles/animations.css +135 -0
  84. package/src/styles/element-plus.css +174 -0
  85. package/src/text/count-up.ts +108 -0
  86. package/src/text/typewriter.ts +109 -0
  87. package/src/theme/dark.css +19 -0
  88. package/src/theme/light.css +19 -0
  89. package/src/theme/theme.ts +65 -0
  90. package/src/transitions/blur-reveal.ts +92 -0
  91. package/src/transitions/collapse.ts +112 -0
  92. package/src/transitions/lazy-image.ts +87 -0
  93. package/src/transitions/list.ts +75 -0
  94. package/src/transitions/loading.ts +95 -0
  95. package/src/transitions/parallax.ts +60 -0
  96. package/src/transitions/shimmer.ts +105 -0
  97. package/src/transitions/toast.ts +151 -0
  98. package/src/types.d.ts +4 -0
@@ -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
+ }