feat: adicionando controles de texto

This commit is contained in:
Lucas Santana 2025-01-26 11:55:06 -03:00
parent 59a7adfeee
commit dd9e2f4dd3
5 changed files with 535 additions and 88 deletions

View File

@ -1,46 +1,129 @@
import React, { useState, useEffect } from 'react';
import { ChevronUp, ChevronDown, Play, Pause } from 'lucide-react';
interface WordHighlighterProps {
text: string; // Texto completo
highlightedWords: string[]; // Palavras para destacar (ex: palavras difíceis)
difficultWords: string[]; // Palavras que o aluno teve dificuldade
onWordClick: (word: string) => void; // Função para quando clicar na palavra
highlightSpeed?: number; // palavras por minuto
initialFontSize?: number;
}
export function WordHighlighter({
text,
highlightedWords,
difficultWords,
onWordClick
onWordClick,
highlightSpeed = 60,
initialFontSize = 18
}: WordHighlighterProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [fontSize, setFontSize] = useState(initialFontSize);
// Divide o texto em palavras mantendo a pontuação
const words = text.split(/(\s+)/).filter(word => word.trim().length > 0);
useEffect(() => {
if (!isPlaying) return;
const intervalTime = (60 / highlightSpeed) * 1000;
const interval = setInterval(() => {
setCurrentWordIndex((prevIndex) => {
if (prevIndex >= words.length - 1) {
setIsPlaying(false);
return prevIndex;
}
return prevIndex + 1;
});
}, intervalTime);
return () => clearInterval(interval);
}, [isPlaying, highlightSpeed, words.length]);
const handleFontSizeChange = (delta: number) => {
setFontSize(prev => Math.min(Math.max(12, prev + delta), 32));
};
const togglePlayPause = () => {
if (!isPlaying) {
setCurrentWordIndex(0);
}
setIsPlaying(!isPlaying);
};
return (
<div className="leading-relaxed text-lg space-y-4">
{words.map((word, i) => {
// Remove pontuação para comparação
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
// Determina o estilo baseado no tipo da palavra
const isHighlighted = highlightedWords.includes(cleanWord);
const isDifficult = difficultWords.includes(cleanWord);
return (
<span
key={i}
onClick={() => onWordClick(word)}
className={`
inline-block mx-1 px-1 rounded cursor-pointer transition-all
hover:scale-110
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
hover:bg-gray-100
`}
title="Clique para ver mais informações"
<div className="space-y-4">
{/* Controles */}
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<button
onClick={() => handleFontSizeChange(-2)}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Diminuir fonte"
>
{word}
</span>
);
})}
<ChevronDown className="h-5 w-5" />
</button>
<span className="text-sm font-medium">{fontSize}px</span>
<button
onClick={() => handleFontSizeChange(2)}
className="p-2 rounded-lg hover:bg-gray-100"
aria-label="Aumentar fonte"
>
<ChevronUp className="h-5 w-5" />
</button>
</div>
<button
onClick={togglePlayPause}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-100 text-purple-700 hover:bg-purple-200"
>
{isPlaying ? (
<>
<Pause className="h-4 w-4" />
Pausar Leitura
</>
) : (
<>
<Play className="h-4 w-4" />
Iniciar Leitura
</>
)}
</button>
</div>
{/* Texto */}
<div
className="leading-relaxed space-y-4"
style={{ fontSize: `${fontSize}px` }}
>
{words.map((word, i) => {
const cleanWord = word.toLowerCase().replace(/[.,!?;:]/, '');
const isHighlighted = highlightedWords.includes(cleanWord);
const isDifficult = difficultWords.includes(cleanWord);
const isCurrentWord = i === currentWordIndex && isPlaying;
return (
<span
key={i}
onClick={() => onWordClick(word)}
className={`
inline-block mx-1 px-1 rounded cursor-pointer transition-all
hover:scale-110
${isHighlighted ? 'bg-yellow-200 hover:bg-yellow-300' : ''}
${isDifficult ? 'bg-red-100 hover:bg-red-200' : ''}
${isCurrentWord ? 'bg-purple-200 scale-110' : ''}
hover:bg-gray-100
`}
title="Clique para ver mais informações"
>
{word}
</span>
);
})}
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
import { formatTextWithSyllables } from '../../features/syllables/utils/syllableSplitter';
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
text: string;
@ -19,11 +20,8 @@ export const AdaptiveText = React.memo(({
className,
...props
}: AdaptiveTextProps) => {
// Transformar o texto mantendo espaços em branco se necessário
const transformedText = React.useMemo(() => {
const transformed = isUpperCase ? text.toUpperCase() : text;
return preserveWhitespace ? transformed : transformed.trim();
}, [text, isUpperCase, preserveWhitespace]);
const formattedText = formatTextWithSyllables(text, highlightSyllables);
const finalText = isUpperCase ? formattedText.toUpperCase() : formattedText;
return React.createElement(
Component,
@ -34,9 +32,7 @@ export const AdaptiveText = React.memo(({
),
...props
},
highlightSyllables ? (
<SyllableHighlighter text={transformedText} />
) : transformedText
finalText
);
});

View File

@ -0,0 +1,250 @@
import React from 'react';
import {
Type,
ChevronUp,
ChevronDown,
Play,
Pause,
Timer,
ArrowLeftRight,
MoveVertical
} from 'lucide-react';
import { cn } from '../../lib/utils';
interface TextControlsProps {
isUpperCase: boolean;
onToggleUpperCase: () => void;
isSyllablesEnabled: boolean;
onToggleSyllables: () => void;
fontSize: number;
onFontSizeChange: (size: number) => void;
readingSpeed: number;
onReadingSpeedChange: (speed: number) => void;
letterSpacing: number;
onLetterSpacingChange: (spacing: number) => void;
wordSpacing: number;
onWordSpacingChange: (spacing: number) => void;
lineHeight: number;
onLineHeightChange: (height: number) => void;
isLoading?: boolean;
className?: string;
isHighlighting?: boolean;
onToggleHighlight?: () => void;
}
export function TextControls({
isUpperCase,
onToggleUpperCase,
isSyllablesEnabled,
onToggleSyllables,
fontSize,
onFontSizeChange,
readingSpeed,
onReadingSpeedChange,
letterSpacing,
onLetterSpacingChange,
wordSpacing,
onWordSpacingChange,
lineHeight,
onLineHeightChange,
isLoading = false,
className,
isHighlighting = false,
onToggleHighlight
}: TextControlsProps) {
const handleFontSizeChange = (delta: number) => {
const newSize = Math.min(Math.max(12, fontSize + delta), 32);
onFontSizeChange(newSize);
};
const handleReadingSpeedChange = (delta: number) => {
const newSpeed = Math.min(Math.max(30, readingSpeed + delta), 300);
onReadingSpeedChange(newSpeed);
};
const handleLetterSpacingChange = (delta: number) => {
const newSpacing = Math.min(Math.max(0, letterSpacing + delta), 10);
onLetterSpacingChange(newSpacing);
};
const handleWordSpacingChange = (delta: number) => {
const newSpacing = Math.min(Math.max(0, wordSpacing + delta), 20);
onWordSpacingChange(newSpacing);
};
const handleLineHeightChange = (delta: number) => {
const newHeight = Math.min(Math.max(1, lineHeight + delta), 3);
onLineHeightChange(newHeight);
};
return (
<div className={cn("space-y-4", className)}>
{/* Primeira Seção: Controles Principais */}
<div className="flex items-center gap-3 flex-wrap">
{/* Controle de Maiúsculas */}
<button
onClick={onToggleUpperCase}
disabled={isLoading}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
'border border-gray-200',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
title={isUpperCase ? 'Mudar para minúsculas' : 'MUDAR PARA MAIÚSCULAS'}
>
<Type className="h-4 w-4" />
<span className="text-sm font-medium select-none">
{isUpperCase ? 'Aa: Minúsculas' : 'AA: MAIÚSCULAS'}
</span>
</button>
{/* Controle de Sílabas */}
<button
onClick={onToggleSyllables}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900',
'border border-gray-200',
isSyllablesEnabled ? 'bg-purple-50 text-purple-600 border-purple-200' : 'hover:bg-gray-100'
)}
>
<span className="text-sm font-medium">-la-bas</span>
</button>
{/* Word Highlighter */}
<button
onClick={onToggleHighlight}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900',
'border border-gray-200',
isHighlighting ? 'bg-blue-50 text-blue-600 border-blue-200' : 'hover:bg-gray-100'
)}
>
{isHighlighting ? (
<>
<Pause className="h-4 w-4" />
<span className="text-sm font-medium">Pausar</span>
</>
) : (
<>
<Play className="h-4 w-4" />
<span className="text-sm font-medium">Destacar Palavras</span>
</>
)}
</button>
</div>
{/* Segunda Seção: Ajustes de Texto */}
<div className="flex items-center gap-3 flex-wrap">
{/* Controle de Tamanho da Fonte */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleFontSizeChange(-2)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir fonte"
>
<ChevronDown className="h-4 w-4" />
</button>
<span className="text-sm font-medium px-2">{fontSize}px</span>
<button
onClick={() => handleFontSizeChange(2)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar fonte"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Velocidade de Leitura */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleReadingSpeedChange(-10)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir velocidade"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<Timer className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{readingSpeed} ppm</span>
</div>
<button
onClick={() => handleReadingSpeedChange(10)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar velocidade"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Espaçamento entre Letras */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleLetterSpacingChange(-0.5)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir espaçamento entre letras"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<Type className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{letterSpacing}px</span>
</div>
<button
onClick={() => handleLetterSpacingChange(0.5)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar espaçamento entre letras"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Espaçamento entre Palavras */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleWordSpacingChange(-1)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir espaçamento entre palavras"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<ArrowLeftRight className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{wordSpacing}px</span>
</div>
<button
onClick={() => handleWordSpacingChange(1)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar espaçamento entre palavras"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
{/* Controle de Altura da Linha */}
<div className="flex items-center gap-1 border border-gray-200 rounded-lg">
<button
onClick={() => handleLineHeightChange(-0.1)}
className="p-1.5 hover:bg-gray-100 rounded-l-lg"
aria-label="Diminuir altura da linha"
>
<ChevronDown className="h-4 w-4" />
</button>
<div className="flex items-center gap-1 px-2">
<MoveVertical className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium">{lineHeight.toFixed(1)}</span>
</div>
<button
onClick={() => handleLineHeightChange(0.1)}
className="p-1.5 hover:bg-gray-100 rounded-r-lg"
aria-label="Aumentar altura da linha"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

View File

@ -19,4 +19,17 @@ export function splitIntoSyllables(word: string): string[] {
}
return syllables.length > 0 ? syllables : [word];
}
export function highlightSyllables(text: string): string {
const words = text.split(/\s+/);
return words.map(word => {
const syllables = splitIntoSyllables(word);
return syllables.join('-');
}).join(' ');
}
export function formatTextWithSyllables(text: string, shouldHighlight: boolean): string {
if (!shouldHighlight) return text;
return highlightSyllables(text);
}

View File

@ -13,6 +13,8 @@ import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '../../components
import { useSession } from '../../hooks/useSession';
import { useSyllables } from '../../features/syllables/hooks/useSyllables';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
import { TextControls } from '../../components/ui/text-controls';
import { cn } from '../../lib/utils';
interface StoryRecording {
@ -399,8 +401,83 @@ export function StoryPage() {
const [isDeleting, setIsDeleting] = useState(false);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const { isHighlighted, toggleHighlight } = useSyllables();
const { isHighlighted: isSyllablesEnabled, toggleHighlight: toggleSyllables } = useSyllables();
const [fontSize, setFontSize] = useState(18);
const [readingSpeed, setReadingSpeed] = useState(120); // 120 palavras por minuto
const [letterSpacing, setLetterSpacing] = useState(0);
const [wordSpacing, setWordSpacing] = useState(0);
const [lineHeight, setLineHeight] = useState(1.5);
const [isHighlighting, setIsHighlighting] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(-1);
const highlightInterval = React.useRef<number | null>(null);
// Função para dividir o texto em palavras
const getWords = (text: string) => text.split(/\s+/);
// Atualizar o useEffect do highlighting para usar readingSpeed
useEffect(() => {
if (isHighlighting) {
const words = getWords(story?.content?.pages?.[currentPage]?.text || '');
const intervalTime = (60 / readingSpeed) * 1000; // Converter palavras por minuto para milissegundos
highlightInterval.current = window.setInterval(() => {
setCurrentWordIndex(prev => {
if (prev >= words.length - 1) {
setIsHighlighting(false);
return -1;
}
return prev + 1;
});
}, intervalTime);
return () => {
if (highlightInterval.current) {
window.clearInterval(highlightInterval.current);
}
};
} else {
setCurrentWordIndex(-1);
if (highlightInterval.current) {
window.clearInterval(highlightInterval.current);
}
}
}, [isHighlighting, currentPage, story?.content?.pages, readingSpeed]);
// Função para renderizar o texto com destaque
const renderHighlightedText = (text: string) => {
if (!text || currentWordIndex === -1) {
return (
<AdaptiveText
text={text}
isUpperCase={isUpperCase}
highlightSyllables={isSyllablesEnabled}
/>
);
}
const words = getWords(text);
return (
<div className="leading-relaxed whitespace-pre-wrap break-words">
{words.map((word, index) => (
<span
key={index}
className={cn(
'inline-block',
'transition-colors duration-200',
index === currentWordIndex && 'bg-yellow-200 rounded px-0.5'
)}
>
<AdaptiveText
text={word}
isUpperCase={isUpperCase}
highlightSyllables={isSyllablesEnabled}
/>
{' '}
</span>
))}
</div>
);
};
React.useEffect(() => {
const fetchStory = async () => {
@ -447,8 +524,6 @@ export function StoryPage() {
fetchStory();
}, [id]);
React.useEffect(() => {
const fetchRecordings = async () => {
if (!story?.id) return;
@ -628,12 +703,12 @@ export function StoryPage() {
isLoading={isLoading}
/>
<button
onClick={toggleHighlight}
onClick={toggleSyllables}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors text-gray-600 hover:text-gray-900 hover:bg-gray-100 border border-gray-200"
>
<TextSelect className="h-4 w-4" />
<span className="text-sm font-medium">
{isHighlighted ? 'Desativar Sílabas' : 'Ativar Sílabas'}
{isSyllablesEnabled ? 'Desativar Sílabas' : 'Ativar Sílabas'}
</span>
</button>
<button
@ -643,78 +718,108 @@ export function StoryPage() {
<Share2 className="h-5 w-5" />
Compartilhar
</button>
{
/* <button
onClick={() => setIsPlaying(!isPlaying)}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
>
<Volume2 className="h-5 w-5" />
{isPlaying ? 'Pausar' : 'Ouvir'}
</button>
*/}
</div>
</div>
{/* História Principal */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-8">
{/* Imagem da página atual */}
{story?.content?.pages?.[currentPage]?.image && (
<ImageWithLoading
src={story.content.pages[currentPage].image}
alt={`Página ${currentPage + 1}`}
className="w-full h-full object-cover"
/>
)}
<div className="p-8">
<AdaptiveTitle
text={story?.title || ''}
{/* Conteúdo Principal */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
{/* 1. Controles de Texto */}
<div className="border-b border-gray-200 pb-6">
<TextControls
isUpperCase={isUpperCase}
className="text-3xl font-bold text-gray-900 mb-6"
/>
{/* Texto da página atual */}
<AdaptiveParagraph
text={story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
isUpperCase={isUpperCase}
highlightSyllables={isHighlighted}
className="text-xl leading-relaxed text-gray-700 mb-8"
onToggleUpperCase={toggleUppercase}
isSyllablesEnabled={isSyllablesEnabled}
onToggleSyllables={toggleSyllables}
fontSize={fontSize}
onFontSizeChange={setFontSize}
readingSpeed={readingSpeed}
onReadingSpeedChange={setReadingSpeed}
letterSpacing={letterSpacing}
onLetterSpacingChange={setLetterSpacing}
wordSpacing={wordSpacing}
onWordSpacingChange={setWordSpacing}
lineHeight={lineHeight}
onLineHeightChange={setLineHeight}
isLoading={isLoading}
isHighlighting={isHighlighting}
onToggleHighlight={() => setIsHighlighting(prev => !prev)}
/>
</div>
{/* Gravador de áudio */}
<AudioRecorder
storyId={story.id}
studentId={story.student_id}
onAudioUploaded={(audioUrl) => {
console.log('Áudio gravado:', audioUrl);
{/* 2. Título da História */}
<div className="border-b border-gray-200 pb-6">
<h1 className="text-2xl font-bold text-gray-900">{story?.title}</h1>
</div>
{/* 3. Texto da História */}
<div className="border-b border-gray-200 pb-6">
<div
className="prose max-w-none overflow-hidden"
style={{
fontSize: `${fontSize}px`,
letterSpacing: `${letterSpacing}px`,
wordSpacing: `${wordSpacing}px`,
lineHeight: lineHeight
}}
/>
>
{renderHighlightedText(story?.content?.pages?.[currentPage]?.text || '')}
</div>
</div>
{/* Navegação entre páginas */}
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
{/* 4. Controles de Navegação */}
<div className="border-b border-gray-200 pb-6">
<div className="flex justify-between items-center">
<button
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
onClick={() => setCurrentPage(prev => prev - 1)}
disabled={currentPage === 0}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowLeft className="h-5 w-5" />
Anterior
Página Anterior
</button>
<span className="text-sm text-gray-500">
Página {currentPage + 1} de {story.content.pages.length}
Página {currentPage + 1} de {story?.content?.pages?.length}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
disabled={currentPage === story.content.pages.length - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
onClick={() => setCurrentPage(prev => prev + 1)}
disabled={currentPage === (story?.content?.pages?.length ?? 0) - 1}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
Próxima
Próxima Página
<ArrowRight className="h-5 w-5" />
</button>
</div>
</div>
{/* 5. Imagem da História */}
<div className="border-b border-gray-200 pb-6">
{story?.content?.pages?.[currentPage]?.image ? (
<ImageWithLoading
src={story.content.pages[currentPage].image}
alt={`Ilustração da página ${currentPage + 1}`}
className="w-full rounded-lg"
/>
) : (
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Sem imagem para esta página</p>
</div>
)}
</div>
{/* 6. Controle de Gravação */}
<div>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Gravação de Áudio</h2>
<AudioRecorder
storyId={story.id}
onRecordingComplete={(recording) => {
setRecordings(prev => [recording, ...prev]);
}}
/>
</div>
</div>
</div>
{/* Dashboard de métricas */}