Enlaces a las aplicaciones html
Elaboradas con Canvas de Gemini
Lectura progresiva. Las palabras van apareciendo poco a poco. https://disanedu.linexedu.eu/aplicaciones/inteligencia/lectura/lectura_progresiva.html
Lectura condicionada. Las palabras van desapareciendo poco a poco. https://disanedu.linexedu.eu/aplicaciones/inteligencia/lectura/lectura_condicionada.html
Lectura progresiva + Lectura condicionada https://disanedu.linexedu.eu/aplicaciones/inteligencia/lectura/lectura_interactiva.html
Elaboradas con Claude
Lectura progresiva. Las palabras van apareciendo poco a poco. https://disanedu.linexedu.eu/aplicaciones/inteligencia/lectura/lectura_a_mi_ritmo.html
Lectura condicionada. Las palabras van desapareciendo poco a poco. https://disanedu.linexedu.eu/aplicaciones/inteligencia/lectura/lectura-condicionada.html
Lectura progresiva + Lectura condicionada https://disanedu.linexedu.eu/aplicaciones/inteligencia/lectura/lectura_progresiva_condicionada.html
Aplicación html en un Site
Código html lectura interactiva de Gemini
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lectura Interactiva</title>
<style>
/* Importar tipografía OpenDyslexic real */
@import url('https://fonts.cdnfonts.com/css/opendyslexic');
/* =========================================
VARIABLES Y TIPOGRAFÍA BASE
========================================= */
:root {
--primary: #4F46E5; /* Indigo para un diseño educativo moderno */
--primary-hover: #4338CA;
--secondary: #10B981;
--secondary-hover: #059669;
--danger: #EF4444;
--danger-hover: #DC2626;
--bg-color: #F3F4F6;
--text-color: #1F2937;
--card-bg: #FFFFFF;
--border-color: #E5E7EB;
--focus-ring: #60A5FA;
}
@font-face {
font-family: 'OpenDyslexic';
src: local('OpenDyslexic'), local('OpenDyslexic-Regular');
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
transition: background-color 0.3s, color 0.3s;
overflow-x: hidden;
}
/* =========================================
ESTILOS GENERALES Y ACCESIBILIDAD
========================================= */
button, input, select, textarea {
font-family: inherit;
font-size: 1rem;
}
button {
cursor: pointer;
border: none;
border-radius: 12px;
padding: 12px 24px;
font-weight: bold;
transition: transform 0.1s, background-color 0.2s, box-shadow 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
button:active { transform: scale(0.96); }
.btn-primary { background-color: var(--primary); color: white; }
.btn-primary:hover { background-color: var(--primary-hover); }
.btn-success { background-color: var(--secondary); color: white; }
.btn-success:hover { background-color: var(--secondary-hover); }
.btn-danger { background-color: var(--danger); color: white; }
.btn-danger:hover { background-color: var(--danger-hover); }
.btn-outline { background-color: transparent; border: 2px solid var(--primary); color: var(--primary); }
.btn-outline:hover { background-color: var(--primary); color: white; }
.btn-large { font-size: 1.5rem; padding: 16px 48px; border-radius: 16px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
/* =========================================
CONTENEDORES Y VISTAS
========================================= */
.view {
display: none;
min-height: 100vh;
padding: 24px;
flex-direction: column;
align-items: center;
}
.view.active { display: flex; }
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
background: var(--card-bg);
padding: 32px;
border-radius: 24px;
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
}
h1, h2, h3 { color: inherit; margin-bottom: 24px; text-align: center; }
/* =========================================
PANTALLA DE CONFIGURACIÓN Y TEXTOS
========================================= */
.form-group { margin-bottom: 24px; width: 100%; }
.form-group label { display: block; font-weight: bold; margin-bottom: 8px; font-size: 1.1rem; }
.form-control { width: 100%; padding: 12px; border: 2px solid var(--border-color); border-radius: 12px; }
.flex-row { display: flex; gap: 16px; flex-wrap: wrap; }
.flex-row > * { flex: 1; min-width: 200px; }
.slider-container { display: flex; align-items: center; gap: 16px; }
input[type="range"] { flex: 1; height: 8px; border-radius: 4px; background: var(--border-color); appearance: none; }
input[type="range"]::-webkit-slider-thumb { appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--primary); cursor: pointer; }
.color-presets { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.color-preset { width: 40px; height: 40px; border-radius: 50%; border: 2px solid #ccc; cursor: pointer; }
#contrast-warning { display: none; background: #FEF3C7; color: #92400E; padding: 12px; border-radius: 8px; margin-top: 8px; font-weight: bold; text-align: center; }
.preview-box {
margin-top: 24px; padding: 24px; border-radius: 16px; border: 2px dashed var(--border-color);
text-align: center; min-height: 120px; display: flex; align-items: center; justify-content: center;
}
.text-list { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; max-height: 400px; overflow-y: auto; padding-right: 8px; }
.text-item {
display: flex; justify-content: space-between; align-items: center; padding: 16px;
background: var(--bg-color); border-radius: 12px; border: 1px solid var(--border-color);
}
.text-item-title { font-weight: bold; font-size: 1.1rem; flex: 1; }
.text-actions { display: flex; gap: 8px; }
/* =========================================
PANTALLA DE LECTURA
========================================= */
#view-reading.active {
/* Uso block para garantizar cálculo de scroll en móviles iOS/Android */
display: block !important;
padding: 24px 5% 24px 5%;
transition: background-color 0.5s;
}
#reading-container {
width: 100%; max-width: 900px;
margin: 0 auto;
text-align: left; white-space: pre-wrap; word-wrap: break-word;
}
.reading-controls {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
display: flex; gap: 16px; background: rgba(255,255,255,0.9); padding: 16px 24px;
border-radius: 32px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); backdrop-filter: blur(4px);
flex-wrap: wrap; justify-content: center; z-index: 100;
}
@media (prefers-color-scheme: dark) {
.reading-controls { background: rgba(0,0,0,0.8); }
}
/* =========================================
PANTALLA FINAL Y ANIMACIONES
========================================= */
#view-completion { text-align: center; justify-content: center; }
.motivational-msg { font-size: 3rem; font-weight: bold; color: var(--primary); animation: popIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
#confetti-canvas { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 999; }
@keyframes popIn { 0% { opacity: 0; transform: scale(0.5); } 100% { opacity: 1; transform: scale(1); } }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* =========================================
UTILIDADES
========================================= */
.hidden { display: none !important; }
.text-center { text-align: center; }
.mt-4 { margin-top: 16px; }
.mt-8 { margin-top: 32px; }
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;}
</style>
<!-- Librerías externas para importar PDF y DOCX -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"></script>
</head>
<body>
<!-- LIENZO PARA ANIMACIONES (CONFETI) -->
<canvas id="confetti-canvas"></canvas>
<!-- VISTA 1: INICIO -->
<div id="view-home" class="view active">
<div class="container text-center" style="margin-top: 10vh;">
<h1 style="font-size: 2.5rem; color: var(--primary);">Lectura Interactiva</h1>
<p style="font-size: 1.2rem; margin-bottom: 32px;">
Fomenta la agilidad y fluidez lectora.<br>
Elige entre lectura progresiva (aparición) o condicionada (desvanecimiento) al ritmo que necesites.
</p>
<button class="btn-primary btn-large" onclick="app.showView('view-config')" aria-label="Comenzar">
¡COMENZAR!
</button>
</div>
</div>
<!-- VISTA 2: CONFIGURACIÓN -->
<div id="view-config" class="view">
<div class="container">
<div class="header-actions">
<h1>Configuración</h1>
<button class="btn-outline" onclick="app.showView('view-text-manager')">📚 Mis Textos</button>
</div>
<div class="form-group">
<label for="mode-select">Modo de lectura:</label>
<select id="mode-select" class="form-control" onchange="app.updatePreview()">
<option value="progresiva">Lectura Progresiva (Las palabras aparecen poco a poco)</option>
<option value="condicionada">Lectura Condicionada (Las palabras desaparecen poco a poco)</option>
</select>
</div>
<div class="form-group">
<label for="text-select">Texto seleccionado:</label>
<select id="text-select" class="form-control" onchange="app.updatePreview()"></select>
</div>
<div class="flex-row">
<div class="form-group">
<label for="font-select">Fuente:</label>
<select id="font-select" class="form-control" onchange="app.updatePreview()">
<option value="'OpenDyslexic', 'Comic Sans MS', sans-serif">OpenDyslexic</option>
<option value="'Comic Sans MS', cursive, sans-serif">Comic Sans MS</option>
<option value="Arial, sans-serif">Arial</option>
<option value="'Times New Roman', serif">Times New Roman</option>
</select>
</div>
<div class="form-group">
<label>Velocidad: <span id="wpm-display">120</span> palabras por minuto</label>
<div class="slider-container">
<span>30</span>
<input type="range" id="wpm-slider" min="30" max="500" step="10" value="120" oninput="app.updateWpmDisplay()">
<span>500</span>
</div>
</div>
</div>
<div class="form-group">
<label>Tamaño de letra: <span id="size-display">32</span>px</label>
<div class="slider-container">
<span style="font-size: 14px;">A</span>
<input type="range" id="size-slider" min="14" max="100" step="2" value="32" oninput="app.updatePreview()">
<span style="font-size: 40px;">A</span>
</div>
</div>
<div class="flex-row">
<div class="form-group">
<label for="bg-color">Color del fondo:</label>
<input type="color" id="bg-color" class="form-control" value="#FFFFFF" style="height: 50px; padding: 4px;" oninput="app.updatePreview()">
<div class="color-presets">
<div class="color-preset" style="background: #FFFFFF;" onclick="app.setColor('bg', '#FFFFFF')"></div>
<div class="color-preset" style="background: #FEF3C7;" onclick="app.setColor('bg', '#FEF3C7')"></div>
<div class="color-preset" style="background: #FEF9C3;" onclick="app.setColor('bg', '#FEF9C3')"></div>
<div class="color-preset" style="background: #1E3A8A;" onclick="app.setColor('bg', '#1E3A8A')"></div>
<div class="color-preset" style="background: #000000;" onclick="app.setColor('bg', '#000000')"></div>
</div>
</div>
<div class="form-group">
<label for="text-color">Color del texto:</label>
<input type="color" id="text-color" class="form-control" value="#000000" style="height: 50px; padding: 4px;" oninput="app.updatePreview()">
<div class="color-presets">
<div class="color-preset" style="background: #000000;" onclick="app.setColor('text', '#000000')"></div>
<div class="color-preset" style="background: #FFFFFF;" onclick="app.setColor('text', '#FFFFFF')"></div>
<div class="color-preset" style="background: #374151;" onclick="app.setColor('text', '#374151')"></div>
</div>
</div>
</div>
<button class="btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="app.resetColors()">Restablecer colores predeterminados</button>
<div id="contrast-warning">⚠️ Esta combinación puede resultar difícil de leer. Te recomendamos elegir colores con mayor contraste.</div>
<div class="flex-row mt-4">
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="anim-toggle" checked style="width: 24px; height: 24px;" onchange="app.saveConfig()">
<label for="anim-toggle" style="margin: 0;">Animaciones activadas</label>
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="sound-toggle" checked style="width: 24px; height: 24px;" onchange="app.saveConfig()">
<label for="sound-toggle" style="margin: 0;">Sonidos activados</label>
</div>
</div>
<label style="display: block; font-weight: bold; margin-bottom: 8px; font-size: 1.1rem; margin-top: 24px;">Vista previa:</label>
<div class="preview-box" id="preview-box">
Así se verá tu texto mientras lees.
</div>
<div class="text-center mt-8">
<button class="btn-success btn-large" onclick="app.startReading()" aria-label="Comenzar lectura">
▶ COMENZAR
</button>
</div>
</div>
</div>
<!-- VISTA 3: GESTIÓN DE TEXTOS -->
<div id="view-text-manager" class="view">
<div class="container">
<div class="header-actions">
<h1>Mis Textos</h1>
<button class="btn-outline" onclick="app.showView('view-config')">⬅ Volver</button>
</div>
<div class="flex-row" style="margin-bottom: 16px;">
<input type="text" id="search-text" class="form-control" placeholder="🔍 Buscar un texto..." oninput="app.renderTextList()">
<select id="sort-text" class="form-control" onchange="app.renderTextList()">
<option value="original">Textos originales primero</option>
<option value="added">Textos añadidos primero</option>
<option value="az">Orden alfabético (A-Z)</option>
<option value="za">Orden alfabético (Z-A)</option>
<option value="newest">Más recientes primero</option>
<option value="oldest">Más antiguos primero</option>
</select>
</div>
<div class="flex-row" style="margin-bottom: 16px;">
<button class="btn-primary" onclick="app.openEditor()">➕ Añadir texto</button>
<input type="file" id="file-import" accept=".txt,.docx,.pdf,.odt" class="hidden" onchange="app.importFile(event)">
<button class="btn-outline" onclick="document.getElementById('file-import').click()">📁 Importar archivo</button>
</div>
<p style="font-size: 0.85rem; color: #666;">Recuerda: al importar, la primera línea del documento se utilizará como el título automáticamente.</p>
<div class="text-list" id="text-list">
<!-- Textos dinámicos -->
</div>
<div class="mt-8 text-center">
<button class="btn-danger" onclick="app.restoreOriginalTextsConfirm()">🔄 Restaurar textos originales</button>
</div>
</div>
</div>
<!-- PANEL DE EDICIÓN DE TEXTO -->
<div id="view-editor" class="view" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 200; justify-content: center; padding: 16px;">
<div class="container" style="max-height: 90vh; display: flex; flex-direction: column;">
<h2 id="editor-title">Añadir texto</h2>
<input type="hidden" id="editor-id">
<div class="form-group">
<label>Título:</label>
<input type="text" id="editor-titulo" class="form-control" placeholder="Escribe el título aquí...">
</div>
<div class="form-group" style="flex: 1; display: flex; flex-direction: column;">
<label>Contenido:</label>
<textarea id="editor-contenido" class="form-control" style="flex: 1; min-height: 200px; resize: none;" placeholder="Escribe o pega el texto aquí..."></textarea>
</div>
<div class="header-actions" style="margin-top: 16px; margin-bottom: 0;">
<button class="btn-outline" onclick="app.closeEditor()">Cancelar</button>
<button class="btn-success" onclick="app.saveText()">Guardar</button>
</div>
</div>
</div>
<!-- VISTA 4: PANTALLA DE LECTURA -->
<div id="view-reading" class="view">
<div id="reading-container"></div>
<!-- ESPACIADOR FÍSICO INVISIBLE: Garantiza un área muerta enorme para el scroll, los botones NUNCA taparán el texto -->
<div style="height: 250px; width: 100%; display: block; clear: both;"></div>
<div class="reading-controls">
<button id="btn-pause" class="btn-primary" onclick="app.readingEngine.pause()" aria-label="Pausar">⏸ Pausar</button>
<button id="btn-resume" class="btn-success hidden" onclick="app.readingEngine.resume()" aria-label="Reanudar">▶ Reanudar</button>
<button class="btn-outline" onclick="app.readingEngine.restart()" aria-label="Reiniciar">🔄 Reiniciar</button>
<button class="btn-danger" onclick="app.readingEngine.exit()" aria-label="Salir">✕ Salir</button>
</div>
</div>
<!-- VISTA 5: FINALIZACIÓN -->
<div id="view-completion" class="view">
<div class="motivational-msg" id="motivational-msg">
¡Fantástico! Has terminado.
</div>
</div>
<script>
/* =========================================
DATOS INICIALES (TEXTOS ORIGINALES)
========================================= */
const TEXTOS_ORIGINALES = [
{ id: "texto1", titulo: "EL RATÓN BROMISTA", contenido: `Érase un ratón muy bromista, llamado Quesito.\nTodos le consideraban simpático, menos el elefante Trompudo. Aunque parezca mentira, Trompudo se asustaba mucho cuando veía un ratón y no le gustaban las bromas de Quesito.\nUn día, Trompudo se estaba bañando en un gran pozo del río cuando se acercó Quesito y le dijo:\n-Sal de ahí ahora mismo que tengo que decirte algo importante.\n-Saldré cuando quiera -contestó Trompudo con cara de pocos amigos.\n-Sal, te digo, o si no, ya verás -insistió el ratón muy serio con las manos en jarra.\n-Si no, ¿qué? ¡Como te dé un trompazo!\nEl elefante estaba muy a gusto en el agua y no quería salir, pero tenía miedo de que fuera importante. Así que salió del río.\n-¿Qué pasa, enano?\n-Ya nada - contestó Quesito.\nSólo quería saber si te habías puesto mi bañador.` },
{ id: "texto2", titulo: "COMO FUERON ENGAÑADOS LOS MALOS HIJOS", contenido: `Un hombre muy rico, creyendo que estaba a punto de morir, llamó a sus hijos y dividió entre ellos sus propiedades. Sin embargo, no murió y al levantarse de la cama, se encontró con que sus hijos ya no le querían, ni tenían con él las delicadezas que antes, cuando todos esperaban conseguir mayor parte de su fortuna.\nTodos le trataban mal, y no se recataban para decir que deseaban que muriese lo más pronto posible, ya que su vida sólo originaba gastos y molestias.\nEl pobre hombre no cesaba de llorar, y un día se encontró con un viejo amigo, a quien contó lo que le ocurría. El amigo, conmovido por lo que acababa de oír, prometió hallar una solución a aquel estado de cosas.\nEn efecto, la encontró y a los pocos días llegó con gran pompa a la casa de su amigo, seguido de diez criados que eran portadores de unos pesados sacos llenos de piedras.\nCuando estuvieron solos, el amigo dijo:\n-Te he traído estas piedras para engañar a tus hijos. Cuando me marche vendrán a ver lo que te he traído. Diles que he venido a pagarte una deuda muy antigua, y que eres más rico que antes. Ya verás cómo todos se desviven por ti. Volveré dentro de algún tiempo para ver cómo van las cosas.\nCuando, transcurridos unos meses, volvió el amigo, encontró al viejo rodeado de sus hijos, que todos a una se desvivían por él. Y así siguieron haciéndolo hasta que murió, descubriendo entonces el engaño, que tenían bien merecido.` },
{ id: "texto3", titulo: "LAS VERDURAS CANTORAS", contenido: `Pepino, Rábano, Tomate y Coliflor eran cuatro amigos que vivían en un hermoso huerto. Todas las mañanas se ponían a cantar, que era lo que más les gustaba en esta vida. Pero cantaban fatal. Tan mal lo hacían que todos los pájaros huían del huerto.\nUn buen día pasó por aquel lugar un famoso médico y les propuso hacerles una operación para aclararles la voz. Todos se operaron y lograron tener una voz con la que entonaban hermosos cantos. Desde aquella mañana, en aquel hermoso huerto se escuchan cantos felices que hacen bailar a los pájaros.` },
{ id: "texto4", titulo: "VIAJE AL FUTURO", contenido: `Había una vez una niña que se llamaba Elena. Era muy soñadora y deseaba ver el futuro más que nada en este mundo. Un día paseando se encontró con una cajita. Sintió mucha curiosidad por saber qué había dentro. De pronto, la cajita se abrió y la niña logró meterse dentro de ella. Viajó a toda velocidad y en pocos segundos llegó a un mundo maravilloso y desconocido.\n-¡Estoy en el futuro! -exclamó la niña.\nEnseguida encontró a un niño. Averiguó que se llamaba Alberto y le preguntó:\n-¿En qué año estamos?\n-Estamos en el año 2897- contestó el niño.\nElena, sorprendida, miró a su alrededor y pensó: ¿cómo habré podido llegar hasta aquí?\n-Es muy sencillo –le dijo Alberto- Has viajado en el vehículo más extraordinario que existe. Se llama Imaginación.` },
{ id: "texto5", titulo: "LA JIRAFA BAJITA", contenido: `Había una vez una jirafa muy bajita. Tan bajita era, que sólo comía hierba y pasto seco porque no alcanzaba las hojas de los árboles. Un día, quiso beber en un río, pero se acercó tanto a la orilla que resbaló y cayó al agua. Fue nadando río abajo y llegó a una granja donde encontró ocas, vacas, gallinas y ovejas. Al principio se asustó mucho, pero los animales le dijeron que no tuviese miedo. Ella se tranquilizó. Se hizo amiga de todos y se quedó allí a vivir. Jugaba con los animales y se sentía muy feliz. Tan feliz era que empezó a crecer y crecer hasta convertirse en una jirafa altísima. ¡Ya podía comer las hojas de los árboles! Y para dar las gracias a sus amigos les dejaba deslizarse por el tobogán de su cuello.` },
{ id: "texto6", titulo: "EL LEÑADOR SINCERO", contenido: `Un leñador muy pobre fue un día a cortar leña y, al pasar por un puente, se le cayó el hacha a un río profundo y no pudo sacarla. Una ninfa se compadeció de él. Se le apareció en el agua con un hacha de oro en la mano y le dijo:\n-¿Es tuya este hacha?\n-No -respondió el leñador. Entonces la ninfa le presentó otra de plata. \n-¿Es ésta? -No, tampoco ésa es la mía.\nPor último, la ninfa sacó del agua una vieja hacha de hierro y el leñador exclamó muy contento:\n-¡Ésa, ésa es mi hacha!\nEntonces la ninfa le entregó las tres: \n-Por haber sido tan sincero, de ahora en adelante, no te faltará de nada.\nCuando se enteró uno de sus amigos de lo que hacía la ninfa, se fue al puente, dejó caer el hacha y fingió que lloraba. Enseguida apareció la ninfa con un hacha de oro. \n-¿Es ésta tu hacha? -le preguntó para probarle. \n-¡Sí, sí es mi hacha! -respondió alargando la mano. Al oír la mentira, la ninfa desapareció en las aguas y él se quedó sin hacha.` },
{ id: "texto7", titulo: "HUESO Y PELLEJO", contenido: `Un buen hombre supo que pronto habría de morir y le dio pena pensar que su anciana mujer se iba a quedar sola.\n«Cariño -le dijo un día- te he comprado dos perros para que te hagan compañía y te protejan». A ella le pareció una buena idea. Como estaban muy flacos, la buena mujer los llamó Hueso y Pellejo.\nUn día los sacó a pasear como todas las noches, un ladrón aprovechó la ocasión para entrar en su casa a robar. Estaba buscando joyas y dinero cuando oyó que la anciana volvía. Como ya no podía huir, se escondió debajo de la cama. Cuando la viuda se acostó, el ladrón se quedó muy quieto, pero, de pronto, dio un fuerte estornudo. La mujer, asustada, disimuló para que el ladrón no le hiciera daño. Empezó a lamentarse de sus penas y decía:\n-Ay, me estoy quedando en ¡Hueso y Pellejo! Nada más que ¡Hueso y Pellejo! -repetía elevando la voz más y más.\nLos perros entraron al oír sus voces y retuvieron al ladrón debajo de la cama hasta que llegó la policía.` },
{ id: "texto8", titulo: "EL PÁJARO AZUL", contenido: `En una aldea perdida en el bosque vivía una pareja muy joven, en una vieja cabaña que había sido de los abuelos. Aunque lo tenían todo, el hombre no se sentía contento, porque pensaba que no era feliz, a pesar de lo mucho que lo quería su esposa. \nUn buen día se levantó y le dijo a la mujer: "He tenido un sueño maravilloso. En él se me aparecía el pájaro azul de la felicidad. También me dijo que si yo lo encontraba siempre sería muy feliz, así que voy a buscar el pájaro azul de la felicidad. Cuando lo traiga seremos los dos muy felices". Y así, echó a andar por los caminos de este mundo. Y caminó, caminó,…\nViajó por muchos lugares durante muchos años, y poco a poco fue envejeciendo hasta que ya, cansado de tanto caminar, regresó a la aldea. Pero cuando llegó a la cabaña se encontró a una viejecita, su esposa, y se dio cuenta que la verdadera felicidad estaba allí, con su esposa.` },
{ id: "texto9", titulo: "EL GATO MUNDO", contenido: `Una mujer tenía tres hijos ya mayores: uno alto y flaco, otro bajo y gordo, y el tercero, ni alto ni bajo, ni flaco ni gordo. Se le murió uno tras otro, y un día, en que también se fue al cielo su marido, los vecinos no la dejaron sola ni un momento.\nEn aquel pueblo las viudas no comían nada hasta después del entierro del marido y, por la noche, la pobre se moría de hambre. Aprovechando que todos se habían ido a cenar, puso al fuego unos choricillos y, cuando se disponía a comerlos, llamaron a la puerta dos vecinas y tuvo que esconder la sartén debajo del armario de la cocina por donde tenían que pasar.\nMientras velaban al muerto, veía a través de la puerta cómo su gato Mundo sacaba un chorizo tras otro y se los comía en un rincón. Ella se lamentaba en voz alta: «¡Ay, Mundo, Mundo, que uno a uno te los vas llevando todos!»\nSus amigas, creyendo que se refería a la muerte de sus seres queridos, trataban de consolarla mientras ella repetía: «¡Ay, Mundo, Mundo!»` },
{ id: "texto10", titulo: "LA GALLINA DE LOS HUEVOS DE ORO", contenido: `Érase un labrador tan pobre, tan pobre, que ni siquiera poseía una vaca. Era el más pobre de la aldea. Y resulta que un día trabajando en el campo y lamentándose de su suerte, apareció un enanito que le dijo:\n-Buen hombre, he oído tus lamentaciones y voy a hacer que tu fortuna cambie. Toma esta gallina; es tan maravillosa que todos los días pone un huevo de oro.\nEl enanito desapareció sin más ni más y el labrador llevó la gallina a su corral. Al día siguiente, ¡oh sorpresa!, encontró un huevo de oro. Lo puso en una cestita y se fue con ella a la ciudad, donde vendió el huevo por un alto precio.\nAl día siguiente, loco de alegría, encontró otro huevo de oro. ¡Por fin la fortuna había entrado a su casa! Todos los días tenía un nuevo huevo.\nFue así que poco a poco, con el producto de la venta de los huevos, fue convirtiéndose en el hombre más rico de la comarca. Sin embargo, una insensata avaricia hizo presa su corazón y pensó:\n“¿Por qué esperar a que cada día la gallina ponga un huevo? Mejor la mato y descubriré la mina de oro que lleva dentro”.\nY así lo hizo, pero en el interior de la gallina no encontró ninguna mina. A causa de la avaricia tan desmedida que tuvo, este tonto aldeano malogró la fortuna que tenía.` }
];
const audioEngine = {
ctx: null,
init() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
playClick() {
if (!app.config.sound) return;
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain); gain.connect(this.ctx.destination);
osc.type = 'sine'; osc.frequency.setValueAtTime(600, this.ctx.currentTime);
gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.1);
osc.start(); osc.stop(this.ctx.currentTime + 0.1);
},
playSuccess() {
if (!app.config.sound) return;
this.init();
const notes = [261.63, 329.63, 392.00, 523.25]; // C4, E4, G4, C5
notes.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.connect(gain); gain.connect(this.ctx.destination);
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, this.ctx.currentTime + i * 0.15);
gain.gain.setValueAtTime(0, this.ctx.currentTime + i * 0.15);
gain.gain.linearRampToValueAtTime(0.2, this.ctx.currentTime + i * 0.15 + 0.05);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + i * 0.15 + 0.3);
osc.start(this.ctx.currentTime + i * 0.15);
osc.stop(this.ctx.currentTime + i * 0.15 + 0.3);
});
}
};
document.addEventListener('click', (e) => {
if (e.target.closest('button')) audioEngine.playClick();
});
const animationEngine = {
canvas: null,
ctx: null,
particles: [],
animationId: null,
init() {
this.canvas = document.getElementById('confetti-canvas');
this.ctx = this.canvas.getContext('2d');
this.resize();
window.addEventListener('resize', () => this.resize());
},
resize() {
if(this.canvas) {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
},
start() {
if (!app.config.anim || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
this.particles = [];
for (let i = 0; i < 150; i++) {
this.particles.push({
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height - this.canvas.height,
w: Math.random() * 10 + 5,
h: Math.random() * 10 + 5,
dx: Math.random() * 4 - 2,
dy: Math.random() * 5 + 2,
color: `hsl(${Math.random() * 360}, 100%, 50%)`,
rot: Math.random() * 360,
dRot: Math.random() * 10 - 5
});
}
this.loop();
},
loop() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
let active = false;
this.particles.forEach(p => {
p.x += p.dx; p.y += p.dy; p.rot += p.dRot;
if (p.y < this.canvas.height) active = true;
this.ctx.save();
this.ctx.translate(p.x, p.y);
this.ctx.rotate(p.rot * Math.PI / 180);
this.ctx.fillStyle = p.color;
this.ctx.fillRect(-p.w/2, -p.h/2, p.w, p.h);
this.ctx.restore();
});
if (active) this.animationId = requestAnimationFrame(() => this.loop());
},
stop() {
if (this.animationId) cancelAnimationFrame(this.animationId);
if (this.ctx) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
};
/* =========================================
MOTOR DE LECTURA UNIFICADO
========================================= */
const readingEngine = {
tokens: [],
currentIndex: 0,
timer: null,
container: null,
isActive: false,
mode: 'progresiva', // progresiva | condicionada
init(text, containerId, mode) {
this.container = document.getElementById(containerId);
this.container.innerHTML = '';
this.currentIndex = 0;
this.isActive = true;
this.mode = mode;
// Tokenizar
const regex = /(\S+)([\s]*)/g;
this.tokens = [];
let match;
while ((match = regex.exec(text)) !== null) {
this.tokens.push({ word: match[1], space: match[2] });
}
this.updateButtons(false);
if (this.mode === 'condicionada') {
// Renderizar completo y luego ocultar progresivamente
this.tokens.forEach((token, index) => {
const span = document.createElement('span');
span.id = `token-${index}`;
span.textContent = token.word + token.space;
span.style.color = app.config.color;
if (app.config.anim && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
span.style.transition = 'color 0.2s';
}
this.container.appendChild(span);
});
setTimeout(() => {
if(this.isActive) this.playNext();
}, 2000); // 2 segundos de pausa inicial
} else {
// Modo progresivo, comienza vacío y va apareciendo
this.playNext();
}
},
playNext() {
if (!this.isActive) return;
if (this.currentIndex >= this.tokens.length) {
this.finish();
return;
}
const token = this.tokens[this.currentIndex];
if (this.mode === 'condicionada') {
// Ocultar palabra volviéndola transparente
const span = document.getElementById(`token-${this.currentIndex}`);
if (span) span.style.color = 'transparent';
} else {
// Mostrar palabra progresivamente
const span = document.createElement('span');
span.textContent = token.word + token.space;
if(app.config.anim && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
span.style.opacity = 0;
span.style.transition = 'opacity 0.2s';
}
this.container.appendChild(span);
if(app.config.anim) {
void span.offsetWidth; // forzar reflow
span.style.opacity = 1;
}
// Auto scroll en progresiva
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
}
// Cálculo de tiempo base
let delay = 60000 / app.config.wpm;
const lastChar = token.word.slice(-1);
// Respeto orgánico de la puntuación
if (['.', ':', ';', '!', '?'].includes(lastChar)) {
delay *= 2;
} else if (lastChar === ',') {
delay *= 1.5;
}
this.currentIndex++;
this.timer = setTimeout(() => this.playNext(), delay);
},
pause() {
if (this.timer) clearTimeout(this.timer);
this.isActive = false;
this.updateButtons(true);
},
resume() {
if (this.isActive) return;
this.isActive = true;
this.updateButtons(false);
this.playNext();
},
restart() {
this.pause();
const textId = app.config.textId;
const textObj = app.texts.find(t => t.id === textId);
if (textObj) {
this.init(textObj.contenido, 'reading-container', app.config.mode);
}
},
exit() {
this.pause();
app.showView('view-config');
},
finish() {
this.isActive = false;
this.updateButtons(true);
document.getElementById('btn-resume').classList.add('hidden');
document.getElementById('btn-pause').classList.add('hidden');
setTimeout(() => {
if (app.currentView !== 'view-reading') return;
app.showCompletion();
}, 7000); // 7 segundos de visualización final
},
updateButtons(isPaused) {
const btnPause = document.getElementById('btn-pause');
const btnResume = document.getElementById('btn-resume');
if (isPaused) {
btnPause.classList.add('hidden');
btnResume.classList.remove('hidden');
} else {
btnPause.classList.remove('hidden');
btnResume.classList.add('hidden');
}
}
};
/* =========================================
LÓGICA PRINCIPAL DE LA APLICACIÓN
========================================= */
const app = {
texts: [],
config: {
mode: 'progresiva', // Valor por defecto
textId: 'texto1',
font: "'OpenDyslexic', 'Comic Sans MS', sans-serif",
size: 32,
wpm: 120,
bg: '#FFFFFF',
color: '#000000',
anim: true,
sound: true
},
currentView: 'view-home',
readingEngine: readingEngine,
mensajesMotivadores: [
"¡Fantástico! Has terminado.",
"¡Muy bien! Sigue así.",
"¡Excelente trabajo!",
"¡Lo has conseguido!",
"¡Cada vez lees mejor!",
"¡Enhorabuena, gran lector!",
"¡Misión cumplida!",
"¡Has hecho un trabajo estupendo!",
"¡Sigue disfrutando de la lectura!",
"¡Genial! Otro reto superado."
],
init() {
this.loadData();
animationEngine.init();
this.renderTextSelect();
this.updateUIFromConfig();
document.getElementById('view-editor').addEventListener('click', (e) => {
if (e.target.id === 'view-editor') this.closeEditor();
});
},
showView(viewId) {
document.querySelectorAll('.view:not(#view-editor)').forEach(v => v.classList.remove('active'));
document.getElementById(viewId).classList.add('active');
this.currentView = viewId;
window.scrollTo(0, 0);
if (viewId === 'view-reading') {
this.applyColorsToReading();
} else {
this.resetBodyColors();
}
},
loadData() {
// Usamos nueva clave única para la app unificada
const savedTexts = localStorage.getItem('lecturaInteractivaTexts');
if (savedTexts) {
this.texts = JSON.parse(savedTexts);
} else {
this.restoreOriginalTexts(false);
}
const savedConfig = localStorage.getItem('lecturaInteractivaConfig');
if (savedConfig) {
this.config = { ...this.config, ...JSON.parse(savedConfig) };
if (!this.texts.find(t => t.id === this.config.textId)) {
this.config.textId = this.texts[0].id;
}
}
},
saveData() {
localStorage.setItem('lecturaInteractivaTexts', JSON.stringify(this.texts));
},
saveConfig() {
this.config.mode = document.getElementById('mode-select').value;
this.config.font = document.getElementById('font-select').value;
this.config.size = document.getElementById('size-slider').value;
this.config.wpm = document.getElementById('wpm-slider').value;
this.config.bg = document.getElementById('bg-color').value;
this.config.color = document.getElementById('text-color').value;
this.config.textId = document.getElementById('text-select').value;
this.config.anim = document.getElementById('anim-toggle').checked;
this.config.sound = document.getElementById('sound-toggle').checked;
localStorage.setItem('lecturaInteractivaConfig', JSON.stringify(this.config));
},
updateUIFromConfig() {
document.getElementById('mode-select').value = this.config.mode || 'progresiva';
document.getElementById('font-select').value = this.config.font;
document.getElementById('size-slider').value = this.config.size;
document.getElementById('wpm-slider').value = this.config.wpm;
document.getElementById('bg-color').value = this.config.bg;
document.getElementById('text-color').value = this.config.color;
document.getElementById('text-select').value = this.config.textId;
document.getElementById('anim-toggle').checked = this.config.anim;
document.getElementById('sound-toggle').checked = this.config.sound;
this.updateWpmDisplay();
this.updatePreview();
},
updateWpmDisplay() {
const wpm = document.getElementById('wpm-slider').value;
document.getElementById('wpm-display').textContent = wpm;
this.saveConfig();
},
updatePreview() {
this.saveConfig();
const preview = document.getElementById('preview-box');
document.getElementById('size-display').textContent = this.config.size;
preview.style.fontFamily = this.config.font;
preview.style.fontSize = this.config.size + 'px';
preview.style.backgroundColor = this.config.bg;
preview.style.color = this.config.color;
preview.textContent = "Así se verá tu texto mientras lees.";
this.checkContrast(this.config.bg, this.config.color);
},
setColor(type, hex) {
if (type === 'bg') document.getElementById('bg-color').value = hex;
if (type === 'text') document.getElementById('text-color').value = hex;
this.updatePreview();
},
resetColors() {
this.setColor('bg', '#FFFFFF');
this.setColor('text', '#000000');
},
getLuminance(r, g, b) {
const a = [r, g, b].map(v => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
},
hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null;
},
checkContrast(hexBg, hexText) {
const rgbBg = this.hexToRgb(hexBg);
const rgbText = this.hexToRgb(hexText);
if (!rgbBg || !rgbText) return;
const l1 = this.getLuminance(rgbBg.r, rgbBg.g, rgbBg.b);
const l2 = this.getLuminance(rgbText.r, rgbText.g, rgbText.b);
const lightest = Math.max(l1, l2);
const darkest = Math.min(l1, l2);
const ratio = (lightest + 0.05) / (darkest + 0.05);
const warning = document.getElementById('contrast-warning');
if (ratio < 4.5) warning.style.display = 'block';
else warning.style.display = 'none';
},
renderTextSelect() {
const select = document.getElementById('text-select');
select.innerHTML = '';
this.texts.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.titulo;
select.appendChild(opt);
});
select.value = this.config.textId;
},
renderTextList() {
const list = document.getElementById('text-list');
const search = document.getElementById('search-text').value.toLowerCase();
const sort = document.getElementById('sort-text').value;
list.innerHTML = '';
let filtered = this.texts.filter(t => t.titulo.toLowerCase().includes(search));
filtered.sort((a, b) => {
if (sort === 'az') return a.titulo.localeCompare(b.titulo);
if (sort === 'za') return b.titulo.localeCompare(a.titulo);
if (sort === 'original') return (b.original ? 1 : 0) - (a.original ? 1 : 0);
if (sort === 'added') return (a.original ? 1 : 0) - (b.original ? 1 : 0);
if (sort === 'newest') return b.timestamp - a.timestamp;
if (sort === 'oldest') return a.timestamp - b.timestamp;
return 0;
});
filtered.forEach(t => {
const item = document.createElement('div');
item.className = 'text-item';
item.innerHTML = `
<div class="text-item-title">${t.titulo} ${t.original ? '<span style="font-size:0.8em;color:#888;">(Original)</span>' : ''}</div>
<div class="text-actions">
<button class="btn-success" style="padding: 8px 12px;" onclick="app.selectTextFromManager('${t.id}')">Seleccionar</button>
<button class="btn-outline" style="padding: 8px 12px;" onclick="app.openEditor('${t.id}')">✏ Editar</button>
<button class="btn-danger" style="padding: 8px 12px;" onclick="app.deleteText('${t.id}')">🗑 Eliminar</button>
</div>
`;
list.appendChild(item);
});
},
selectTextFromManager(id) {
this.config.textId = id;
this.saveConfig();
this.updateUIFromConfig();
this.showView('view-config');
},
openEditor(id = null) {
const view = document.getElementById('view-editor');
const title = document.getElementById('editor-title');
const idField = document.getElementById('editor-id');
const titleField = document.getElementById('editor-titulo');
const contentField = document.getElementById('editor-contenido');
if (id) {
const t = this.texts.find(x => x.id === id);
title.textContent = "Editar texto";
idField.value = t.id;
titleField.value = t.titulo;
contentField.value = t.contenido;
} else {
title.textContent = "Añadir nuevo texto";
idField.value = "";
titleField.value = "";
contentField.value = "";
}
view.classList.add('active');
},
closeEditor() {
document.getElementById('view-editor').classList.remove('active');
},
saveText() {
const idField = document.getElementById('editor-id').value;
const title = document.getElementById('editor-titulo').value.trim();
const content = document.getElementById('editor-contenido').value.trim();
if (!title || !content) {
alert("El título y el contenido son obligatorios.");
return;
}
let newId = idField;
if (idField) {
const t = this.texts.find(x => x.id === idField);
t.titulo = title;
t.contenido = content;
t.timestamp = Date.now();
} else {
newId = 'custom_' + Date.now();
this.texts.push({
id: newId,
titulo: title,
contenido: content,
original: false,
timestamp: Date.now()
});
}
// Autoseleccionar el texto guardado
this.config.textId = newId;
this.saveData();
this.renderTextSelect();
this.saveConfig();
this.updateUIFromConfig();
this.renderTextList();
this.closeEditor();
},
deleteText(id) {
if (confirm('¿Seguro que quieres eliminar este texto?')) {
this.texts = this.texts.filter(t => t.id !== id);
if (this.config.textId === id) {
this.config.textId = this.texts[0] ? this.texts[0].id : '';
this.saveConfig();
this.updateUIFromConfig();
}
this.saveData();
this.renderTextSelect();
this.renderTextList();
}
},
restoreOriginalTextsConfirm() {
if(confirm("¿Seguro que quieres restaurar los textos originales? Esto eliminará todos los textos añadidos y los cambios realizados en los originales.")) {
this.restoreOriginalTexts(true);
}
},
restoreOriginalTexts(render = true) {
this.texts = TEXTOS_ORIGINALES.map(t => ({
...t,
original: true,
timestamp: Date.now()
}));
this.saveData();
if (render) {
this.renderTextSelect();
this.renderTextList();
}
},
async importFile(event) {
const file = event.target.files[0];
if (!file) return;
const extension = file.name.split('.').pop().toLowerCase();
let textContent = "";
try {
if (extension === 'txt') {
textContent = await file.text();
} else if (extension === 'docx') {
if (window.mammoth) {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({arrayBuffer: arrayBuffer});
textContent = result.value;
} else {
throw new Error("Librería de DOCX no cargada.");
}
} else if (extension === 'pdf') {
if (window['pdfjs-dist/build/pdf']) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await window['pdfjs-dist/build/pdf'].getDocument({data: arrayBuffer}).promise;
let fullText = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContentObj = await page.getTextContent();
let pageText = "";
let lastY = null;
textContentObj.items.forEach(item => {
if (lastY !== null && Math.abs(item.transform[5] - lastY) > 2) {
pageText += '\n';
}
pageText += item.str;
lastY = item.transform[5];
});
fullText += pageText + "\n";
}
textContent = fullText;
} else {
throw new Error("Librería de PDF no cargada.");
}
} else if (extension === 'odt') {
const bufferText = await file.text();
textContent = bufferText.replace(/[^\x20-\x7E\n\ráéíóúÁÉÍÓÚñÑüÜ¿¡]/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
alert(`Aviso: Has importado un archivo .odt mediante extracción básica (sin conexión).`);
} else {
alert("Formato no soportado.");
return;
}
if (!textContent.trim()) {
alert("No se pudo extraer texto del archivo o el archivo está vacío.");
return;
}
const allLines = textContent.split(/\r?\n/);
const nonEmptyLines = allLines.filter(line => line.trim() !== '');
const title = nonEmptyLines.length > 0 ? nonEmptyLines[0].trim() : file.name;
let content = textContent;
if (nonEmptyLines.length > 0) {
const firstLineIndex = allLines.findIndex(line => line.trim() !== '');
content = allLines.slice(firstLineIndex + 1).join('\n').trim() || textContent;
}
const newId = 'custom_' + Date.now();
this.texts.push({
id: newId,
titulo: title,
contenido: content,
original: false,
timestamp: Date.now()
});
// Auto-seleccionar el texto importado
this.config.textId = newId;
this.saveData();
this.renderTextSelect();
this.saveConfig();
this.updateUIFromConfig();
this.renderTextList();
alert("Texto importado correctamente.");
} catch (error) {
alert("Error al leer el archivo: " + error.message);
}
event.target.value = '';
},
startReading() {
const textId = this.config.textId;
const textObj = this.texts.find(t => t.id === textId);
if (!textObj) {
alert("Por favor, selecciona un texto primero.");
return;
}
this.showView('view-reading');
const container = document.getElementById('reading-container');
container.style.fontFamily = this.config.font;
container.style.fontSize = this.config.size + 'px';
// Pasar el modo seleccionado al motor unificado
this.readingEngine.init(textObj.contenido, 'reading-container', this.config.mode);
},
applyColorsToReading() {
document.body.style.backgroundColor = this.config.bg;
document.body.style.color = this.config.color;
},
resetBodyColors() {
document.body.style.backgroundColor = 'var(--bg-color)';
document.body.style.color = 'var(--text-color)';
},
showCompletion() {
this.showView('view-completion');
const msgEl = document.getElementById('motivational-msg');
const randomMsg = this.mensajesMotivadores[Math.floor(Math.random() * this.mensajesMotivadores.length)];
msgEl.textContent = randomMsg;
audioEngine.playSuccess();
animationEngine.start();
setTimeout(() => {
animationEngine.stop();
this.showView('view-config');
}, 4000);
}
};
// Autoejecución al cargar el DOM
document.addEventListener('DOMContentLoaded', () => {
app.init();
});
</script>
</body>
</html>