Skip to content

Commit 70d896e

Browse files
Copilotmorganney
andcommitted
Add default render prop implementation to restore highlighting for complex content
Co-authored-by: morganney <[email protected]>
1 parent f86129c commit 70d896e

File tree

1 file changed

+104
-3
lines changed

1 file changed

+104
-3
lines changed

packages/tts-react/src/component.tsx

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { useMemo, useCallback } from 'react'
2-
import type { CSSProperties } from 'react'
1+
import { useMemo, useCallback, createElement, cloneElement, Fragment, isValidElement } from 'react'
2+
import type { CSSProperties, ReactNode } from 'react'
33

44
import { useTts } from './hook.js'
5-
import type { TTSHookProps } from './hook.js'
5+
import type { TTSHookProps, TTSRenderProp } from './hook.js'
6+
import type { TTSBoundaryUpdate } from './controller.js'
7+
import { isStringOrNumber, stripPunctuation, extractTextFromChildren } from './utils.js'
8+
import { Highlighter } from './highlighter.js'
69
import { iconSizes, Sizes } from './icons.js'
710
import type { SvgProps } from './icons.js'
811
import { Control, padding as ctrlPadding } from './control.js'
@@ -103,6 +106,90 @@ const content = (): CSSProperties => {
103106
gridArea: 'cnt'
104107
}
105108
}
109+
110+
/**
111+
* Default render prop implementation that handles complex React children
112+
* by recursively applying highlighting similar to the old Children.map approach
113+
*/
114+
const createDefaultRenderer = (
115+
text: string,
116+
markColor?: string,
117+
markBackgroundColor?: string
118+
): TTSRenderProp => {
119+
let textBuffer = ''
120+
121+
const parseChildrenRecursively = (
122+
children: ReactNode,
123+
boundary: TTSBoundaryUpdate,
124+
markTextAsSpoken: boolean
125+
): ReactNode => {
126+
if (!children) return children
127+
128+
// Handle arrays of children
129+
if (Array.isArray(children)) {
130+
return children.map((child: ReactNode) =>
131+
parseChildrenRecursively(child, boundary, markTextAsSpoken)
132+
)
133+
}
134+
135+
// Handle React elements
136+
if (isValidElement(children)) {
137+
const childProps = children.props && typeof children.props === 'object' ? children.props : {}
138+
139+
return cloneElement(children, {
140+
...childProps,
141+
children: parseChildrenRecursively(
142+
'children' in childProps ? (childProps.children as ReactNode) : undefined,
143+
boundary,
144+
markTextAsSpoken
145+
)
146+
} as Record<string, unknown>)
147+
}
148+
149+
// Handle string and number primitives
150+
if (isStringOrNumber(children)) {
151+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
152+
const childText = String(children)
153+
const { word, startChar, endChar } = boundary
154+
const bufferTextLength = textBuffer.length
155+
156+
textBuffer += `${childText} `
157+
158+
if (markTextAsSpoken && word) {
159+
const start = startChar - bufferTextLength
160+
const end = endChar - bufferTextLength
161+
const prev = childText.substring(0, start)
162+
const found = childText.substring(start, end)
163+
const after = childText.substring(end, childText.length)
164+
165+
if (found && stripPunctuation(found) === stripPunctuation(word)) {
166+
const Highlight = createElement(Highlighter, {
167+
text: found,
168+
mark: stripPunctuation(found),
169+
color: markColor,
170+
backgroundColor: markBackgroundColor
171+
})
172+
173+
return createElement(
174+
Fragment,
175+
{ key: `tts-${start}-${end}` },
176+
prev,
177+
Highlight,
178+
after
179+
)
180+
}
181+
}
182+
}
183+
184+
return children
185+
}
186+
187+
return ({ children, boundary, markTextAsSpoken }) => {
188+
// Reset the buffer for each render
189+
textBuffer = ''
190+
return parseChildrenRecursively(children, boundary, markTextAsSpoken)
191+
}
192+
}
106193
/**
107194
* `useTts` is a React hook for converting text to speech using
108195
* the `SpeechSynthesis` and `SpeechSynthesisUtterance` Browser API's.
@@ -128,6 +215,8 @@ const TextToSpeech = ({
128215
voice,
129216
volume,
130217
children,
218+
text,
219+
render,
131220
position,
132221
onStart,
133222
onPause,
@@ -148,12 +237,24 @@ const TextToSpeech = ({
148237
markTextAsSpoken = false,
149238
useStopOverPause = false
150239
}: TTSProps) => {
240+
// Create a default renderer for complex content when markTextAsSpoken is true
241+
// but no custom render prop is provided
242+
const defaultRenderer = useMemo(() => {
243+
if (markTextAsSpoken && !render) {
244+
const extractedText = text || (children ? extractTextFromChildren(children) : '')
245+
return createDefaultRenderer(extractedText, markColor, markBackgroundColor)
246+
}
247+
return undefined
248+
}, [markTextAsSpoken, render, text, children, markColor, markBackgroundColor])
249+
151250
const { state, replay, toggleMute, playOrPause, playOrStop, ttsChildren } = useTts({
152251
lang,
153252
rate,
154253
voice,
155254
volume,
156255
children,
256+
text,
257+
render: render || defaultRenderer,
157258
onStart,
158259
onPause,
159260
onBoundary,

0 commit comments

Comments
 (0)