PaperStack / components /BionicText.tsx
Akhil-Theerthala's picture
Upload 32 files
46a757e verified
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;