feat: improved user feedback

This commit is contained in:
2026-07-13 14:42:44 +02:00
parent 5805c96d57
commit e257436a5a
5 changed files with 150 additions and 21 deletions
+6
View File
@@ -67,6 +67,12 @@
left: 0; left: 0;
width: 100%; width: 100%;
border: none; border: none;
/* @page tiene margin: 0 (ver más abajo), así que sin esto el contenido
llegaría hasta el borde físico de la hoja. Se pone aquí (como
padding del propio contenido) en vez de en @page porque el soporte
de "margin" en @page es más inconsistente entre navegadores. */
padding: 2cm;
box-sizing: border-box;
} }
#preview-title { #preview-title {
+15 -5
View File
@@ -8,9 +8,9 @@
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Rellenada por js/dnd.js con el @page (tamaño/orientación) elegido en <!-- Rellenada por js/page-settings.js con el @page (tamaño/orientación)
"Personalización de página": un <style> aparte porque @page no se elegido en "Personalización de página": un <style> aparte porque
puede cambiar como una propiedad normal desde JS. --> @page no se puede cambiar como una propiedad normal desde JS. -->
<style id="page-size-style"></style> <style id="page-size-style"></style>
</head> </head>
@@ -30,11 +30,20 @@
<option value="en">English</option> <option value="en">English</option>
</select> </select>
<button id="save-pattern" type="button" class="btn btn-sm btn-outline">Guardar</button> <button id="save-pattern" type="button" class="btn btn-sm btn-outline">Guardar</button>
<button id="load-pattern" type="button" class="btn btn-sm btn-outline">Cargar</button> <button id="load-pattern" type="button" class="btn btn-sm btn-outline"
data-confirm-message="¿Seguro que quieres cargar el patrón guardado? Se perderán los cambios que no hayas guardado.">Cargar</button>
<button id="export-pdf" type="button" class="btn btn-sm">Exportar a PDF</button> <button id="export-pdf" type="button" class="btn btn-sm">Exportar a PDF</button>
</div> </div>
</header> </header>
<!-- Aviso breve de que "Guardar" ha funcionado: sin esto, guardar en
localStorage no da ninguna señal de que ha ocurrido algo. -->
<div id="save-toast" class="toast toast-top toast-end no-print hidden">
<div class="alert alert-success">
<span data-message="Guardado">Guardado</span>
</div>
</div>
<div class="p-4 pt-0"> <div class="p-4 pt-0">
<!-- Configuración única para todo el documento (no es una sección más: <!-- Configuración única para todo el documento (no es una sección más:
@@ -130,7 +139,8 @@
data-drag-title="Arrastrar para reordenar" data-drag-title="Arrastrar para reordenar"
data-collapse-title="Colapsar / expandir" data-collapse-title="Colapsar / expandir"
data-duplicate-title="Duplicar sección" data-duplicate-title="Duplicar sección"
data-remove-title="Eliminar sección"></div> data-remove-title="Eliminar sección"
data-remove-confirm="¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las secciones que contiene."></div>
</section> </section>
<section id="panel-output" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile"> <section id="panel-output" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile">
+91 -12
View File
@@ -30,6 +30,13 @@ export function appendOutputLine(target, tag, text, className) {
target.appendChild(el); 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 // 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 // secciones dentro de sí misma (con su propia barra de "+ tipo" y su propio
// orden independiente), para organizar visualmente un conjunto de // orden independiente), para organizar visualmente un conjunto de
@@ -105,6 +112,22 @@ const SECTION_TYPES = [
Array.from(groupContainer.children).forEach(child => renderOutputSection(child, wrapper)); Array.from(groupContainer.children).forEach(child => renderOutputSection(child, wrapper));
if (wrapper.children.length) target.appendChild(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', type: 'title',
@@ -114,6 +137,7 @@ const SECTION_TYPES = [
serialize: (section, titleInput) => ({ type: 'title', text: getTranslations(titleInput) }), serialize: (section, titleInput) => ({ type: 'title', text: getTranslations(titleInput) }),
render: (section, titleInput, target) => render: (section, titleInput, target) =>
appendOutputLine(target, 'h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1'), appendOutputLine(target, 'h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1'),
getName: (titleInput) => titleInput.value.trim(),
}, },
{ {
type: 'subtitle', type: 'subtitle',
@@ -123,6 +147,7 @@ const SECTION_TYPES = [
serialize: (section, subtitleInput) => ({ type: 'subtitle', text: getTranslations(subtitleInput) }), serialize: (section, subtitleInput) => ({ type: 'subtitle', text: getTranslations(subtitleInput) }),
render: (section, subtitleInput, target) => render: (section, subtitleInput, target) =>
appendOutputLine(target, 'h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1'), appendOutputLine(target, 'h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1'),
getName: (subtitleInput) => subtitleInput.value.trim(),
}, },
{ {
type: 'text', type: 'text',
@@ -131,6 +156,7 @@ const SECTION_TYPES = [
matches: (section) => section.querySelector('.text-input'), matches: (section) => section.querySelector('.text-input'),
serialize: (section, textInput) => ({ type: 'text', text: getTranslations(textInput) }), serialize: (section, textInput) => ({ type: 'text', text: getTranslations(textInput) }),
render: (section, textInput, target) => appendOutputLine(target, 'p', textInput.value.trim(), ''), render: (section, textInput, target) => appendOutputLine(target, 'p', textInput.value.trim(), ''),
getName: (textInput) => textInput.value.trim(),
}, },
{ {
type: 'note', type: 'note',
@@ -165,6 +191,7 @@ const SECTION_TYPES = [
note.textContent = text; note.textContent = text;
target.appendChild(note); target.appendChild(note);
}, },
getName: (noteInput) => noteInput.value.trim(),
}, },
{ {
type: 'materials', type: 'materials',
@@ -190,6 +217,10 @@ const SECTION_TYPES = [
}); });
target.appendChild(ul); target.appendChild(ul);
}, },
getName: (materialsList) => Array.from(materialsList.querySelectorAll('.material-input'))
.map(input => input.value.trim())
.filter(Boolean)
.join(', '),
}, },
{ {
type: 'dnd', type: 'dnd',
@@ -206,6 +237,7 @@ const SECTION_TYPES = [
return { type: 'dnd', items }; return { type: 'dnd', items };
}, },
render: (section, dndCanvas, target) => appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), ''), render: (section, dndCanvas, target) => appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), ''),
getName: (dndCanvas) => getDndCanvasText(dndCanvas),
}, },
{ {
type: 'image', 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, // Aporta a `target` lo que corresponda según el tipo de `section` (título,
// subtítulo, texto, nota, materiales, patrón, imagen o grupo). // subtítulo, texto, nota, materiales, patrón, imagen o grupo).
export function renderOutputSection(section, target) { export function renderOutputSection(section, target) {
for (const entry of SECTION_TYPES) { const found = resolveSectionMatch(section);
const match = entry.matches(section); if (found) found.entry.render(section, found.match, target);
if (match) {
entry.render(section, match, target);
return;
}
}
} }
// 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 // Convierte una sección en un objeto plano según su tipo (recursivo si es
// un "Grupo", ver su entrada en SECTION_TYPES). // un "Grupo", ver su entrada en SECTION_TYPES).
export function serializeSection(section) { export function serializeSection(section) {
for (const entry of SECTION_TYPES) { const found = resolveSectionMatch(section);
const match = entry.matches(section); return found ? found.entry.serialize(section, found.match) : null;
if (match) return entry.serialize(section, match);
}
return null;
} }
// Recorre #panel-top-sections y lo convierte en un array de objetos planos // Recorre #panel-top-sections y lo convierte en un array de objetos planos
+16 -3
View File
@@ -17,7 +17,7 @@ import { makeDraggable, addDndItem, setupDropZone } from './dnd-items.js';
// qué contenedor meter `section` (el nivel superior, o el de un grupo), y // 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. // `body` es donde hay que appendear el contenido propio de cada tipo.
export function createSectionShell(typeLabel) { export function createSectionShell(typeLabel) {
const section = document.createElement('div'); const section = document.createElement('section');
section.className = 'top-section border border-base-300 rounded-box p-4 flex flex-col gap-2'; 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 // Toda la cabecera es la zona de arrastre para reordenar (no solo el
@@ -92,9 +92,14 @@ export function createSectionShell(typeLabel) {
collapseBtn.title = sectionsContainer.dataset.collapseTitle; collapseBtn.title = sectionsContainer.dataset.collapseTitle;
// Etiqueta que indica de qué tipo es la sección (Título, Texto, Imagen...). // 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'); const badge = document.createElement('span');
badge.className = 'badge badge-sm badge-outline select-none'; badge.className = 'section-badge badge badge-sm badge-outline select-none';
badge.textContent = typeLabel; badge.textContent = typeLabel;
badge.dataset.typeLabel = typeLabel;
const spacer = document.createElement('div'); const spacer = document.createElement('div');
spacer.className = 'flex-1'; spacer.className = 'flex-1';
@@ -118,7 +123,15 @@ export function createSectionShell(typeLabel) {
removeBtn.className = 'btn btn-xs btn-circle'; removeBtn.className = 'btn btn-xs btn-circle';
removeBtn.textContent = '✕'; removeBtn.textContent = '✕';
removeBtn.title = sectionsContainer.dataset.removeTitle; removeBtn.title = sectionsContainer.dataset.removeTitle;
removeBtn.addEventListener('click', () => section.remove()); 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(dragHandle);
header.appendChild(collapseBtn); header.appendChild(collapseBtn);
+22 -1
View File
@@ -10,14 +10,35 @@ import { renderOutput } from './render.js';
const SECTIONS_STORAGE_KEY = 'sections-data'; const SECTIONS_STORAGE_KEY = 'sections-data';
// Guardar en localStorage no da ninguna señal por sí solo; este aviso breve
// confirma al usuario que sí ha ocurrido algo. Se reutiliza el mismo
// temporizador en clics seguidos para que no se oculte a mitad de un nuevo
// aviso si el usuario pulsa "Guardar" varias veces seguidas.
const saveToast = document.getElementById('save-toast');
let saveToastTimeout;
function showSaveToast() {
saveToast.classList.remove('hidden');
clearTimeout(saveToastTimeout);
saveToastTimeout = setTimeout(() => saveToast.classList.add('hidden'), 2000);
}
document.getElementById('save-pattern').addEventListener('click', () => { document.getElementById('save-pattern').addEventListener('click', () => {
const data = { sections: serializeSections(), pageSettings: serializePageSettings() }; const data = { sections: serializeSections(), pageSettings: serializePageSettings() };
localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(data));
showSaveToast();
}); });
document.getElementById('load-pattern').addEventListener('click', () => { const loadBtn = document.getElementById('load-pattern');
loadBtn.addEventListener('click', () => {
const raw = localStorage.getItem(SECTIONS_STORAGE_KEY); const raw = localStorage.getItem(SECTIONS_STORAGE_KEY);
if (!raw) return; if (!raw) return;
// Cargar sustituye todo lo que haya en pantalla sin posibilidad de
// deshacerlo, así que se confirma antes: podría haber cambios sin guardar.
if (!confirm(loadBtn.dataset.confirmMessage)) return;
const data = JSON.parse(raw); const data = JSON.parse(raw);
buildSectionsFrom(data.sections || []); buildSectionsFrom(data.sections || []);
applyPageSettings(data.pageSettings); applyPageSettings(data.pageSettings);