Spaces:
Sleeping
Sleeping
| import React, { useMemo, ReactElement } from 'react'; | |
| import { TechnicalTerm } from '../types'; | |
| interface Props { | |
| text: string; | |
| bionicEnabled?: boolean; | |
| highlightTerms?: TechnicalTerm[]; | |
| currentTTSWord?: number; // Index of word being spoken for TTS highlighting | |
| } | |
| // Color palette for consistent term highlighting | |
| const TERM_COLORS = [ | |
| 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200', | |
| 'bg-purple-100 dark:bg-purple-900/40 text-purple-800 dark:text-purple-200', | |
| 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200', | |
| 'bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200', | |
| 'bg-rose-100 dark:bg-rose-900/40 text-rose-800 dark:text-rose-200', | |
| 'bg-cyan-100 dark:bg-cyan-900/40 text-cyan-800 dark:text-cyan-200', | |
| 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-800 dark:text-indigo-200', | |
| 'bg-teal-100 dark:bg-teal-900/40 text-teal-800 dark:text-teal-200', | |
| ]; | |
| /** | |
| * Apply Bionic Reading formatting to a word | |
| * Bold the first portion of each word based on length | |
| */ | |
| const applyBionicReading = (word: string): ReactElement => { | |
| if (word.length <= 1) return <>{word}</>; | |
| // Calculate how many letters to bold | |
| // Short words (1-3): 1 letter | |
| // Medium words (4-6): 2 letters | |
| // Longer words (7+): ~40% of the word | |
| let boldCount: number; | |
| if (word.length <= 3) { | |
| boldCount = 1; | |
| } else if (word.length <= 6) { | |
| boldCount = 2; | |
| } else { | |
| boldCount = Math.ceil(word.length * 0.4); | |
| } | |
| const boldPart = word.slice(0, boldCount); | |
| const normalPart = word.slice(boldCount); | |
| return ( | |
| <> | |
| <span className="font-bold">{boldPart}</span> | |
| <span className="font-normal opacity-80">{normalPart}</span> | |
| </> | |
| ); | |
| }; | |
| /** | |
| * Check if a word matches a technical term (case-insensitive) | |
| */ | |
| const findMatchingTerm = ( | |
| word: string, | |
| terms: TechnicalTerm[] | |
| ): { term: TechnicalTerm; colorClass: string } | null => { | |
| const cleanWord = word.toLowerCase().replace(/[^a-z0-9]/g, ''); | |
| for (let i = 0; i < terms.length; i++) { | |
| const termLower = terms[i].term.toLowerCase(); | |
| if (cleanWord === termLower || cleanWord.includes(termLower)) { | |
| return { | |
| term: terms[i], | |
| colorClass: terms[i].highlightColor || TERM_COLORS[i % TERM_COLORS.length] | |
| }; | |
| } | |
| } | |
| return null; | |
| }; | |
| const BionicText: React.FC<Props> = ({ | |
| text, | |
| bionicEnabled = false, | |
| highlightTerms = [], | |
| currentTTSWord | |
| }) => { | |
| const processedContent = useMemo(() => { | |
| // Split text into words while preserving whitespace and punctuation | |
| const words = text.split(/(\s+)/); | |
| let wordIndex = 0; | |
| return words.map((segment, idx) => { | |
| // If it's whitespace, just return it | |
| if (/^\s+$/.test(segment)) { | |
| return <span key={idx}>{segment}</span>; | |
| } | |
| const currentWordIdx = wordIndex; | |
| wordIndex++; | |
| // Check if this word is currently being spoken (TTS) | |
| const isTTSActive = currentTTSWord !== undefined && currentWordIdx === currentTTSWord; | |
| // Check if this word matches a technical term | |
| const matchingTerm = highlightTerms.length > 0 | |
| ? findMatchingTerm(segment, highlightTerms) | |
| : null; | |
| // Base styling | |
| let className = ''; | |
| let content: ReactElement | string = segment; | |
| // Apply TTS highlighting (karaoke style) | |
| if (isTTSActive) { | |
| className = 'bg-brand-200 dark:bg-brand-700 px-1 rounded transition-colors duration-150'; | |
| } | |
| // Apply term highlighting | |
| if (matchingTerm) { | |
| className = `${matchingTerm.colorClass} px-1 py-0.5 rounded cursor-help transition-colors`; | |
| } | |
| // Apply bionic reading | |
| if (bionicEnabled) { | |
| content = applyBionicReading(segment); | |
| } | |
| // If it's a technical term, wrap with tooltip | |
| if (matchingTerm) { | |
| return ( | |
| <span | |
| key={idx} | |
| className={`inline-block ${className} group relative`} | |
| title={matchingTerm.term.definition} | |
| > | |
| {content} | |
| {/* Inline tooltip on hover */} | |
| <span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap max-w-xs z-50 shadow-lg"> | |
| <span className="font-bold">{matchingTerm.term.term}:</span>{' '} | |
| {matchingTerm.term.definition} | |
| </span> | |
| </span> | |
| ); | |
| } | |
| return ( | |
| <span key={idx} className={className}> | |
| {content} | |
| </span> | |
| ); | |
| }); | |
| }, [text, bionicEnabled, highlightTerms, currentTTSWord]); | |
| return <span className="bionic-text">{processedContent}</span>; | |
| }; | |
| /** | |
| * Hook for processing markdown content with bionic reading | |
| */ | |
| export const useBionicMarkdown = (content: string, enabled: boolean): string => { | |
| return useMemo(() => { | |
| if (!enabled) return content; | |
| // Process markdown while preserving syntax | |
| // We'll bold the first letters of words that aren't part of markdown syntax | |
| return content.replace( | |
| /(?<![#*_`\[\]])(\b\w{2,}\b)(?![#*_`\[\]])/g, | |
| (match) => { | |
| const boldCount = match.length <= 3 ? 1 : match.length <= 6 ? 2 : Math.ceil(match.length * 0.4); | |
| return `**${match.slice(0, boldCount)}**${match.slice(boldCount)}`; | |
| } | |
| ); | |
| }, [content, enabled]); | |
| }; | |
| export default BionicText; | |