// Registro de tipos de sección: une, para cada tipo, cómo crearlo // (create/button), cómo reconocerlo dentro de una sección ya creada // (matches), cómo convertirlo a datos planos (serialize) y cómo pintarlo // en la vista previa (render). Añadir un tipo de sección nuevo es añadir // una entrada a SECTION_TYPES; el resto de funciones de este módulo // (serializar, reconstruir, duplicar, pintar) son genéricas y no necesitan // tocarse. import { sectionsContainer, panelTop } from './dom-refs.js'; import { getTranslations } from './i18n.js'; import { getDndCanvasText } from './dnd-items.js'; import { setupSectionsReorder } from './drag-reorder.js'; import { createSectionShell, addTitleSection, addSubtitleSection, addTextSection, addNoteSection, addMaterialsSection, addImageSection, addDndSection, } from './sections.js'; // Añade una línea de texto a `target` (si no está vacía). export function appendOutputLine(target, tag, text, className) { if (!text) return; const el = document.createElement(tag); el.className = className; el.textContent = text; target.appendChild(el); } // Recorta un nombre derivado del contenido para que quepa en el badge de // la cabecera sin desbordarla. const SECTION_NAME_MAX_LENGTH = 40; function truncate(text) { return text.length > SECTION_NAME_MAX_LENGTH ? `${text.slice(0, SECTION_NAME_MAX_LENGTH - 1)}…` : text; } // Sección de "grupo": no tiene contenido propio, solo agrupa varias // secciones dentro de sí misma (con su propia barra de "+ tipo" y su propio // orden independiente), para organizar visualmente un conjunto de // secciones relacionadas. Vive aquí (no en sections.js) porque necesita el // registro completo (SECTION_TYPES) para poder crear cualquier tipo de // sección hija, incluida ella misma (permite grupos anidados sin más). // `children`, si se pasa, precarga su contenido (al restaurar un patrón // guardado). function addGroupSection(data, children = [], container = sectionsContainer) { const { section, body } = createSectionShell(data.typeLabel); section.classList.add('bg-base-200/30'); // Marcado con la clase 'group-container' para que el resto de este // módulo pueda reconocer que esta sección es un grupo. const groupContainer = document.createElement('div'); groupContainer.className = 'group-container flex flex-col gap-4'; const toolbar = document.createElement('div'); toolbar.className = 'flex flex-wrap gap-2 mb-4'; SECTION_TYPES.forEach(({ button, create }) => { const btn = button.cloneNode(true); btn.removeAttribute('id'); // Puede haber varios grupos, cada uno con su propia copia de estos botones. btn.addEventListener('click', () => create(undefined, groupContainer)); toolbar.appendChild(btn); }); body.appendChild(toolbar); body.appendChild(groupContainer); container.appendChild(section); setupSectionsReorder(groupContainer, section); buildSectionsFrom(children, groupContainer); } const addTitleBtn = document.getElementById('add-title-section'); const addSubtitleBtn = document.getElementById('add-subtitle-section'); const addTextBtn = document.getElementById('add-text-section'); const addNoteBtn = document.getElementById('add-note-section'); const addMaterialsBtn = document.getElementById('add-materials-section'); const addImageBtn = document.getElementById('add-image-section'); const addDndBtn = document.getElementById('add-dnd-section'); const addGroupBtn = document.getElementById('add-group-section'); // El orden de esta lista importa: 'group' va primero porque si no, un // grupo que contenga p.ej. una sección de texto haría que el `matches` de // "Texto" encontrase esa textarea anidada y tratase el grupo entero como // texto (ver renderOutputSection/serializeSection más abajo, que recorren // esta lista en orden y se quedan con el primer tipo que encaje). // // - button/create: qué botón "+ tipo" lo añade y cómo se crea, a partir de // datos guardados (`sectionData`) o en blanco si no se pasa nada. // - matches: cómo reconocerlo dentro de una sección ya creada; devuelve el // elemento relevante (o nada si la sección no es de este tipo). // - serialize/render: cómo convertirlo a datos planos y cómo pintarlo en // la vista previa, a partir de ese elemento. const SECTION_TYPES = [ { type: 'group', button: addGroupBtn, create: (sectionData, container) => addGroupSection(addGroupBtn.dataset, sectionData?.children, container), matches: (section) => section.querySelector('.group-container'), serialize: (section, groupContainer) => ({ type: 'group', children: Array.from(groupContainer.children).map(serializeSection).filter(Boolean), }), // Se recorren sus propias secciones (recursivamente) dentro de un // bloque aparte, con una guía visual a la izquierda. render: (section, groupContainer, target) => { const wrapper = document.createElement('div'); wrapper.className = 'my-2 flex flex-col gap-1'; Array.from(groupContainer.children).forEach(child => renderOutputSection(child, wrapper)); if (wrapper.children.length) target.appendChild(wrapper); }, // Si el grupo tiene, entre sus secciones directas, un título o un // subtítulo, se usa su texto como nombre (es lo más representativo de // "de qué trata" el grupo); si no, se cae al recuento de secciones. getName: (groupContainer) => { const heading = Array.from(groupContainer.children) .map(resolveSectionMatch) .find(found => found && (found.entry.type === 'title' || found.entry.type === 'subtitle')); if (heading) { const text = heading.match.value.trim(); if (text) return text; } const count = groupContainer.children.length; if (!count) return ''; return count === 1 ? '1 sección' : `${count} secciones`; }, }, { type: 'title', button: addTitleBtn, create: (sectionData, container) => addTitleSection(addTitleBtn.dataset, sectionData?.text, container), matches: (section) => section.querySelector('.title-input'), serialize: (section, titleInput) => ({ type: 'title', text: getTranslations(titleInput) }), render: (section, titleInput, target) => appendOutputLine(target, 'h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1'), getName: (titleInput) => titleInput.value.trim(), }, { type: 'subtitle', button: addSubtitleBtn, create: (sectionData, container) => addSubtitleSection(addSubtitleBtn.dataset, sectionData?.text, container), matches: (section) => section.querySelector('.subtitle-input'), serialize: (section, subtitleInput) => ({ type: 'subtitle', text: getTranslations(subtitleInput) }), render: (section, subtitleInput, target) => appendOutputLine(target, 'h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1'), getName: (subtitleInput) => subtitleInput.value.trim(), }, { type: 'text', button: addTextBtn, create: (sectionData, container) => addTextSection(addTextBtn.dataset, sectionData?.text, container), matches: (section) => section.querySelector('.text-input'), serialize: (section, textInput) => ({ type: 'text', text: getTranslations(textInput) }), render: (section, textInput, target) => appendOutputLine(target, 'p', textInput.value.trim(), ''), getName: (textInput) => textInput.value.trim(), }, { type: 'note', button: addNoteBtn, create: (sectionData, container) => addNoteSection( addNoteBtn.dataset, sectionData?.text, container, { text: sectionData?.textColor, bg: sectionData?.bgColor }, ), matches: (section) => section.querySelector('.note-input'), serialize: (section, noteInput) => { const textColorInput = section.querySelector('.note-text-color'); const bgColorInput = section.querySelector('.note-bg-color'); return { type: 'note', text: getTranslations(noteInput), textColor: textColorInput ? textColorInput.value : undefined, bgColor: bgColorInput ? bgColorInput.value : undefined, }; }, // Se destaca con un "alert" de daisyUI (para la forma: padding, bordes // redondeados...) pero con los colores de texto y fondo que haya // elegido el usuario, en vez de los del tema. render: (section, noteInput, target) => { const text = noteInput.value.trim(); if (!text) return; const note = document.createElement('div'); note.className = 'alert text-sm my-2'; const textColorInput = section.querySelector('.note-text-color'); const bgColorInput = section.querySelector('.note-bg-color'); if (textColorInput) note.style.color = textColorInput.value; if (bgColorInput) note.style.backgroundColor = bgColorInput.value; note.textContent = text; target.appendChild(note); }, getName: (noteInput) => noteInput.value.trim(), }, { type: 'materials', button: addMaterialsBtn, create: (sectionData, container) => addMaterialsSection(addMaterialsBtn.dataset, sectionData?.materials, container), matches: (section) => section.querySelector('.materials-list'), serialize: (section, materialsList) => ({ type: 'materials', materials: Array.from(materialsList.querySelectorAll('.material-input')).map(getTranslations), }), // Una línea por elemento de la lista, en vez de un único párrafo. render: (section, materialsList, target) => { const items = Array.from(materialsList.querySelectorAll('.material-input')) .map(input => input.value.trim()) .filter(Boolean); if (!items.length) return; const ul = document.createElement('ul'); ul.className = 'list-disc list-inside my-1'; items.forEach(text => { const li = document.createElement('li'); li.textContent = text; ul.appendChild(li); }); target.appendChild(ul); }, getName: (materialsList) => Array.from(materialsList.querySelectorAll('.material-input')) .map(input => input.value.trim()) .filter(Boolean) .join(', '), }, { type: 'dnd', button: addDndBtn, create: (sectionData, container) => addDndSection(addDndBtn.dataset, sectionData?.items, container), matches: (section) => section.querySelector('.dnd-canvas'), serialize: (section, dndCanvas) => { const items = Array.from(dndCanvas.children).map(group => { const input = group.querySelector('input'); const label = group.querySelector('li'); if (!input || !label) return null; return { label: label.textContent.trim(), count: Number(input.value) }; }).filter(Boolean); return { type: 'dnd', items }; }, render: (section, dndCanvas, target) => appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), ''), getName: (dndCanvas) => getDndCanvasText(dndCanvas), }, { type: 'image', button: addImageBtn, create: (sectionData, container) => addImageSection(addImageBtn.dataset, sectionData?.dataUrl, container), matches: (section) => section.querySelector('img'), serialize: (section, img) => ({ type: 'image', dataUrl: !img.classList.contains('hidden') ? img.src : '' }), // Si ya hay una imagen cargada, se copia (con el tamaño limitado) al resultado. render: (section, img, target) => { if (!img.src || img.classList.contains('hidden')) return; const clone = document.createElement('img'); clone.src = img.src; clone.className = 'max-w-full max-h-48 object-contain rounded-box my-2'; target.appendChild(clone); }, }, ]; // Encuentra, en orden, la primera entrada de SECTION_TYPES cuyo `matches` // encaje con `section`, y devuelve tanto la entrada como lo que encontró // `matches` (el elemento con el que trabajan serialize/render/getName). // Centraliza aquí ese recorrido porque se repite en varios sitios: al // pintar, al serializar, al nombrar el badge, e incluso dentro del propio // `getName` de "Grupo" (para saber si una de sus secciones directas es un // título o un subtítulo). function resolveSectionMatch(section) { for (const entry of SECTION_TYPES) { const match = entry.matches(section); if (match) return { entry, match }; } return null; } // Aporta a `target` lo que corresponda según el tipo de `section` (título, // subtítulo, texto, nota, materiales, patrón, imagen o grupo). export function renderOutputSection(section, target) { const found = resolveSectionMatch(section); if (found) found.entry.render(section, found.match, target); } // Nombre corto derivado del contenido de la sección (p.ej. el propio texto // de un título, o "3 PB, 2 Disminución" para un patrón), para que el badge // de la cabecera sea reconocible incluso con la sección colapsada. No todos // los tipos lo tienen (una imagen no tiene un nombre natural sin guardar // también el nombre de archivo); en ese caso se deja solo la etiqueta de // tipo. function getSectionName(section) { const found = resolveSectionMatch(section); return found?.entry.getName ? truncate(found.entry.getName(found.match)) : ''; } // Actualiza el badge de una sección con "Tipo: nombre" (o solo "Tipo" si // todavía no hay contenido). El tipo original se guarda en // data-type-label (ver createSectionShell en sections.js) porque el propio // textContent del badge se sobrescribe aquí. Solo se toca el DOM si el // texto cambia de verdad: asignar textContent (aunque sea al mismo valor) // borra y vuelve a crear el nodo de texto, lo que cuenta como mutación // para el MutationObserver de más abajo y disparaba un bucle infinito // (cada actualización generaba otra mutación que volvía a llamar a esta // misma función). function updateSectionBadge(section) { const badge = section.querySelector('.section-badge'); if (!badge) return; const name = getSectionName(section); const text = name ? `${badge.dataset.typeLabel}: ${name}` : badge.dataset.typeLabel; if (badge.textContent !== text) badge.textContent = text; } // Recorre todas las secciones (a cualquier nivel de anidamiento) y // refresca su badge. Se dispara con los mismos eventos que renderOutput() // en render.js, pero de forma independiente: cada módulo escucha los // mismos eventos compartidos en sectionsContainer para su propio cometido. function updateAllBadges() { sectionsContainer.querySelectorAll('.top-section').forEach(updateSectionBadge); } sectionsContainer.addEventListener('input', updateAllBadges); sectionsContainer.addEventListener('change', updateAllBadges); new MutationObserver(updateAllBadges).observe(sectionsContainer, { childList: true, subtree: true }); // Convierte una sección en un objeto plano según su tipo (recursivo si es // un "Grupo", ver su entrada en SECTION_TYPES). export function serializeSection(section) { const found = resolveSectionMatch(section); return found ? found.entry.serialize(section, found.match) : null; } // Recorre #panel-top-sections y lo convierte en un array de objetos planos // (uno por sección, anidados si son grupos), listo para guardarse como // JSON en cualquier sitio (localStorage, o enviado a un backend Django). export function serializeSections() { return Array.from(sectionsContainer.children).map(serializeSection).filter(Boolean); } // Añade a `container` la sección que describe `sectionData` (el mismo // formato que genera serializeSection()), delegando en el `create` del // tipo que corresponda. La sección siempre se añade al final de // `container`; quien necesite colocarla en otra posición (ver // duplicateSection()) debe moverla después. export function addSectionFromData(sectionData, container) { const entry = SECTION_TYPES.find(t => t.type === sectionData.type); if (entry) entry.create(sectionData, container); } // Reconstruye `container` (#panel-top-sections por defecto, o el de un // grupo) a partir del array que genera serializeSections(), sustituyendo // las secciones que ya tuviera. export function buildSectionsFrom(sectionsData, container = sectionsContainer) { container.innerHTML = ''; sectionsData.forEach(sectionData => addSectionFromData(sectionData, container)); } // Esqueleto de secciones con el que arranca la página (en el mismo formato // que genera serializeSections()), a modo de plantilla de partida en vez // de dejar el panel en blanco. Los grupos anidados salen "gratis": se // reconstruyen recorriendo `children` a través de buildSectionsFrom(), el // mismo mecanismo que restaura un patrón guardado. const DEFAULT_SECTIONS = [ { type: 'image', dataUrl: '' }, { type: 'title', text: {} }, { type: 'materials', materials: [] }, { type: 'group', children: [ { type: 'title', text: {} }, { type: 'group', children: [ { type: 'subtitle', text: {} }, { type: 'dnd', items: [] }, ], }, ], }, ]; // Se llama una vez al arrancar (ver main.js); si ya hay secciones (p.ej. // porque se ha cargado un patrón guardado antes de esto) no sustituye nada. export function applyDefaultSkeleton() { if (sectionsContainer.children.length === 0) buildSectionsFrom(DEFAULT_SECTIONS); } // Duplica `section`: la serializa (incluido su contenido si es un "Grupo") // y reconstruye una copia a partir de esos datos, insertándola justo // debajo de la original dentro del mismo contenedor (nivel superior, o el // de un grupo). addSectionFromData() siempre añade al final del // contenedor, así que la copia se reubica después con insertBefore. export function duplicateSection(section) { const data = serializeSection(section); if (!data) return; const container = section.parentElement; addSectionFromData(data, container); const clone = container.lastElementChild; container.insertBefore(clone, section.nextSibling); } // El botón "duplicar" de cada sección (ver createSectionShell en // sections.js) dispara este evento en vez de llamar aquí directamente, así // sections.js no necesita importar este módulo. sectionsContainer.addEventListener('section:duplicate', (e) => duplicateSection(e.target)); // Reordenado por arrastre de las secciones de nivel superior (ver // addGroupSection() para el de dentro de un grupo). setupSectionsReorder(sectionsContainer, panelTop); // Botones de la barra superior para añadir cada tipo de sección. Los textos // (etiqueta de tipo, placeholders...) se leen de sus data-* en el HTML, no // están escritos aquí, para que la plantilla Django pueda traducirlos. SECTION_TYPES.forEach(({ button, create }) => { button.addEventListener('click', () => create(undefined, undefined)); });