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