feat: improved user feedback
This commit is contained in:
+91
-12
@@ -30,6 +30,13 @@ export function appendOutputLine(target, tag, text, className) {
|
||||
target.appendChild(el);
|
||||
}
|
||||
|
||||
// Recorta un nombre derivado del contenido para que quepa en el badge de
|
||||
// la cabecera sin desbordarla.
|
||||
const SECTION_NAME_MAX_LENGTH = 40;
|
||||
function truncate(text) {
|
||||
return text.length > SECTION_NAME_MAX_LENGTH ? `${text.slice(0, SECTION_NAME_MAX_LENGTH - 1)}…` : text;
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -105,6 +112,22 @@ const SECTION_TYPES = [
|
||||
Array.from(groupContainer.children).forEach(child => renderOutputSection(child, wrapper));
|
||||
if (wrapper.children.length) target.appendChild(wrapper);
|
||||
},
|
||||
// Si el grupo tiene, entre sus secciones directas, un título o un
|
||||
// subtítulo, se usa su texto como nombre (es lo más representativo de
|
||||
// "de qué trata" el grupo); si no, se cae al recuento de secciones.
|
||||
getName: (groupContainer) => {
|
||||
const heading = Array.from(groupContainer.children)
|
||||
.map(resolveSectionMatch)
|
||||
.find(found => found && (found.entry.type === 'title' || found.entry.type === 'subtitle'));
|
||||
if (heading) {
|
||||
const text = heading.match.value.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
const count = groupContainer.children.length;
|
||||
if (!count) return '';
|
||||
return count === 1 ? '1 sección' : `${count} secciones`;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'title',
|
||||
@@ -114,6 +137,7 @@ const SECTION_TYPES = [
|
||||
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'),
|
||||
getName: (titleInput) => titleInput.value.trim(),
|
||||
},
|
||||
{
|
||||
type: 'subtitle',
|
||||
@@ -123,6 +147,7 @@ const SECTION_TYPES = [
|
||||
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'),
|
||||
getName: (subtitleInput) => subtitleInput.value.trim(),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
@@ -131,6 +156,7 @@ const SECTION_TYPES = [
|
||||
matches: (section) => section.querySelector('.text-input'),
|
||||
serialize: (section, textInput) => ({ type: 'text', text: getTranslations(textInput) }),
|
||||
render: (section, textInput, target) => appendOutputLine(target, 'p', textInput.value.trim(), ''),
|
||||
getName: (textInput) => textInput.value.trim(),
|
||||
},
|
||||
{
|
||||
type: 'note',
|
||||
@@ -165,6 +191,7 @@ const SECTION_TYPES = [
|
||||
note.textContent = text;
|
||||
target.appendChild(note);
|
||||
},
|
||||
getName: (noteInput) => noteInput.value.trim(),
|
||||
},
|
||||
{
|
||||
type: 'materials',
|
||||
@@ -190,6 +217,10 @@ const SECTION_TYPES = [
|
||||
});
|
||||
target.appendChild(ul);
|
||||
},
|
||||
getName: (materialsList) => Array.from(materialsList.querySelectorAll('.material-input'))
|
||||
.map(input => input.value.trim())
|
||||
.filter(Boolean)
|
||||
.join(', '),
|
||||
},
|
||||
{
|
||||
type: 'dnd',
|
||||
@@ -206,6 +237,7 @@ const SECTION_TYPES = [
|
||||
return { type: 'dnd', items };
|
||||
},
|
||||
render: (section, dndCanvas, target) => appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), ''),
|
||||
getName: (dndCanvas) => getDndCanvasText(dndCanvas),
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
@@ -224,26 +256,73 @@ const SECTION_TYPES = [
|
||||
},
|
||||
];
|
||||
|
||||
// Encuentra, en orden, la primera entrada de SECTION_TYPES cuyo `matches`
|
||||
// encaje con `section`, y devuelve tanto la entrada como lo que encontró
|
||||
// `matches` (el elemento con el que trabajan serialize/render/getName).
|
||||
// Centraliza aquí ese recorrido porque se repite en varios sitios: al
|
||||
// pintar, al serializar, al nombrar el badge, e incluso dentro del propio
|
||||
// `getName` de "Grupo" (para saber si una de sus secciones directas es un
|
||||
// título o un subtítulo).
|
||||
function resolveSectionMatch(section) {
|
||||
for (const entry of SECTION_TYPES) {
|
||||
const match = entry.matches(section);
|
||||
if (match) return { entry, match };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
const found = resolveSectionMatch(section);
|
||||
if (found) found.entry.render(section, found.match, target);
|
||||
}
|
||||
|
||||
// Nombre corto derivado del contenido de la sección (p.ej. el propio texto
|
||||
// de un título, o "3 PB, 2 Disminución" para un patrón), para que el badge
|
||||
// de la cabecera sea reconocible incluso con la sección colapsada. No todos
|
||||
// los tipos lo tienen (una imagen no tiene un nombre natural sin guardar
|
||||
// también el nombre de archivo); en ese caso se deja solo la etiqueta de
|
||||
// tipo.
|
||||
function getSectionName(section) {
|
||||
const found = resolveSectionMatch(section);
|
||||
return found?.entry.getName ? truncate(found.entry.getName(found.match)) : '';
|
||||
}
|
||||
|
||||
// Actualiza el badge de una sección con "Tipo: nombre" (o solo "Tipo" si
|
||||
// todavía no hay contenido). El tipo original se guarda en
|
||||
// data-type-label (ver createSectionShell en sections.js) porque el propio
|
||||
// textContent del badge se sobrescribe aquí. Solo se toca el DOM si el
|
||||
// texto cambia de verdad: asignar textContent (aunque sea al mismo valor)
|
||||
// borra y vuelve a crear el nodo de texto, lo que cuenta como mutación
|
||||
// para el MutationObserver de más abajo y disparaba un bucle infinito
|
||||
// (cada actualización generaba otra mutación que volvía a llamar a esta
|
||||
// misma función).
|
||||
function updateSectionBadge(section) {
|
||||
const badge = section.querySelector('.section-badge');
|
||||
if (!badge) return;
|
||||
const name = getSectionName(section);
|
||||
const text = name ? `${badge.dataset.typeLabel}: ${name}` : badge.dataset.typeLabel;
|
||||
if (badge.textContent !== text) badge.textContent = text;
|
||||
}
|
||||
|
||||
// Recorre todas las secciones (a cualquier nivel de anidamiento) y
|
||||
// refresca su badge. Se dispara con los mismos eventos que renderOutput()
|
||||
// en render.js, pero de forma independiente: cada módulo escucha los
|
||||
// mismos eventos compartidos en sectionsContainer para su propio cometido.
|
||||
function updateAllBadges() {
|
||||
sectionsContainer.querySelectorAll('.top-section').forEach(updateSectionBadge);
|
||||
}
|
||||
|
||||
sectionsContainer.addEventListener('input', updateAllBadges);
|
||||
sectionsContainer.addEventListener('change', updateAllBadges);
|
||||
new MutationObserver(updateAllBadges).observe(sectionsContainer, { childList: true, subtree: true });
|
||||
|
||||
// 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;
|
||||
const found = resolveSectionMatch(section);
|
||||
return found ? found.entry.serialize(section, found.match) : null;
|
||||
}
|
||||
|
||||
// Recorre #panel-top-sections y lo convierte en un array de objetos planos
|
||||
|
||||
Reference in New Issue
Block a user