Files
crochet/js/sections.js
T

405 lines
17 KiB
JavaScript

// 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;
});