383 lines
17 KiB
JavaScript
383 lines
17 KiB
JavaScript
// 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));
|
|
}
|
|
|
|
// 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));
|
|
});
|