feat: added group section
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(echo \"exit: $?\")"
|
"Bash(echo \"exit: $?\")",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(command -v python3)",
|
||||||
|
"Bash(pip3 show *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -32,7 +32,11 @@
|
|||||||
<div class="flex flex-col md:flex-row gap-4">
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
|
||||||
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4">
|
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4">
|
||||||
<h2 class="text-lg font-semibold mb-2">Secciones</h2>
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h2 class="text-lg font-semibold">Secciones</h2>
|
||||||
|
<button id="toggle-collapse-all" type="button" class="btn btn-xs btn-outline"
|
||||||
|
data-collapse-all-label="Colapsar todo" data-expand-all-label="Expandir todo">Colapsar todo</button>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
<button id="add-title-section" type="button" class="btn btn-sm"
|
<button id="add-title-section" type="button" class="btn btn-sm"
|
||||||
data-type-label="Título" data-placeholder="Título...">+ Título</button>
|
data-type-label="Título" data-placeholder="Título...">+ Título</button>
|
||||||
@@ -44,10 +48,13 @@
|
|||||||
data-type-label="Imagen">+ Imagen</button>
|
data-type-label="Imagen">+ Imagen</button>
|
||||||
<button id="add-dnd-section" type="button" class="btn btn-sm"
|
<button id="add-dnd-section" type="button" class="btn btn-sm"
|
||||||
data-type-label="Patrón" data-elements-label="Elementos">+ Patrón</button>
|
data-type-label="Patrón" data-elements-label="Elementos">+ Patrón</button>
|
||||||
|
<button id="add-group-section" type="button" class="btn btn-sm"
|
||||||
|
data-type-label="Grupo">+ Grupo</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="panel-top-sections" class="flex flex-col gap-4"
|
<div id="panel-top-sections" class="flex flex-col gap-4"
|
||||||
data-drag-title="Arrastrar para reordenar"
|
data-drag-title="Arrastrar para reordenar"
|
||||||
data-collapse-title="Colapsar / expandir"
|
data-collapse-title="Colapsar / expandir"
|
||||||
|
data-duplicate-title="Duplicar sección"
|
||||||
data-remove-title="Eliminar sección"></div>
|
data-remove-title="Eliminar sección"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -64,8 +71,8 @@
|
|||||||
<!-- Fuente de datos de los elementos arrastrables: se mantiene oculta y
|
<!-- Fuente de datos de los elementos arrastrables: se mantiene oculta y
|
||||||
se clona (haciéndola visible) dentro de cada sección de arrastrar y soltar. -->
|
se clona (haciéndola visible) dentro de cada sección de arrastrar y soltar. -->
|
||||||
<ul id="draggable-items-source" class="menu bg-base-100 w-full hidden">
|
<ul id="draggable-items-source" class="menu bg-base-100 w-full hidden">
|
||||||
<li class="my-2 breadcrumb cursor-pointer" id="1"> PB</li>
|
<li class="my-2 breadcrumb cursor-pointer select-none" id="1"> PB</li>
|
||||||
<li class="my-2 breadcrumb cursor-pointer" id="2">Disminución</li>
|
<li class="my-2 breadcrumb cursor-pointer select-none" id="2">Disminución</li>
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Referencias a los elementos fijos del documento.
|
// Referencias a los elementos fijos del documento.
|
||||||
const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
|
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 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 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í
|
// "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 draggedSection = null;
|
||||||
|
let draggedSectionContainer = null;
|
||||||
|
let draggedSectionHitArea = null;
|
||||||
|
|
||||||
// Igual que draggedSection, pero para reordenar un elemento ya soltado
|
// Igual que draggedSection, pero para reordenar un elemento ya soltado
|
||||||
// (un "grupo": cantidad + punto + botón eliminar) dentro de su zona de
|
// (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 };
|
return { x: touch.clientX, y: touch.clientY };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dado un punto Y del cursor, devuelve la sección justo debajo de la que
|
// Dado un contenedor de secciones y un punto Y del cursor, devuelve la
|
||||||
// hay que insertar la sección arrastrada (o null si va al final).
|
// sección justo debajo de la que hay que insertar la sección arrastrada
|
||||||
function getSectionAfterY(y) {
|
// (o null si va al final). Sirve tanto para #panel-top-sections como para
|
||||||
const sections = Array.from(sectionsContainer.querySelectorAll(':scope > .top-section:not(.dragging)'));
|
// 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) => {
|
return sections.reduce((closest, child) => {
|
||||||
const box = child.getBoundingClientRect();
|
const box = child.getBoundingClientRect();
|
||||||
const offset = y - box.top - box.height / 2;
|
const offset = y - box.top - box.height / 2;
|
||||||
@@ -42,24 +49,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { offset: -Infinity, element: null }).element;
|
}, { offset: -Infinity, element: null }).element;
|
||||||
}
|
}
|
||||||
|
|
||||||
sectionsContainer.addEventListener('dragover', (e) => {
|
// Da de alta el reordenado por arrastre para un contenedor de secciones
|
||||||
if (!draggedSection) return; // No es un arrastre de reordenación, ignorar.
|
// (el de nivel superior, o el de un grupo). `hitArea` es donde se
|
||||||
e.preventDefault();
|
// escuchan dragover/drop: el contenedor encoge al tamaño exacto de sus
|
||||||
e.dataTransfer.dropEffect = 'move';
|
// 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.
|
// Mueve en vivo la sección arrastrada a su nueva posición según el cursor.
|
||||||
const afterElement = getSectionAfterY(e.clientY);
|
const afterElement = getSectionAfterY(container, e.clientY);
|
||||||
if (afterElement == null) {
|
moveIfNeeded(container, draggedSection, afterElement);
|
||||||
sectionsContainer.appendChild(draggedSection);
|
});
|
||||||
} else if (afterElement !== draggedSection) {
|
|
||||||
sectionsContainer.insertBefore(draggedSection, afterElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sectionsContainer.addEventListener('drop', (e) => {
|
hitArea.addEventListener('drop', (e) => {
|
||||||
if (!draggedSection) return;
|
if (draggedSectionContainer !== container) return;
|
||||||
e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador.
|
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
|
// Igual que getSectionAfterY(), pero para una zona de drop con
|
||||||
// flex-wrap: los elementos pueden estar en distintas filas, así que no
|
// 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;
|
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
|
// 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.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.
|
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');
|
const countInput = document.createElement('input');
|
||||||
countInput.type = 'number';
|
countInput.type = 'number';
|
||||||
countInput.min = '1';
|
countInput.min = '1';
|
||||||
@@ -186,12 +219,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('text/plain', 'item');
|
e.dataTransfer.setData('text/plain', 'item');
|
||||||
group.classList.add('dragging');
|
group.classList.add('dragging');
|
||||||
dropCanvas.classList.add('drop-target-active');
|
highlightTarget.classList.add('drop-target-active');
|
||||||
});
|
});
|
||||||
|
|
||||||
clone.addEventListener('dragend', () => {
|
clone.addEventListener('dragend', () => {
|
||||||
group.classList.remove('dragging');
|
group.classList.remove('dragging');
|
||||||
dropCanvas.classList.remove('drop-target-active');
|
highlightTarget.classList.remove('drop-target-active');
|
||||||
draggedItem = null;
|
draggedItem = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,24 +234,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
draggedItem = group;
|
draggedItem = group;
|
||||||
group.classList.add('dragging');
|
group.classList.add('dragging');
|
||||||
dropCanvas.classList.add('drop-target-active');
|
highlightTarget.classList.add('drop-target-active');
|
||||||
|
|
||||||
const onTouchMove = (moveEvent) => {
|
const onTouchMove = (moveEvent) => {
|
||||||
moveEvent.preventDefault();
|
moveEvent.preventDefault();
|
||||||
const point = getTouchPoint(moveEvent);
|
const point = getTouchPoint(moveEvent);
|
||||||
const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y);
|
const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y);
|
||||||
if (afterElement == null) {
|
moveIfNeeded(dropCanvas, draggedItem, afterElement);
|
||||||
dropCanvas.appendChild(draggedItem);
|
|
||||||
} else if (afterElement !== draggedItem) {
|
|
||||||
dropCanvas.insertBefore(draggedItem, afterElement);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchEnd = () => {
|
const onTouchEnd = () => {
|
||||||
document.removeEventListener('touchmove', onTouchMove);
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
document.removeEventListener('touchend', onTouchEnd);
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
group.classList.remove('dragging');
|
group.classList.remove('dragging');
|
||||||
dropCanvas.classList.remove('drop-target-active');
|
highlightTarget.classList.remove('drop-target-active');
|
||||||
draggedItem = null;
|
draggedItem = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,8 +265,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Convierte cualquier contenedor en una zona donde soltar elementos
|
// Convierte cualquier contenedor en una zona donde soltar elementos
|
||||||
// arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el
|
// arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el
|
||||||
// contenido del canvas (al soltar un elemento o cambiar la cantidad).
|
// contenido del canvas (al soltar un elemento o cambiar la cantidad).
|
||||||
function setupDropZone(dropCanvas, onChange) {
|
// `hitArea` (por defecto el propio dropCanvas) es donde se escuchan
|
||||||
dropCanvas.addEventListener('dragover', (e) => {
|
// 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();
|
e.preventDefault();
|
||||||
|
|
||||||
if (draggedItem) {
|
if (draggedItem) {
|
||||||
@@ -245,18 +280,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// que ya estaba en este canvas.
|
// que ya estaba en este canvas.
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
const afterElement = getItemAfterPoint(dropCanvas, e.clientX, e.clientY);
|
const afterElement = getItemAfterPoint(dropCanvas, e.clientX, e.clientY);
|
||||||
if (afterElement == null) {
|
moveIfNeeded(dropCanvas, draggedItem, afterElement);
|
||||||
dropCanvas.appendChild(draggedItem);
|
|
||||||
} else if (afterElement !== draggedItem) {
|
|
||||||
dropCanvas.insertBefore(draggedItem, afterElement);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
});
|
});
|
||||||
|
|
||||||
dropCanvas.addEventListener('drop', (e) => {
|
hitArea.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedItem) return; // El reordenado ya se hizo en el dragover.
|
if (draggedItem) return; // El reordenado ya se hizo en el dragover.
|
||||||
|
|
||||||
@@ -293,136 +324,163 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return parts.filter(Boolean).join(', ');
|
return parts.filter(Boolean).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Añade una línea de texto al resultado (si no está vacía).
|
// Añade una línea de texto a `target` (si no está vacía).
|
||||||
function appendOutputLine(tag, text, className) {
|
function appendOutputLine(target, tag, text, className) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const el = document.createElement(tag);
|
const el = document.createElement(tag);
|
||||||
el.className = className;
|
el.className = className;
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
outputText.appendChild(el);
|
target.appendChild(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recorre todas las secciones (en su orden actual) y reconstruye
|
// Aporta a `target` lo que corresponda según el tipo de `section`: título,
|
||||||
// #panel-output desde cero: cada sección aporta una línea/elemento según
|
// subtítulo, texto, drag&drop o imagen. Si `section` es un "Grupo", se
|
||||||
// su tipo (título, texto, drag&drop o imagen).
|
// 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() {
|
function renderOutput() {
|
||||||
outputText.innerHTML = '';
|
outputText.innerHTML = '';
|
||||||
|
|
||||||
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
|
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
|
||||||
// resultado vacío.
|
// resultado vacío.
|
||||||
if (sectionsContainer.children.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.from(sectionsContainer.children).forEach(section => {
|
Array.from(sectionsContainer.children).forEach(section => renderOutputSection(section, outputText));
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// El resultado se regenera con cualquier cambio dentro de las secciones:
|
// El resultado se regenera con cualquier cambio dentro de las secciones:
|
||||||
// escribir texto/título ('input'), elegir una imagen ('change'), o
|
// escribir texto/título ('input'), elegir una imagen ('change'), o
|
||||||
// añadir/quitar/reordenar secciones o elementos soltados (MutationObserver).
|
// 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('input', renderOutput);
|
||||||
sectionsContainer.addEventListener('change', renderOutput);
|
sectionsContainer.addEventListener('change', renderOutput);
|
||||||
new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true });
|
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
|
// 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
|
// 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.
|
// cuerpo donde cada función addXxxSection() mete su contenido específico.
|
||||||
// Devuelve { section, body }: `section` se añade a #panel-top-sections,
|
// Devuelve { section, body }: quien llama decide en qué contenedor meter
|
||||||
// `body` es donde hay que appendear el contenido propio de cada tipo.
|
// `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) {
|
function createSectionShell(typeLabel) {
|
||||||
const section = document.createElement('div');
|
const section = document.createElement('div');
|
||||||
section.className = 'top-section border border-base-300 rounded-box p-4 flex flex-col gap-2';
|
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');
|
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');
|
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.textContent = '⠿';
|
||||||
dragHandle.title = sectionsContainer.dataset.dragTitle;
|
|
||||||
dragHandle.setAttribute('draggable', 'true');
|
|
||||||
|
|
||||||
dragHandle.addEventListener('dragstart', (e) => {
|
header.addEventListener('dragstart', (e) => {
|
||||||
draggedSection = section;
|
draggedSection = section;
|
||||||
|
draggedSectionContainer = section.parentElement;
|
||||||
|
draggedSectionHitArea = draggedSectionContainer.closest('.top-section') || panelTop;
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores.
|
e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores.
|
||||||
section.classList.add('dragging');
|
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');
|
section.classList.remove('dragging');
|
||||||
sectionsContainer.classList.remove('drop-target-active');
|
draggedSectionHitArea.classList.remove('drop-target-active');
|
||||||
draggedSection = null;
|
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
|
// mientras el dedo se mueve, se reutiliza getSectionAfterY() para ir
|
||||||
// desplazando la sección en vivo dentro de #panel-top-sections.
|
// desplazando la sección en vivo dentro de su contenedor actual.
|
||||||
dragHandle.addEventListener('touchstart', (e) => {
|
header.addEventListener('touchstart', (e) => {
|
||||||
|
if (e.target.closest('button')) return; // No interferir con "colapsar" ni "eliminar".
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
draggedSection = section;
|
draggedSection = section;
|
||||||
|
draggedSectionContainer = section.parentElement;
|
||||||
|
draggedSectionHitArea = draggedSectionContainer.closest('.top-section') || panelTop;
|
||||||
section.classList.add('dragging');
|
section.classList.add('dragging');
|
||||||
sectionsContainer.classList.add('drop-target-active');
|
draggedSectionHitArea.classList.add('drop-target-active');
|
||||||
|
|
||||||
const onTouchMove = (moveEvent) => {
|
const onTouchMove = (moveEvent) => {
|
||||||
moveEvent.preventDefault();
|
moveEvent.preventDefault();
|
||||||
const { y } = getTouchPoint(moveEvent);
|
const { y } = getTouchPoint(moveEvent);
|
||||||
const afterElement = getSectionAfterY(y);
|
const afterElement = getSectionAfterY(draggedSectionContainer, y);
|
||||||
if (afterElement == null) {
|
moveIfNeeded(draggedSectionContainer, draggedSection, afterElement);
|
||||||
sectionsContainer.appendChild(draggedSection);
|
|
||||||
} else if (afterElement !== draggedSection) {
|
|
||||||
sectionsContainer.insertBefore(draggedSection, afterElement);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchEnd = () => {
|
const onTouchEnd = () => {
|
||||||
document.removeEventListener('touchmove', onTouchMove);
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
document.removeEventListener('touchend', onTouchEnd);
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
section.classList.remove('dragging');
|
section.classList.remove('dragging');
|
||||||
sectionsContainer.classList.remove('drop-target-active');
|
draggedSectionHitArea.classList.remove('drop-target-active');
|
||||||
draggedSection = null;
|
draggedSection = null;
|
||||||
|
draggedSectionContainer = null;
|
||||||
|
draggedSectionHitArea = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
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.
|
// muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio.
|
||||||
const collapseBtn = document.createElement('button');
|
const collapseBtn = document.createElement('button');
|
||||||
collapseBtn.type = 'button';
|
collapseBtn.type = 'button';
|
||||||
collapseBtn.className = 'btn btn-xs btn-ghost';
|
collapseBtn.className = 'collapse-btn btn btn-xs btn-ghost';
|
||||||
collapseBtn.textContent = '▾';
|
collapseBtn.textContent = '▾';
|
||||||
collapseBtn.title = sectionsContainer.dataset.collapseTitle;
|
collapseBtn.title = sectionsContainer.dataset.collapseTitle;
|
||||||
|
|
||||||
// Etiqueta que indica de qué tipo es la sección (Título, Texto, Imagen...).
|
// Etiqueta que indica de qué tipo es la sección (Título, Texto, Imagen...).
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'badge badge-sm badge-outline';
|
badge.className = 'badge badge-sm badge-outline select-none';
|
||||||
badge.textContent = typeLabel;
|
badge.textContent = typeLabel;
|
||||||
|
|
||||||
const spacer = document.createElement('div');
|
const spacer = document.createElement('div');
|
||||||
spacer.className = 'flex-1';
|
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');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.type = 'button';
|
removeBtn.type = 'button';
|
||||||
removeBtn.className = 'btn btn-xs btn-circle';
|
removeBtn.className = 'btn btn-xs btn-circle';
|
||||||
@@ -456,10 +526,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
header.appendChild(collapseBtn);
|
header.appendChild(collapseBtn);
|
||||||
header.appendChild(badge);
|
header.appendChild(badge);
|
||||||
header.appendChild(spacer);
|
header.appendChild(spacer);
|
||||||
|
header.appendChild(duplicateBtn);
|
||||||
header.appendChild(removeBtn);
|
header.appendChild(removeBtn);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'flex flex-col gap-2';
|
body.className = 'section-body flex flex-col gap-2';
|
||||||
|
|
||||||
collapseBtn.addEventListener('click', () => {
|
collapseBtn.addEventListener('click', () => {
|
||||||
body.classList.toggle('hidden');
|
body.classList.toggle('hidden');
|
||||||
@@ -471,10 +542,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return { section, body };
|
return { section, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sección de título: un input de una línea que se muestra como <h3> en el resultado.
|
// Sección de título: un input de una línea que se muestra como <h3> en el
|
||||||
// `data` es el dataset del botón "+ Título" (data-type-label, data-placeholder).
|
// resultado. `data` es el dataset del botón "+ Título" (data-type-label,
|
||||||
// `text`, si se pasa, precarga el valor (se usa al restaurar un patrón guardado).
|
// data-placeholder). `text`, si se pasa, precarga el valor (se usa al
|
||||||
function addTitleSection(data, text = '') {
|
// 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 { section, body } = createSectionShell(data.typeLabel);
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -484,12 +557,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
input.value = text;
|
input.value = text;
|
||||||
|
|
||||||
body.appendChild(input);
|
body.appendChild(input);
|
||||||
sectionsContainer.appendChild(section);
|
container.appendChild(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sección de subtítulo: igual que la de título pero se muestra como <h4>
|
// Sección de subtítulo: igual que la de título pero se muestra como <h4>
|
||||||
// (más pequeño) en el resultado, para marcar un encabezado secundario.
|
// (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 { section, body } = createSectionShell(data.typeLabel);
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -499,11 +572,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
input.value = text;
|
input.value = text;
|
||||||
|
|
||||||
body.appendChild(input);
|
body.appendChild(input);
|
||||||
sectionsContainer.appendChild(section);
|
container.appendChild(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sección de texto libre.
|
// Sección de texto libre.
|
||||||
function addTextSection(data, text = '') {
|
function addTextSection(data, text = '', container = sectionsContainer) {
|
||||||
const { section, body } = createSectionShell(data.typeLabel);
|
const { section, body } = createSectionShell(data.typeLabel);
|
||||||
|
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
@@ -512,7 +585,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
|
|
||||||
body.appendChild(textarea);
|
body.appendChild(textarea);
|
||||||
sectionsContainer.appendChild(section);
|
container.appendChild(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sección de imagen: selector de archivo + vista previa (tamaño limitado
|
// 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).
|
// `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
|
// 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.
|
// 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 { section, body } = createSectionShell(data.typeLabel);
|
||||||
|
|
||||||
const fileInput = document.createElement('input');
|
const fileInput = document.createElement('input');
|
||||||
@@ -550,14 +623,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
body.appendChild(fileInput);
|
body.appendChild(fileInput);
|
||||||
body.appendChild(img);
|
body.appendChild(img);
|
||||||
sectionsContainer.appendChild(section);
|
container.appendChild(section);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sección de "arrastrar y soltar": una zona de drop propia junto a su
|
// Sección de "arrastrar y soltar": una zona de drop propia junto a su
|
||||||
// propia lista de elementos arrastrables (clonada de la fuente), para
|
// propia lista de elementos arrastrables (clonada de la fuente), para
|
||||||
// que cada sección de este tipo sea independiente de las demás.
|
// que cada sección de este tipo sea independiente de las demás.
|
||||||
// `items`, si se pasa, precarga los elementos ya soltados: [{ label, count }].
|
// `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 { section, body } = createSectionShell(data.typeLabel);
|
||||||
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
@@ -584,12 +657,54 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
row.appendChild(dndCanvas);
|
row.appendChild(dndCanvas);
|
||||||
row.appendChild(elementsPanel);
|
row.appendChild(elementsPanel);
|
||||||
body.appendChild(row);
|
body.appendChild(row);
|
||||||
sectionsContainer.appendChild(section);
|
container.appendChild(section);
|
||||||
setupDropZone(dndCanvas);
|
setupDropZone(dndCanvas, undefined, section);
|
||||||
|
|
||||||
items.forEach(item => addDndItem(dndCanvas, item.label, item.count));
|
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
|
// 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
|
// (etiqueta de tipo, placeholders...) se leen de sus data-* en el HTML, no
|
||||||
// están escritos aquí, para que la plantilla Django pueda traducirlos.
|
// 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 addTextBtn = document.getElementById('add-text-section');
|
||||||
const addImageBtn = document.getElementById('add-image-section');
|
const addImageBtn = document.getElementById('add-image-section');
|
||||||
const addDndBtn = document.getElementById('add-dnd-section');
|
const addDndBtn = document.getElementById('add-dnd-section');
|
||||||
|
const addGroupBtn = document.getElementById('add-group-section');
|
||||||
|
|
||||||
addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset));
|
addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset));
|
||||||
addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset));
|
addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset));
|
||||||
addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset));
|
addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset));
|
||||||
addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset));
|
addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset));
|
||||||
addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.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
|
// 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 tabSections = document.getElementById('tab-sections');
|
||||||
const tabOutput = document.getElementById('tab-output');
|
const tabOutput = document.getElementById('tab-output');
|
||||||
const panelTop = document.getElementById('panel-top');
|
|
||||||
const panelOutput = document.getElementById('panel-output');
|
const panelOutput = document.getElementById('panel-output');
|
||||||
|
|
||||||
function showTab(tab) {
|
function showTab(tab) {
|
||||||
@@ -646,53 +798,89 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Guardar / cargar las secciones (serializarlas a datos planos)
|
// Guardar / cargar las secciones (serializarlas a datos planos)
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
// Recorre #panel-top-sections y lo convierte en un array de objetos planos
|
// Convierte una sección en un objeto plano según su tipo. Si `section` es
|
||||||
// (uno por sección), listo para guardarse como JSON en cualquier sitio
|
// un "Grupo", se serializa recursivamente cada una de sus propias
|
||||||
// (localStorage, o enviado a un backend Django).
|
// secciones dentro de `children`. El chequeo de grupo va primero por la
|
||||||
function serializeSections() {
|
// misma razón que en renderOutputSection(): evita que un `querySelector`
|
||||||
return Array.from(sectionsContainer.children).map(section => {
|
// más genérico encuentre por error un control anidado de una sección hija.
|
||||||
const titleInput = section.querySelector('.title-input');
|
function serializeSection(section) {
|
||||||
if (titleInput) return { type: 'title', text: titleInput.value };
|
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');
|
const titleInput = section.querySelector('.title-input');
|
||||||
if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value };
|
if (titleInput) return { type: 'title', text: titleInput.value };
|
||||||
|
|
||||||
const textarea = section.querySelector('textarea');
|
const subtitleInput = section.querySelector('.subtitle-input');
|
||||||
if (textarea) return { type: 'text', text: textarea.value };
|
if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value };
|
||||||
|
|
||||||
const dndCanvas = section.querySelector('.dnd-canvas');
|
const textarea = section.querySelector('textarea');
|
||||||
if (dndCanvas) {
|
if (textarea) return { type: 'text', text: textarea.value };
|
||||||
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 img = section.querySelector('img');
|
const dndCanvas = section.querySelector('.dnd-canvas');
|
||||||
if (img) {
|
if (dndCanvas) {
|
||||||
const dataUrl = !img.classList.contains('hidden') ? img.src : '';
|
const items = Array.from(dndCanvas.children).map(group => {
|
||||||
return { type: 'image', dataUrl };
|
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;
|
const img = section.querySelector('img');
|
||||||
}).filter(Boolean);
|
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
|
// Recorre #panel-top-sections y lo convierte en un array de objetos planos
|
||||||
// serializeSections(), sustituyendo las secciones actuales.
|
// (uno por sección, anidados si son grupos), listo para guardarse como
|
||||||
function buildSectionsFrom(sectionsData) {
|
// JSON en cualquier sitio (localStorage, o enviado a un backend Django).
|
||||||
sectionsContainer.innerHTML = '';
|
function serializeSections() {
|
||||||
|
return Array.from(sectionsContainer.children).map(serializeSection).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
sectionsData.forEach(sectionData => {
|
// Añade a `container` la sección que describe `sectionData` (el mismo
|
||||||
if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text);
|
// formato que genera serializeSection()), delegando en la función
|
||||||
if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text);
|
// addXxxSection que corresponda según su tipo. La sección siempre se
|
||||||
if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text);
|
// añade al final de `container`; quien necesite colocarla en otra
|
||||||
if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl);
|
// posición (ver duplicateSection()) debe moverla después.
|
||||||
if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items);
|
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.
|
// Ejemplo de uso: guardar/cargar todas las secciones en localStorage.
|
||||||
|
|||||||
Reference in New Issue
Block a user