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:
chris 2026-05-24 20:55:50 -04:00
parent 48d3533bfc
commit 1d00d86709
2 changed files with 70 additions and 2 deletions

View File

@ -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>
)
}

View File

@ -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; }