From 50c07e83ac26d386ac9d08e8b4f7feee55146c81 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Thu, 9 Jul 2026 10:13:54 +0200 Subject: [PATCH] feat: added group section --- .claude/settings.local.json | 5 +- index.html | 13 +- js/dnd.js | 514 ++++++++++++++++++++++++------------ 3 files changed, 365 insertions(+), 167 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 19959df..2ea4e50 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "Bash(echo \"exit: $?\")" + "Bash(echo \"exit: $?\")", + "Bash(python3 -c ' *)", + "Bash(command -v python3)", + "Bash(pip3 show *)" ] } } diff --git a/index.html b/index.html index 1dbca9b..29b776c 100644 --- a/index.html +++ b/index.html @@ -32,7 +32,11 @@
-

Secciones

+
+

Secciones

+ +
@@ -44,10 +48,13 @@ data-type-label="Imagen">+ Imagen +
@@ -64,8 +71,8 @@ diff --git a/js/dnd.js b/js/dnd.js index fc5e2da..e40e7b2 100644 --- a/js/dnd.js +++ b/js/dnd.js @@ -1,17 +1,22 @@ 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". // --------------------------------------------------------------------- - // Reordenar secciones (arrastrando el asa "⠿" de cada una) + // Reordenar secciones (arrastrando toda la cabecera de cada una) // --------------------------------------------------------------------- - // Sección que se está arrastrando actualmente para reordenar. Se usa como + // 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 interfiere con el drag & drop de los elementos arrastrables. + // 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 @@ -28,10 +33,12 @@ document.addEventListener('DOMContentLoaded', () => { return { x: touch.clientX, y: touch.clientY }; } - // Dado 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). - function getSectionAfterY(y) { - const sections = Array.from(sectionsContainer.querySelectorAll(':scope > .top-section:not(.dragging)')); + // 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; @@ -42,24 +49,28 @@ document.addEventListener('DOMContentLoaded', () => { }, { offset: -Infinity, element: null }).element; } - sectionsContainer.addEventListener('dragover', (e) => { - if (!draggedSection) return; // No es un arrastre de reordenación, ignorar. - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; + // 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(e.clientY); - if (afterElement == null) { - sectionsContainer.appendChild(draggedSection); - } else if (afterElement !== draggedSection) { - sectionsContainer.insertBefore(draggedSection, afterElement); - } - }); + // 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); + }); - sectionsContainer.addEventListener('drop', (e) => { - if (!draggedSection) return; - e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador. - }); + 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 @@ -89,6 +100,23 @@ document.addEventListener('DOMContentLoaded', () => { 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 // --------------------------------------------------------------------- @@ -162,6 +190,11 @@ document.addEventListener('DOMContentLoaded', () => { 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'; @@ -186,12 +219,12 @@ document.addEventListener('DOMContentLoaded', () => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'item'); group.classList.add('dragging'); - dropCanvas.classList.add('drop-target-active'); + highlightTarget.classList.add('drop-target-active'); }); clone.addEventListener('dragend', () => { group.classList.remove('dragging'); - dropCanvas.classList.remove('drop-target-active'); + highlightTarget.classList.remove('drop-target-active'); draggedItem = null; }); @@ -201,24 +234,20 @@ document.addEventListener('DOMContentLoaded', () => { e.preventDefault(); draggedItem = group; group.classList.add('dragging'); - dropCanvas.classList.add('drop-target-active'); + highlightTarget.classList.add('drop-target-active'); const onTouchMove = (moveEvent) => { moveEvent.preventDefault(); const point = getTouchPoint(moveEvent); const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y); - if (afterElement == null) { - dropCanvas.appendChild(draggedItem); - } else if (afterElement !== draggedItem) { - dropCanvas.insertBefore(draggedItem, afterElement); - } + moveIfNeeded(dropCanvas, draggedItem, afterElement); }; const onTouchEnd = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); group.classList.remove('dragging'); - dropCanvas.classList.remove('drop-target-active'); + highlightTarget.classList.remove('drop-target-active'); draggedItem = null; }; @@ -236,8 +265,14 @@ document.addEventListener('DOMContentLoaded', () => { // 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). - function setupDropZone(dropCanvas, onChange) { - dropCanvas.addEventListener('dragover', (e) => { + // `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) { @@ -245,18 +280,14 @@ document.addEventListener('DOMContentLoaded', () => { // que ya estaba en este canvas. e.dataTransfer.dropEffect = 'move'; const afterElement = getItemAfterPoint(dropCanvas, e.clientX, e.clientY); - if (afterElement == null) { - dropCanvas.appendChild(draggedItem); - } else if (afterElement !== draggedItem) { - dropCanvas.insertBefore(draggedItem, afterElement); - } + moveIfNeeded(dropCanvas, draggedItem, afterElement); return; } e.dataTransfer.dropEffect = 'copy'; }); - dropCanvas.addEventListener('drop', (e) => { + hitArea.addEventListener('drop', (e) => { e.preventDefault(); if (draggedItem) return; // El reordenado ya se hizo en el dragover. @@ -293,136 +324,163 @@ document.addEventListener('DOMContentLoaded', () => { return parts.filter(Boolean).join(', '); } - // Añade una línea de texto al resultado (si no está vacía). - function appendOutputLine(tag, text, className) { + // 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; - outputText.appendChild(el); + target.appendChild(el); } - // Recorre todas las secciones (en su orden actual) y reconstruye - // #panel-output desde cero: cada sección aporta una línea/elemento según - // su tipo (título, texto, drag&drop o imagen). + // 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 textarea = section.querySelector('textarea'); + if (textarea) { + appendOutputLine(target, 'p', textarea.value.trim(), ''); + 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 = ''; // 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('p', outputText.dataset.emptyMessage, 'text-base-content/50 italic'); + appendOutputLine(outputText, 'p', outputText.dataset.emptyMessage, 'text-base-content/50 italic'); return; } - Array.from(sectionsContainer.children).forEach(section => { - const titleInput = section.querySelector('.title-input'); - if (titleInput) { - appendOutputLine('h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1'); - return; - } - - const subtitleInput = section.querySelector('.subtitle-input'); - if (subtitleInput) { - appendOutputLine('h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1'); - return; - } - - const textarea = section.querySelector('textarea'); - if (textarea) { - appendOutputLine('p', textarea.value.trim(), ''); - return; - } - - const dndCanvas = section.querySelector('.dnd-canvas'); - if (dndCanvas) { - appendOutputLine('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'; - outputText.appendChild(img); - } - } - }); + 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". sectionsContainer.addEventListener('input', renderOutput); sectionsContainer.addEventListener('change', renderOutput); new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true }); // --------------------------------------------------------------------- - // Creación de secciones (título, texto, imagen, drag & drop) + // 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 }: `section` se añade a #panel-top-sections, - // `body` es donde hay que appendear el contenido propio de cada tipo. + // 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'; + 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 = 'cursor-move select-none text-base-content/50 px-1'; + dragHandle.className = 'select-none text-base-content/50 px-1'; dragHandle.textContent = '⠿'; - dragHandle.title = sectionsContainer.dataset.dragTitle; - dragHandle.setAttribute('draggable', 'true'); - dragHandle.addEventListener('dragstart', (e) => { + 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'); - sectionsContainer.classList.add('drop-target-active'); + draggedSectionHitArea.classList.add('drop-target-active'); }); - dragHandle.addEventListener('dragend', () => { + header.addEventListener('dragend', () => { section.classList.remove('dragging'); - sectionsContainer.classList.remove('drop-target-active'); + draggedSectionHitArea.classList.remove('drop-target-active'); draggedSection = null; + draggedSectionContainer = null; + draggedSectionHitArea = null; }); - // Equivalente táctil del reordenado por dragover/drop de más arriba: + // 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 #panel-top-sections. - dragHandle.addEventListener('touchstart', (e) => { + // 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'); - sectionsContainer.classList.add('drop-target-active'); + draggedSectionHitArea.classList.add('drop-target-active'); const onTouchMove = (moveEvent) => { moveEvent.preventDefault(); const { y } = getTouchPoint(moveEvent); - const afterElement = getSectionAfterY(y); - if (afterElement == null) { - sectionsContainer.appendChild(draggedSection); - } else if (afterElement !== draggedSection) { - sectionsContainer.insertBefore(draggedSection, afterElement); - } + const afterElement = getSectionAfterY(draggedSectionContainer, y); + moveIfNeeded(draggedSectionContainer, draggedSection, afterElement); }; const onTouchEnd = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); section.classList.remove('dragging'); - sectionsContainer.classList.remove('drop-target-active'); + draggedSectionHitArea.classList.remove('drop-target-active'); draggedSection = null; + draggedSectionContainer = null; + draggedSectionHitArea = null; }; document.addEventListener('touchmove', onTouchMove, { passive: false }); @@ -433,18 +491,30 @@ document.addEventListener('DOMContentLoaded', () => { // muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio. const collapseBtn = document.createElement('button'); collapseBtn.type = 'button'; - collapseBtn.className = 'btn btn-xs btn-ghost'; + 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'; + 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'; @@ -456,10 +526,11 @@ document.addEventListener('DOMContentLoaded', () => { header.appendChild(collapseBtn); header.appendChild(badge); header.appendChild(spacer); + header.appendChild(duplicateBtn); header.appendChild(removeBtn); const body = document.createElement('div'); - body.className = 'flex flex-col gap-2'; + body.className = 'section-body flex flex-col gap-2'; collapseBtn.addEventListener('click', () => { body.classList.toggle('hidden'); @@ -471,10 +542,12 @@ document.addEventListener('DOMContentLoaded', () => { 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). - // `text`, si se pasa, precarga el valor (se usa al restaurar un patrón guardado). - function addTitleSection(data, text = '') { + // 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). `text`, si se pasa, precarga el valor (se usa al + // restaurar un patrón guardado). `container` es dónde se añade la + // sección: #panel-top-sections por defecto, o el de un grupo. + function addTitleSection(data, text = '', container = sectionsContainer) { const { section, body } = createSectionShell(data.typeLabel); const input = document.createElement('input'); @@ -484,12 +557,12 @@ document.addEventListener('DOMContentLoaded', () => { input.value = text; body.appendChild(input); - sectionsContainer.appendChild(section); + 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, text = '') { + function addSubtitleSection(data, text = '', container = sectionsContainer) { const { section, body } = createSectionShell(data.typeLabel); const input = document.createElement('input'); @@ -499,11 +572,11 @@ document.addEventListener('DOMContentLoaded', () => { input.value = text; body.appendChild(input); - sectionsContainer.appendChild(section); + container.appendChild(section); } // Sección de texto libre. - function addTextSection(data, text = '') { + function addTextSection(data, text = '', container = sectionsContainer) { const { section, body } = createSectionShell(data.typeLabel); const textarea = document.createElement('textarea'); @@ -512,7 +585,7 @@ document.addEventListener('DOMContentLoaded', () => { textarea.value = text; body.appendChild(textarea); - sectionsContainer.appendChild(section); + container.appendChild(section); } // Sección de imagen: selector de archivo + vista previa (tamaño limitado @@ -520,7 +593,7 @@ document.addEventListener('DOMContentLoaded', () => { // `dataUrl`, si se pasa, precarga la imagen (al restaurar un patrón guardado). // Se guarda como data URL en base64 (no con URL.createObjectURL) porque un // blob: solo es válido durante la sesión actual y no se puede persistir. - function addImageSection(data, dataUrl = '') { + function addImageSection(data, dataUrl = '', container = sectionsContainer) { const { section, body } = createSectionShell(data.typeLabel); const fileInput = document.createElement('input'); @@ -550,14 +623,14 @@ document.addEventListener('DOMContentLoaded', () => { body.appendChild(fileInput); body.appendChild(img); - sectionsContainer.appendChild(section); + container.appendChild(section); } // Sección de "arrastrar y soltar": una zona de drop propia junto a su // propia lista de elementos arrastrables (clonada de la fuente), para // que cada sección de este tipo sea independiente de las demás. // `items`, si se pasa, precarga los elementos ya soltados: [{ label, count }]. - function addDndSection(data, items = []) { + function addDndSection(data, items = [], container = sectionsContainer) { const { section, body } = createSectionShell(data.typeLabel); const row = document.createElement('div'); @@ -584,12 +657,54 @@ document.addEventListener('DOMContentLoaded', () => { row.appendChild(dndCanvas); row.appendChild(elementsPanel); body.appendChild(row); - sectionsContainer.appendChild(section); - setupDropZone(dndCanvas); + container.appendChild(section); + setupDropZone(dndCanvas, undefined, section); items.forEach(item => addDndItem(dndCanvas, item.label, item.count)); } + // 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. Se apoya en las mismas funciones addXxxSection + // y en setupSectionsReorder(), pasándoles su contenedor interno en vez + // del de nivel superior. `children`, si se pasa, precarga su contenido + // (al restaurar un patrón guardado); permite grupos anidados sin más. + 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 renderOutputSection() + // y serializeSection() puedan reconocer que esta sección es un grupo. + const groupContainer = document.createElement('div'); + groupContainer.className = 'group-container flex flex-col gap-4 pl-4 border-l-2 border-base-300'; + + const toolbar = document.createElement('div'); + toolbar.className = 'flex flex-wrap gap-2 mb-4'; + + [ + [addTitleBtn, addTitleSection], + [addSubtitleBtn, addSubtitleSection], + [addTextBtn, addTextSection], + [addImageBtn, addImageSection], + [addDndBtn, addDndSection], + [addGroupBtn, addGroupSection], + ].forEach(([sourceBtn, addFn]) => { + const btn = sourceBtn.cloneNode(true); + btn.removeAttribute('id'); // Puede haber varios grupos, cada uno con su propia copia de estos botones. + btn.addEventListener('click', () => addFn(sourceBtn.dataset, undefined, groupContainer)); + toolbar.appendChild(btn); + }); + + body.appendChild(toolbar); + body.appendChild(groupContainer); + container.appendChild(section); + + setupSectionsReorder(groupContainer, section); + + buildSectionsFrom(children, groupContainer); + } + // 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. @@ -598,12 +713,50 @@ document.addEventListener('DOMContentLoaded', () => { const addTextBtn = document.getElementById('add-text-section'); const addImageBtn = document.getElementById('add-image-section'); const addDndBtn = document.getElementById('add-dnd-section'); + const addGroupBtn = document.getElementById('add-group-section'); addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset)); addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset)); addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset)); addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset)); addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset)); + addGroupBtn.addEventListener('click', () => addGroupSection(addGroupBtn.dataset)); + + // Reordenado por arrastre de las secciones de nivel superior (ver + // addGroupSection() para el de dentro de un grupo). + setupSectionsReorder(sectionsContainer, panelTop); + + // --------------------------------------------------------------------- + // Colapsar / expandir todas las secciones de una vez + // --------------------------------------------------------------------- + + // Colapsa o expande a la vez todas las secciones que haya, sin importar + // su nivel de anidamiento (querySelectorAll recorre también las que + // están dentro de un "Grupo"), sincronizando tanto su cuerpo como el + // icono de su propio botón de colapsar individual. + function setAllSectionsCollapsed(collapsed) { + sectionsContainer.querySelectorAll('.section-body').forEach(body => { + body.classList.toggle('hidden', collapsed); + }); + sectionsContainer.querySelectorAll('.collapse-btn').forEach(btn => { + btn.textContent = collapsed ? '▸' : '▾'; + }); + } + + const toggleCollapseAllBtn = document.getElementById('toggle-collapse-all'); + + toggleCollapseAllBtn.addEventListener('click', () => { + // Se decide según el estado actual (en vez de recordar uno propio) para + // que el botón siga teniendo sentido aunque el usuario haya colapsado o + // expandido secciones sueltas a mano: si queda alguna expandida, el + // siguiente clic las colapsa todas; si no queda ninguna, las expande. + const anyExpanded = Array.from(sectionsContainer.querySelectorAll('.section-body')) + .some(body => !body.classList.contains('hidden')); + setAllSectionsCollapsed(anyExpanded); + toggleCollapseAllBtn.textContent = anyExpanded + ? toggleCollapseAllBtn.dataset.expandAllLabel + : toggleCollapseAllBtn.dataset.collapseAllLabel; + }); // --------------------------------------------------------------------- // Pestañas (solo en móvil, ver css/main.css): alternar entre el panel de @@ -614,7 +767,6 @@ document.addEventListener('DOMContentLoaded', () => { const tabSections = document.getElementById('tab-sections'); const tabOutput = document.getElementById('tab-output'); - const panelTop = document.getElementById('panel-top'); const panelOutput = document.getElementById('panel-output'); function showTab(tab) { @@ -646,53 +798,89 @@ document.addEventListener('DOMContentLoaded', () => { // Guardar / cargar las secciones (serializarlas a datos planos) // --------------------------------------------------------------------- - // Recorre #panel-top-sections y lo convierte en un array de objetos planos - // (uno por sección), listo para guardarse como JSON en cualquier sitio - // (localStorage, o enviado a un backend Django). - function serializeSections() { - return Array.from(sectionsContainer.children).map(section => { - const titleInput = section.querySelector('.title-input'); - if (titleInput) return { type: 'title', text: titleInput.value }; + // Convierte una sección en un objeto plano según su tipo. Si `section` es + // un "Grupo", se serializa recursivamente cada una de sus propias + // secciones dentro de `children`. El chequeo de grupo va primero por la + // misma razón que en renderOutputSection(): evita que un `querySelector` + // más genérico encuentre por error un control anidado de una sección hija. + function serializeSection(section) { + const groupContainer = section.querySelector('.group-container'); + if (groupContainer) { + return { type: 'group', children: Array.from(groupContainer.children).map(serializeSection).filter(Boolean) }; + } - const subtitleInput = section.querySelector('.subtitle-input'); - if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value }; + const titleInput = section.querySelector('.title-input'); + if (titleInput) return { type: 'title', text: titleInput.value }; - const textarea = section.querySelector('textarea'); - if (textarea) return { type: 'text', text: textarea.value }; + const subtitleInput = section.querySelector('.subtitle-input'); + if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value }; - const dndCanvas = section.querySelector('.dnd-canvas'); - if (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 }; - } + const textarea = section.querySelector('textarea'); + if (textarea) return { type: 'text', text: textarea.value }; - const img = section.querySelector('img'); - if (img) { - const dataUrl = !img.classList.contains('hidden') ? img.src : ''; - return { type: 'image', dataUrl }; - } + const dndCanvas = section.querySelector('.dnd-canvas'); + if (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 }; + } - return null; - }).filter(Boolean); + const img = section.querySelector('img'); + if (img) { + const dataUrl = !img.classList.contains('hidden') ? img.src : ''; + return { type: 'image', dataUrl }; + } + + return null; } - // Reconstruye #panel-top-sections a partir del array que genera - // serializeSections(), sustituyendo las secciones actuales. - function buildSectionsFrom(sectionsData) { - sectionsContainer.innerHTML = ''; + // 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). + function serializeSections() { + return Array.from(sectionsContainer.children).map(serializeSection).filter(Boolean); + } - sectionsData.forEach(sectionData => { - if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text); - if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text); - if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text); - if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl); - if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items); - }); + // Añade a `container` la sección que describe `sectionData` (el mismo + // formato que genera serializeSection()), delegando en la función + // addXxxSection que corresponda según su tipo. La sección siempre se + // añade al final de `container`; quien necesite colocarla en otra + // posición (ver duplicateSection()) debe moverla después. + function addSectionFromData(sectionData, container) { + if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text, container); + if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text, container); + if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text, container); + if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl, container); + if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items, container); + if (sectionData.type === 'group') addGroupSection(addGroupBtn.dataset, sectionData.children, 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. + function buildSectionsFrom(sectionsData, container = sectionsContainer) { + container.innerHTML = ''; + sectionsData.forEach(sectionData => addSectionFromData(sectionData, container)); + } + + // Duplica `section`: la serializa (con serializeSection(), 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. + 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); } // Ejemplo de uso: guardar/cargar todas las secciones en localStorage.