document.addEventListener('DOMContentLoaded', () => {
// Referencias a los elementos fijos del documento.
const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
const panelTop = document.getElementById('panel-top'); // Todo el panel "Secciones" (incluye los botones "+ tipo"), usado como zona de detección al reordenar.
const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
const draggableItemsSource = document.getElementById('draggable-items-source'); // Lista oculta con los elementos disponibles para arrastrar: fuente de datos única, se clona (visible) en cada sección de tipo "arrastrar y soltar".
const languageSelect = document.getElementById('language-select'); // Idioma del contenido (no de la interfaz): controla qué versión de los textos de título/subtítulo/texto se muestra y edita.
const panelOutput = document.getElementById('panel-output');
// Controles de "Personalización de página": configuración única para todo
// el documento (no una sección más), ver el bloque más abajo.
const pageTitleInput = document.getElementById('page-title-input');
const pageAuthorInput = document.getElementById('page-author-input');
const pageBgColorInput = document.getElementById('page-bg-color');
const pageFontSelect = document.getElementById('page-font-select');
const pageSizeSelect = document.getElementById('page-size-select');
const pageOrientationSelect = document.getElementById('page-orientation-select');
const pageSizeStyle = document.getElementById('page-size-style');
// Idioma actualmente seleccionado para el contenido. La estructura de
// secciones (cuáles hay, su orden, su tipo) es la misma para todos los
// idiomas; lo único que cambia es qué texto se muestra en cada campo de
// título/subtítulo/texto libre.
let currentLanguage = languageSelect.value;
// ---------------------------------------------------------------------
// Multi-idioma del contenido: título, subtítulo y texto libre guardan un
// texto por idioma en vez de uno solo, para poder editarlos y asociarlos
// al idioma seleccionado sin perder lo ya escrito en los demás.
// ---------------------------------------------------------------------
// Cada input/textarea traducible guarda su propio mapa { idioma: texto }
// serializado en el propio elemento (data-translations), así viaja solo
// con guardar/cargar la sección (ver serializeSection/addTitleSection...),
// sin necesidad de un almacén aparte que haya que mantener sincronizado.
function getTranslations(el) {
try {
return JSON.parse(el.dataset.translations || '{}');
} catch {
return {};
}
}
// Actualiza, dentro del mapa ya guardado en el elemento, el texto del
// idioma indicado (por defecto el actual), sin tocar el resto de idiomas.
function setTranslation(el, text, lang = currentLanguage) {
const translations = getTranslations(el);
translations[lang] = text;
el.dataset.translations = JSON.stringify(translations);
}
// ---------------------------------------------------------------------
// Reordenar secciones (arrastrando toda la cabecera de cada una)
// ---------------------------------------------------------------------
// Sección que se está arrastrando actualmente para reordenar, junto con
// el contenedor al que pertenece (el nivel superior, o el de un grupo) y
// la zona ampliada que se resalta mientras dura el arrastre. Se usan como
// "memoria" compartida entre los listeners en lugar de dataTransfer, así
// no interfieren con el drag & drop de los elementos arrastrables.
let draggedSection = null;
let draggedSectionContainer = null;
let draggedSectionHitArea = null;
// Igual que draggedSection, pero para reordenar un elemento ya soltado
// (un "grupo": cantidad + punto + botón eliminar) dentro de su zona de
// drop. Ver getItemAfterPoint() y setupDropZone() más abajo.
let draggedItem = null;
// La API nativa de Drag and Drop (HTML5) no está implementada en los
// navegadores móviles: no se disparan dragstart/dragover/drop con el
// dedo. Por eso todo el drag & drop de la página (reordenar secciones y
// soltar elementos) también se reimplementa con eventos touch, usando
// este punto como coordenadas comunes.
function getTouchPoint(e) {
const touch = e.touches[0] || e.changedTouches[0];
return { x: touch.clientX, y: touch.clientY };
}
// Dado un contenedor de secciones y un punto Y del cursor, devuelve la
// sección justo debajo de la que hay que insertar la sección arrastrada
// (o null si va al final). Sirve tanto para #panel-top-sections como para
// el contenedor interno de cualquier sección de tipo "Grupo".
function getSectionAfterY(container, y) {
const sections = Array.from(container.querySelectorAll(':scope > .top-section:not(.dragging)'));
return sections.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
}
return closest;
}, { offset: -Infinity, element: null }).element;
}
// Da de alta el reordenado por arrastre para un contenedor de secciones
// (el de nivel superior, o el de un grupo). `hitArea` es donde se
// escuchan dragover/drop: el contenedor encoge al tamaño exacto de sus
// secciones, así que soltar justo por encima de la primera o por debajo
// de la última quedaría fuera de su zona; por eso se usa como hitArea su
// envoltorio con padding (#panel-top, o la sección "Grupo" completa).
function setupSectionsReorder(container, hitArea) {
hitArea.addEventListener('dragover', (e) => {
if (draggedSectionContainer !== container) return; // El arrastre no pertenece a este contenedor (o no hay ninguno).
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Mueve en vivo la sección arrastrada a su nueva posición según el cursor.
const afterElement = getSectionAfterY(container, e.clientY);
moveIfNeeded(container, draggedSection, afterElement);
});
hitArea.addEventListener('drop', (e) => {
if (draggedSectionContainer !== container) return;
e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador.
});
}
// Igual que getSectionAfterY(), pero para una zona de drop con
// flex-wrap: los elementos pueden estar en distintas filas, así que no
// basta con comparar la coordenada Y. Se busca el elemento más cercano al
// punto (por distancia recta a su centro) y se decide si el punto cae
// antes o después según su posición horizontal respecto a ese centro.
function getItemAfterPoint(container, x, y) {
const items = Array.from(container.children).filter(child => !child.classList.contains('dragging'));
if (items.length === 0) return null;
let nearestChild = null;
let nearestCenterX = 0;
let nearestDistance = Infinity;
items.forEach(child => {
const box = child.getBoundingClientRect();
const centerX = box.left + box.width / 2;
const centerY = box.top + box.height / 2;
const distance = Math.hypot(x - centerX, y - centerY);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestChild = child;
nearestCenterX = centerX;
}
});
return x < nearestCenterX ? nearestChild : nearestChild.nextElementSibling;
}
// Mueve `node` a la posición indicada por `afterElement` (null = al
// final) solo si no está ya ahí. dragover/touchmove se disparan decenas
// de veces por segundo mientras se arrastra, y sin esta comprobación se
// repetía un insertBefore/appendChild en cada uno aunque la posición no
// cambiase: eso fuerza un reflow y una mutación del DOM de más en cada
// evento, lo que además retrasa el propio cálculo de posiciones del
// siguiente evento (los rects de los elementos vecinos se recalculan sin
// necesidad) y podía hacer que el reordenado se notase bloqueado o dando
// tirones cerca del límite entre dos elementos.
function moveIfNeeded(container, node, afterElement) {
if (afterElement == null) {
if (container.lastElementChild !== node) container.appendChild(node);
} else if (afterElement !== node && node.nextElementSibling !== afterElement) {
container.insertBefore(node, afterElement);
}
}
// ---------------------------------------------------------------------
// Drag & drop de elementos arrastrables hacia una zona de destino
// ---------------------------------------------------------------------
// Hace arrastrable un
de la lista de "Elementos" y mete su HTML en
// el dataTransfer para poder clonarlo al soltarlo. addDndItem() se define
// más abajo; se referencia aquí dentro del listener, no al declarar la
// función, así que el orden no importa.
function makeDraggable(item) {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/html', item.outerHTML);
});
// Equivalente táctil: crea un clon flotante que sigue al dedo y, al
// soltar, mira qué elemento hay debajo del punto final para saber si
// cayó dentro de una zona de drop.
item.addEventListener('touchstart', (e) => {
e.preventDefault();
const start = getTouchPoint(e);
const ghost = item.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${start.x}px`;
ghost.style.top = `${start.y}px`;
ghost.style.pointerEvents = 'none';
ghost.style.opacity = '0.9';
ghost.style.zIndex = '9999';
// Se agranda y se desplaza por encima del punto de contacto: el dedo
// tapa el elemento original, así que a tamaño normal el usuario no
// vería qué está arrastrando.
ghost.style.transform = 'translate(-50%, -300%) scale(1.8)';
ghost.style.transformOrigin = 'center';
document.body.appendChild(ghost);
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const point = getTouchPoint(moveEvent);
ghost.style.left = `${point.x}px`;
ghost.style.top = `${point.y}px`;
};
const onTouchEnd = (endEvent) => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
ghost.remove();
const point = getTouchPoint(endEvent);
const target = document.elementFromPoint(point.x, point.y);
const dropCanvas = target && target.closest('.dnd-canvas');
if (dropCanvas) addDndItem(dropCanvas, item.textContent.trim(), 1);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
}
// Añade a una zona de drop un grupo: cantidad (input numérico), el
// elemento (buscado en draggableItemsSource por su texto) y un botón para
// eliminarlo. Se usa tanto al soltar un elemento como al restaurar un
// patrón guardado previamente (ver buildSectionsFrom más abajo).
function addDndItem(dropCanvas, label, count) {
const sourceItem = Array.from(draggableItemsSource.children)
.find(li => li.textContent.trim() === label);
if (!sourceItem) return;
const clone = sourceItem.cloneNode(true);
clone.removeAttribute('id');
clone.classList.add('inline-block', 'cursor-move');
clone.setAttribute('draggable', 'true'); // Arrastrable, pero para reordenar dentro del canvas (ver más abajo), no para volver a soltarlo como si fuera nuevo.
// Resalta la misma zona ampliada que usa setupDropZone() (la sección
// completa), no solo dropCanvas, para que el resaltado visual coincida
// con dónde realmente se puede soltar.
const highlightTarget = dropCanvas.closest('.top-section') || dropCanvas;
const countInput = document.createElement('input');
countInput.type = 'number';
countInput.min = '1';
countInput.value = count;
countInput.className = 'input input-bordered input-xs w-16';
const group = document.createElement('div');
group.className = 'inline-flex items-center gap-1';
// Botón para poder quitar el elemento si el usuario se equivoca.
const removeItemBtn = document.createElement('button');
removeItemBtn.type = 'button';
removeItemBtn.className = 'btn btn-xs btn-circle';
removeItemBtn.textContent = '✕';
removeItemBtn.addEventListener('click', () => group.remove());
// Reordenar el elemento dentro de su zona de drop: se distingue de
// "soltar un elemento nuevo" porque ese arrastre parte de la lista de
// "Elementos", no de un punto ya colocado en el canvas.
clone.addEventListener('dragstart', (e) => {
draggedItem = group;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'item');
group.classList.add('dragging');
highlightTarget.classList.add('drop-target-active');
});
clone.addEventListener('dragend', () => {
group.classList.remove('dragging');
highlightTarget.classList.remove('drop-target-active');
draggedItem = null;
});
// Equivalente táctil: mientras el dedo se mueve por encima del canvas,
// reutiliza getItemAfterPoint() para ir desplazando el grupo en vivo.
clone.addEventListener('touchstart', (e) => {
e.preventDefault();
draggedItem = group;
group.classList.add('dragging');
highlightTarget.classList.add('drop-target-active');
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const point = getTouchPoint(moveEvent);
const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y);
moveIfNeeded(dropCanvas, draggedItem, afterElement);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
group.classList.remove('dragging');
highlightTarget.classList.remove('drop-target-active');
draggedItem = null;
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
group.appendChild(countInput);
group.appendChild(clone);
group.appendChild(removeItemBtn);
dropCanvas.appendChild(group);
}
// Convierte cualquier contenedor en una zona donde soltar elementos
// arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el
// contenido del canvas (al soltar un elemento o cambiar la cantidad).
// `hitArea` (por defecto el propio dropCanvas) es donde se escuchan
// dragover/drop. dropCanvas encoge al tamaño exacto de sus elementos, así
// que soltar justo por encima del primero o por debajo del último se
// quedaba fuera de su zona; se pasa la sección completa (con su padding)
// como hitArea para tener el mismo margen que ya se amplió al reordenar
// secciones.
function setupDropZone(dropCanvas, onChange, hitArea = dropCanvas) {
hitArea.addEventListener('dragover', (e) => {
e.preventDefault();
if (draggedItem) {
// No se está soltando un elemento nuevo, se está reordenando uno
// que ya estaba en este canvas.
e.dataTransfer.dropEffect = 'move';
const afterElement = getItemAfterPoint(dropCanvas, e.clientX, e.clientY);
moveIfNeeded(dropCanvas, draggedItem, afterElement);
return;
}
e.dataTransfer.dropEffect = 'copy';
});
hitArea.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedItem) return; // El reordenado ya se hizo en el dragover.
const html = e.dataTransfer.getData('text/html');
if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada).
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const clone = wrapper.firstElementChild;
if (!clone) return;
addDndItem(dropCanvas, clone.textContent.trim(), 1);
});
if (onChange) {
dropCanvas.addEventListener('input', onChange); // Cambios en la cantidad.
new MutationObserver(onChange).observe(dropCanvas, { childList: true }); // Añadir/quitar elementos.
}
}
// ---------------------------------------------------------------------
// Generación del resultado (#panel-output)
// ---------------------------------------------------------------------
// Convierte el contenido de una zona de drop en texto tipo
// "3 Elemento A, 1 Elemento B".
function getDndCanvasText(dndCanvas) {
const parts = Array.from(dndCanvas.children).map(group => {
const input = group.querySelector('input');
const label = group.querySelector('li');
if (!input || !label) return '';
return `${input.value} ${label.textContent}`;
});
return parts.filter(Boolean).join(', ');
}
// Añade una línea de texto a `target` (si no está vacía).
function appendOutputLine(target, tag, text, className) {
if (!text) return;
const el = document.createElement(tag);
el.className = className;
el.textContent = text;
target.appendChild(el);
}
// Aporta a `target` lo que corresponda según el tipo de `section`: título,
// subtítulo, texto, drag&drop o imagen. Si `section` es un "Grupo", se
// recorren sus propias secciones (recursivamente) dentro de un bloque
// aparte, con una guía visual a la izquierda. El chequeo de grupo va
// primero y siempre `return`: si no, un grupo que contenga p.ej. una
// sección de texto haría que el `querySelector('textarea')` de más abajo
// encontrase esa textarea anidada y tratase el grupo entero como texto.
function renderOutputSection(section, target) {
const groupContainer = section.querySelector('.group-container');
if (groupContainer) {
const wrapper = document.createElement('div');
wrapper.className = 'pl-4 border-l-2 border-base-300 my-2 flex flex-col gap-1';
Array.from(groupContainer.children).forEach(child => renderOutputSection(child, wrapper));
if (wrapper.children.length) target.appendChild(wrapper);
return;
}
const titleInput = section.querySelector('.title-input');
if (titleInput) {
appendOutputLine(target, 'h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1');
return;
}
const subtitleInput = section.querySelector('.subtitle-input');
if (subtitleInput) {
appendOutputLine(target, 'h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1');
return;
}
const textInput = section.querySelector('.text-input');
if (textInput) {
appendOutputLine(target, 'p', textInput.value.trim(), '');
return;
}
// Nota/consejo: 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, para que se
// distinga a simple vista de un texto libre.
const noteInput = section.querySelector('.note-input');
if (noteInput) {
const text = noteInput.value.trim();
if (text) {
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);
}
return;
}
// Materiales: una línea por elemento de la lista, en vez de un único párrafo.
const materialsList = section.querySelector('.materials-list');
if (materialsList) {
const items = Array.from(materialsList.querySelectorAll('.material-input'))
.map(input => input.value.trim())
.filter(Boolean);
if (items.length) {
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);
}
return;
}
const dndCanvas = section.querySelector('.dnd-canvas');
if (dndCanvas) {
appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), '');
return;
}
// Sección de imagen: si ya hay una imagen cargada, se copia (con el
// tamaño limitado) al resultado.
const previewImg = section.querySelector('img');
if (previewImg) {
if (previewImg.src && !previewImg.classList.contains('hidden')) {
const img = document.createElement('img');
img.src = previewImg.src;
img.className = 'max-w-full max-h-48 object-contain rounded-box my-2';
target.appendChild(img);
}
}
}
// Recorre todas las secciones de nivel superior (en su orden actual) y
// reconstruye #panel-output desde cero.
function renderOutput() {
outputText.innerHTML = '';
// Título y autor del patrón (ver "Personalización de página"): son
// configuración de todo el documento, no una sección más, así que se
// pintan siempre delante, haya o no secciones añadidas.
appendOutputLine(outputText, 'h1', pageTitleInput.value.trim(), 'text-2xl font-bold mb-1');
appendOutputLine(outputText, 'p', pageAuthorInput.value.trim(), 'text-sm text-base-content/70 mb-2');
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
// resultado vacío.
if (sectionsContainer.children.length === 0) {
appendOutputLine(outputText, 'p', outputText.dataset.emptyMessage, 'text-base-content/50 italic');
return;
}
Array.from(sectionsContainer.children).forEach(section => renderOutputSection(section, outputText));
}
// El resultado se regenera con cualquier cambio dentro de las secciones:
// escribir texto/título ('input'), elegir una imagen ('change'), o
// añadir/quitar/reordenar secciones o elementos soltados (MutationObserver).
// Al estar en #panel-top-sections y usar subtree:true, esto ya cubre
// también los cambios dentro de las secciones anidadas en un "Grupo".
// Si el campo es traducible (título/subtítulo/texto), cada pulsación de
// tecla también se guarda en su mapa de traducciones para el idioma
// actual, así no hace falta un paso aparte de "guardar" al cambiar de
// idioma o al serializar la sección.
const TRANSLATABLE_FIELDS_SELECTOR = '.title-input, .subtitle-input, .material-input, textarea'; // textarea cubre tanto "Texto" (.text-input) como "Nota" (.note-input).
sectionsContainer.addEventListener('input', (e) => {
if (e.target.matches(TRANSLATABLE_FIELDS_SELECTOR)) {
setTranslation(e.target, e.target.value);
}
renderOutput();
});
sectionsContainer.addEventListener('change', renderOutput);
new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true });
// Al cambiar el idioma del contenido, se sustituye en cada campo
// traducible (a cualquier nivel de anidamiento) el texto mostrado por el
// que tenga guardado para el nuevo idioma (vacío si aún no se ha escrito
// nada en ese idioma), y se repinta la vista previa.
languageSelect.addEventListener('change', () => {
currentLanguage = languageSelect.value;
sectionsContainer.querySelectorAll(TRANSLATABLE_FIELDS_SELECTOR).forEach(el => {
el.value = getTranslations(el)[currentLanguage] || '';
});
renderOutput();
});
// ---------------------------------------------------------------------
// Creación de secciones (título, texto, imagen, drag & drop, grupo)
// ---------------------------------------------------------------------
// Crea el "armazón" común a toda sección: el recuadro, la cabecera (asa
// para reordenar, tipo de sección, botón de colapsar y de eliminar) y el
// cuerpo donde cada función addXxxSection() mete su contenido específico.
// Devuelve { section, body }: quien llama decide en qué contenedor meter
// `section` (el nivel superior, o el de un grupo), y `body` es donde hay
// que appendear el contenido propio de cada tipo.
function createSectionShell(typeLabel) {
const section = document.createElement('div');
section.className = 'top-section border border-base-300 rounded-box p-4 flex flex-col gap-2';
// Toda la cabecera es la zona de arrastre para reordenar (no solo el
// icono "⠿"), así que draggable/dragstart/etc. van en `header`.
const header = document.createElement('div');
header.className = 'flex items-center gap-2 cursor-move';
header.title = sectionsContainer.dataset.dragTitle;
header.setAttribute('draggable', 'true');
const dragHandle = document.createElement('span');
dragHandle.className = 'select-none text-base-content/50 px-1';
dragHandle.textContent = '⠿';
header.addEventListener('dragstart', (e) => {
draggedSection = section;
draggedSectionContainer = section.parentElement;
draggedSectionHitArea = draggedSectionContainer.closest('.top-section') || panelTop;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores.
section.classList.add('dragging');
draggedSectionHitArea.classList.add('drop-target-active');
});
header.addEventListener('dragend', () => {
section.classList.remove('dragging');
draggedSectionHitArea.classList.remove('drop-target-active');
draggedSection = null;
draggedSectionContainer = null;
draggedSectionHitArea = null;
});
// Equivalente táctil del reordenado por dragover/drop de setupSectionsReorder():
// mientras el dedo se mueve, se reutiliza getSectionAfterY() para ir
// desplazando la sección en vivo dentro de su contenedor actual.
header.addEventListener('touchstart', (e) => {
if (e.target.closest('button')) return; // No interferir con "colapsar" ni "eliminar".
e.preventDefault();
draggedSection = section;
draggedSectionContainer = section.parentElement;
draggedSectionHitArea = draggedSectionContainer.closest('.top-section') || panelTop;
section.classList.add('dragging');
draggedSectionHitArea.classList.add('drop-target-active');
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const { y } = getTouchPoint(moveEvent);
const afterElement = getSectionAfterY(draggedSectionContainer, y);
moveIfNeeded(draggedSectionContainer, draggedSection, afterElement);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
section.classList.remove('dragging');
draggedSectionHitArea.classList.remove('drop-target-active');
draggedSection = null;
draggedSectionContainer = null;
draggedSectionHitArea = null;
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
// Botón para colapsar/expandir el cuerpo de la sección, útil cuando hay
// muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio.
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.className = 'collapse-btn btn btn-xs btn-ghost';
collapseBtn.textContent = '▾';
collapseBtn.title = sectionsContainer.dataset.collapseTitle;
// Etiqueta que indica de qué tipo es la sección (Título, Texto, Imagen...).
const badge = document.createElement('span');
badge.className = 'badge badge-sm badge-outline select-none';
badge.textContent = typeLabel;
const spacer = document.createElement('div');
spacer.className = 'flex-1';
// Botón para duplicar la sección: se apoya en serializeSection() /
// addSectionFromData() (definidas más abajo, pero disponibles aquí por
// el hoisting de las funciones declaradas con "function") para clonar su
// contenido, sea del tipo que sea (incluido un "Grupo" con todo lo que
// contenga dentro).
const duplicateBtn = document.createElement('button');
duplicateBtn.type = 'button';
duplicateBtn.className = 'btn btn-xs btn-circle';
duplicateBtn.textContent = '⧉';
duplicateBtn.title = sectionsContainer.dataset.duplicateTitle;
duplicateBtn.addEventListener('click', () => duplicateSection(section));
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-xs btn-circle';
removeBtn.textContent = '✕';
removeBtn.title = sectionsContainer.dataset.removeTitle;
removeBtn.addEventListener('click', () => section.remove());
header.appendChild(dragHandle);
header.appendChild(collapseBtn);
header.appendChild(badge);
header.appendChild(spacer);
header.appendChild(duplicateBtn);
header.appendChild(removeBtn);
const body = document.createElement('div');
body.className = 'section-body flex flex-col gap-2';
collapseBtn.addEventListener('click', () => {
body.classList.toggle('hidden');
collapseBtn.textContent = body.classList.contains('hidden') ? '▸' : '▾';
});
section.appendChild(header);
section.appendChild(body);
return { section, body };
}
// Sección de título: un input de una línea que se muestra como en el
// resultado. `data` es el dataset del botón "+ Título" (data-type-label,
// data-placeholder). `translations`, si se pasa, es el mapa { idioma:
// texto } completo (se usa al restaurar un patrón guardado o al
// duplicar una sección); se muestra el que corresponda al idioma actual.
// `container` es dónde se añade la sección: #panel-top-sections por
// defecto, o el de un grupo.
function addTitleSection(data, translations = {}, container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const input = document.createElement('input');
input.type = 'text';
input.className = 'title-input input input-bordered w-full';
input.placeholder = data.placeholder;
input.dataset.translations = JSON.stringify(translations);
input.value = translations[currentLanguage] || '';
body.appendChild(input);
container.appendChild(section);
}
// Sección de subtítulo: igual que la de título pero se muestra como
// (más pequeño) en el resultado, para marcar un encabezado secundario.
function addSubtitleSection(data, translations = {}, container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const input = document.createElement('input');
input.type = 'text';
input.className = 'subtitle-input input input-bordered w-full';
input.placeholder = data.placeholder;
input.dataset.translations = JSON.stringify(translations);
input.value = translations[currentLanguage] || '';
body.appendChild(input);
container.appendChild(section);
}
// Sección de texto libre. Se marca con la clase 'text-input' (además de
// 'textarea') para poder distinguirla de la de "Nota" en
// serializeSection()/renderOutputSection(): ambas son un