feat: splitted js in different modules
This commit is contained in:
+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;
|
||||
});
|
||||
Reference in New Issue
Block a user