From 1d00d86709553ffef6054fcabb5cecaa3b24b04b Mon Sep 17 00:00:00 2001 From: chris Date: Sun, 24 May 2026 20:55:50 -0400 Subject: [PATCH] Add read-aloud: reads story from cursor to end using Web Speech API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🔊 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 --- frontend/src/components/Toolbar.jsx | 57 ++++++++++++++++++++++++++++- frontend/src/styles/index.css | 15 ++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Toolbar.jsx b/frontend/src/components/Toolbar.jsx index 2f5c0a8..6a6f96e 100644 --- a/frontend/src/components/Toolbar.jsx +++ b/frontend/src/components/Toolbar.jsx @@ -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 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 {fontSize}px + + + + ) } diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index d94a60f..c6810dd 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -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; }