From 0873d2ed1bd193238282150be354e59c44e9514a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 03:39:03 +0000 Subject: [PATCH 1/6] Remove Children.map usage and implement alternative approach Co-authored-by: morganney <848178+morganney@users.noreply.github.com> --- packages/tts-react/__tests__/hook.tsx | 56 +++++++++ packages/tts-react/__tests__/utils.ts | 44 ++++++- packages/tts-react/src/hook.tsx | 174 +++++++++++++------------- packages/tts-react/src/utils.ts | 32 ++++- 4 files changed, 215 insertions(+), 91 deletions(-) diff --git a/packages/tts-react/__tests__/hook.tsx b/packages/tts-react/__tests__/hook.tsx index f202383..32d51d1 100644 --- a/packages/tts-react/__tests__/hook.tsx +++ b/packages/tts-react/__tests__/hook.tsx @@ -321,4 +321,60 @@ describe('useTts', () => { global.dispatchEvent(new Event('beforeunload')) expect(global.speechSynthesis.cancel).toHaveBeenCalled() }) + + it('accepts text prop as an alternative to children', async () => { + const testText = 'This is direct text for TTS' + const { result } = renderHook( + ({ text }) => useTts({ text }), + { + initialProps: { + text: testText + } + } + ) + + expect(result.current.spokenText).toBe(testText) + expect(result.current.ttsChildren).toBe(testText) + }) + + it('prefers text prop over children when both are provided', async () => { + const testText = 'Direct text prop' + const childrenText = 'Children text' + const { result } = renderHook( + ({ text, children }) => useTts({ text, children }), + { + initialProps: { + text: testText, + children:

{childrenText}

+ } + } + ) + + expect(result.current.spokenText).toBe(testText) + }) + + it('supports render prop for custom highlighting', async () => { + const testText = 'Test text for highlighting' + const mockRender = jest.fn((params) => params.children) + + const { result } = renderHook( + ({ text, render, markTextAsSpoken }) => useTts({ text, render, markTextAsSpoken }), + { + initialProps: { + text: testText, + render: mockRender, + markTextAsSpoken: true + } + } + ) + + expect(result.current.spokenText).toBe(testText) + expect(mockRender).toHaveBeenCalledWith({ + children: testText, + boundary: expect.any(Object), + markTextAsSpoken: true, + markColor: undefined, + markBackgroundColor: undefined + }) + }) }) diff --git a/packages/tts-react/__tests__/utils.ts b/packages/tts-react/__tests__/utils.ts index c06830a..161ff56 100644 --- a/packages/tts-react/__tests__/utils.ts +++ b/packages/tts-react/__tests__/utils.ts @@ -1,9 +1,49 @@ -import { describe } from '@jest/globals' +import { describe, test } from '@jest/globals' +import { createElement } from 'react' -import { isPunctuation } from '../src/utils.js' +import { isPunctuation, extractTextFromChildren } from '../src/utils.js' describe('utils', () => { it('has isPunctuation', () => { expect(isPunctuation('?')).toBe(true) }) + + describe('extractTextFromChildren', () => { + test('extracts text from string children', () => { + expect(extractTextFromChildren('hello world')).toBe('hello world') + }) + + test('extracts text from number children', () => { + expect(extractTextFromChildren(42)).toBe('42') + }) + + test('extracts text from array of children', () => { + expect(extractTextFromChildren(['hello', ' ', 'world'])).toBe('hello world') + }) + + test('extracts text from React elements', () => { + const element = createElement('p', {}, 'Hello from element') + expect(extractTextFromChildren(element)).toBe('Hello from element') + }) + + test('extracts text from nested React elements', () => { + const nested = createElement('span', {}, 'nested text') + const element = createElement('p', {}, 'Hello ', nested, ' world') + expect(extractTextFromChildren(element)).toBe('Hello nested text world') + }) + + test('handles null and undefined children', () => { + expect(extractTextFromChildren(null)).toBe('') + expect(extractTextFromChildren(undefined)).toBe('') + }) + + test('handles boolean children', () => { + expect(extractTextFromChildren(true)).toBe('') + expect(extractTextFromChildren(false)).toBe('') + }) + + test('handles empty array', () => { + expect(extractTextFromChildren([])).toBe('') + }) + }) }) diff --git a/packages/tts-react/src/hook.tsx b/packages/tts-react/src/hook.tsx index edf756e..c9b89c9 100644 --- a/packages/tts-react/src/hook.tsx +++ b/packages/tts-react/src/hook.tsx @@ -4,17 +4,14 @@ import { useReducer, useCallback, useEffect, - Children, - cloneElement, createElement, - isValidElement, Fragment } from 'react' import type { ReactNode } from 'react' import { Controller, ControllerStub, Events } from './controller.js' import type { ControllerOptions, TTSBoundaryUpdate, TTSEvent } from './controller.js' -import { isStringOrNumber, stripPunctuation, noop } from './utils.js' +import { stripPunctuation, noop, extractTextFromChildren } from './utils.js' import { Highlighter } from './highlighter.js' /** @@ -78,9 +75,24 @@ interface MarkStyles { /** Background color of the currently marked word. */ markBackgroundColor?: string } +/** + * Render prop function type for custom text highlighting + */ +type TTSRenderProp = (params: { + children: ReactNode + boundary: TTSBoundaryUpdate + markTextAsSpoken: boolean + markColor?: string + markBackgroundColor?: string +}) => ReactNode + interface TTSHookProps extends MarkStyles { /** The spoken text is extracted from here. */ - children: ReactNode + children?: ReactNode + /** Direct text to be spoken - alternative to children. */ + text?: string + /** Render prop for custom highlighting - receives highlighting parameters */ + render?: TTSRenderProp /** The `SpeechSynthesisUtterance.lang` to use. */ lang?: ControllerOptions['lang'] /** The `SpeechSynthesisUtterance.voice` to use. */ @@ -175,82 +187,61 @@ interface TTSHookResponse { /** The original children with a possible included if using `markTextAsSpoken`. */ ttsChildren: ReactNode } -interface TextBuffer { - text: string -} -interface ParseChildrenProps extends MarkStyles { - children: ReactNode - buffer: TextBuffer - boundary: TTSBoundaryUpdate - markTextAsSpoken: boolean -} +/** + * Creates highlighted text using render prop pattern instead of Children.map + */ +const createHighlightedContent = ( + children: ReactNode, + text: string, + boundary: TTSBoundaryUpdate, + markTextAsSpoken: boolean, + markColor?: string, + markBackgroundColor?: string, + renderProp?: TTSRenderProp +): ReactNode => { + // If a render prop is provided, use it + if (renderProp) { + return renderProp({ + children, + boundary, + markTextAsSpoken, + markColor, + markBackgroundColor + }) + } -const isObjectReactNode = (value: unknown): value is Record => - typeof value === 'object' && value !== null -const parseChildrenRecursively = ({ - children, - buffer, - boundary, - markColor, - markBackgroundColor, - markTextAsSpoken -}: ParseChildrenProps): ReactNode => { - return Children.map(children, (child) => { - let currentChild = child - - if (isValidElement(child)) { - const childProps = isObjectReactNode(child.props) ? child.props : {} - - currentChild = cloneElement(child, { - ...childProps, - // @ts-expect-error - `children` is not a valid prop for ReactElement - children: parseChildrenRecursively({ - buffer, - boundary, - markColor, - markBackgroundColor, - markTextAsSpoken, - children: childProps.children - }) - }) - } + // If not marking text as spoken or no boundary word, return original children + if (!markTextAsSpoken || !boundary.word || !children) { + return children + } - if (isStringOrNumber(child)) { - const text = (child as string | number).toString() - const { word, startChar, endChar } = boundary - const bufferTextLength = buffer.text.length - - buffer.text += `${text} ` - - if (markTextAsSpoken && word) { - const start = startChar - bufferTextLength - const end = endChar - bufferTextLength - const prev = text.substring(0, start) - const found = text.substring(start, end) - const after = text.substring(end, text.length) - - if (found) { - const Highlight = createElement(Highlighter, { - text: found, - mark: stripPunctuation(found), - color: markColor, - backgroundColor: markBackgroundColor - }) - const Highlighted = createElement( - Fragment, - { key: `tts-${start}-${end}` }, - prev, - Highlight, - after - ) - - return Highlighted - } - } + // For simple text content, apply highlighting directly + if (typeof children === 'string') { + const { word, startChar, endChar } = boundary + const prev = text.substring(0, startChar) + const found = text.substring(startChar, endChar) + const after = text.substring(endChar) + + if (found && stripPunctuation(found) === stripPunctuation(word)) { + const Highlight = createElement(Highlighter, { + text: found, + mark: stripPunctuation(found), + color: markColor, + backgroundColor: markBackgroundColor + }) + + return createElement( + Fragment, + { key: `tts-${startChar}-${endChar}` }, + prev, + Highlight, + after + ) } + } - return currentChild - }) + // For complex content, return as-is (highlighting would need custom render prop) + return children } const defaultBoundary = { word: '', startChar: 0, endChar: 0 } const reducer = (state: TTSHookState, action: Action): TTSHookState => { @@ -305,6 +296,8 @@ const useTts = ({ volume, voice, children, + text, + render, markColor, markBackgroundColor, onStart, @@ -334,20 +327,24 @@ const useTts = ({ isReady: isSynthSupported && typeof fetchAudioData === 'undefined' }) const [ttsChildren, spokenText] = useMemo(() => { - const buffer: TextBuffer = { text: '' } - const parsed = parseChildrenRecursively({ - children, - buffer, + // Use text prop if provided, otherwise extract from children + const extractedText = text || (children ? extractTextFromChildren(children) : '') + + // Create highlighted content using the new approach + const highlightedContent = createHighlightedContent( + children || extractedText, + extractedText, + state.boundary, + markTextAsSpoken, markColor, markBackgroundColor, - markTextAsSpoken, - boundary: state.boundary - }) + render + ) - spokenTextRef.current = buffer.text.trim() + spokenTextRef.current = extractedText.trim() - return [parsed, spokenTextRef.current] - }, [children, state.boundary, markColor, markBackgroundColor, markTextAsSpoken]) + return [highlightedContent, spokenTextRef.current] + }, [children, text, state.boundary, markColor, markBackgroundColor, markTextAsSpoken, render]) const controller = useMemo( () => isSynthSupported @@ -674,5 +671,6 @@ export type { TTSEventHandler, TTSErrorHandler, TTSBoundaryHandler, - TTSAudioChangeHandler + TTSAudioChangeHandler, + TTSRenderProp } diff --git a/packages/tts-react/src/utils.ts b/packages/tts-react/src/utils.ts index 57ee275..1847e33 100644 --- a/packages/tts-react/src/utils.ts +++ b/packages/tts-react/src/utils.ts @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { isValidElement } from 'react' const punctuationRgx = /[^\P{P}'/-]+/gu const isStringOrNumber = (value: ReactNode): boolean => { @@ -14,4 +15,33 @@ const isPunctuation = (text: string): boolean => { } const noop = (): void => {} -export { isStringOrNumber, stripPunctuation, isPunctuation, noop, punctuationRgx } +/** + * Extracts text content from React children without using Children.map + * This is an alternative to the legacy Children.map API + */ +const extractTextFromChildren = (children: ReactNode): string => { + if (!children) { + return '' + } + + // Handle string and number primitives + if (typeof children === 'string' || typeof children === 'number') { + return children.toString() + } + + // Handle arrays of children + if (Array.isArray(children)) { + return children.map((child: ReactNode) => extractTextFromChildren(child)).join(' ') + } + + // Handle React elements + if (isValidElement(children) && children.props && 'children' in children.props) { + // For React elements, recursively extract text from their children + return extractTextFromChildren(children.props.children as ReactNode) + } + + // Handle other types (null, undefined, boolean, etc.) + return '' +} + +export { isStringOrNumber, stripPunctuation, isPunctuation, noop, punctuationRgx, extractTextFromChildren } From fde9f3f09667b3911325f02c199b70284d61f3f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 03:40:38 +0000 Subject: [PATCH 2/6] Fix TypeScript compilation error in text extraction Co-authored-by: morganney <848178+morganney@users.noreply.github.com> --- packages/tts-react/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tts-react/src/utils.ts b/packages/tts-react/src/utils.ts index 1847e33..ecb03bd 100644 --- a/packages/tts-react/src/utils.ts +++ b/packages/tts-react/src/utils.ts @@ -35,7 +35,7 @@ const extractTextFromChildren = (children: ReactNode): string => { } // Handle React elements - if (isValidElement(children) && children.props && 'children' in children.props) { + if (isValidElement(children) && children.props && typeof children.props === 'object' && children.props !== null && 'children' in children.props) { // For React elements, recursively extract text from their children return extractTextFromChildren(children.props.children as ReactNode) } From 574f50821d91a333d06b7f5e70e8de119d68bd62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 04:20:35 +0000 Subject: [PATCH 3/6] Fix TypeScript lint error in test file for render prop mock Co-authored-by: morganney <848178+morganney@users.noreply.github.com> --- packages/tts-react/__tests__/hook.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tts-react/__tests__/hook.tsx b/packages/tts-react/__tests__/hook.tsx index 32d51d1..7c3faec 100644 --- a/packages/tts-react/__tests__/hook.tsx +++ b/packages/tts-react/__tests__/hook.tsx @@ -4,6 +4,7 @@ import { renderHook, act, waitFor } from '@testing-library/react' import { SpeechSynthesisMock } from './speechSynthesis.mock.js' import { SpeechSynthesisEventMock } from './speechSynthesisEvent.mock.js' import { useTts } from '../src/hook.js' +import type { TTSRenderProp } from '../src/hook.js' import { stripPunctuation } from '../src/utils.js' import './setup.js' @@ -355,7 +356,7 @@ describe('useTts', () => { it('supports render prop for custom highlighting', async () => { const testText = 'Test text for highlighting' - const mockRender = jest.fn((params) => params.children) + const mockRender = jest.fn, Parameters>((params: Parameters[0]) => params.children) const { result } = renderHook( ({ text, render, markTextAsSpoken }) => useTts({ text, render, markTextAsSpoken }), From f86129cb45e73c89b865db9ef3712595764e0732 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 04:30:29 +0000 Subject: [PATCH 4/6] Fix TypeScript lint error in render prop test by replacing expect.any with type checking Co-authored-by: morganney <848178+morganney@users.noreply.github.com> --- packages/tts-react/__tests__/hook.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/tts-react/__tests__/hook.tsx b/packages/tts-react/__tests__/hook.tsx index 7c3faec..c36a63d 100644 --- a/packages/tts-react/__tests__/hook.tsx +++ b/packages/tts-react/__tests__/hook.tsx @@ -370,12 +370,17 @@ describe('useTts', () => { ) expect(result.current.spokenText).toBe(testText) - expect(mockRender).toHaveBeenCalledWith({ - children: testText, - boundary: expect.any(Object), - markTextAsSpoken: true, - markColor: undefined, - markBackgroundColor: undefined - }) + expect(mockRender).toHaveBeenCalledTimes(1) + const callArgs = mockRender.mock.calls[0][0] + expect(callArgs.children).toBe(testText) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(typeof callArgs.boundary.word).toBe('string') + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(typeof callArgs.boundary.startChar).toBe('number') + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(typeof callArgs.boundary.endChar).toBe('number') + expect(callArgs.markTextAsSpoken).toBe(true) + expect(callArgs.markColor).toBeUndefined() + expect(callArgs.markBackgroundColor).toBeUndefined() }) }) From 70d896ee0df900d5906422fe79fe859b7f32b54e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:54:27 +0000 Subject: [PATCH 5/6] Add default render prop implementation to restore highlighting for complex content Co-authored-by: morganney <848178+morganney@users.noreply.github.com> --- packages/tts-react/src/component.tsx | 107 ++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/tts-react/src/component.tsx b/packages/tts-react/src/component.tsx index 6c779a0..a74cde3 100644 --- a/packages/tts-react/src/component.tsx +++ b/packages/tts-react/src/component.tsx @@ -1,8 +1,11 @@ -import { useMemo, useCallback } from 'react' -import type { CSSProperties } from 'react' +import { useMemo, useCallback, createElement, cloneElement, Fragment, isValidElement } from 'react' +import type { CSSProperties, ReactNode } from 'react' import { useTts } from './hook.js' -import type { TTSHookProps } from './hook.js' +import type { TTSHookProps, TTSRenderProp } from './hook.js' +import type { TTSBoundaryUpdate } from './controller.js' +import { isStringOrNumber, stripPunctuation, extractTextFromChildren } from './utils.js' +import { Highlighter } from './highlighter.js' import { iconSizes, Sizes } from './icons.js' import type { SvgProps } from './icons.js' import { Control, padding as ctrlPadding } from './control.js' @@ -103,6 +106,90 @@ const content = (): CSSProperties => { gridArea: 'cnt' } } + +/** + * Default render prop implementation that handles complex React children + * by recursively applying highlighting similar to the old Children.map approach + */ +const createDefaultRenderer = ( + text: string, + markColor?: string, + markBackgroundColor?: string +): TTSRenderProp => { + let textBuffer = '' + + const parseChildrenRecursively = ( + children: ReactNode, + boundary: TTSBoundaryUpdate, + markTextAsSpoken: boolean + ): ReactNode => { + if (!children) return children + + // Handle arrays of children + if (Array.isArray(children)) { + return children.map((child: ReactNode) => + parseChildrenRecursively(child, boundary, markTextAsSpoken) + ) + } + + // Handle React elements + if (isValidElement(children)) { + const childProps = children.props && typeof children.props === 'object' ? children.props : {} + + return cloneElement(children, { + ...childProps, + children: parseChildrenRecursively( + 'children' in childProps ? (childProps.children as ReactNode) : undefined, + boundary, + markTextAsSpoken + ) + } as Record) + } + + // Handle string and number primitives + if (isStringOrNumber(children)) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const childText = String(children) + const { word, startChar, endChar } = boundary + const bufferTextLength = textBuffer.length + + textBuffer += `${childText} ` + + if (markTextAsSpoken && word) { + const start = startChar - bufferTextLength + const end = endChar - bufferTextLength + const prev = childText.substring(0, start) + const found = childText.substring(start, end) + const after = childText.substring(end, childText.length) + + if (found && stripPunctuation(found) === stripPunctuation(word)) { + const Highlight = createElement(Highlighter, { + text: found, + mark: stripPunctuation(found), + color: markColor, + backgroundColor: markBackgroundColor + }) + + return createElement( + Fragment, + { key: `tts-${start}-${end}` }, + prev, + Highlight, + after + ) + } + } + } + + return children + } + + return ({ children, boundary, markTextAsSpoken }) => { + // Reset the buffer for each render + textBuffer = '' + return parseChildrenRecursively(children, boundary, markTextAsSpoken) + } +} /** * `useTts` is a React hook for converting text to speech using * the `SpeechSynthesis` and `SpeechSynthesisUtterance` Browser API's. @@ -128,6 +215,8 @@ const TextToSpeech = ({ voice, volume, children, + text, + render, position, onStart, onPause, @@ -148,12 +237,24 @@ const TextToSpeech = ({ markTextAsSpoken = false, useStopOverPause = false }: TTSProps) => { + // Create a default renderer for complex content when markTextAsSpoken is true + // but no custom render prop is provided + const defaultRenderer = useMemo(() => { + if (markTextAsSpoken && !render) { + const extractedText = text || (children ? extractTextFromChildren(children) : '') + return createDefaultRenderer(extractedText, markColor, markBackgroundColor) + } + return undefined + }, [markTextAsSpoken, render, text, children, markColor, markBackgroundColor]) + const { state, replay, toggleMute, playOrPause, playOrStop, ttsChildren } = useTts({ lang, rate, voice, volume, children, + text, + render: render || defaultRenderer, onStart, onPause, onBoundary, From 727413d1fa523aa5405fe30e2bd26e39aee7179c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 Aug 2025 03:42:05 +0000 Subject: [PATCH 6/6] Add comprehensive render.story.tsx demonstrating render prop usage Co-authored-by: morganney <848178+morganney@users.noreply.github.com> --- packages/story/src/render.story.tsx | 407 ++++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 packages/story/src/render.story.tsx diff --git a/packages/story/src/render.story.tsx b/packages/story/src/render.story.tsx new file mode 100644 index 0000000..70da082 --- /dev/null +++ b/packages/story/src/render.story.tsx @@ -0,0 +1,407 @@ +import type { Meta, StoryFn } from '@storybook/react' +import type { ReactNode } from 'react' + +import { TextToSpeech, Positions, Sizes } from 'tts-react' + +/** + * Custom highlight component with gradient background + */ +const GradientHighlight = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +/** + * Custom highlight component with pulsing animation + */ +const PulsingHighlight = ({ children }: { children: ReactNode }) => ( + + {children} + + +) + +/** + * Custom highlight component with border styling + */ +const BorderHighlight = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +/** + * Basic render prop example with custom highlighting + */ +const BasicRenderProp: StoryFn = (args) => { + return ( +
+

Basic Custom Render Prop

+

This example demonstrates a custom render prop that applies gradient highlighting to spoken words.

+ { + if (!markTextAsSpoken || !boundary.word) { + return children + } + + // Extract the spoken word and apply custom highlighting + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const text = typeof children === 'string' ? children : String(children) + const { startChar, endChar } = boundary + const before = text.substring(0, startChar) + const spoken = text.substring(startChar, endChar) + const after = text.substring(endChar) + + return ( + <> + {before} + {spoken} + {after} + + ) + }} + /> +
+ ) +} + +/** + * Animated render prop example + */ +const AnimatedRenderProp: StoryFn = (args) => { + return ( +
+

Animated Custom Highlighting

+

This example shows how to create animated highlighting effects using the render prop.

+ { + if (!markTextAsSpoken || !boundary.word) { + return children + } + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const text = typeof children === 'string' ? children : String(children) + const { startChar, endChar } = boundary + const before = text.substring(0, startChar) + const spoken = text.substring(startChar, endChar) + const after = text.substring(endChar) + + return ( + <> + {before} + {spoken} + {after} + + ) + }} + /> +
+ ) +} + +/** + * Border style render prop example + */ +const BorderStyleRenderProp: StoryFn = (args) => { + return ( +
+

Border Style Custom Highlighting

+

This example demonstrates custom border-based highlighting using the render prop.

+ { + if (!markTextAsSpoken || !boundary.word) { + return children + } + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const text = typeof children === 'string' ? children : String(children) + const { startChar, endChar } = boundary + const before = text.substring(0, startChar) + const spoken = text.substring(startChar, endChar) + const after = text.substring(endChar) + + return ( + <> + {before} + {spoken} + {after} + + ) + }} + /> +
+ ) +} + +/** + * Complex content with render prop + */ +const ComplexContentRenderProp: StoryFn = (args) => { + return ( +
+

Complex Content with Custom Rendering

+

This example shows how the render prop works with more complex JSX content.

+ { + if (!markTextAsSpoken || !boundary.word) { + return children + } + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const text = typeof children === 'string' ? children : String(children) + const { startChar, endChar } = boundary + const before = text.substring(0, startChar) + const spoken = text.substring(startChar, endChar) + const after = text.substring(endChar) + + return ( + <> + {before} + + {spoken} + + {after} + + ) + }}> +
+

A Complex Example

+

This paragraph contains bold text and italic text to demonstrate how the render prop handles complex content.

+
    +
  • First list item with some text
  • +
  • Second list item with more content
  • +
+
+
+
+ ) +} + +/** + * Conditional highlighting render prop + */ +const ConditionalRenderProp: StoryFn = (args) => { + return ( +
+

Conditional Custom Highlighting

+

This example demonstrates conditional highlighting based on word length or content.

+ { + if (!markTextAsSpoken || !boundary.word) { + return children + } + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const text = typeof children === 'string' ? children : String(children) + const { startChar, endChar } = boundary + const before = text.substring(0, startChar) + const spoken = text.substring(startChar, endChar) + const after = text.substring(endChar) + + // Apply different styles based on word length + const style = spoken.length > 5 + ? { + backgroundColor: '#f44336', + color: 'white', + padding: '4px 8px', + borderRadius: '8px', + fontWeight: 'bold', + textTransform: 'uppercase' as const + } + : { + backgroundColor: '#2196f3', + color: 'white', + padding: '2px 4px', + borderRadius: '4px', + fontStyle: 'italic' + } + + return ( + <> + {before} + {spoken} + {after} + + ) + }} + /> +
+ ) +} + +/** + * Multi-color render prop example + */ +const MultiColorRenderProp: StoryFn = (args) => { + const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50'] + let colorIndex = 0 + + return ( +
+

Multi-Color Custom Highlighting

+

This example cycles through different colors for each word that's spoken.

+ { + if (!markTextAsSpoken || !boundary.word) { + return children + } + + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const text = typeof children === 'string' ? children : String(children) + const { startChar, endChar } = boundary + const before = text.substring(0, startChar) + const spoken = text.substring(startChar, endChar) + const after = text.substring(endChar) + + // Get the next color in the cycle + const color = colors[colorIndex % colors.length] + colorIndex++ + + return ( + <> + {before} + + {spoken} + + {after} + + ) + }} + /> +
+ ) +} + +// Configure story arguments +BasicRenderProp.args = { + markTextAsSpoken: true, + position: Positions.LC, + size: Sizes.MEDIUM +} + +AnimatedRenderProp.args = { + markTextAsSpoken: true, + position: Positions.LC, + size: Sizes.MEDIUM +} + +BorderStyleRenderProp.args = { + markTextAsSpoken: true, + position: Positions.LC, + size: Sizes.MEDIUM +} + +ComplexContentRenderProp.args = { + markTextAsSpoken: true, + position: Positions.TC, + size: Sizes.MEDIUM +} + +ConditionalRenderProp.args = { + markTextAsSpoken: true, + position: Positions.LC, + size: Sizes.MEDIUM +} + +MultiColorRenderProp.args = { + markTextAsSpoken: true, + position: Positions.LC, + size: Sizes.MEDIUM +} + +// Disable certain controls for render prop stories since they're handled by the render function +const commonArgTypes = { + markColor: { control: false }, + markBackgroundColor: { control: false }, + render: { control: false }, + markTextAsSpoken: { control: false } +} + +BasicRenderProp.argTypes = commonArgTypes +AnimatedRenderProp.argTypes = commonArgTypes +BorderStyleRenderProp.argTypes = commonArgTypes +ComplexContentRenderProp.argTypes = commonArgTypes +ConditionalRenderProp.argTypes = commonArgTypes +MultiColorRenderProp.argTypes = commonArgTypes + +export default { + title: 'tts-react/render-prop', + component: TextToSpeech, + parameters: { + docs: { + description: { + component: 'Examples demonstrating how to use the render prop feature for custom text highlighting during speech synthesis.' + } + } + } +} as Meta + +export { + BasicRenderProp, + AnimatedRenderProp, + BorderStyleRenderProp, + ComplexContentRenderProp, + ConditionalRenderProp, + MultiColorRenderProp +} \ No newline at end of file