Skip to content

Commit 16046cf

Browse files
committed
feat: implement real-time text highlighting and improved chunking for read-aloud engine
1 parent 31cbe20 commit 16046cf

4 files changed

Lines changed: 108 additions & 58 deletions

File tree

docs/.vitepress/theme/components/ReadAloud.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,23 @@ const {
294294
flex: 1;
295295
}
296296
}
297+
</style>
298+
299+
<style>
300+
/* Global text highlighting styling for currently read DOM element */
301+
.read-aloud-highlight {
302+
background-color: #fef08a !important;
303+
color: #0f172a !important;
304+
border-radius: 6px;
305+
padding: 2px 6px;
306+
transition: all 0.3s ease-in-out;
307+
box-shadow: 0 0 0 2px #fde047, 0 4px 6px -1px rgba(234, 179, 8, 0.2);
308+
}
309+
310+
.dark .read-aloud-highlight {
311+
background-color: rgba(234, 179, 8, 0.3) !important;
312+
color: #fef9c3 !important;
313+
border-radius: 6px;
314+
box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.5), 0 4px 6px -1px rgba(0, 0, 0, 0.3);
315+
}
297316
</style>
Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,78 @@
1+
export interface ChunkItem {
2+
text: string
3+
element: Element | null
4+
}
5+
16
export function useArticleText() {
2-
const getArticleText = (): string => {
3-
if (typeof document === 'undefined') return ''
7+
const getArticleChunks = (): ChunkItem[] => {
8+
if (typeof document === 'undefined') return []
49
const docElement = document.querySelector('.vp-doc')
5-
if (!docElement) return ''
6-
7-
const clone = docElement.cloneNode(true) as HTMLElement
8-
9-
// Remove non-text UI elements
10-
clone.querySelectorAll('.line-numbers-wrapper, .copy, .header-anchor, script, style, .read-aloud-container, nav, footer, .VPNav, .VPFooter, button, img, svg, .vp-code, pre, code').forEach(el => el.remove())
11-
12-
let text = clone.innerText || clone.textContent || ''
13-
14-
// Normalize text for natural speech pacing
15-
text = text
16-
.replace(/#{1,6}\s+/g, '. ')
17-
.replace(/^[\s]*[-*+]\s+/gm, '. ')
18-
.replace(/\n+/g, '. ')
19-
.replace(/\s+/g, ' ')
20-
.replace(/\b(e\.g\.|i\.e\.|vs\.|etc\.)/gi, match => match.replace(/\./g, ''))
21-
.replace(/([.!?])\s*([A-Z])/g, '$1 $2')
22-
.replace(/`([^`]+)`/g, '$1')
23-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
24-
.trim()
25-
26-
return text
27-
}
10+
if (!docElement) return []
11+
12+
const elements = docElement.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote')
13+
const chunks: ChunkItem[] = []
14+
15+
elements.forEach((el) => {
16+
// Exclude UI controls, read-aloud component, pre & code blocks
17+
if (el.closest('.read-aloud-container, pre, .vp-code')) return
18+
19+
const clone = el.cloneNode(true) as HTMLElement
20+
clone.querySelectorAll('.line-numbers-wrapper, .copy, .header-anchor, script, style, .read-aloud-container, button, img, svg, code').forEach(child => child.remove())
21+
22+
let text = clone.innerText || clone.textContent || ''
23+
text = text
24+
.replace(/\s+/g, ' ')
25+
.replace(/\b(e\.g\.|i\.e\.|vs\.|etc\.)/gi, match => match.replace(/\./g, ''))
26+
.replace(/`([^`]+)`/g, '$1')
27+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
28+
.trim()
29+
30+
if (!text) return
31+
32+
const rawSentences = text.match(/[^.!?\n]+[.!?\n]+/g) || [text]
33+
let current = ''
2834

29-
const splitIntoChunks = (text: string): string[] => {
30-
const rawSentences = text.match(/[^.!?\n]+[.!?\n]+/g) || [text]
31-
const result: string[] = []
32-
let current = ''
33-
34-
for (const sentence of rawSentences) {
35-
const trimmed = sentence.trim()
36-
if (!trimmed) continue
37-
if ((current + ' ' + trimmed).length > 250) {
38-
if (current) result.push(current.trim())
39-
current = trimmed
40-
} else {
41-
current = current ? current + ' ' + trimmed : trimmed
35+
for (const sentence of rawSentences) {
36+
const trimmed = sentence.trim()
37+
if (!trimmed) continue
38+
if ((current + ' ' + trimmed).length > 250) {
39+
if (current) chunks.push({ text: current.trim(), element: el })
40+
current = trimmed
41+
} else {
42+
current = current ? current + ' ' + trimmed : trimmed
43+
}
4244
}
45+
if (current.trim()) {
46+
chunks.push({ text: current.trim(), element: el })
47+
}
48+
})
49+
50+
return chunks
51+
}
52+
53+
const highlightElement = (el: Element | null) => {
54+
if (typeof document === 'undefined') return
55+
56+
document.querySelectorAll('.read-aloud-highlight').forEach(item => {
57+
item.classList.remove('read-aloud-highlight')
58+
})
59+
60+
if (el) {
61+
el.classList.add('read-aloud-highlight')
62+
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
4363
}
44-
if (current.trim()) result.push(current.trim())
45-
return result.length > 0 ? result : [text]
64+
}
65+
66+
const clearHighlight = () => {
67+
if (typeof document === 'undefined') return
68+
document.querySelectorAll('.read-aloud-highlight').forEach(item => {
69+
item.classList.remove('read-aloud-highlight')
70+
})
4671
}
4772

4873
return {
49-
getArticleText,
50-
splitIntoChunks,
74+
getArticleChunks,
75+
highlightElement,
76+
clearHighlight,
5177
}
5278
}

docs/.vitepress/theme/composables/usePuterTTS.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ref } from 'vue'
2+
import type { ChunkItem } from './useArticleText'
23

34
export interface PuterVoice {
45
id: string
@@ -32,12 +33,11 @@ export function usePuterTTS() {
3233
prefetchMap.clear()
3334
}
3435

35-
const fetchPuterChunk = (chunkIndex: number, chunks: string[]): Promise<HTMLAudioElement | null> => {
36+
const fetchPuterChunk = (chunkIndex: number, chunks: ChunkItem[]): Promise<HTMLAudioElement | null> => {
3637
if (chunkIndex < 0 || chunkIndex >= chunks.length) {
3738
return Promise.resolve(null)
3839
}
3940

40-
// Optimized memory key: voice:index
4141
const cacheKey = `${selectedPuterVoice.value}:${chunkIndex}`
4242

4343
if (audioCache.has(cacheKey)) {
@@ -53,7 +53,7 @@ export function usePuterTTS() {
5353
const puterModule = await import('@heyputer/puter.js')
5454
const puter = puterModule.default || puterModule.puter || puterModule
5555

56-
const audio = await puter.ai.txt2speech(chunks[chunkIndex], {
56+
const audio = await puter.ai.txt2speech(chunks[chunkIndex].text, {
5757
provider: 'openai',
5858
model: 'tts-1',
5959
voice: selectedPuterVoice.value,

docs/.vitepress/theme/composables/useReadAloudEngine.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { ref, watch, onUnmounted, computed } from 'vue'
22
import { useRoute } from 'vitepress'
3-
import { useArticleText } from './useArticleText'
3+
import { useArticleText, type ChunkItem } from './useArticleText'
44
import { useBrowserTTS } from './useBrowserTTS'
55
import { usePuterTTS } from './usePuterTTS'
66

77
export function useReadAloudEngine() {
88
const route = useRoute()
9-
const { getArticleText, splitIntoChunks } = useArticleText()
9+
const { getArticleChunks, highlightElement, clearHighlight } = useArticleText()
1010
const browserTTS = useBrowserTTS()
1111
const puterTTS = usePuterTTS()
1212

@@ -18,11 +18,9 @@ export function useReadAloudEngine() {
1818
const currentRate = ref(1.0)
1919
const rates = [0.8, 1.0, 1.25, 1.5, 2.0]
2020

21-
const chunks = ref<string[]>([])
21+
const chunks = ref<ChunkItem[]>([])
2222
const currentChunkIndex = ref(0)
2323

24-
// Decoupled support check: Puter TTS works keylessly in any browser with internet;
25-
// Component is supported if either Puter AI is available or Browser Speech API is available.
2624
const isSupported = computed(() => true)
2725

2826
const stopSpeech = () => {
@@ -33,6 +31,7 @@ export function useReadAloudEngine() {
3331
currentChunkIndex.value = 0
3432
chunks.value = []
3533

34+
clearHighlight()
3635
puterTTS.stopPuterAudio()
3736
browserTTS.stopBrowserTTS()
3837
}
@@ -44,6 +43,10 @@ export function useReadAloudEngine() {
4443
}
4544

4645
const idx = currentChunkIndex.value
46+
const currentChunk = chunks.value[idx]
47+
48+
// Highlight active DOM element in yellow
49+
highlightElement(currentChunk ? currentChunk.element : null)
4750

4851
if (!puterTTS.isPuterChunkCached(idx)) {
4952
isLoadingModel.value = true
@@ -56,7 +59,6 @@ export function useReadAloudEngine() {
5659
isLoadingModel.value = false
5760
loadingProgress.value = ''
5861
if (!audio) {
59-
// Fallback to browser TTS if Puter AI fails
6062
if (browserTTS.isBrowserSupported.value) {
6163
ttsMode.value = 'browser'
6264
speakNextBrowserChunk()
@@ -99,10 +101,13 @@ export function useReadAloudEngine() {
99101
}
100102

101103
const idx = currentChunkIndex.value
102-
const text = chunks.value[idx]
104+
const currentChunk = chunks.value[idx]
105+
106+
// Highlight active DOM element in yellow
107+
highlightElement(currentChunk ? currentChunk.element : null)
103108

104109
browserTTS.speakBrowserChunk(
105-
text,
110+
currentChunk.text,
106111
currentRate.value,
107112
() => {
108113
isPlaying.value = true
@@ -125,7 +130,7 @@ export function useReadAloudEngine() {
125130

126131
const togglePlayPause = async () => {
127132
if (isPlaying.value && !isPaused.value) {
128-
// Pause
133+
// Pause: Keep highlight visible on current element
129134
isPaused.value = true
130135
if (ttsMode.value === 'browser') {
131136
browserTTS.stopBrowserTTS()
@@ -149,12 +154,12 @@ export function useReadAloudEngine() {
149154
return
150155
}
151156

152-
// Start new speech
157+
// Start fresh
153158
stopSpeech()
154-
const fullText = getArticleText()
155-
if (!fullText) return
159+
const articleChunks = getArticleChunks()
160+
if (!articleChunks || articleChunks.length === 0) return
156161

157-
chunks.value = splitIntoChunks(fullText)
162+
chunks.value = articleChunks
158163
currentChunkIndex.value = 0
159164
isPlaying.value = true
160165
isPaused.value = false

0 commit comments

Comments
 (0)