422 lines
18 KiB
JavaScript
422 lines
18 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('section');
|
|
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...).
|
|
// 'section-badge' y data-type-label permiten a section-types.js (que no
|
|
// conoce la estructura interna de una sección) actualizar este texto con
|
|
// un nombre derivado del contenido ("Título: Bufanda de lana") sin perder
|
|
// de vista cuál es la etiqueta de tipo original.
|
|
const badge = document.createElement('span');
|
|
// 'min-w-0 truncate': por defecto un elemento flex no encoge por debajo
|
|
// del tamaño de su contenido, así que un nombre largo (p.ej. una lista de
|
|
// materiales) desbordaba la cabecera en pantallas estrechas en vez de
|
|
// recortarse con "…".
|
|
badge.className = 'section-badge badge badge-sm badge-outline select-none min-w-0 truncate';
|
|
badge.textContent = typeLabel;
|
|
badge.dataset.typeLabel = 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', () => {
|
|
// Solo se confirma al eliminar un "Grupo" (reconocible por su propia
|
|
// clase 'group-container', sin necesidad de importar section-types.js):
|
|
// puede contener muchas secciones dentro, así que perderlo de un clic
|
|
// es mucho más costoso que perder una sección individual.
|
|
const isGroup = section.querySelector('.group-container') != null;
|
|
if (isGroup && !confirm(sectionsContainer.dataset.removeConfirm)) return;
|
|
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-96 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;
|
|
});
|