feat: splitted js in different modules
This commit is contained in:
+5
-1
@@ -55,6 +55,10 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">Color del texto</span>
|
||||||
|
<input id="page-text-color" type="color" value="#000000">
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-sm">Color de fondo</span>
|
<span class="text-sm">Color de fondo</span>
|
||||||
<input id="page-bg-color" type="color" value="#ffffff">
|
<input id="page-bg-color" type="color" value="#ffffff">
|
||||||
@@ -147,6 +151,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
<script src="js/dnd.js"></script>
|
<script type="module" src="js/main.js"></script>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
// Drag & drop de elementos arrastrables (los <li> de "Elementos") hacia una
|
||||||
|
// zona de destino (el "dnd-canvas" de una sección de "Patrón").
|
||||||
|
|
||||||
|
import { draggableItemsSource } from './dom-refs.js';
|
||||||
|
import { getTouchPoint, moveIfNeeded, getItemAfterPoint, itemDrag } from './drag-reorder.js';
|
||||||
|
|
||||||
|
// Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en el
|
||||||
|
// dataTransfer para poder clonarlo al soltarlo. addDndItem() se usa aquí
|
||||||
|
// dentro del listener aunque se declare más abajo: en JS los `function` se
|
||||||
|
// izan (hoisting), así que el orden dentro del módulo no importa.
|
||||||
|
export 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.
|
||||||
|
export 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) => {
|
||||||
|
itemDrag.item = 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');
|
||||||
|
itemDrag.item = 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();
|
||||||
|
itemDrag.item = 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, itemDrag.item, afterElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
|
group.classList.remove('dragging');
|
||||||
|
highlightTarget.classList.remove('drop-target-active');
|
||||||
|
itemDrag.item = 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.
|
||||||
|
export function setupDropZone(dropCanvas, onChange, hitArea = dropCanvas) {
|
||||||
|
hitArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (itemDrag.item) {
|
||||||
|
// 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, itemDrag.item, afterElement);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
});
|
||||||
|
|
||||||
|
hitArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (itemDrag.item) 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convierte el contenido de una zona de drop en texto tipo
|
||||||
|
// "3 Elemento A, 1 Elemento B".
|
||||||
|
export 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(', ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Referencias a los elementos fijos del documento, en un único sitio para
|
||||||
|
// que el resto de módulos no repitan `document.getElementById` para lo
|
||||||
|
// mismo. Los módulos ES se ejecutan después de que el HTML esté parseado
|
||||||
|
// (como `defer`), así que no hace falta esperar a DOMContentLoaded.
|
||||||
|
|
||||||
|
export const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
|
||||||
|
export const panelTop = document.getElementById('panel-top'); // Todo el panel "Secciones" (incluye los botones "+ tipo"), usado como zona de detección al reordenar.
|
||||||
|
export const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
|
||||||
|
export 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".
|
||||||
|
export 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.
|
||||||
|
export 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 page-settings.js.
|
||||||
|
export const pageTitleInput = document.getElementById('page-title-input');
|
||||||
|
export const pageAuthorInput = document.getElementById('page-author-input');
|
||||||
|
export const pageTextColorInput = document.getElementById('page-text-color');
|
||||||
|
export const pageBgColorInput = document.getElementById('page-bg-color');
|
||||||
|
export const pageFontSelect = document.getElementById('page-font-select');
|
||||||
|
export const pageSizeSelect = document.getElementById('page-size-select');
|
||||||
|
export const pageOrientationSelect = document.getElementById('page-orientation-select');
|
||||||
|
export const pageSizeStyle = document.getElementById('page-size-style');
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Motor genérico de reordenado por arrastre, usado tanto para las secciones
|
||||||
|
// (arrastrando su cabecera) como para los elementos ya soltados dentro de
|
||||||
|
// una sección de "Patrón". 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, así que todo esto también se
|
||||||
|
// reimplementa con eventos touch usando getTouchPoint() como coordenadas
|
||||||
|
// comunes.
|
||||||
|
|
||||||
|
// 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 exporta como
|
||||||
|
// un objeto mutable (en vez de variables sueltas con getters/setters)
|
||||||
|
// porque quien arrastra (sections.js) y quien reordena
|
||||||
|
// (setupSectionsReorder, aquí mismo) necesitan compartir el mismo estado
|
||||||
|
// en todo momento.
|
||||||
|
export const sectionDrag = { section: null, container: null, hitArea: null };
|
||||||
|
|
||||||
|
// Igual que sectionDrag, pero para reordenar un elemento ya soltado (un
|
||||||
|
// "grupo": cantidad + punto + botón eliminar) dentro de su zona de drop.
|
||||||
|
export const itemDrag = { item: null };
|
||||||
|
|
||||||
|
export function getTouchPoint(e) {
|
||||||
|
const touch = e.touches[0] || e.changedTouches[0];
|
||||||
|
return { x: touch.clientX, y: touch.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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".
|
||||||
|
export 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).
|
||||||
|
export function setupSectionsReorder(container, hitArea) {
|
||||||
|
hitArea.addEventListener('dragover', (e) => {
|
||||||
|
if (sectionDrag.container !== 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, sectionDrag.section, afterElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
hitArea.addEventListener('drop', (e) => {
|
||||||
|
if (sectionDrag.container !== 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.
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
// Multi-idioma del contenido: título, subtítulo, texto libre, nota y cada
|
||||||
|
// línea de materiales 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.
|
||||||
|
|
||||||
|
import { sectionsContainer, languageSelect } from './dom-refs.js';
|
||||||
|
|
||||||
|
// Selector común a todo campo traducible: se usa tanto para sincronizar el
|
||||||
|
// mapa de traducciones en cada pulsación de tecla como para refrescar los
|
||||||
|
// campos visibles al cambiar de idioma. `textarea` cubre tanto "Texto"
|
||||||
|
// (.text-input) como "Nota" (.note-input): ambas son un <textarea>.
|
||||||
|
export const TRANSLATABLE_FIELDS_SELECTOR = '.title-input, .subtitle-input, .material-input, textarea';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// traducible.
|
||||||
|
let currentLanguage = languageSelect.value;
|
||||||
|
|
||||||
|
export function getCurrentLanguage() {
|
||||||
|
return currentLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, sin necesidad de un almacén aparte que
|
||||||
|
// haya que mantener sincronizado.
|
||||||
|
export 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.
|
||||||
|
export function setTranslation(el, text, lang = currentLanguage) {
|
||||||
|
const translations = getTranslations(el);
|
||||||
|
translations[lang] = text;
|
||||||
|
el.dataset.translations = JSON.stringify(translations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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). `onLanguageChange` se llama después, para que quien la registre
|
||||||
|
// (render.js) pueda repintar la vista previa.
|
||||||
|
export function setupLanguageSelect(onLanguageChange) {
|
||||||
|
languageSelect.addEventListener('change', () => {
|
||||||
|
currentLanguage = languageSelect.value;
|
||||||
|
sectionsContainer.querySelectorAll(TRANSLATABLE_FIELDS_SELECTOR).forEach(el => {
|
||||||
|
el.value = getTranslations(el)[currentLanguage] || '';
|
||||||
|
});
|
||||||
|
onLanguageChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// Punto de entrada. Importar cada módulo ya ejecuta su propio cableado (los
|
||||||
|
// listeners que registra a nivel de módulo); aquí solo queda pintar el
|
||||||
|
// estado inicial una vez todo está montado.
|
||||||
|
|
||||||
|
import { renderOutput } from './render.js';
|
||||||
|
import { renderPageMeta } from './page-settings.js';
|
||||||
|
import './section-types.js';
|
||||||
|
import './tabs.js';
|
||||||
|
import './storage.js';
|
||||||
|
|
||||||
|
renderPageMeta();
|
||||||
|
renderOutput(); // Pinta el mensaje de "sin secciones" nada más cargar la página.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Personalización de página: título, autor, color de texto y 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 storage.js) y se aplica directamente sobre #panel-output.
|
||||||
|
|
||||||
|
import {
|
||||||
|
panelOutput,
|
||||||
|
pageTitleInput,
|
||||||
|
pageAuthorInput,
|
||||||
|
pageTextColorInput,
|
||||||
|
pageBgColorInput,
|
||||||
|
pageFontSelect,
|
||||||
|
pageSizeSelect,
|
||||||
|
pageOrientationSelect,
|
||||||
|
pageSizeStyle,
|
||||||
|
} from './dom-refs.js';
|
||||||
|
|
||||||
|
// Aplica a #panel-output el color de texto/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).
|
||||||
|
export function renderPageMeta() {
|
||||||
|
panelOutput.style.color = pageTextColorInput.value;
|
||||||
|
panelOutput.style.backgroundColor = pageBgColorInput.value;
|
||||||
|
panelOutput.style.fontFamily = pageFontSelect.value;
|
||||||
|
pageSizeStyle.textContent = `@page { size: ${pageSizeSelect.value} ${pageOrientationSelect.value}; }`;
|
||||||
|
document.title = pageTitleInput.value.trim() || 'Crochet';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePageSettings() {
|
||||||
|
return {
|
||||||
|
title: pageTitleInput.value,
|
||||||
|
author: pageAuthorInput.value,
|
||||||
|
textColor: pageTextColorInput.value,
|
||||||
|
bgColor: pageBgColorInput.value,
|
||||||
|
font: pageFontSelect.value,
|
||||||
|
pageSize: pageSizeSelect.value,
|
||||||
|
orientation: pageOrientationSelect.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPageSettings(settings = {}) {
|
||||||
|
pageTitleInput.value = settings.title || '';
|
||||||
|
pageAuthorInput.value = settings.author || '';
|
||||||
|
pageTextColorInput.value = settings.textColor || '#000000';
|
||||||
|
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));
|
||||||
|
[pageTextColorInput, pageBgColorInput, pageFontSelect, pageSizeSelect, pageOrientationSelect].forEach(el => {
|
||||||
|
el.addEventListener('input', renderPageMeta);
|
||||||
|
el.addEventListener('change', renderPageMeta);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Genera el resultado (#panel-output) a partir de las secciones actuales y
|
||||||
|
// de "Personalización de página" (título/autor), y decide cuándo hay que
|
||||||
|
// repintarlo: al escribir, al cambiar de idioma, o al añadir/quitar/
|
||||||
|
// reordenar secciones o elementos soltados.
|
||||||
|
|
||||||
|
import { outputText, sectionsContainer, pageTitleInput, pageAuthorInput } from './dom-refs.js';
|
||||||
|
import { TRANSLATABLE_FIELDS_SELECTOR, setTranslation, setupLanguageSelect } from './i18n.js';
|
||||||
|
import { renderOutputSection, appendOutputLine } from './section-types.js';
|
||||||
|
|
||||||
|
// Recorre todas las secciones de nivel superior (en su orden actual) y
|
||||||
|
// reconstruye #panel-output desde cero.
|
||||||
|
export 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 opacity-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, 'opacity-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/nota/materiales), 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.
|
||||||
|
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 });
|
||||||
|
|
||||||
|
setupLanguageSelect(renderOutput);
|
||||||
|
|
||||||
|
// El título/autor del patrón no pasan por sectionsContainer, así que se
|
||||||
|
// escuchan aparte.
|
||||||
|
[pageTitleInput, pageAuthorInput].forEach(el => el.addEventListener('input', renderOutput));
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// Registro de tipos de sección: une, para cada tipo, cómo crearlo
|
||||||
|
// (create/button), cómo reconocerlo dentro de una sección ya creada
|
||||||
|
// (matches), cómo convertirlo a datos planos (serialize) y cómo pintarlo
|
||||||
|
// en la vista previa (render). Añadir un tipo de sección nuevo es añadir
|
||||||
|
// una entrada a SECTION_TYPES; el resto de funciones de este módulo
|
||||||
|
// (serializar, reconstruir, duplicar, pintar) son genéricas y no necesitan
|
||||||
|
// tocarse.
|
||||||
|
|
||||||
|
import { sectionsContainer, panelTop } from './dom-refs.js';
|
||||||
|
import { getTranslations } from './i18n.js';
|
||||||
|
import { getDndCanvasText } from './dnd-items.js';
|
||||||
|
import { setupSectionsReorder } from './drag-reorder.js';
|
||||||
|
import {
|
||||||
|
createSectionShell,
|
||||||
|
addTitleSection,
|
||||||
|
addSubtitleSection,
|
||||||
|
addTextSection,
|
||||||
|
addNoteSection,
|
||||||
|
addMaterialsSection,
|
||||||
|
addImageSection,
|
||||||
|
addDndSection,
|
||||||
|
} from './sections.js';
|
||||||
|
|
||||||
|
// Añade una línea de texto a `target` (si no está vacía).
|
||||||
|
export function appendOutputLine(target, tag, text, className) {
|
||||||
|
if (!text) return;
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
el.className = className;
|
||||||
|
el.textContent = text;
|
||||||
|
target.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. Vive aquí (no en sections.js) porque necesita el
|
||||||
|
// registro completo (SECTION_TYPES) para poder crear cualquier tipo de
|
||||||
|
// sección hija, incluida ella misma (permite grupos anidados sin más).
|
||||||
|
// `children`, si se pasa, precarga su contenido (al restaurar un patrón
|
||||||
|
// guardado).
|
||||||
|
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 el resto de este
|
||||||
|
// módulo pueda reconocer que esta sección es un grupo.
|
||||||
|
const groupContainer = document.createElement('div');
|
||||||
|
groupContainer.className = 'group-container flex flex-col gap-4 border-l-2 border-base-300';
|
||||||
|
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'flex flex-wrap gap-2 mb-4';
|
||||||
|
|
||||||
|
SECTION_TYPES.forEach(({ button, create }) => {
|
||||||
|
const btn = button.cloneNode(true);
|
||||||
|
btn.removeAttribute('id'); // Puede haber varios grupos, cada uno con su propia copia de estos botones.
|
||||||
|
btn.addEventListener('click', () => create(undefined, groupContainer));
|
||||||
|
toolbar.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
body.appendChild(toolbar);
|
||||||
|
body.appendChild(groupContainer);
|
||||||
|
container.appendChild(section);
|
||||||
|
|
||||||
|
setupSectionsReorder(groupContainer, section);
|
||||||
|
|
||||||
|
buildSectionsFrom(children, groupContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// El orden de esta lista importa: 'group' va primero porque si no, un
|
||||||
|
// grupo que contenga p.ej. una sección de texto haría que el `matches` de
|
||||||
|
// "Texto" encontrase esa textarea anidada y tratase el grupo entero como
|
||||||
|
// texto (ver renderOutputSection/serializeSection más abajo, que recorren
|
||||||
|
// esta lista en orden y se quedan con el primer tipo que encaje).
|
||||||
|
//
|
||||||
|
// - button/create: qué botón "+ tipo" lo añade y cómo se crea, a partir de
|
||||||
|
// datos guardados (`sectionData`) o en blanco si no se pasa nada.
|
||||||
|
// - matches: cómo reconocerlo dentro de una sección ya creada; devuelve el
|
||||||
|
// elemento relevante (o nada si la sección no es de este tipo).
|
||||||
|
// - serialize/render: cómo convertirlo a datos planos y cómo pintarlo en
|
||||||
|
// la vista previa, a partir de ese elemento.
|
||||||
|
const SECTION_TYPES = [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
button: addGroupBtn,
|
||||||
|
create: (sectionData, container) => addGroupSection(addGroupBtn.dataset, sectionData?.children, container),
|
||||||
|
matches: (section) => section.querySelector('.group-container'),
|
||||||
|
serialize: (section, groupContainer) => ({
|
||||||
|
type: 'group',
|
||||||
|
children: Array.from(groupContainer.children).map(serializeSection).filter(Boolean),
|
||||||
|
}),
|
||||||
|
// Se recorren sus propias secciones (recursivamente) dentro de un
|
||||||
|
// bloque aparte, con una guía visual a la izquierda.
|
||||||
|
render: (section, groupContainer, target) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = '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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'title',
|
||||||
|
button: addTitleBtn,
|
||||||
|
create: (sectionData, container) => addTitleSection(addTitleBtn.dataset, sectionData?.text, container),
|
||||||
|
matches: (section) => section.querySelector('.title-input'),
|
||||||
|
serialize: (section, titleInput) => ({ type: 'title', text: getTranslations(titleInput) }),
|
||||||
|
render: (section, titleInput, target) =>
|
||||||
|
appendOutputLine(target, 'h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'subtitle',
|
||||||
|
button: addSubtitleBtn,
|
||||||
|
create: (sectionData, container) => addSubtitleSection(addSubtitleBtn.dataset, sectionData?.text, container),
|
||||||
|
matches: (section) => section.querySelector('.subtitle-input'),
|
||||||
|
serialize: (section, subtitleInput) => ({ type: 'subtitle', text: getTranslations(subtitleInput) }),
|
||||||
|
render: (section, subtitleInput, target) =>
|
||||||
|
appendOutputLine(target, 'h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
button: addTextBtn,
|
||||||
|
create: (sectionData, container) => addTextSection(addTextBtn.dataset, sectionData?.text, container),
|
||||||
|
matches: (section) => section.querySelector('.text-input'),
|
||||||
|
serialize: (section, textInput) => ({ type: 'text', text: getTranslations(textInput) }),
|
||||||
|
render: (section, textInput, target) => appendOutputLine(target, 'p', textInput.value.trim(), ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'note',
|
||||||
|
button: addNoteBtn,
|
||||||
|
create: (sectionData, container) => addNoteSection(
|
||||||
|
addNoteBtn.dataset, sectionData?.text, container,
|
||||||
|
{ text: sectionData?.textColor, bg: sectionData?.bgColor },
|
||||||
|
),
|
||||||
|
matches: (section) => section.querySelector('.note-input'),
|
||||||
|
serialize: (section, 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// 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.
|
||||||
|
render: (section, noteInput, target) => {
|
||||||
|
const text = noteInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'materials',
|
||||||
|
button: addMaterialsBtn,
|
||||||
|
create: (sectionData, container) => addMaterialsSection(addMaterialsBtn.dataset, sectionData?.materials, container),
|
||||||
|
matches: (section) => section.querySelector('.materials-list'),
|
||||||
|
serialize: (section, materialsList) => ({
|
||||||
|
type: 'materials',
|
||||||
|
materials: Array.from(materialsList.querySelectorAll('.material-input')).map(getTranslations),
|
||||||
|
}),
|
||||||
|
// Una línea por elemento de la lista, en vez de un único párrafo.
|
||||||
|
render: (section, materialsList, target) => {
|
||||||
|
const items = Array.from(materialsList.querySelectorAll('.material-input'))
|
||||||
|
.map(input => input.value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!items.length) return;
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'dnd',
|
||||||
|
button: addDndBtn,
|
||||||
|
create: (sectionData, container) => addDndSection(addDndBtn.dataset, sectionData?.items, container),
|
||||||
|
matches: (section) => section.querySelector('.dnd-canvas'),
|
||||||
|
serialize: (section, 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 };
|
||||||
|
},
|
||||||
|
render: (section, dndCanvas, target) => appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
button: addImageBtn,
|
||||||
|
create: (sectionData, container) => addImageSection(addImageBtn.dataset, sectionData?.dataUrl, container),
|
||||||
|
matches: (section) => section.querySelector('img'),
|
||||||
|
serialize: (section, img) => ({ type: 'image', dataUrl: !img.classList.contains('hidden') ? img.src : '' }),
|
||||||
|
// Si ya hay una imagen cargada, se copia (con el tamaño limitado) al resultado.
|
||||||
|
render: (section, img, target) => {
|
||||||
|
if (!img.src || img.classList.contains('hidden')) return;
|
||||||
|
const clone = document.createElement('img');
|
||||||
|
clone.src = img.src;
|
||||||
|
clone.className = 'max-w-full max-h-48 object-contain rounded-box my-2';
|
||||||
|
target.appendChild(clone);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Aporta a `target` lo que corresponda según el tipo de `section` (título,
|
||||||
|
// subtítulo, texto, nota, materiales, patrón, imagen o grupo).
|
||||||
|
export function renderOutputSection(section, target) {
|
||||||
|
for (const entry of SECTION_TYPES) {
|
||||||
|
const match = entry.matches(section);
|
||||||
|
if (match) {
|
||||||
|
entry.render(section, match, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convierte una sección en un objeto plano según su tipo (recursivo si es
|
||||||
|
// un "Grupo", ver su entrada en SECTION_TYPES).
|
||||||
|
export function serializeSection(section) {
|
||||||
|
for (const entry of SECTION_TYPES) {
|
||||||
|
const match = entry.matches(section);
|
||||||
|
if (match) return entry.serialize(section, match);
|
||||||
|
}
|
||||||
|
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).
|
||||||
|
export 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 el `create` del
|
||||||
|
// tipo que corresponda. La sección siempre se añade al final de
|
||||||
|
// `container`; quien necesite colocarla en otra posición (ver
|
||||||
|
// duplicateSection()) debe moverla después.
|
||||||
|
export function addSectionFromData(sectionData, container) {
|
||||||
|
const entry = SECTION_TYPES.find(t => t.type === sectionData.type);
|
||||||
|
if (entry) entry.create(sectionData, 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.
|
||||||
|
export function buildSectionsFrom(sectionsData, container = sectionsContainer) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
sectionsData.forEach(sectionData => addSectionFromData(sectionData, container));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplica `section`: la serializa (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.
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// El botón "duplicar" de cada sección (ver createSectionShell en
|
||||||
|
// sections.js) dispara este evento en vez de llamar aquí directamente, así
|
||||||
|
// sections.js no necesita importar este módulo.
|
||||||
|
sectionsContainer.addEventListener('section:duplicate', (e) => duplicateSection(e.target));
|
||||||
|
|
||||||
|
// Reordenado por arrastre de las secciones de nivel superior (ver
|
||||||
|
// addGroupSection() para el de dentro de un grupo).
|
||||||
|
setupSectionsReorder(sectionsContainer, panelTop);
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
SECTION_TYPES.forEach(({ button, create }) => {
|
||||||
|
button.addEventListener('click', () => create(undefined, undefined));
|
||||||
|
});
|
||||||
+404
@@ -0,0 +1,404 @@
|
|||||||
|
// Creación de secciones (título, subtítulo, texto, nota, materiales, imagen
|
||||||
|
// y patrón): el "armazón" común (createSectionShell) y una función
|
||||||
|
// addXxxSection por tipo. La sección de "grupo" no vive aquí sino en
|
||||||
|
// section-types.js, porque para reconstruir sus hijas necesita el registro
|
||||||
|
// completo de tipos (y este módulo no lo conoce, para que no haya un
|
||||||
|
// import circular entre ambos).
|
||||||
|
|
||||||
|
import { panelTop, sectionsContainer, draggableItemsSource } from './dom-refs.js';
|
||||||
|
import { getCurrentLanguage } from './i18n.js';
|
||||||
|
import { getTouchPoint, moveIfNeeded, getSectionAfterY, sectionDrag } from './drag-reorder.js';
|
||||||
|
import { makeDraggable, addDndItem, setupDropZone } from './dnd-items.js';
|
||||||
|
|
||||||
|
// 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, duplicar 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.
|
||||||
|
export 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) => {
|
||||||
|
sectionDrag.section = section;
|
||||||
|
sectionDrag.container = section.parentElement;
|
||||||
|
sectionDrag.hitArea = sectionDrag.container.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');
|
||||||
|
sectionDrag.hitArea.classList.add('drop-target-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
header.addEventListener('dragend', () => {
|
||||||
|
section.classList.remove('dragging');
|
||||||
|
sectionDrag.hitArea.classList.remove('drop-target-active');
|
||||||
|
sectionDrag.section = null;
|
||||||
|
sectionDrag.container = null;
|
||||||
|
sectionDrag.hitArea = 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", "duplicar" ni "eliminar".
|
||||||
|
e.preventDefault();
|
||||||
|
sectionDrag.section = section;
|
||||||
|
sectionDrag.container = section.parentElement;
|
||||||
|
sectionDrag.hitArea = sectionDrag.container.closest('.top-section') || panelTop;
|
||||||
|
section.classList.add('dragging');
|
||||||
|
sectionDrag.hitArea.classList.add('drop-target-active');
|
||||||
|
|
||||||
|
const onTouchMove = (moveEvent) => {
|
||||||
|
moveEvent.preventDefault();
|
||||||
|
const { y } = getTouchPoint(moveEvent);
|
||||||
|
const afterElement = getSectionAfterY(sectionDrag.container, y);
|
||||||
|
moveIfNeeded(sectionDrag.container, sectionDrag.section, afterElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
|
section.classList.remove('dragging');
|
||||||
|
sectionDrag.hitArea.classList.remove('drop-target-active');
|
||||||
|
sectionDrag.section = null;
|
||||||
|
sectionDrag.container = null;
|
||||||
|
sectionDrag.hitArea = 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 dispara con un evento en vez de
|
||||||
|
// llamar directamente a duplicateSection() (que vive en
|
||||||
|
// section-types.js), para que este módulo no necesite importarlo: es
|
||||||
|
// section-types.js quien depende de sections.js (para crear cada tipo),
|
||||||
|
// no al revés.
|
||||||
|
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', () => {
|
||||||
|
section.dispatchEvent(new CustomEvent('section:duplicate', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
export 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[getCurrentLanguage()] || '';
|
||||||
|
|
||||||
|
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.
|
||||||
|
export 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[getCurrentLanguage()] || '';
|
||||||
|
|
||||||
|
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 section-types.js:
|
||||||
|
// ambas son un <textarea>, así que buscar por la etiqueta genérica sería
|
||||||
|
// ambiguo.
|
||||||
|
export 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[getCurrentLanguage()] || '';
|
||||||
|
|
||||||
|
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).
|
||||||
|
export 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[getCurrentLanguage()] || '';
|
||||||
|
|
||||||
|
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).
|
||||||
|
export 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[getCurrentLanguage()] || '';
|
||||||
|
|
||||||
|
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.
|
||||||
|
export 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');
|
||||||
|
// La lectura es asíncrona: el 'change' del propio fileInput ya
|
||||||
|
// burbujeó antes de que la imagen estuviera lista. Se dispara sobre
|
||||||
|
// `img` (no sobre fileInput, para no volver a disparar este mismo
|
||||||
|
// listener) para que el 'change' delegado de render.js repinte la
|
||||||
|
// vista previa ahora que sí hay imagen.
|
||||||
|
img.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
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 }].
|
||||||
|
export 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Guardar/cargar el patrón completo (secciones + personalización de
|
||||||
|
// página) en localStorage. Para integrarlo con Django, bastaría con
|
||||||
|
// sustituir estos dos listeners por una llamada a la API (POST del JSON de
|
||||||
|
// serializeSections()+serializePageSettings() / GET para restaurarlo con
|
||||||
|
// buildSectionsFrom()+applyPageSettings()).
|
||||||
|
|
||||||
|
import { serializeSections, buildSectionsFrom } from './section-types.js';
|
||||||
|
import { serializePageSettings, applyPageSettings } from './page-settings.js';
|
||||||
|
import { renderOutput } from './render.js';
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
// 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. También la exportación a PDF, que se apoya
|
||||||
|
// en showTab() para asegurarse de que "Vista previa" esté visible antes de
|
||||||
|
// imprimir.
|
||||||
|
|
||||||
|
import { panelTop, panelOutput } from './dom-refs.js';
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user