Add read-aloud: reads story from cursor to end using Web Speech API
- 🔊 button at far-right of toolbar; toggles to ⏹ while reading
- Extracts plain text from cursor position to end of document via
editor.state.doc.textBetween — works with cursor or selection
- Splits into sentence chunks to work around Chrome's ~15s utterance
cutoff bug; each chunk's onend fires the next
- isReadingRef guards the closure so Stop cancels mid-sentence cleanly
- Gentle pulse animation on the active button (reading-pulse keyframe)
- No server required — uses built-in browser speechSynthesis
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
48d3533bfc
commit
1d00d86709
@ -1,7 +1,50 @@
|
||||
import { useRef } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
||||
const fileRef = useRef()
|
||||
const [isReading, setIsReading] = useState(false)
|
||||
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
||||
|
||||
function startReading() {
|
||||
if (!('speechSynthesis' in window)) return
|
||||
|
||||
// Grab text from cursor (or selection start) to end of document
|
||||
const { from } = editor.state.selection
|
||||
const end = editor.state.doc.content.size
|
||||
const text = editor.state.doc.textBetween(from, end, '\n', ' ').trim()
|
||||
if (!text) return
|
||||
|
||||
// Split into sentences — Chrome stops an utterance after ~15 s if it's too long
|
||||
const chunks = (
|
||||
text.match(/[^.!?…]+[.!?…]*['"'"]?\s*/g)
|
||||
?.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
) || [text]
|
||||
|
||||
speechSynthesis.cancel() // clear any leftover utterance
|
||||
isReadingRef.current = true
|
||||
setIsReading(true)
|
||||
|
||||
let idx = 0
|
||||
function next() {
|
||||
if (!isReadingRef.current || idx >= chunks.length) {
|
||||
isReadingRef.current = false
|
||||
setIsReading(false)
|
||||
return
|
||||
}
|
||||
const u = new SpeechSynthesisUtterance(chunks[idx++])
|
||||
u.onend = next
|
||||
u.onerror = () => { isReadingRef.current = false; setIsReading(false) }
|
||||
speechSynthesis.speak(u)
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
function stopReading() {
|
||||
isReadingRef.current = false
|
||||
setIsReading(false)
|
||||
speechSynthesis.cancel()
|
||||
}
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
@ -83,6 +126,16 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
||||
<button className="toolbar-btn font-btn" onMouseDown={e => { e.preventDefault(); onFontSizeChange(-1) }} title="Smaller text">A−</button>
|
||||
<span className="toolbar-font-size">{fontSize}px</span>
|
||||
<button className="toolbar-btn font-btn" onMouseDown={e => { e.preventDefault(); onFontSizeChange(+1) }} title="Larger text">A+</button>
|
||||
|
||||
<span className="toolbar-sep" />
|
||||
|
||||
<button
|
||||
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}
|
||||
title={isReading ? 'Stop reading' : 'Read aloud from cursor (reads to end of story)'}
|
||||
>
|
||||
{isReading ? '⏹' : '🔊'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1463,6 +1463,21 @@ button { cursor: pointer; font-family: inherit; }
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Read Aloud ───────────────────────────────────────── */
|
||||
|
||||
.read-aloud-btn { font-size: 1rem; line-height: 1; padding: 0.2rem 0.45rem; }
|
||||
|
||||
.read-aloud-btn.reading-active {
|
||||
color: var(--accent-hi);
|
||||
border-color: var(--accent);
|
||||
animation: reading-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes reading-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* ── Writing Prompt ───────────────────────────────────── */
|
||||
|
||||
.prompt-wrap { position: relative; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user