feat: splitted js in different modules

This commit is contained in:
2026-07-13 13:49:19 +02:00
parent 692cd2b481
commit a04d70755b
13 changed files with 1290 additions and 1207 deletions
+5 -1
View File
@@ -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
View File
@@ -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(', ');
}
-1206
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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');
+109
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+60
View File
@@ -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);
});
+53
View File
@@ -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));
+303
View File
@@ -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
View File
@@ -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;
});
+25
View File
@@ -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
View File
@@ -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();
});