File size: 5,562 Bytes
46a757e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
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;