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:
@@ -23,6 +23,11 @@ const transcript = ref('')
|
|||||||
const interimTranscript = ref('')
|
const interimTranscript = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
|
// Typing animation state
|
||||||
|
const animatedTranscript = ref('')
|
||||||
|
let typingTimeout: number | null = null
|
||||||
|
let lastAnimatedLength = 0
|
||||||
|
|
||||||
// Position and drag state
|
// Position and drag state
|
||||||
const position = ref({ x: 0, y: 0 })
|
const position = ref({ x: 0, y: 0 })
|
||||||
const hasCustomPosition = ref(false)
|
const hasCustomPosition = ref(false)
|
||||||
@@ -401,6 +406,12 @@ function stopRecording() {
|
|||||||
function clearTranscript() {
|
function clearTranscript() {
|
||||||
transcript.value = ''
|
transcript.value = ''
|
||||||
interimTranscript.value = ''
|
interimTranscript.value = ''
|
||||||
|
animatedTranscript.value = ''
|
||||||
|
lastAnimatedLength = 0
|
||||||
|
if (typingTimeout) {
|
||||||
|
clearTimeout(typingTimeout)
|
||||||
|
typingTimeout = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectSocket() {
|
function connectSocket() {
|
||||||
@@ -613,6 +624,46 @@ function sendTranscriptAndClose() {
|
|||||||
typeChar()
|
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 () => {
|
onMounted(async () => {
|
||||||
recognition = initRecognition()
|
recognition = initRecognition()
|
||||||
// Use capture phase to intercept before terminal or other elements
|
// Use capture phase to intercept before terminal or other elements
|
||||||
@@ -632,6 +683,7 @@ onBeforeUnmount(() => {
|
|||||||
disconnectSocket()
|
disconnectSocket()
|
||||||
disconnectWhisperSocket()
|
disconnectWhisperSocket()
|
||||||
if (chunkInterval) clearInterval(chunkInterval)
|
if (chunkInterval) clearInterval(chunkInterval)
|
||||||
|
if (typingTimeout) clearTimeout(typingTimeout)
|
||||||
if (mediaStream) {
|
if (mediaStream) {
|
||||||
mediaStream.getTracks().forEach(track => track.stop())
|
mediaStream.getTracks().forEach(track => track.stop())
|
||||||
}
|
}
|
||||||
@@ -711,10 +763,10 @@ defineExpose({
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="transcript" :class="{ empty: !transcript && !interimTranscript }">
|
<div class="transcript" :class="{ empty: !animatedTranscript && !interimTranscript }">
|
||||||
<span class="final">{{ transcript }}</span>
|
<span class="final">{{ animatedTranscript }}</span><span class="cursor" v-if="animatedTranscript && animatedTranscript.length < transcript.length">|</span>
|
||||||
<span class="interim">{{ interimTranscript }}</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...
|
Presiona el micrófono o mantén Ctrl+Space...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -938,6 +990,17 @@ defineExpose({
|
|||||||
font-style: italic;
|
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 {
|
.transcript .placeholder {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user