501 lines
21 KiB
JavaScript
501 lines
21 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
// Referencias a los elementos fijos del documento.
|
|
const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
|
|
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)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Sección que se está arrastrando actualmente para reordenar. Se usa como
|
|
// "memoria" compartida entre los listeners en lugar de dataTransfer, así
|
|
// no interfiere con el drag & drop de los elementos arrastrables.
|
|
let draggedSection = null;
|
|
|
|
// 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)'));
|
|
return sections.reduce((closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset, element: child };
|
|
}
|
|
return closest;
|
|
}, { offset: -Infinity, element: null }).element;
|
|
}
|
|
|
|
sectionsContainer.addEventListener('dragover', (e) => {
|
|
if (!draggedSection) return; // No es un arrastre de reordenación, ignorar.
|
|
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);
|
|
}
|
|
});
|
|
|
|
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.
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Drag & drop de elementos arrastrables hacia una zona de destino
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en
|
|
// el dataTransfer para poder clonarlo al soltarlo.
|
|
function makeDraggable(item) {
|
|
item.setAttribute('draggable', 'true');
|
|
item.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
e.dataTransfer.setData('text/html', item.outerHTML);
|
|
});
|
|
}
|
|
|
|
// Añade a una zona de drop un grupo: cantidad (input numérico), el
|
|
// elemento (buscado en draggableItemsSource por su texto) y un botón para
|
|
// eliminarlo. Se usa tanto al soltar un elemento como al restaurar un
|
|
// patrón guardado previamente (ver buildSectionsFrom más abajo).
|
|
function addDndItem(dropCanvas, label, count) {
|
|
const sourceItem = Array.from(draggableItemsSource.children)
|
|
.find(li => li.textContent.trim() === label);
|
|
if (!sourceItem) return;
|
|
|
|
const clone = sourceItem.cloneNode(true);
|
|
clone.removeAttribute('draggable');
|
|
clone.removeAttribute('id');
|
|
clone.classList.add('inline-block');
|
|
|
|
const countInput = document.createElement('input');
|
|
countInput.type = 'number';
|
|
countInput.min = '1';
|
|
countInput.value = count;
|
|
countInput.className = 'input input-bordered input-xs w-16';
|
|
|
|
const group = document.createElement('div');
|
|
group.className = 'inline-flex items-center gap-1';
|
|
|
|
// Botón para poder quitar el elemento si el usuario se equivoca.
|
|
const removeItemBtn = document.createElement('button');
|
|
removeItemBtn.type = 'button';
|
|
removeItemBtn.className = 'btn btn-xs btn-circle';
|
|
removeItemBtn.textContent = '✕';
|
|
removeItemBtn.addEventListener('click', () => group.remove());
|
|
|
|
group.appendChild(countInput);
|
|
group.appendChild(clone);
|
|
group.appendChild(removeItemBtn);
|
|
|
|
dropCanvas.appendChild(group);
|
|
}
|
|
|
|
// Convierte cualquier contenedor en una zona donde soltar elementos
|
|
// arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el
|
|
// contenido del canvas (al soltar un elemento o cambiar la cantidad).
|
|
function setupDropZone(dropCanvas, onChange) {
|
|
dropCanvas.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
});
|
|
|
|
dropCanvas.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
const html = e.dataTransfer.getData('text/html');
|
|
if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada).
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = html;
|
|
const clone = wrapper.firstElementChild;
|
|
if (!clone) return;
|
|
|
|
addDndItem(dropCanvas, clone.textContent.trim(), 1);
|
|
});
|
|
|
|
if (onChange) {
|
|
dropCanvas.addEventListener('input', onChange); // Cambios en la cantidad.
|
|
new MutationObserver(onChange).observe(dropCanvas, { childList: true }); // Añadir/quitar elementos.
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Generación del resultado (#panel-output)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// Convierte el contenido de una zona de drop en texto tipo
|
|
// "3 Elemento A, 1 Elemento B".
|
|
function getDndCanvasText(dndCanvas) {
|
|
const parts = Array.from(dndCanvas.children).map(group => {
|
|
const input = group.querySelector('input');
|
|
const label = group.querySelector('li');
|
|
if (!input || !label) return '';
|
|
return `${input.value} ${label.textContent}`;
|
|
});
|
|
return parts.filter(Boolean).join(', ');
|
|
}
|
|
|
|
// Añade una línea de texto al resultado (si no está vacía).
|
|
function appendOutputLine(tag, text, className) {
|
|
if (!text) return;
|
|
const el = document.createElement(tag);
|
|
el.className = className;
|
|
el.textContent = text;
|
|
outputText.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).
|
|
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');
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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).
|
|
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)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// 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.
|
|
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';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'flex items-center gap-2';
|
|
|
|
const dragHandle = document.createElement('span');
|
|
dragHandle.className = 'cursor-move select-none text-base-content/50 px-1';
|
|
dragHandle.textContent = '⠿';
|
|
dragHandle.title = sectionsContainer.dataset.dragTitle;
|
|
dragHandle.setAttribute('draggable', 'true');
|
|
|
|
dragHandle.addEventListener('dragstart', (e) => {
|
|
draggedSection = section;
|
|
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', 'opacity-50');
|
|
});
|
|
|
|
dragHandle.addEventListener('dragend', () => {
|
|
section.classList.remove('dragging', 'opacity-50');
|
|
draggedSection = null;
|
|
});
|
|
|
|
// Botón para colapsar/expandir el cuerpo de la sección, útil cuando hay
|
|
// muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio.
|
|
const collapseBtn = document.createElement('button');
|
|
collapseBtn.type = 'button';
|
|
collapseBtn.className = '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.textContent = typeLabel;
|
|
|
|
const spacer = document.createElement('div');
|
|
spacer.className = 'flex-1';
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'btn btn-xs btn-circle';
|
|
removeBtn.textContent = '✕';
|
|
removeBtn.title = sectionsContainer.dataset.removeTitle;
|
|
removeBtn.addEventListener('click', () => section.remove());
|
|
|
|
header.appendChild(dragHandle);
|
|
header.appendChild(collapseBtn);
|
|
header.appendChild(badge);
|
|
header.appendChild(spacer);
|
|
header.appendChild(removeBtn);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'flex flex-col gap-2';
|
|
|
|
collapseBtn.addEventListener('click', () => {
|
|
body.classList.toggle('hidden');
|
|
collapseBtn.textContent = body.classList.contains('hidden') ? '▸' : '▾';
|
|
});
|
|
|
|
section.appendChild(header);
|
|
section.appendChild(body);
|
|
return { section, body };
|
|
}
|
|
|
|
// Sección de título: un input de una línea que se muestra como <h3> 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 = '') {
|
|
const { section, body } = createSectionShell(data.typeLabel);
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'title-input input input-bordered w-full';
|
|
input.placeholder = data.placeholder;
|
|
input.value = text;
|
|
|
|
body.appendChild(input);
|
|
sectionsContainer.appendChild(section);
|
|
}
|
|
|
|
// 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.
|
|
function addSubtitleSection(data, text = '') {
|
|
const { section, body } = createSectionShell(data.typeLabel);
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'subtitle-input input input-bordered w-full';
|
|
input.placeholder = data.placeholder;
|
|
input.value = text;
|
|
|
|
body.appendChild(input);
|
|
sectionsContainer.appendChild(section);
|
|
}
|
|
|
|
// Sección de texto libre.
|
|
function addTextSection(data, text = '') {
|
|
const { section, body } = createSectionShell(data.typeLabel);
|
|
|
|
const textarea = document.createElement('textarea');
|
|
textarea.className = 'textarea textarea-bordered w-full';
|
|
textarea.placeholder = data.placeholder;
|
|
textarea.value = text;
|
|
|
|
body.appendChild(textarea);
|
|
sectionsContainer.appendChild(section);
|
|
}
|
|
|
|
// Sección de imagen: selector de archivo + vista previa (tamaño limitado
|
|
// para que una foto de alta resolución no ocupe toda la pantalla).
|
|
// `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 = '') {
|
|
const { section, body } = createSectionShell(data.typeLabel);
|
|
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.accept = 'image/*';
|
|
fileInput.className = 'file-input file-input-bordered w-full mb-2';
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'max-w-full max-h-48 object-contain rounded-box hidden';
|
|
|
|
if (dataUrl) {
|
|
img.src = dataUrl;
|
|
img.classList.remove('hidden');
|
|
}
|
|
|
|
fileInput.addEventListener('change', () => {
|
|
const file = fileInput.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.addEventListener('load', () => {
|
|
img.src = reader.result;
|
|
img.classList.remove('hidden');
|
|
});
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
body.appendChild(fileInput);
|
|
body.appendChild(img);
|
|
sectionsContainer.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 = []) {
|
|
const { section, body } = createSectionShell(data.typeLabel);
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'flex gap-4';
|
|
|
|
const dndCanvas = document.createElement('div');
|
|
dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap items-center gap-2 min-h-24';
|
|
|
|
const elementsPanel = document.createElement('aside');
|
|
elementsPanel.className = 'w-48 shrink-0 border border-base-300 rounded-box p-4';
|
|
|
|
const elementsTitle = document.createElement('h3');
|
|
elementsTitle.className = 'font-semibold mb-2';
|
|
elementsTitle.textContent = data.elementsLabel;
|
|
|
|
const list = draggableItemsSource.cloneNode(true);
|
|
list.removeAttribute('id'); // Evitar ids duplicados: puede haber varias secciones de este tipo.
|
|
list.classList.remove('hidden'); // La fuente está oculta; el clon sí debe mostrarse.
|
|
list.querySelectorAll('li').forEach(makeDraggable);
|
|
|
|
elementsPanel.appendChild(elementsTitle);
|
|
elementsPanel.appendChild(list);
|
|
|
|
row.appendChild(dndCanvas);
|
|
row.appendChild(elementsPanel);
|
|
body.appendChild(row);
|
|
sectionsContainer.appendChild(section);
|
|
setupDropZone(dndCanvas);
|
|
|
|
items.forEach(item => addDndItem(dndCanvas, item.label, item.count));
|
|
}
|
|
|
|
// 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.
|
|
const addTitleBtn = document.getElementById('add-title-section');
|
|
const addSubtitleBtn = document.getElementById('add-subtitle-section');
|
|
const addTextBtn = document.getElementById('add-text-section');
|
|
const addImageBtn = document.getElementById('add-image-section');
|
|
const addDndBtn = document.getElementById('add-dnd-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));
|
|
|
|
// Exportar #panel-output a PDF: se usa el diálogo de impresión del propio
|
|
// navegador (con "Guardar como PDF") en vez de una librería externa, ya
|
|
// que las librerías tipo html2canvas no renderizan bien los colores oklch
|
|
// que usa Tailwind v4. La hoja de estilos de impresión (css/main.css)
|
|
// aísla #panel-output para que solo se exporte el resultado.
|
|
document.getElementById('export-pdf').addEventListener('click', () => {
|
|
window.print();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------
|
|
// 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 };
|
|
|
|
const subtitleInput = section.querySelector('.subtitle-input');
|
|
if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value };
|
|
|
|
const textarea = section.querySelector('textarea');
|
|
if (textarea) return { type: 'text', text: textarea.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 img = section.querySelector('img');
|
|
if (img) {
|
|
const dataUrl = !img.classList.contains('hidden') ? img.src : '';
|
|
return { type: 'image', dataUrl };
|
|
}
|
|
|
|
return null;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
// Reconstruye #panel-top-sections a partir del array que genera
|
|
// serializeSections(), sustituyendo las secciones actuales.
|
|
function buildSectionsFrom(sectionsData) {
|
|
sectionsContainer.innerHTML = '';
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Ejemplo de uso: guardar/cargar todas las secciones en localStorage.
|
|
// Para integrarlo con Django, bastaría con sustituir estos dos listeners
|
|
// por una llamada a la API (POST del JSON de serializeSections() / GET
|
|
// para pasarle el resultado a buildSectionsFrom()).
|
|
const SECTIONS_STORAGE_KEY = 'sections-data';
|
|
|
|
document.getElementById('save-pattern').addEventListener('click', () => {
|
|
localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(serializeSections()));
|
|
});
|
|
|
|
document.getElementById('load-pattern').addEventListener('click', () => {
|
|
const raw = localStorage.getItem(SECTIONS_STORAGE_KEY);
|
|
if (!raw) return;
|
|
buildSectionsFrom(JSON.parse(raw));
|
|
});
|
|
|
|
renderOutput(); // Pinta el mensaje de "sin secciones" nada más cargar la página.
|
|
});
|