feat: initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(echo \"exit: $?\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* HTML: <div class="breadcrumb"></div> */
|
||||
.breadcrumb {
|
||||
--s: 20px; /* control the shape */
|
||||
|
||||
line-height: 1.8; /* control the height */
|
||||
padding-inline: calc(var(--s) + .3em);
|
||||
clip-path: polygon(0 0,calc(100% - var(--s)) 0,100% 50%,calc(100% - var(--s)) 100%,0 100%,var(--s) 50%);
|
||||
background: #EAEAEA;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* Al exportar/imprimir, solo se muestra #panel-output (y no sus propios
|
||||
controles, marcados con .no-print), ocultando el resto de la página. */
|
||||
@media print {
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#panel-output,
|
||||
#panel-output * {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
#panel-output .no-print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#panel-output {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Crochet</title>
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<div class="flex gap-4 p-4 items-start">
|
||||
|
||||
<section id="panel-top" class="w-1/2 border border-base-300 rounded-box p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-lg font-semibold">Secciones</h2>
|
||||
<div class="flex gap-2">
|
||||
<button id="save-pattern" type="button" class="btn btn-sm btn-outline">Guardar</button>
|
||||
<button id="load-pattern" type="button" class="btn btn-sm btn-outline">Cargar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button id="add-title-section" type="button" class="btn btn-sm"
|
||||
data-type-label="Título" data-placeholder="Título...">+ Título</button>
|
||||
<button id="add-subtitle-section" type="button" class="btn btn-sm"
|
||||
data-type-label="Subtítulo" data-placeholder="Subtítulo...">+ Subtítulo</button>
|
||||
<button id="add-text-section" type="button" class="btn btn-sm"
|
||||
data-type-label="Texto" data-placeholder="Escribe aquí...">+ Texto</button>
|
||||
<button id="add-image-section" type="button" class="btn btn-sm"
|
||||
data-type-label="Imagen">+ Imagen</button>
|
||||
<button id="add-dnd-section" type="button" class="btn btn-sm"
|
||||
data-type-label="Patrón" data-elements-label="Elementos">+ Patrón</button>
|
||||
</div>
|
||||
<div id="panel-top-sections" class="flex flex-col gap-4"
|
||||
data-drag-title="Arrastrar para reordenar"
|
||||
data-collapse-title="Colapsar / expandir"
|
||||
data-remove-title="Eliminar sección"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-output" class="w-1/2 border border-base-300 rounded-box p-4">
|
||||
<div class="flex items-center justify-between mb-2 no-print">
|
||||
<h2 class="text-lg font-semibold">Vista previa</h2>
|
||||
<button id="export-pdf" type="button" class="btn btn-sm">Exportar a PDF</button>
|
||||
</div>
|
||||
<div id="panel-output-text" class="text-sm"
|
||||
data-empty-message="Añade una sección para ver aquí el resultado."></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Fuente de datos de los elementos arrastrables: se mantiene oculta y
|
||||
se clona (haciéndola visible) dentro de cada sección de arrastrar y soltar. -->
|
||||
<ul id="draggable-items-source" class="menu bg-base-100 w-full hidden">
|
||||
<li class="my-2 breadcrumb cursor-pointer" id="1"> PB</li>
|
||||
<li class="my-2 breadcrumb cursor-pointer" id="2">Disminución</li>
|
||||
</ul>
|
||||
</main>
|
||||
</body>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
</html>
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Referencias a los elementos fijos del documento.
|
||||
const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
|
||||
const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
|
||||
const draggableItemsSource = document.getElementById('draggable-items-source'); // Lista oculta con los elementos disponibles para arrastrar: fuente de datos única, se clona (visible) en cada sección de tipo "arrastrar y soltar".
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Reordenar secciones (arrastrando el asa "⠿" de cada una)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Sección que se está arrastrando actualmente para reordenar. Se usa como
|
||||
// "memoria" compartida entre los listeners en lugar de dataTransfer, así
|
||||
// no interfiere con el drag & drop de los elementos arrastrables.
|
||||
let draggedSection = null;
|
||||
|
||||
// Dado un punto Y del cursor, devuelve la sección justo debajo de la que
|
||||
// hay que insertar la sección arrastrada (o null si va al final).
|
||||
function getSectionAfterY(y) {
|
||||
const sections = Array.from(sectionsContainer.querySelectorAll(':scope > .top-section:not(.dragging)'));
|
||||
return sections.reduce((closest, child) => {
|
||||
const box = child.getBoundingClientRect();
|
||||
const offset = y - box.top - box.height / 2;
|
||||
if (offset < 0 && offset > closest.offset) {
|
||||
return { offset, element: child };
|
||||
}
|
||||
return closest;
|
||||
}, { offset: -Infinity, element: null }).element;
|
||||
}
|
||||
|
||||
sectionsContainer.addEventListener('dragover', (e) => {
|
||||
if (!draggedSection) return; // No es un arrastre de reordenación, ignorar.
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Mueve en vivo la sección arrastrada a su nueva posición según el cursor.
|
||||
const afterElement = getSectionAfterY(e.clientY);
|
||||
if (afterElement == null) {
|
||||
sectionsContainer.appendChild(draggedSection);
|
||||
} else if (afterElement !== draggedSection) {
|
||||
sectionsContainer.insertBefore(draggedSection, afterElement);
|
||||
}
|
||||
});
|
||||
|
||||
sectionsContainer.addEventListener('drop', (e) => {
|
||||
if (!draggedSection) return;
|
||||
e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador.
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Drag & drop de elementos arrastrables hacia una zona de destino
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en
|
||||
// el dataTransfer para poder clonarlo al soltarlo.
|
||||
function makeDraggable(item) {
|
||||
item.setAttribute('draggable', 'true');
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData('text/html', item.outerHTML);
|
||||
});
|
||||
}
|
||||
|
||||
// Añade a una zona de drop un grupo: cantidad (input numérico), el
|
||||
// elemento (buscado en draggableItemsSource por su texto) y un botón para
|
||||
// eliminarlo. Se usa tanto al soltar un elemento como al restaurar un
|
||||
// patrón guardado previamente (ver buildSectionsFrom más abajo).
|
||||
function addDndItem(dropCanvas, label, count) {
|
||||
const sourceItem = Array.from(draggableItemsSource.children)
|
||||
.find(li => li.textContent.trim() === label);
|
||||
if (!sourceItem) return;
|
||||
|
||||
const clone = sourceItem.cloneNode(true);
|
||||
clone.removeAttribute('draggable');
|
||||
clone.removeAttribute('id');
|
||||
clone.classList.add('inline-block');
|
||||
|
||||
const countInput = document.createElement('input');
|
||||
countInput.type = 'number';
|
||||
countInput.min = '1';
|
||||
countInput.value = count;
|
||||
countInput.className = 'input input-bordered input-xs w-16';
|
||||
|
||||
const group = document.createElement('div');
|
||||
group.className = 'inline-flex items-center gap-1';
|
||||
|
||||
// Botón para poder quitar el elemento si el usuario se equivoca.
|
||||
const removeItemBtn = document.createElement('button');
|
||||
removeItemBtn.type = 'button';
|
||||
removeItemBtn.className = 'btn btn-xs btn-circle';
|
||||
removeItemBtn.textContent = '✕';
|
||||
removeItemBtn.addEventListener('click', () => group.remove());
|
||||
|
||||
group.appendChild(countInput);
|
||||
group.appendChild(clone);
|
||||
group.appendChild(removeItemBtn);
|
||||
|
||||
dropCanvas.appendChild(group);
|
||||
}
|
||||
|
||||
// Convierte cualquier contenedor en una zona donde soltar elementos
|
||||
// arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el
|
||||
// contenido del canvas (al soltar un elemento o cambiar la cantidad).
|
||||
function setupDropZone(dropCanvas, onChange) {
|
||||
dropCanvas.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
dropCanvas.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const html = e.dataTransfer.getData('text/html');
|
||||
if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada).
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = html;
|
||||
const clone = wrapper.firstElementChild;
|
||||
if (!clone) return;
|
||||
|
||||
addDndItem(dropCanvas, clone.textContent.trim(), 1);
|
||||
});
|
||||
|
||||
if (onChange) {
|
||||
dropCanvas.addEventListener('input', onChange); // Cambios en la cantidad.
|
||||
new MutationObserver(onChange).observe(dropCanvas, { childList: true }); // Añadir/quitar elementos.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Generación del resultado (#panel-output)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Convierte el contenido de una zona de drop en texto tipo
|
||||
// "3 Elemento A, 1 Elemento B".
|
||||
function getDndCanvasText(dndCanvas) {
|
||||
const parts = Array.from(dndCanvas.children).map(group => {
|
||||
const input = group.querySelector('input');
|
||||
const label = group.querySelector('li');
|
||||
if (!input || !label) return '';
|
||||
return `${input.value} ${label.textContent}`;
|
||||
});
|
||||
return parts.filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
// Añade una línea de texto al resultado (si no está vacía).
|
||||
function appendOutputLine(tag, text, className) {
|
||||
if (!text) return;
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
el.textContent = text;
|
||||
outputText.appendChild(el);
|
||||
}
|
||||
|
||||
// Recorre todas las secciones (en su orden actual) y reconstruye
|
||||
// #panel-output desde cero: cada sección aporta una línea/elemento según
|
||||
// su tipo (título, texto, drag&drop o imagen).
|
||||
function renderOutput() {
|
||||
outputText.innerHTML = '';
|
||||
|
||||
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
|
||||
// resultado vacío.
|
||||
if (sectionsContainer.children.length === 0) {
|
||||
appendOutputLine('p', outputText.dataset.emptyMessage, 'text-base-content/50 italic');
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(sectionsContainer.children).forEach(section => {
|
||||
const titleInput = section.querySelector('.title-input');
|
||||
if (titleInput) {
|
||||
appendOutputLine('h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1');
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitleInput = section.querySelector('.subtitle-input');
|
||||
if (subtitleInput) {
|
||||
appendOutputLine('h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1');
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = section.querySelector('textarea');
|
||||
if (textarea) {
|
||||
appendOutputLine('p', textarea.value.trim(), '');
|
||||
return;
|
||||
}
|
||||
|
||||
const dndCanvas = section.querySelector('.dnd-canvas');
|
||||
if (dndCanvas) {
|
||||
appendOutputLine('p', getDndCanvasText(dndCanvas), '');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sección de imagen: si ya hay una imagen cargada, se copia (con el
|
||||
// tamaño limitado) al resultado.
|
||||
const previewImg = section.querySelector('img');
|
||||
if (previewImg) {
|
||||
if (previewImg.src && !previewImg.classList.contains('hidden')) {
|
||||
const img = document.createElement('img');
|
||||
img.src = previewImg.src;
|
||||
img.className = 'max-w-full max-h-48 object-contain rounded-box my-2';
|
||||
outputText.appendChild(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// El resultado se regenera con cualquier cambio dentro de las secciones:
|
||||
// escribir texto/título ('input'), elegir una imagen ('change'), o
|
||||
// añadir/quitar/reordenar secciones o elementos soltados (MutationObserver).
|
||||
sectionsContainer.addEventListener('input', renderOutput);
|
||||
sectionsContainer.addEventListener('change', renderOutput);
|
||||
new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true });
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Creación de secciones (título, texto, imagen, drag & drop)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Crea el "armazón" común a toda sección: el recuadro, la cabecera (asa
|
||||
// para reordenar, tipo de sección, botón de colapsar y de eliminar) y el
|
||||
// cuerpo donde cada función addXxxSection() mete su contenido específico.
|
||||
// Devuelve { section, body }: `section` se añade a #panel-top-sections,
|
||||
// `body` es donde hay que appendear el contenido propio de cada tipo.
|
||||
function createSectionShell(typeLabel) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'top-section border border-base-300 rounded-box p-4 flex flex-col gap-2';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-center gap-2';
|
||||
|
||||
const dragHandle = document.createElement('span');
|
||||
dragHandle.className = 'cursor-move select-none text-base-content/50 px-1';
|
||||
dragHandle.textContent = '⠿';
|
||||
dragHandle.title = sectionsContainer.dataset.dragTitle;
|
||||
dragHandle.setAttribute('draggable', 'true');
|
||||
|
||||
dragHandle.addEventListener('dragstart', (e) => {
|
||||
draggedSection = section;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores.
|
||||
section.classList.add('dragging', 'opacity-50');
|
||||
});
|
||||
|
||||
dragHandle.addEventListener('dragend', () => {
|
||||
section.classList.remove('dragging', 'opacity-50');
|
||||
draggedSection = null;
|
||||
});
|
||||
|
||||
// Botón para colapsar/expandir el cuerpo de la sección, útil cuando hay
|
||||
// muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio.
|
||||
const collapseBtn = document.createElement('button');
|
||||
collapseBtn.type = 'button';
|
||||
collapseBtn.className = 'btn btn-xs btn-ghost';
|
||||
collapseBtn.textContent = '▾';
|
||||
collapseBtn.title = sectionsContainer.dataset.collapseTitle;
|
||||
|
||||
// Etiqueta que indica de qué tipo es la sección (Título, Texto, Imagen...).
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge badge-sm badge-outline';
|
||||
badge.textContent = typeLabel;
|
||||
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'flex-1';
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-xs btn-circle';
|
||||
removeBtn.textContent = '✕';
|
||||
removeBtn.title = sectionsContainer.dataset.removeTitle;
|
||||
removeBtn.addEventListener('click', () => section.remove());
|
||||
|
||||
header.appendChild(dragHandle);
|
||||
header.appendChild(collapseBtn);
|
||||
header.appendChild(badge);
|
||||
header.appendChild(spacer);
|
||||
header.appendChild(removeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'flex flex-col gap-2';
|
||||
|
||||
collapseBtn.addEventListener('click', () => {
|
||||
body.classList.toggle('hidden');
|
||||
collapseBtn.textContent = body.classList.contains('hidden') ? '▸' : '▾';
|
||||
});
|
||||
|
||||
section.appendChild(header);
|
||||
section.appendChild(body);
|
||||
return { section, body };
|
||||
}
|
||||
|
||||
// Sección de título: un input de una línea que se muestra como <h3> en el resultado.
|
||||
// `data` es el dataset del botón "+ Título" (data-type-label, data-placeholder).
|
||||
// `text`, si se pasa, precarga el valor (se usa al restaurar un patrón guardado).
|
||||
function addTitleSection(data, text = '') {
|
||||
const { section, body } = createSectionShell(data.typeLabel);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'title-input input input-bordered w-full';
|
||||
input.placeholder = data.placeholder;
|
||||
input.value = text;
|
||||
|
||||
body.appendChild(input);
|
||||
sectionsContainer.appendChild(section);
|
||||
}
|
||||
|
||||
// Sección de subtítulo: igual que la de título pero se muestra como <h4>
|
||||
// (más pequeño) en el resultado, para marcar un encabezado secundario.
|
||||
function addSubtitleSection(data, text = '') {
|
||||
const { section, body } = createSectionShell(data.typeLabel);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'subtitle-input input input-bordered w-full';
|
||||
input.placeholder = data.placeholder;
|
||||
input.value = text;
|
||||
|
||||
body.appendChild(input);
|
||||
sectionsContainer.appendChild(section);
|
||||
}
|
||||
|
||||
// Sección de texto libre.
|
||||
function addTextSection(data, text = '') {
|
||||
const { section, body } = createSectionShell(data.typeLabel);
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'textarea textarea-bordered w-full';
|
||||
textarea.placeholder = data.placeholder;
|
||||
textarea.value = text;
|
||||
|
||||
body.appendChild(textarea);
|
||||
sectionsContainer.appendChild(section);
|
||||
}
|
||||
|
||||
// Sección de imagen: selector de archivo + vista previa (tamaño limitado
|
||||
// para que una foto de alta resolución no ocupe toda la pantalla).
|
||||
// `dataUrl`, si se pasa, precarga la imagen (al restaurar un patrón guardado).
|
||||
// Se guarda como data URL en base64 (no con URL.createObjectURL) porque un
|
||||
// blob: solo es válido durante la sesión actual y no se puede persistir.
|
||||
function addImageSection(data, dataUrl = '') {
|
||||
const { section, body } = createSectionShell(data.typeLabel);
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.className = 'file-input file-input-bordered w-full mb-2';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'max-w-full max-h-48 object-contain rounded-box hidden';
|
||||
|
||||
if (dataUrl) {
|
||||
img.src = dataUrl;
|
||||
img.classList.remove('hidden');
|
||||
}
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => {
|
||||
img.src = reader.result;
|
||||
img.classList.remove('hidden');
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
body.appendChild(fileInput);
|
||||
body.appendChild(img);
|
||||
sectionsContainer.appendChild(section);
|
||||
}
|
||||
|
||||
// Sección de "arrastrar y soltar": una zona de drop propia junto a su
|
||||
// propia lista de elementos arrastrables (clonada de la fuente), para
|
||||
// que cada sección de este tipo sea independiente de las demás.
|
||||
// `items`, si se pasa, precarga los elementos ya soltados: [{ label, count }].
|
||||
function addDndSection(data, items = []) {
|
||||
const { section, body } = createSectionShell(data.typeLabel);
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex gap-4';
|
||||
|
||||
const dndCanvas = document.createElement('div');
|
||||
dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap items-center gap-2 min-h-24';
|
||||
|
||||
const elementsPanel = document.createElement('aside');
|
||||
elementsPanel.className = 'w-48 shrink-0 border border-base-300 rounded-box p-4';
|
||||
|
||||
const elementsTitle = document.createElement('h3');
|
||||
elementsTitle.className = 'font-semibold mb-2';
|
||||
elementsTitle.textContent = data.elementsLabel;
|
||||
|
||||
const list = draggableItemsSource.cloneNode(true);
|
||||
list.removeAttribute('id'); // Evitar ids duplicados: puede haber varias secciones de este tipo.
|
||||
list.classList.remove('hidden'); // La fuente está oculta; el clon sí debe mostrarse.
|
||||
list.querySelectorAll('li').forEach(makeDraggable);
|
||||
|
||||
elementsPanel.appendChild(elementsTitle);
|
||||
elementsPanel.appendChild(list);
|
||||
|
||||
row.appendChild(dndCanvas);
|
||||
row.appendChild(elementsPanel);
|
||||
body.appendChild(row);
|
||||
sectionsContainer.appendChild(section);
|
||||
setupDropZone(dndCanvas);
|
||||
|
||||
items.forEach(item => addDndItem(dndCanvas, item.label, item.count));
|
||||
}
|
||||
|
||||
// Botones de la barra superior para añadir cada tipo de sección. Los textos
|
||||
// (etiqueta de tipo, placeholders...) se leen de sus data-* en el HTML, no
|
||||
// están escritos aquí, para que la plantilla Django pueda traducirlos.
|
||||
const addTitleBtn = document.getElementById('add-title-section');
|
||||
const addSubtitleBtn = document.getElementById('add-subtitle-section');
|
||||
const addTextBtn = document.getElementById('add-text-section');
|
||||
const addImageBtn = document.getElementById('add-image-section');
|
||||
const addDndBtn = document.getElementById('add-dnd-section');
|
||||
|
||||
addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset));
|
||||
addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset));
|
||||
addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset));
|
||||
addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset));
|
||||
addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset));
|
||||
|
||||
// Exportar #panel-output a PDF: se usa el diálogo de impresión del propio
|
||||
// navegador (con "Guardar como PDF") en vez de una librería externa, ya
|
||||
// que las librerías tipo html2canvas no renderizan bien los colores oklch
|
||||
// que usa Tailwind v4. La hoja de estilos de impresión (css/main.css)
|
||||
// aísla #panel-output para que solo se exporte el resultado.
|
||||
document.getElementById('export-pdf').addEventListener('click', () => {
|
||||
window.print();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Guardar / cargar las secciones (serializarlas a datos planos)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Recorre #panel-top-sections y lo convierte en un array de objetos planos
|
||||
// (uno por sección), listo para guardarse como JSON en cualquier sitio
|
||||
// (localStorage, o enviado a un backend Django).
|
||||
function serializeSections() {
|
||||
return Array.from(sectionsContainer.children).map(section => {
|
||||
const titleInput = section.querySelector('.title-input');
|
||||
if (titleInput) return { type: 'title', text: titleInput.value };
|
||||
|
||||
const subtitleInput = section.querySelector('.subtitle-input');
|
||||
if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value };
|
||||
|
||||
const textarea = section.querySelector('textarea');
|
||||
if (textarea) return { type: 'text', text: textarea.value };
|
||||
|
||||
const dndCanvas = section.querySelector('.dnd-canvas');
|
||||
if (dndCanvas) {
|
||||
const items = Array.from(dndCanvas.children).map(group => {
|
||||
const input = group.querySelector('input');
|
||||
const label = group.querySelector('li');
|
||||
if (!input || !label) return null;
|
||||
return { label: label.textContent.trim(), count: Number(input.value) };
|
||||
}).filter(Boolean);
|
||||
return { type: 'dnd', items };
|
||||
}
|
||||
|
||||
const img = section.querySelector('img');
|
||||
if (img) {
|
||||
const dataUrl = !img.classList.contains('hidden') ? img.src : '';
|
||||
return { type: 'image', dataUrl };
|
||||
}
|
||||
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
// Reconstruye #panel-top-sections a partir del array que genera
|
||||
// serializeSections(), sustituyendo las secciones actuales.
|
||||
function buildSectionsFrom(sectionsData) {
|
||||
sectionsContainer.innerHTML = '';
|
||||
|
||||
sectionsData.forEach(sectionData => {
|
||||
if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text);
|
||||
if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text);
|
||||
if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text);
|
||||
if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl);
|
||||
if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items);
|
||||
});
|
||||
}
|
||||
|
||||
// Ejemplo de uso: guardar/cargar todas las secciones en localStorage.
|
||||
// Para integrarlo con Django, bastaría con sustituir estos dos listeners
|
||||
// por una llamada a la API (POST del JSON de serializeSections() / GET
|
||||
// para pasarle el resultado a buildSectionsFrom()).
|
||||
const SECTIONS_STORAGE_KEY = 'sections-data';
|
||||
|
||||
document.getElementById('save-pattern').addEventListener('click', () => {
|
||||
localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(serializeSections()));
|
||||
});
|
||||
|
||||
document.getElementById('load-pattern').addEventListener('click', () => {
|
||||
const raw = localStorage.getItem(SECTIONS_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
buildSectionsFrom(JSON.parse(raw));
|
||||
});
|
||||
|
||||
renderOutput(); // Pinta el mensaje de "sin secciones" nada más cargar la página.
|
||||
});
|
||||
Reference in New Issue
Block a user