Skip to content

Commit e0bb8a1

Browse files
Merge pull request #2 from Harshjeet/harsh-dev
feat: add ReadAloud component with Puter AI and Web Speech API support
2 parents eb3c44f + 16046cf commit e0bb8a1

8 files changed

Lines changed: 1052 additions & 17 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
<script setup lang="ts">
2+
import { useReadAloudEngine } from '../composables/useReadAloudEngine'
3+
4+
const {
5+
isSupported,
6+
ttsMode,
7+
isPlaying,
8+
isPaused,
9+
isLoadingModel,
10+
loadingProgress,
11+
currentRate,
12+
rates,
13+
puterVoices,
14+
selectedPuterVoice,
15+
browserVoices,
16+
selectedBrowserVoiceIndex,
17+
isBrowserSupported,
18+
togglePlayPause,
19+
stopSpeech,
20+
setRate,
21+
onEngineChange,
22+
onVoiceChange,
23+
} = useReadAloudEngine()
24+
</script>
25+
26+
<template>
27+
<div v-if="isSupported" class="read-aloud-container">
28+
<div class="read-aloud-bar">
29+
<div class="read-aloud-left">
30+
<button
31+
class="action-btn main-btn"
32+
:class="{ active: isPlaying && !isPaused, loading: isLoadingModel }"
33+
:disabled="isLoadingModel"
34+
@click="togglePlayPause"
35+
:title="isPlaying && !isPaused ? 'Pause Reading' : (isPaused ? 'Resume Reading' : 'Listen to article')"
36+
>
37+
<svg v-if="isLoadingModel" class="spinner" viewBox="0 0 24 24" width="18" height="18">
38+
<circle class="path" cx="12" cy="12" r="10" fill="none" stroke-width="3"></circle>
39+
</svg>
40+
<svg v-else-if="!isPlaying || isPaused" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
41+
<path d="M8 5v14l11-7z"/>
42+
</svg>
43+
<svg v-else xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
44+
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
45+
</svg>
46+
<span>{{ isLoadingModel ? 'Loading...' : (isPlaying && !isPaused ? 'Pause' : (isPaused ? 'Resume' : 'Listen')) }}</span>
47+
</button>
48+
49+
<button
50+
v-if="isPlaying || isPaused"
51+
class="action-btn stop-btn"
52+
@click="stopSpeech"
53+
title="Stop Reading"
54+
>
55+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
56+
<path d="M6 6h12v12H6z"/>
57+
</svg>
58+
</button>
59+
60+
<span v-if="isLoadingModel" class="loading-text">{{ loadingProgress }}</span>
61+
</div>
62+
63+
<div class="read-aloud-right">
64+
<!-- TTS Engine Selector -->
65+
<select
66+
v-model="ttsMode"
67+
@change="onEngineChange"
68+
class="mode-select"
69+
title="Select TTS Engine"
70+
>
71+
<option value="puter">Puter AI (Natural OpenAI)</option>
72+
<option v-if="isBrowserSupported" value="browser">Browser Native (Offline)</option>
73+
</select>
74+
75+
<!-- Voice Selector for Puter AI -->
76+
<select
77+
v-if="ttsMode === 'puter'"
78+
v-model="selectedPuterVoice"
79+
@change="onVoiceChange"
80+
class="voice-select"
81+
title="Select Puter AI Voice"
82+
>
83+
<option
84+
v-for="voice in puterVoices"
85+
:key="voice.id"
86+
:value="voice.id"
87+
>
88+
{{ voice.name }}
89+
</option>
90+
</select>
91+
92+
<!-- Voice Selector for Browser Native -->
93+
<select
94+
v-else-if="ttsMode === 'browser' && isBrowserSupported"
95+
v-model="selectedBrowserVoiceIndex"
96+
@change="onVoiceChange"
97+
class="voice-select"
98+
title="Select Browser Voice"
99+
>
100+
<option
101+
v-for="(voice, index) in browserVoices"
102+
:key="voice.name"
103+
:value="index"
104+
>
105+
{{ voice.name }} ({{ voice.lang }})
106+
</option>
107+
</select>
108+
109+
<!-- Speed control -->
110+
<div class="speed-selector">
111+
<button
112+
v-for="rate in rates"
113+
:key="rate"
114+
class="rate-pill"
115+
:class="{ active: currentRate === rate }"
116+
@click="setRate(rate)"
117+
>
118+
{{ rate }}x
119+
</button>
120+
</div>
121+
</div>
122+
</div>
123+
</div>
124+
</template>
125+
126+
<style scoped>
127+
.read-aloud-container {
128+
margin: 1rem 0;
129+
padding: 0.75rem;
130+
background: var(--vp-c-bg-soft);
131+
border: 1px solid var(--vp-c-divider);
132+
border-radius: 8px;
133+
}
134+
135+
.read-aloud-bar {
136+
display: flex;
137+
align-items: center;
138+
justify-content: space-between;
139+
gap: 0.75rem;
140+
flex-wrap: wrap;
141+
}
142+
143+
.read-aloud-left,
144+
.read-aloud-right {
145+
display: flex;
146+
align-items: center;
147+
gap: 0.5rem;
148+
}
149+
150+
.loading-text {
151+
font-size: 0.8rem;
152+
color: var(--vp-c-brand-1);
153+
font-weight: 500;
154+
}
155+
156+
.action-btn {
157+
display: inline-flex;
158+
align-items: center;
159+
gap: 0.4rem;
160+
padding: 0.4rem 0.8rem;
161+
border-radius: 6px;
162+
font-size: 0.875rem;
163+
font-weight: 600;
164+
cursor: pointer;
165+
border: 1px solid var(--vp-c-divider);
166+
background: var(--vp-c-bg-alt);
167+
color: var(--vp-c-text-1);
168+
transition: all 0.2s ease;
169+
}
170+
171+
.action-btn:hover:not(:disabled) {
172+
border-color: var(--vp-c-brand-1);
173+
color: var(--vp-c-brand-1);
174+
}
175+
176+
.action-btn.main-btn {
177+
background: var(--vp-c-brand-1);
178+
color: #ffffff;
179+
border-color: var(--vp-c-brand-1);
180+
}
181+
182+
.action-btn.main-btn:hover:not(:disabled) {
183+
background: var(--vp-c-brand-2);
184+
border-color: var(--vp-c-brand-2);
185+
color: #ffffff;
186+
}
187+
188+
.action-btn.main-btn.active {
189+
background: #eab308;
190+
border-color: #eab308;
191+
color: #000000;
192+
}
193+
194+
.action-btn.stop-btn {
195+
padding: 0.4rem 0.5rem;
196+
color: var(--vp-c-text-2);
197+
}
198+
199+
.action-btn.stop-btn:hover {
200+
border-color: #ef4444;
201+
color: #ef4444;
202+
}
203+
204+
.mode-select,
205+
.voice-select {
206+
padding: 0.35rem 0.6rem;
207+
border-radius: 6px;
208+
font-size: 0.8rem;
209+
background: var(--vp-c-bg-alt);
210+
color: var(--vp-c-text-1);
211+
border: 1px solid var(--vp-c-divider);
212+
cursor: pointer;
213+
max-width: 220px;
214+
}
215+
216+
.mode-select:focus,
217+
.voice-select:focus {
218+
outline: none;
219+
border-color: var(--vp-c-brand-1);
220+
}
221+
222+
.speed-selector {
223+
display: flex;
224+
background: var(--vp-c-bg-alt);
225+
border: 1px solid var(--vp-c-divider);
226+
border-radius: 6px;
227+
padding: 2px;
228+
}
229+
230+
.rate-pill {
231+
border: none;
232+
background: transparent;
233+
padding: 0.25rem 0.45rem;
234+
font-size: 0.75rem;
235+
font-weight: 500;
236+
color: var(--vp-c-text-2);
237+
border-radius: 4px;
238+
cursor: pointer;
239+
transition: all 0.15s ease;
240+
}
241+
242+
.rate-pill:hover {
243+
color: var(--vp-c-text-1);
244+
}
245+
246+
.rate-pill.active {
247+
background: var(--vp-c-brand-1);
248+
color: #ffffff;
249+
font-weight: 600;
250+
}
251+
252+
.spinner {
253+
animation: rotate 1.5s linear infinite;
254+
}
255+
256+
.spinner .path {
257+
stroke: currentColor;
258+
stroke-linecap: round;
259+
animation: dash 1.5s ease-in-out infinite;
260+
}
261+
262+
@keyframes rotate {
263+
100% {
264+
transform: rotate(360deg);
265+
}
266+
}
267+
268+
@keyframes dash {
269+
0% {
270+
stroke-dasharray: 1, 150;
271+
stroke-dashoffset: 0;
272+
}
273+
50% {
274+
stroke-dasharray: 90, 150;
275+
stroke-dashoffset: -35;
276+
}
277+
100% {
278+
stroke-dasharray: 90, 150;
279+
stroke-dashoffset: -124;
280+
}
281+
}
282+
283+
@media (max-width: 640px) {
284+
.read-aloud-bar {
285+
flex-direction: column;
286+
align-items: stretch;
287+
}
288+
.read-aloud-right {
289+
justify-content: space-between;
290+
}
291+
.mode-select,
292+
.voice-select {
293+
max-width: 100%;
294+
flex: 1;
295+
}
296+
}
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+
}
316+
</style>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
export interface ChunkItem {
2+
text: string
3+
element: Element | null
4+
}
5+
6+
export function useArticleText() {
7+
const getArticleChunks = (): ChunkItem[] => {
8+
if (typeof document === 'undefined') return []
9+
const docElement = document.querySelector('.vp-doc')
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 = ''
34+
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+
}
44+
}
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' })
63+
}
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+
})
71+
}
72+
73+
return {
74+
getArticleChunks,
75+
highlightElement,
76+
clearHighlight,
77+
}
78+
}

0 commit comments

Comments
 (0)