feat: Add typing animation to voice transcription

- Text appears letter by letter (15-25ms per character)
- Blinking cursor shows while text is animating
- Animation continues from last position for new chunks
- Smooth visual feedback for transcription progress
This commit is contained in:
2026-02-14 00:28:26 -06:00
parent ac17a9f292
commit 9f1e10b8d5

View File

@@ -23,6 +23,11 @@ const transcript = ref('')
const interimTranscript = ref('')
const error = ref('')
// Typing animation state
const animatedTranscript = ref('')
let typingTimeout: number | null = null
let lastAnimatedLength = 0
// Position and drag state
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
@@ -401,6 +406,12 @@ function stopRecording() {
function clearTranscript() {
transcript.value = ''
interimTranscript.value = ''
animatedTranscript.value = ''
lastAnimatedLength = 0
if (typingTimeout) {
clearTimeout(typingTimeout)
typingTimeout = null
}
}
function connectSocket() {
@@ -613,6 +624,46 @@ function sendTranscriptAndClose() {
typeChar()
}
// Typing animation effect
function animateTyping(targetText: string) {
// Clear any pending animation
if (typingTimeout) {
clearTimeout(typingTimeout)
typingTimeout = null
}
// If new text is shorter, just set it (user cleared or correction)
if (targetText.length < animatedTranscript.value.length) {
animatedTranscript.value = targetText
lastAnimatedLength = targetText.length
return
}
// Start from where we left off
const startIndex = lastAnimatedLength
// Type remaining characters one by one
function typeNext(index: number) {
if (index <= targetText.length) {
animatedTranscript.value = targetText.substring(0, index)
lastAnimatedLength = index
if (index < targetText.length) {
// Faster typing speed: 15-25ms per character
const delay = 15 + Math.random() * 10
typingTimeout = window.setTimeout(() => typeNext(index + 1), delay)
}
}
}
typeNext(startIndex)
}
// Watch transcript changes for typing animation
watch(transcript, (newVal) => {
animateTyping(newVal)
})
onMounted(async () => {
recognition = initRecognition()
// Use capture phase to intercept before terminal or other elements
@@ -632,6 +683,7 @@ onBeforeUnmount(() => {
disconnectSocket()
disconnectWhisperSocket()
if (chunkInterval) clearInterval(chunkInterval)
if (typingTimeout) clearTimeout(typingTimeout)
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
}
@@ -711,10 +763,10 @@ defineExpose({
<!-- Content -->
<div class="content">
<div class="transcript" :class="{ empty: !transcript && !interimTranscript }">
<span class="final">{{ transcript }}</span>
<div class="transcript" :class="{ empty: !animatedTranscript && !interimTranscript }">
<span class="final">{{ animatedTranscript }}</span><span class="cursor" v-if="animatedTranscript && animatedTranscript.length < transcript.length">|</span>
<span class="interim">{{ interimTranscript }}</span>
<span v-if="!transcript && !interimTranscript" class="placeholder">
<span v-if="!animatedTranscript && !interimTranscript" class="placeholder">
Presiona el micrófono o mantén Ctrl+Space...
</span>
</div>
@@ -938,6 +990,17 @@ defineExpose({
font-style: italic;
}
.transcript .cursor {
color: #4a9;
font-weight: bold;
animation: blink 0.6s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.transcript .placeholder {
color: #888;
}