@@ -4,17 +4,14 @@ import {
44 useReducer ,
55 useCallback ,
66 useEffect ,
7- Children ,
8- cloneElement ,
97 createElement ,
10- isValidElement ,
118 Fragment
129} from 'react'
1310import type { ReactNode } from 'react'
1411
1512import { Controller , ControllerStub , Events } from './controller.js'
1613import type { ControllerOptions , TTSBoundaryUpdate , TTSEvent } from './controller.js'
17- import { isStringOrNumber , stripPunctuation , noop } from './utils.js'
14+ import { stripPunctuation , noop , extractTextFromChildren } from './utils.js'
1815import { Highlighter } from './highlighter.js'
1916
2017/**
@@ -78,9 +75,24 @@ interface MarkStyles {
7875 /** Background color of the currently marked word. */
7976 markBackgroundColor ?: string
8077}
78+ /**
79+ * Render prop function type for custom text highlighting
80+ */
81+ type TTSRenderProp = ( params : {
82+ children : ReactNode
83+ boundary : TTSBoundaryUpdate
84+ markTextAsSpoken : boolean
85+ markColor ?: string
86+ markBackgroundColor ?: string
87+ } ) => ReactNode
88+
8189interface TTSHookProps extends MarkStyles {
8290 /** The spoken text is extracted from here. */
83- children : ReactNode
91+ children ?: ReactNode
92+ /** Direct text to be spoken - alternative to children. */
93+ text ?: string
94+ /** Render prop for custom highlighting - receives highlighting parameters */
95+ render ?: TTSRenderProp
8496 /** The `SpeechSynthesisUtterance.lang` to use. */
8597 lang ?: ControllerOptions [ 'lang' ]
8698 /** The `SpeechSynthesisUtterance.voice` to use. */
@@ -175,82 +187,61 @@ interface TTSHookResponse {
175187 /** The original children with a possible <mark> included if using `markTextAsSpoken`. */
176188 ttsChildren : ReactNode
177189}
178- interface TextBuffer {
179- text : string
180- }
181- interface ParseChildrenProps extends MarkStyles {
182- children : ReactNode
183- buffer : TextBuffer
184- boundary : TTSBoundaryUpdate
185- markTextAsSpoken : boolean
186- }
190+ /**
191+ * Creates highlighted text using render prop pattern instead of Children.map
192+ */
193+ const createHighlightedContent = (
194+ children : ReactNode ,
195+ text : string ,
196+ boundary : TTSBoundaryUpdate ,
197+ markTextAsSpoken : boolean ,
198+ markColor ?: string ,
199+ markBackgroundColor ?: string ,
200+ renderProp ?: TTSRenderProp
201+ ) : ReactNode => {
202+ // If a render prop is provided, use it
203+ if ( renderProp ) {
204+ return renderProp ( {
205+ children,
206+ boundary,
207+ markTextAsSpoken,
208+ markColor,
209+ markBackgroundColor
210+ } )
211+ }
187212
188- const isObjectReactNode = ( value : unknown ) : value is Record < string , ReactNode > =>
189- typeof value === 'object' && value !== null
190- const parseChildrenRecursively = ( {
191- children,
192- buffer,
193- boundary,
194- markColor,
195- markBackgroundColor,
196- markTextAsSpoken
197- } : ParseChildrenProps ) : ReactNode => {
198- return Children . map ( children , ( child ) => {
199- let currentChild = child
200-
201- if ( isValidElement ( child ) ) {
202- const childProps = isObjectReactNode ( child . props ) ? child . props : { }
203-
204- currentChild = cloneElement ( child , {
205- ...childProps ,
206- // @ts -expect-error - `children` is not a valid prop for ReactElement
207- children : parseChildrenRecursively ( {
208- buffer,
209- boundary,
210- markColor,
211- markBackgroundColor,
212- markTextAsSpoken,
213- children : childProps . children
214- } )
215- } )
216- }
213+ // If not marking text as spoken or no boundary word, return original children
214+ if ( ! markTextAsSpoken || ! boundary . word || ! children ) {
215+ return children
216+ }
217217
218- if ( isStringOrNumber ( child ) ) {
219- const text = ( child as string | number ) . toString ( )
220- const { word, startChar, endChar } = boundary
221- const bufferTextLength = buffer . text . length
222-
223- buffer . text += `${ text } `
224-
225- if ( markTextAsSpoken && word ) {
226- const start = startChar - bufferTextLength
227- const end = endChar - bufferTextLength
228- const prev = text . substring ( 0 , start )
229- const found = text . substring ( start , end )
230- const after = text . substring ( end , text . length )
231-
232- if ( found ) {
233- const Highlight = createElement ( Highlighter , {
234- text : found ,
235- mark : stripPunctuation ( found ) ,
236- color : markColor ,
237- backgroundColor : markBackgroundColor
238- } )
239- const Highlighted = createElement (
240- Fragment ,
241- { key : `tts-${ start } -${ end } ` } ,
242- prev ,
243- Highlight ,
244- after
245- )
246-
247- return Highlighted
248- }
249- }
218+ // For simple text content, apply highlighting directly
219+ if ( typeof children === 'string' ) {
220+ const { word, startChar, endChar } = boundary
221+ const prev = text . substring ( 0 , startChar )
222+ const found = text . substring ( startChar , endChar )
223+ const after = text . substring ( endChar )
224+
225+ if ( found && stripPunctuation ( found ) === stripPunctuation ( word ) ) {
226+ const Highlight = createElement ( Highlighter , {
227+ text : found ,
228+ mark : stripPunctuation ( found ) ,
229+ color : markColor ,
230+ backgroundColor : markBackgroundColor
231+ } )
232+
233+ return createElement (
234+ Fragment ,
235+ { key : `tts-${ startChar } -${ endChar } ` } ,
236+ prev ,
237+ Highlight ,
238+ after
239+ )
250240 }
241+ }
251242
252- return currentChild
253- } )
243+ // For complex content, return as-is (highlighting would need custom render prop)
244+ return children
254245}
255246const defaultBoundary = { word : '' , startChar : 0 , endChar : 0 }
256247const reducer = ( state : TTSHookState , action : Action ) : TTSHookState => {
@@ -305,6 +296,8 @@ const useTts = ({
305296 volume,
306297 voice,
307298 children,
299+ text,
300+ render,
308301 markColor,
309302 markBackgroundColor,
310303 onStart,
@@ -334,20 +327,24 @@ const useTts = ({
334327 isReady : isSynthSupported && typeof fetchAudioData === 'undefined'
335328 } )
336329 const [ ttsChildren , spokenText ] = useMemo ( ( ) => {
337- const buffer : TextBuffer = { text : '' }
338- const parsed = parseChildrenRecursively ( {
339- children,
340- buffer,
330+ // Use text prop if provided, otherwise extract from children
331+ const extractedText = text || ( children ? extractTextFromChildren ( children ) : '' )
332+
333+ // Create highlighted content using the new approach
334+ const highlightedContent = createHighlightedContent (
335+ children || extractedText ,
336+ extractedText ,
337+ state . boundary ,
338+ markTextAsSpoken ,
341339 markColor ,
342340 markBackgroundColor ,
343- markTextAsSpoken,
344- boundary : state . boundary
345- } )
341+ render
342+ )
346343
347- spokenTextRef . current = buffer . text . trim ( )
344+ spokenTextRef . current = extractedText . trim ( )
348345
349- return [ parsed , spokenTextRef . current ]
350- } , [ children , state . boundary , markColor , markBackgroundColor , markTextAsSpoken ] )
346+ return [ highlightedContent , spokenTextRef . current ]
347+ } , [ children , text , state . boundary , markColor , markBackgroundColor , markTextAsSpoken , render ] )
351348 const controller = useMemo (
352349 ( ) =>
353350 isSynthSupported
@@ -674,5 +671,6 @@ export type {
674671 TTSEventHandler ,
675672 TTSErrorHandler ,
676673 TTSBoundaryHandler ,
677- TTSAudioChangeHandler
674+ TTSAudioChangeHandler ,
675+ TTSRenderProp
678676}
0 commit comments