feat: improved user feedback
This commit is contained in:
@@ -67,6 +67,12 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
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 {
|
||||
|
||||
+15
-5
@@ -8,9 +8,9 @@
|
||||
<link rel="stylesheet" href="css/main.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>
|
||||
<!-- Rellenada por js/dnd.js con el @page (tamaño/orientación) elegido en
|
||||
"Personalización de página": un <style> aparte porque @page no se
|
||||
puede cambiar como una propiedad normal desde JS. -->
|
||||
<!-- Rellenada por js/page-settings.js con el @page (tamaño/orientación)
|
||||
elegido en "Personalización de página": un <style> aparte porque
|
||||
@page no se puede cambiar como una propiedad normal desde JS. -->
|
||||
<style id="page-size-style"></style>
|
||||
</head>
|
||||
|
||||
@@ -30,11 +30,20 @@
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
<!-- 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-collapse-title="Colapsar / expandir"
|
||||
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 id="panel-output" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile">
|
||||
|
||||
+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
|
||||
|
||||
+16
-3
@@ -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
|
||||
// `body` es donde hay que appendear el contenido propio de cada tipo.
|
||||
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';
|
||||
|
||||
// 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;
|
||||
|
||||
// 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');
|
||||
badge.className = 'badge badge-sm badge-outline select-none';
|
||||
badge.className = 'section-badge badge badge-sm badge-outline select-none';
|
||||
badge.textContent = typeLabel;
|
||||
badge.dataset.typeLabel = typeLabel;
|
||||
|
||||
const spacer = document.createElement('div');
|
||||
spacer.className = 'flex-1';
|
||||
@@ -118,7 +123,15 @@ export function createSectionShell(typeLabel) {
|
||||
removeBtn.className = 'btn btn-xs btn-circle';
|
||||
removeBtn.textContent = '✕';
|
||||
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(collapseBtn);
|
||||
|
||||
+22
-1
@@ -10,14 +10,35 @@ import { renderOutput } from './render.js';
|
||||
|
||||
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', () => {
|
||||
const data = { sections: serializeSections(), pageSettings: serializePageSettings() };
|
||||
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);
|
||||
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);
|
||||
buildSectionsFrom(data.sections || []);
|
||||
applyPageSettings(data.pageSettings);
|
||||
|
||||
Reference in New Issue
Block a user