@troshab/slidev-theme-troshab 0.1.0
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/CLAUDE.md +537 -0
- package/LICENSE +134 -0
- package/README.md +168 -0
- package/SKILL.md +414 -0
- package/components/AnimatedCounter.vue +35 -0
- package/components/Background.vue +204 -0
- package/components/Callout.vue +135 -0
- package/components/Card.vue +75 -0
- package/components/CardGrid.vue +67 -0
- package/components/CaseStudy.vue +66 -0
- package/components/CodeDiff.vue +229 -0
- package/components/CodeHighlight.vue +337 -0
- package/components/ColorSwatch.vue +114 -0
- package/components/Confetti.vue +292 -0
- package/components/Conversation.vue +405 -0
- package/components/Countdown.vue +476 -0
- package/components/Definition.vue +59 -0
- package/components/DeviceMockup.vue +392 -0
- package/components/Funnel.vue +87 -0
- package/components/Icon.vue +73 -0
- package/components/Iframe.vue +38 -0
- package/components/Image.vue +69 -0
- package/components/ImageCompare.vue +436 -0
- package/components/MatrixGrid.vue +85 -0
- package/components/MermaidChart.vue +299 -0
- package/components/Metric.vue +161 -0
- package/components/PersonCard.vue +165 -0
- package/components/PricingTable.vue +144 -0
- package/components/Progress.vue +100 -0
- package/components/Pyramid.vue +81 -0
- package/components/QRCode.vue +137 -0
- package/components/QuoteBlock.vue +101 -0
- package/components/SpeechBubble.vue +169 -0
- package/components/Stepper.vue +542 -0
- package/components/StyledList.vue +156 -0
- package/components/StyledText.vue +275 -0
- package/components/SwotGrid.vue +99 -0
- package/components/Tags.vue +20 -0
- package/components/Testimonial.vue +243 -0
- package/components/Typewriter.vue +181 -0
- package/components_base/AnimatedCounter.vue +208 -0
- package/components_base/CodeHighlight.vue +364 -0
- package/composables/useColors.ts +101 -0
- package/composables/useShiki.ts +81 -0
- package/example_content.md +371 -0
- package/example_dark.md +10 -0
- package/example_slides/001-cover.md +15 -0
- package/example_slides/002-agenda.md +25 -0
- package/example_slides/003-section-layouts.md +14 -0
- package/example_slides/004-fullscreen-centered.md +7 -0
- package/example_slides/005-fullscreen-align-bottom.md +14 -0
- package/example_slides/006-fullscreen-no-padding.md +14 -0
- package/example_slides/007-fullscreen-bg-image-dark.md +13 -0
- package/example_slides/008-fullscreen-bg-image-light.md +13 -0
- package/example_slides/009-fullscreen-bg-gradient.md +15 -0
- package/example_slides/010-fullscreen-bg-color.md +13 -0
- package/example_slides/011-split-basic.md +17 -0
- package/example_slides/012-split-image-text.md +18 -0
- package/example_slides/013-split-contrast.md +22 -0
- package/example_slides/014-columns-basic.md +13 -0
- package/example_slides/015-columns-two.md +26 -0
- package/example_slides/016-columns-ratios.md +22 -0
- package/example_slides/017-columns-three.md +31 -0
- package/example_slides/018-columns-four.md +22 -0
- package/example_slides/019-columns-alignment.md +23 -0
- package/example_slides/020-columns-styled.md +21 -0
- package/example_slides/021-footnote-prop.md +16 -0
- package/example_slides/022-iframe-fullscreen.md +8 -0
- package/example_slides/023-iframe-split.md +18 -0
- package/example_slides/024-section-components.md +14 -0
- package/example_slides/025-styled-text.md +9 -0
- package/example_slides/026-styled-text.md +15 -0
- package/example_slides/027-text-formatting.md +28 -0
- package/example_slides/028-text-spoiler.md +15 -0
- package/example_slides/029-icon-component.md +47 -0
- package/example_slides/030-metric-component.md +29 -0
- package/example_slides/031-person-card.md +33 -0
- package/example_slides/032-styled-list.md +50 -0
- package/example_slides/033-color-swatch.md +35 -0
- package/example_slides/034-code-highlight.md +9 -0
- package/example_slides/035-iframe-component.md +9 -0
- package/example_slides/036-callout.md +15 -0
- package/example_slides/037-card-grid.md +27 -0
- package/example_slides/038-stepper-variants.md +18 -0
- package/example_slides/039-stepper-clicks.md +49 -0
- package/example_slides/040-stepper-interactive.md +28 -0
- package/example_slides/041-tags-progress.md +21 -0
- package/example_slides/042-speech-bubble.md +30 -0
- package/example_slides/043-conversation.md +13 -0
- package/example_slides/044-device-iphone.md +26 -0
- package/example_slides/045-device-browser.md +7 -0
- package/example_slides/046-qrcode.md +26 -0
- package/example_slides/047-countdown.md +14 -0
- package/example_slides/048-typewriter.md +8 -0
- package/example_slides/049-confetti.md +16 -0
- package/example_slides/050-image-compare.md +13 -0
- package/example_slides/051-code-diff.md +24 -0
- package/example_slides/052-quote-block.md +8 -0
- package/example_slides/053-testimonial.md +26 -0
- package/example_slides/054-testimonial-featured.md +16 -0
- package/example_slides/055-funnel.md +12 -0
- package/example_slides/056-pyramid.md +13 -0
- package/example_slides/057-pricing-table.md +9 -0
- package/example_slides/058-swot-grid.md +12 -0
- package/example_slides/059-matrix-grid.md +12 -0
- package/example_slides/060-case-study.md +11 -0
- package/example_slides/061-definition.md +15 -0
- package/example_slides/062-mermaid-intro.md +34 -0
- package/example_slides/063-mermaid-flowchart.md +19 -0
- package/example_slides/064-mermaid-sequence.md +17 -0
- package/example_slides/065-mermaid-xy-chart.md +16 -0
- package/example_slides/066-mermaid-pie.md +17 -0
- package/example_slides/067-mermaid-class.md +19 -0
- package/example_slides/068-mermaid-state.md +19 -0
- package/example_slides/069-mermaid-er.md +22 -0
- package/example_slides/070-mermaid-gantt.md +24 -0
- package/example_slides/071-mermaid-timeline.md +17 -0
- package/example_slides/072-mermaid-mindmap.md +21 -0
- package/example_slides/073-mermaid-gitgraph.md +20 -0
- package/example_slides/074-mermaid-split.md +30 -0
- package/example_slides/075-mermaid-columns.md +32 -0
- package/example_slides/076-section-addons.md +14 -0
- package/example_slides/077-asciinema.md +27 -0
- package/example_slides/078-fancyarrow.md +31 -0
- package/example_slides/079-fancyarrow-demo.md +23 -0
- package/example_slides/080-section-theme.md +14 -0
- package/example_slides/081-color-architecture.md +22 -0
- package/example_slides/082-semantic-text-colors.md +25 -0
- package/example_slides/083-typography.md +16 -0
- package/example_slides/084-typography-rationale.md +22 -0
- package/example_slides/085-icons.md +24 -0
- package/example_slides/086-tables.md +14 -0
- package/example_slides/087-code-blocks.md +18 -0
- package/example_slides/088-motion-modes.md +35 -0
- package/example_slides/089-slide-transitions.md +31 -0
- package/example_slides/090-v-click-reveals.md +40 -0
- package/example_slides/091-accessibility.md +27 -0
- package/example_slides/092-safe-zone.md +17 -0
- package/example_slides/093-questions.md +8 -0
- package/example_white.md +10 -0
- package/fonts/IBMPlexMono-Medium.woff2 +1449 -0
- package/fonts/IBMPlexMono-Regular.woff2 +1449 -0
- package/fonts/IBMPlexSans-Bold.woff2 +1449 -0
- package/fonts/IBMPlexSans-Medium.woff2 +1449 -0
- package/fonts/IBMPlexSans-Regular.woff2 +1449 -0
- package/fonts/IBMPlexSans-SemiBold.woff2 +1449 -0
- package/fonts/LICENSE.txt +93 -0
- package/layouts/slide.vue +251 -0
- package/package.json +62 -0
- package/public/avatars/alice.png +0 -0
- package/public/avatars/bob.png +0 -0
- package/public/avatars/carol.png +0 -0
- package/scripts/chart-audit.mjs +216 -0
- package/scripts/contrast-audit.mjs +299 -0
- package/scripts/generate-palette.mjs +395 -0
- package/scripts/integrity-audit.mjs +357 -0
- package/scripts/shared/css-utils.mjs +216 -0
- package/scripts/shiki-audit.mjs +300 -0
- package/scripts/typography-audit.mjs +300 -0
- package/setup/main.ts +107 -0
- package/setup/mermaid.ts +237 -0
- package/setup/shiki.ts +40 -0
- package/snippets/demo.ts +26 -0
- package/styles/base.css +1053 -0
- package/styles/colors.css +422 -0
- package/styles/index.css +12 -0
- package/styles/motion.css +486 -0
- package/uno.config.ts +67 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Testimonial component - customer quote with avatar
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <Testimonial quote="Great product!" author="John Doe" />
|
|
7
|
+
* <Testimonial
|
|
8
|
+
* quote="This changed everything for us."
|
|
9
|
+
* author="Jane Smith"
|
|
10
|
+
* role="CTO"
|
|
11
|
+
* company="Acme Inc"
|
|
12
|
+
* avatar="/avatars/jane.jpg"
|
|
13
|
+
* :rating="5"
|
|
14
|
+
* />
|
|
15
|
+
* <Testimonial quote="Amazing!" author="Bob" variant="featured" />
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { computed } from 'vue'
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<{
|
|
21
|
+
quote: string
|
|
22
|
+
author: string
|
|
23
|
+
role?: string
|
|
24
|
+
company?: string
|
|
25
|
+
avatar?: string
|
|
26
|
+
rating?: number // 0-5 stars
|
|
27
|
+
variant?: 'card' | 'minimal' | 'featured'
|
|
28
|
+
}>(), {
|
|
29
|
+
rating: 0,
|
|
30
|
+
variant: 'card'
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const authorInitial = computed(() => {
|
|
34
|
+
return props.author.charAt(0).toUpperCase()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const stars = computed(() => {
|
|
38
|
+
return Array.from({ length: 5 }, (_, i) => i < props.rating)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const attribution = computed(() => {
|
|
42
|
+
const parts = []
|
|
43
|
+
if (props.role) parts.push(props.role)
|
|
44
|
+
if (props.company) parts.push(props.company)
|
|
45
|
+
return parts.join(' at ')
|
|
46
|
+
})
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div class="testimonial" :class="`testimonial-${variant}`">
|
|
51
|
+
<!-- Quote icon for featured variant -->
|
|
52
|
+
<div v-if="variant === 'featured'" class="testimonial-quote-icon">
|
|
53
|
+
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
54
|
+
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/>
|
|
55
|
+
</svg>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<!-- Quote text -->
|
|
59
|
+
<blockquote class="testimonial-quote">
|
|
60
|
+
<p>{{ quote }}</p>
|
|
61
|
+
</blockquote>
|
|
62
|
+
|
|
63
|
+
<!-- Rating stars -->
|
|
64
|
+
<div v-if="rating > 0" class="testimonial-rating">
|
|
65
|
+
<span
|
|
66
|
+
v-for="(filled, idx) in stars"
|
|
67
|
+
:key="idx"
|
|
68
|
+
class="testimonial-star"
|
|
69
|
+
:class="{ 'testimonial-star-filled': filled }"
|
|
70
|
+
>★</span>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Author info -->
|
|
74
|
+
<div class="testimonial-author">
|
|
75
|
+
<div class="testimonial-avatar">
|
|
76
|
+
<img v-if="avatar" :src="avatar" :alt="author" />
|
|
77
|
+
<span v-else class="testimonial-initial">{{ authorInitial }}</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="testimonial-info">
|
|
80
|
+
<div class="testimonial-name">{{ author }}</div>
|
|
81
|
+
<div v-if="attribution" class="testimonial-role">{{ attribution }}</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style>
|
|
88
|
+
.testimonial {
|
|
89
|
+
display: flex;
|
|
90
|
+
flex-direction: column;
|
|
91
|
+
gap: var(--space-md);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Card variant (default) */
|
|
95
|
+
.testimonial-card {
|
|
96
|
+
background-color: var(--color-bg-soft);
|
|
97
|
+
border-radius: 0.5rem;
|
|
98
|
+
padding: var(--space-lg);
|
|
99
|
+
border: 1px solid var(--color-border);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Minimal variant */
|
|
103
|
+
.testimonial-minimal {
|
|
104
|
+
padding: var(--space-md) 0;
|
|
105
|
+
border-left: 4px solid var(--color-primary);
|
|
106
|
+
padding-left: var(--space-md);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Featured variant */
|
|
110
|
+
.testimonial-featured {
|
|
111
|
+
background: var(--gradient-primary);
|
|
112
|
+
color: var(--color-primary-foreground);
|
|
113
|
+
border-radius: 0.75rem;
|
|
114
|
+
padding: var(--space-xl);
|
|
115
|
+
text-align: center;
|
|
116
|
+
align-items: center;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.testimonial-featured .testimonial-quote p {
|
|
120
|
+
color: inherit;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.testimonial-featured .testimonial-role {
|
|
124
|
+
opacity: 0.8;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.testimonial-featured .testimonial-initial {
|
|
128
|
+
background-color: color-mix(in srgb, var(--color-white) 20%, transparent);
|
|
129
|
+
color: inherit;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Quote icon */
|
|
133
|
+
.testimonial-quote-icon {
|
|
134
|
+
width: 48px;
|
|
135
|
+
height: 48px;
|
|
136
|
+
opacity: 0.3;
|
|
137
|
+
margin-bottom: var(--space-sm);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.testimonial-quote-icon svg {
|
|
141
|
+
width: 100%;
|
|
142
|
+
height: 100%;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Quote — override global blockquote border */
|
|
146
|
+
.testimonial .testimonial-quote {
|
|
147
|
+
margin: 0;
|
|
148
|
+
padding: 0;
|
|
149
|
+
border: none;
|
|
150
|
+
max-width: none;
|
|
151
|
+
color: inherit;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.testimonial-quote p {
|
|
155
|
+
margin: 0;
|
|
156
|
+
font-size: var(--font-size-base);
|
|
157
|
+
line-height: var(--line-height-body);
|
|
158
|
+
color: var(--color-text);
|
|
159
|
+
font-style: normal;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.testimonial-featured .testimonial-quote p {
|
|
163
|
+
font-size: var(--font-size-h2);
|
|
164
|
+
line-height: 1.3;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Rating */
|
|
168
|
+
.testimonial-rating {
|
|
169
|
+
display: flex;
|
|
170
|
+
gap: 0.25em;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.testimonial-star {
|
|
174
|
+
font-size: 1.25rem;
|
|
175
|
+
color: var(--color-border);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.testimonial-star-filled {
|
|
179
|
+
color: var(--color-warning);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Author */
|
|
183
|
+
.testimonial-author {
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
gap: var(--space-sm);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.testimonial-featured .testimonial-author {
|
|
190
|
+
flex-direction: column;
|
|
191
|
+
text-align: center;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.testimonial-avatar {
|
|
195
|
+
width: 48px;
|
|
196
|
+
height: 48px;
|
|
197
|
+
border-radius: 50%;
|
|
198
|
+
overflow: hidden;
|
|
199
|
+
flex-shrink: 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.testimonial-avatar img {
|
|
203
|
+
width: 100%;
|
|
204
|
+
height: 100%;
|
|
205
|
+
object-fit: cover;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.testimonial-initial {
|
|
209
|
+
width: 100%;
|
|
210
|
+
height: 100%;
|
|
211
|
+
display: flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
justify-content: center;
|
|
214
|
+
font-size: 1.25rem;
|
|
215
|
+
font-weight: var(--font-weight-semibold);
|
|
216
|
+
background: var(--gradient-primary);
|
|
217
|
+
color: var(--color-primary-foreground);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.testimonial-card .testimonial-initial,
|
|
221
|
+
.testimonial-minimal .testimonial-initial {
|
|
222
|
+
background: var(--gradient-primary);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.testimonial-info {
|
|
226
|
+
display: flex;
|
|
227
|
+
flex-direction: column;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.testimonial-name {
|
|
231
|
+
font-weight: var(--font-weight-semibold);
|
|
232
|
+
color: var(--color-text);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.testimonial-featured .testimonial-name {
|
|
236
|
+
color: inherit;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.testimonial-role {
|
|
240
|
+
font-size: var(--font-size-small);
|
|
241
|
+
color: var(--color-text-secondary);
|
|
242
|
+
}
|
|
243
|
+
</style>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Typewriter component - text typing animation effect
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <Typewriter text="Hello, World!" />
|
|
7
|
+
* <Typewriter :text="['First line', 'Second line']" loop />
|
|
8
|
+
* <Typewriter text="Fast typing" :speed="30" />
|
|
9
|
+
* <Typewriter text="With cursor" cursor="|" />
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
13
|
+
import type { ColorWithInherit } from '../composables/useColors'
|
|
14
|
+
import { textColorVar } from '../composables/useColors'
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<{
|
|
17
|
+
text: string | string[]
|
|
18
|
+
speed?: number // ms per character
|
|
19
|
+
deleteSpeed?: number // ms per character when deleting
|
|
20
|
+
delay?: number // initial delay before typing
|
|
21
|
+
pauseBetween?: number // pause between strings (for arrays)
|
|
22
|
+
cursor?: boolean | string
|
|
23
|
+
loop?: boolean
|
|
24
|
+
autostart?: boolean
|
|
25
|
+
color?: ColorWithInherit
|
|
26
|
+
}>(), {
|
|
27
|
+
speed: 50,
|
|
28
|
+
deleteSpeed: 30,
|
|
29
|
+
delay: 0,
|
|
30
|
+
pauseBetween: 1500,
|
|
31
|
+
cursor: true,
|
|
32
|
+
loop: false,
|
|
33
|
+
autostart: true,
|
|
34
|
+
color: 'inherit',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
(e: 'complete'): void
|
|
39
|
+
(e: 'typed', text: string): void
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const displayText = ref('')
|
|
43
|
+
const isTyping = ref(false)
|
|
44
|
+
const cursorVisible = ref(true)
|
|
45
|
+
|
|
46
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
47
|
+
let cursorIntervalId: ReturnType<typeof setInterval> | null = null
|
|
48
|
+
|
|
49
|
+
const texts = computed(() => {
|
|
50
|
+
return Array.isArray(props.text) ? props.text : [props.text]
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const cursorChar = computed(() => {
|
|
54
|
+
if (props.cursor === false) return ''
|
|
55
|
+
if (typeof props.cursor === 'string') return props.cursor
|
|
56
|
+
return '|'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async function sleep(ms: number) {
|
|
60
|
+
return new Promise(resolve => {
|
|
61
|
+
timeoutId = setTimeout(resolve, ms)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function typeText(text: string) {
|
|
66
|
+
for (let i = 0; i <= text.length; i++) {
|
|
67
|
+
displayText.value = text.slice(0, i)
|
|
68
|
+
await sleep(props.speed)
|
|
69
|
+
}
|
|
70
|
+
emit('typed', text)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function deleteText() {
|
|
74
|
+
const currentLength = displayText.value.length
|
|
75
|
+
for (let i = currentLength; i >= 0; i--) {
|
|
76
|
+
displayText.value = displayText.value.slice(0, i)
|
|
77
|
+
await sleep(props.deleteSpeed)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runAnimation() {
|
|
82
|
+
isTyping.value = true
|
|
83
|
+
|
|
84
|
+
if (props.delay > 0) {
|
|
85
|
+
await sleep(props.delay)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
do {
|
|
89
|
+
for (let i = 0; i < texts.value.length; i++) {
|
|
90
|
+
await typeText(texts.value[i])
|
|
91
|
+
|
|
92
|
+
// If not the last text or looping, delete and continue
|
|
93
|
+
if (i < texts.value.length - 1 || props.loop) {
|
|
94
|
+
await sleep(props.pauseBetween)
|
|
95
|
+
await deleteText()
|
|
96
|
+
await sleep(props.speed * 5)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} while (props.loop)
|
|
100
|
+
|
|
101
|
+
isTyping.value = false
|
|
102
|
+
emit('complete')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function start() {
|
|
106
|
+
stop()
|
|
107
|
+
displayText.value = ''
|
|
108
|
+
runAnimation()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stop() {
|
|
112
|
+
if (timeoutId) {
|
|
113
|
+
clearTimeout(timeoutId)
|
|
114
|
+
timeoutId = null
|
|
115
|
+
}
|
|
116
|
+
isTyping.value = false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Cursor blink effect
|
|
120
|
+
function startCursorBlink() {
|
|
121
|
+
if (props.cursor) {
|
|
122
|
+
cursorIntervalId = setInterval(() => {
|
|
123
|
+
cursorVisible.value = !cursorVisible.value
|
|
124
|
+
}, 530)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onMounted(() => {
|
|
129
|
+
startCursorBlink()
|
|
130
|
+
if (props.autostart) {
|
|
131
|
+
start()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
onUnmounted(() => {
|
|
136
|
+
stop()
|
|
137
|
+
if (cursorIntervalId) {
|
|
138
|
+
clearInterval(cursorIntervalId)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Watch for text changes
|
|
143
|
+
watch(() => props.text, () => {
|
|
144
|
+
if (props.autostart) {
|
|
145
|
+
start()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Expose for external control
|
|
150
|
+
defineExpose({ start, stop })
|
|
151
|
+
</script>
|
|
152
|
+
|
|
153
|
+
<template>
|
|
154
|
+
<span class="typewriter" :style="{ color: color !== 'inherit' ? textColorVar[color] : undefined }">
|
|
155
|
+
<span class="typewriter-text">{{ displayText }}</span>
|
|
156
|
+
<span
|
|
157
|
+
v-if="cursor"
|
|
158
|
+
class="typewriter-cursor"
|
|
159
|
+
:class="{ 'typewriter-cursor-visible': cursorVisible }"
|
|
160
|
+
>{{ cursorChar }}</span>
|
|
161
|
+
</span>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<style>
|
|
165
|
+
.typewriter {
|
|
166
|
+
display: inline;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.typewriter-text {
|
|
170
|
+
white-space: pre-wrap;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.typewriter-cursor {
|
|
174
|
+
opacity: 0;
|
|
175
|
+
transition: opacity 0.1s;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.typewriter-cursor-visible {
|
|
179
|
+
opacity: 1;
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* AnimatedCounter component - animated number counting up
|
|
4
|
+
*
|
|
5
|
+
* Smart v-click integration:
|
|
6
|
+
* - No `at` prop (default): auto-registers one click slot, animates on click
|
|
7
|
+
* - `at="0"`: animates on slide enter (no click registration)
|
|
8
|
+
* - `at="-1"`: manual only (call animate()/resetToStart() via ref)
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <AnimatedCounter :value="1000" /> <!-- auto v-click -->
|
|
12
|
+
* <AnimatedCounter :value="50000" prefix="$" separator="," />
|
|
13
|
+
* <AnimatedCounter :value="3.14" :decimals="2" :at="0" /> <!-- on slide enter -->
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ref, unref, computed, watch, watchEffect, inject, onMounted, onUnmounted } from 'vue'
|
|
17
|
+
import type { Ref } from 'vue'
|
|
18
|
+
|
|
19
|
+
const defaultClicksContext = { current: 0 }
|
|
20
|
+
const defaultNav = { currentSlideNo: -1 }
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<{
|
|
23
|
+
value: number
|
|
24
|
+
from?: number
|
|
25
|
+
duration?: number // ms
|
|
26
|
+
prefix?: string
|
|
27
|
+
suffix?: string
|
|
28
|
+
decimals?: number
|
|
29
|
+
separator?: string // thousands separator
|
|
30
|
+
easing?: 'linear' | 'easeOut' | 'easeInOut'
|
|
31
|
+
at?: number // undefined = auto-register click, 0 = on slide enter, -1 = manual
|
|
32
|
+
}>(), {
|
|
33
|
+
from: 0,
|
|
34
|
+
duration: 2000,
|
|
35
|
+
prefix: '',
|
|
36
|
+
suffix: '',
|
|
37
|
+
decimals: 0,
|
|
38
|
+
separator: '',
|
|
39
|
+
easing: 'easeOut',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const clicksContext = inject<Ref<{ current: number }>>('$$slidev-clicks-context', ref(defaultClicksContext))
|
|
43
|
+
const slidevContext = inject<{ nav: { currentSlideNo: number } }>('$$slidev-context', { nav: defaultNav })
|
|
44
|
+
const slidePage = inject<Ref<number>>('$$slidev-page', ref(-1))
|
|
45
|
+
|
|
46
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
47
|
+
const currentValue = ref(props.from)
|
|
48
|
+
const isAutoRegistered = ref(false)
|
|
49
|
+
let animationFrameId: number | null = null
|
|
50
|
+
let hasAnimated = false
|
|
51
|
+
|
|
52
|
+
const formattedValue = computed(() => {
|
|
53
|
+
let num = currentValue.value.toFixed(props.decimals)
|
|
54
|
+
|
|
55
|
+
if (props.separator) {
|
|
56
|
+
const parts = num.split('.')
|
|
57
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, props.separator)
|
|
58
|
+
num = parts.join('.')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `${props.prefix}${num}${props.suffix}`
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const easingFunctions = {
|
|
65
|
+
linear: (t: number) => t,
|
|
66
|
+
easeOut: (t: number) => 1 - Math.pow(1 - t, 3),
|
|
67
|
+
easeInOut: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cancelAnimation() {
|
|
71
|
+
if (animationFrameId) {
|
|
72
|
+
cancelAnimationFrame(animationFrameId)
|
|
73
|
+
animationFrameId = null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function animate() {
|
|
78
|
+
cancelAnimation()
|
|
79
|
+
|
|
80
|
+
const startValue = props.from
|
|
81
|
+
const endValue = props.value
|
|
82
|
+
const startTime = performance.now()
|
|
83
|
+
const easingFn = easingFunctions[props.easing]
|
|
84
|
+
|
|
85
|
+
function step(currentTime: number) {
|
|
86
|
+
const elapsed = currentTime - startTime
|
|
87
|
+
const progress = Math.min(elapsed / props.duration, 1)
|
|
88
|
+
const easedProgress = easingFn(progress)
|
|
89
|
+
|
|
90
|
+
currentValue.value = startValue + (endValue - startValue) * easedProgress
|
|
91
|
+
|
|
92
|
+
if (progress < 1) {
|
|
93
|
+
animationFrameId = requestAnimationFrame(step)
|
|
94
|
+
} else {
|
|
95
|
+
currentValue.value = endValue
|
|
96
|
+
animationFrameId = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
requestAnimationFrame(step)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resetToStart() {
|
|
104
|
+
cancelAnimation()
|
|
105
|
+
currentValue.value = props.from
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Slidev keeps all slides in DOM (no KeepAlive). Detect slide activation
|
|
109
|
+
// by watching nav.currentSlideNo and comparing to this slide's page number.
|
|
110
|
+
onMounted(() => {
|
|
111
|
+
// Auto v-click registration: when at is undefined (default), register with click system
|
|
112
|
+
if (props.at === undefined) {
|
|
113
|
+
const ctx = clicksContext.value
|
|
114
|
+
if (ctx?.calculate && rootEl.value) {
|
|
115
|
+
const info = ctx.calculate('+1')
|
|
116
|
+
if (info) {
|
|
117
|
+
ctx.register(rootEl.value, info)
|
|
118
|
+
isAutoRegistered.value = true
|
|
119
|
+
|
|
120
|
+
const isActiveRef = info.isActive
|
|
121
|
+
|
|
122
|
+
watchEffect(() => {
|
|
123
|
+
const active = isActiveRef.value
|
|
124
|
+
if (active && !hasAnimated) {
|
|
125
|
+
hasAnimated = true
|
|
126
|
+
animate()
|
|
127
|
+
} else if (!active && hasAnimated) {
|
|
128
|
+
hasAnimated = false
|
|
129
|
+
resetToStart()
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Fallback for non-Slidev contexts
|
|
137
|
+
if (unref(slidePage) < 0) {
|
|
138
|
+
if (props.at === undefined || props.at === 0) {
|
|
139
|
+
hasAnimated = true
|
|
140
|
+
animate()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
onUnmounted(() => {
|
|
146
|
+
cancelAnimation()
|
|
147
|
+
if (isAutoRegistered.value && clicksContext.value?.unregister && rootEl.value) {
|
|
148
|
+
clicksContext.value.unregister(rootEl.value)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
watch(() => slidevContext.nav?.currentSlideNo, (currentNo, prevNo) => {
|
|
153
|
+
const myPage = unref(slidePage)
|
|
154
|
+
if (myPage < 0) return // non-Slidev context
|
|
155
|
+
|
|
156
|
+
const isActive = currentNo + 1 === myPage
|
|
157
|
+
const wasActive = prevNo !== undefined && prevNo + 1 === myPage
|
|
158
|
+
|
|
159
|
+
if (isActive && !wasActive) {
|
|
160
|
+
// Slide just became active: reset and trigger
|
|
161
|
+
hasAnimated = false
|
|
162
|
+
resetToStart()
|
|
163
|
+
if (props.at === 0) {
|
|
164
|
+
hasAnimated = true
|
|
165
|
+
animate()
|
|
166
|
+
}
|
|
167
|
+
} else if (!isActive && wasActive) {
|
|
168
|
+
// Slide deactivated: cancel animation
|
|
169
|
+
cancelAnimation()
|
|
170
|
+
}
|
|
171
|
+
}, { immediate: true })
|
|
172
|
+
|
|
173
|
+
// Watch clicks: passive fallback for explicit at > 0 (rare)
|
|
174
|
+
watch(() => clicksContext.value?.current, (current) => {
|
|
175
|
+
if (isAutoRegistered.value || props.at === undefined || props.at <= 0) return
|
|
176
|
+
const cur = current ?? 0
|
|
177
|
+
|
|
178
|
+
if (cur >= props.at && !hasAnimated) {
|
|
179
|
+
hasAnimated = true
|
|
180
|
+
animate()
|
|
181
|
+
} else if (cur < props.at && hasAnimated) {
|
|
182
|
+
hasAnimated = false
|
|
183
|
+
resetToStart()
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Watch for value changes (re-animate if already triggered)
|
|
188
|
+
watch(() => props.value, () => {
|
|
189
|
+
if (hasAnimated) {
|
|
190
|
+
animate()
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
defineExpose({ animate, resetToStart })
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<template>
|
|
198
|
+
<span ref="rootEl" class="animated-counter">
|
|
199
|
+
{{ formattedValue }}
|
|
200
|
+
</span>
|
|
201
|
+
</template>
|
|
202
|
+
|
|
203
|
+
<style>
|
|
204
|
+
.animated-counter {
|
|
205
|
+
font-variant-numeric: tabular-nums;
|
|
206
|
+
display: inline-block;
|
|
207
|
+
}
|
|
208
|
+
</style>
|