From dd9e2f4dd39325852d1dcb19f2cb6d405654cfa9 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sun, 26 Jan 2025 11:55:06 -0300 Subject: [PATCH] feat: adicionando controles de texto --- src/components/learning/WordHighlighter.tsx | 135 ++++++++-- src/components/ui/adaptive-text.tsx | 12 +- src/components/ui/text-controls.tsx | 250 ++++++++++++++++++ .../syllables/utils/syllableSplitter.ts | 13 + src/pages/student-dashboard/StoryPage.tsx | 213 +++++++++++---- 5 files changed, 535 insertions(+), 88 deletions(-) create mode 100644 src/components/ui/text-controls.tsx diff --git a/src/components/learning/WordHighlighter.tsx b/src/components/learning/WordHighlighter.tsx index db13061..7ca5fbb 100644 --- a/src/components/learning/WordHighlighter.tsx +++ b/src/components/learning/WordHighlighter.tsx @@ -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 ( -
- {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 ( - 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" +
+ {/* Controles */} +
+
+ + {fontSize}px + +
+ + +
+ + {/* Texto */} +
+ {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 ( + 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} + + ); + })} +
); } \ No newline at end of file diff --git a/src/components/ui/adaptive-text.tsx b/src/components/ui/adaptive-text.tsx index 2219666..1e1f6cd 100644 --- a/src/components/ui/adaptive-text.tsx +++ b/src/components/ui/adaptive-text.tsx @@ -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 { 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 ? ( - - ) : transformedText + finalText ); }); diff --git a/src/components/ui/text-controls.tsx b/src/components/ui/text-controls.tsx new file mode 100644 index 0000000..1c5638f --- /dev/null +++ b/src/components/ui/text-controls.tsx @@ -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 ( +
+ {/* Primeira Seção: Controles Principais */} +
+ {/* Controle de Maiúsculas */} + + + {/* Controle de Sílabas */} + + + {/* Word Highlighter */} + +
+ + {/* Segunda Seção: Ajustes de Texto */} +
+ {/* Controle de Tamanho da Fonte */} +
+ + {fontSize}px + +
+ + {/* Controle de Velocidade de Leitura */} +
+ +
+ + {readingSpeed} ppm +
+ +
+ + {/* Controle de Espaçamento entre Letras */} +
+ +
+ + {letterSpacing}px +
+ +
+ + {/* Controle de Espaçamento entre Palavras */} +
+ +
+ + {wordSpacing}px +
+ +
+ + {/* Controle de Altura da Linha */} +
+ +
+ + {lineHeight.toFixed(1)} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/features/syllables/utils/syllableSplitter.ts b/src/features/syllables/utils/syllableSplitter.ts index ebc571f..0cdb649 100644 --- a/src/features/syllables/utils/syllableSplitter.ts +++ b/src/features/syllables/utils/syllableSplitter.ts @@ -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); } \ No newline at end of file diff --git a/src/pages/student-dashboard/StoryPage.tsx b/src/pages/student-dashboard/StoryPage.tsx index 40fdefa..7f70bee 100644 --- a/src/pages/student-dashboard/StoryPage.tsx +++ b/src/pages/student-dashboard/StoryPage.tsx @@ -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(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 ( + + ); + } + + const words = getWords(text); + return ( +
+ {words.map((word, index) => ( + + + {' '} + + ))} +
+ ); + }; 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} /> - { - /* - */}
- {/* História Principal */} -
- {/* Imagem da página atual */} - {story?.content?.pages?.[currentPage]?.image && ( - - )} - -
- + {/* 1. Controles de Texto */} +
+ - - {/* Texto da página atual */} - setIsHighlighting(prev => !prev)} /> +
- {/* Gravador de áudio */} - { - console.log('Áudio gravado:', audioUrl); + {/* 2. Título da História */} +
+

{story?.title}

+
+ + {/* 3. Texto da História */} +
+
+ > + {renderHighlightedText(story?.content?.pages?.[currentPage]?.text || '')} +
+
- {/* Navegação entre páginas */} -
+ {/* 4. Controles de Navegação */} +
+
- Página {currentPage + 1} de {story.content.pages.length} + Página {currentPage + 1} de {story?.content?.pages?.length}
+ + {/* 5. Imagem da História */} +
+ {story?.content?.pages?.[currentPage]?.image ? ( + + ) : ( +
+

Sem imagem para esta página

+
+ )} +
+ + {/* 6. Controle de Gravação */} +
+
+

Gravação de Áudio

+ { + setRecordings(prev => [recording, ...prev]); + }} + /> +
+
{/* Dashboard de métricas */}