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 }) {
|
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
||||||
const fileRef = useRef()
|
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
|
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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1463,6 +1463,21 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
font-variant-numeric: tabular-nums;
|
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 ───────────────────────────────────── */
|
/* ── Writing Prompt ───────────────────────────────────── */
|
||||||
|
|
||||||
.prompt-wrap { position: relative; }
|
.prompt-wrap { position: relative; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user