feat: added more sections and page customization

This commit is contained in:
2026-07-13 12:34:42 +02:00
parent bfb020531b
commit 692cd2b481
3 changed files with 328 additions and 13 deletions
+253 -12
View File
@@ -5,6 +5,17 @@ document.addEventListener('DOMContentLoaded', () => {
const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
const draggableItemsSource = document.getElementById('draggable-items-source'); // Lista oculta con los elementos disponibles para arrastrar: fuente de datos única, se clona (visible) en cada sección de tipo "arrastrar y soltar".
const languageSelect = document.getElementById('language-select'); // Idioma del contenido (no de la interfaz): controla qué versión de los textos de título/subtítulo/texto se muestra y edita.
const panelOutput = document.getElementById('panel-output');
// Controles de "Personalización de página": configuración única para todo
// el documento (no una sección más), ver el bloque más abajo.
const pageTitleInput = document.getElementById('page-title-input');
const pageAuthorInput = document.getElementById('page-author-input');
const pageBgColorInput = document.getElementById('page-bg-color');
const pageFontSelect = document.getElementById('page-font-select');
const pageSizeSelect = document.getElementById('page-size-select');
const pageOrientationSelect = document.getElementById('page-orientation-select');
const pageSizeStyle = document.getElementById('page-size-style');
// Idioma actualmente seleccionado para el contenido. La estructura de
// secciones (cuáles hay, su orden, su tipo) es la misma para todos los
@@ -395,9 +406,48 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
const textarea = section.querySelector('textarea');
if (textarea) {
appendOutputLine(target, 'p', textarea.value.trim(), '');
const textInput = section.querySelector('.text-input');
if (textInput) {
appendOutputLine(target, 'p', textInput.value.trim(), '');
return;
}
// Nota/consejo: se destaca con un "alert" de daisyUI (para la forma:
// padding, bordes redondeados...) pero con los colores de texto y fondo
// que haya elegido el usuario, en vez de los del tema, para que se
// distinga a simple vista de un texto libre.
const noteInput = section.querySelector('.note-input');
if (noteInput) {
const text = noteInput.value.trim();
if (text) {
const note = document.createElement('div');
note.className = 'alert text-sm my-2';
const textColorInput = section.querySelector('.note-text-color');
const bgColorInput = section.querySelector('.note-bg-color');
if (textColorInput) note.style.color = textColorInput.value;
if (bgColorInput) note.style.backgroundColor = bgColorInput.value;
note.textContent = text;
target.appendChild(note);
}
return;
}
// Materiales: una línea por elemento de la lista, en vez de un único párrafo.
const materialsList = section.querySelector('.materials-list');
if (materialsList) {
const items = Array.from(materialsList.querySelectorAll('.material-input'))
.map(input => input.value.trim())
.filter(Boolean);
if (items.length) {
const ul = document.createElement('ul');
ul.className = 'list-disc list-inside my-1';
items.forEach(text => {
const li = document.createElement('li');
li.textContent = text;
ul.appendChild(li);
});
target.appendChild(ul);
}
return;
}
@@ -425,6 +475,12 @@ document.addEventListener('DOMContentLoaded', () => {
function renderOutput() {
outputText.innerHTML = '';
// Título y autor del patrón (ver "Personalización de página"): son
// configuración de todo el documento, no una sección más, así que se
// pintan siempre delante, haya o no secciones añadidas.
appendOutputLine(outputText, 'h1', pageTitleInput.value.trim(), 'text-2xl font-bold mb-1');
appendOutputLine(outputText, 'p', pageAuthorInput.value.trim(), 'text-sm text-base-content/70 mb-2');
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
// resultado vacío.
if (sectionsContainer.children.length === 0) {
@@ -444,8 +500,10 @@ document.addEventListener('DOMContentLoaded', () => {
// tecla también se guarda en su mapa de traducciones para el idioma
// actual, así no hace falta un paso aparte de "guardar" al cambiar de
// idioma o al serializar la sección.
const TRANSLATABLE_FIELDS_SELECTOR = '.title-input, .subtitle-input, .material-input, textarea'; // textarea cubre tanto "Texto" (.text-input) como "Nota" (.note-input).
sectionsContainer.addEventListener('input', (e) => {
if (e.target.matches('.title-input, .subtitle-input, textarea')) {
if (e.target.matches(TRANSLATABLE_FIELDS_SELECTOR)) {
setTranslation(e.target, e.target.value);
}
renderOutput();
@@ -459,7 +517,7 @@ document.addEventListener('DOMContentLoaded', () => {
// nada en ese idioma), y se repinta la vista previa.
languageSelect.addEventListener('change', () => {
currentLanguage = languageSelect.value;
sectionsContainer.querySelectorAll('.title-input, .subtitle-input, textarea').forEach(el => {
sectionsContainer.querySelectorAll(TRANSLATABLE_FIELDS_SELECTOR).forEach(el => {
el.value = getTranslations(el)[currentLanguage] || '';
});
renderOutput();
@@ -633,12 +691,15 @@ document.addEventListener('DOMContentLoaded', () => {
container.appendChild(section);
}
// Sección de texto libre.
// Sección de texto libre. Se marca con la clase 'text-input' (además de
// 'textarea') para poder distinguirla de la de "Nota" en
// serializeSection()/renderOutputSection(): ambas son un <textarea>, así
// que buscar por la etiqueta genérica sería ambiguo.
function addTextSection(data, translations = {}, container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const textarea = document.createElement('textarea');
textarea.className = 'textarea textarea-bordered w-full';
textarea.className = 'text-input textarea textarea-bordered w-full';
textarea.placeholder = data.placeholder;
textarea.dataset.translations = JSON.stringify(translations);
textarea.value = translations[currentLanguage] || '';
@@ -647,6 +708,102 @@ document.addEventListener('DOMContentLoaded', () => {
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).
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[currentLanguage] || '';
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).
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[currentLanguage] || '';
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).
@@ -745,6 +902,8 @@ document.addEventListener('DOMContentLoaded', () => {
[addTitleBtn, addTitleSection],
[addSubtitleBtn, addSubtitleSection],
[addTextBtn, addTextSection],
[addNoteBtn, addNoteSection],
[addMaterialsBtn, addMaterialsSection],
[addImageBtn, addImageSection],
[addDndBtn, addDndSection],
[addGroupBtn, addGroupSection],
@@ -770,6 +929,8 @@ document.addEventListener('DOMContentLoaded', () => {
const addTitleBtn = document.getElementById('add-title-section');
const addSubtitleBtn = document.getElementById('add-subtitle-section');
const addTextBtn = document.getElementById('add-text-section');
const addNoteBtn = document.getElementById('add-note-section');
const addMaterialsBtn = document.getElementById('add-materials-section');
const addImageBtn = document.getElementById('add-image-section');
const addDndBtn = document.getElementById('add-dnd-section');
const addGroupBtn = document.getElementById('add-group-section');
@@ -777,6 +938,8 @@ document.addEventListener('DOMContentLoaded', () => {
addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset));
addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset));
addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset));
addNoteBtn.addEventListener('click', () => addNoteSection(addNoteBtn.dataset));
addMaterialsBtn.addEventListener('click', () => addMaterialsSection(addMaterialsBtn.dataset));
addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset));
addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset));
addGroupBtn.addEventListener('click', () => addGroupSection(addGroupBtn.dataset));
@@ -817,6 +980,57 @@ document.addEventListener('DOMContentLoaded', () => {
: toggleCollapseAllBtn.dataset.collapseAllLabel;
});
// ---------------------------------------------------------------------
// Personalización de página: color de fondo, tipografía y tamaño/
// orientación del PDF. A diferencia de las secciones, es única para todo
// el documento (no se repite, reordena ni duplica), así que no pasa por
// serializeSections()/buildSectionsFrom(): se guarda aparte (ver más
// abajo, en el guardado/carga) y se aplica directamente sobre
// #panel-output.
// ---------------------------------------------------------------------
// Aplica a #panel-output el color de fondo y la tipografía elegidos, el
// tamaño de página al diálogo de impresión (a través de un <style> propio,
// ya que @page no se puede tocar como una propiedad normal desde JS), y
// el título del patrón como título de la pestaña del navegador (que de
// paso el propio navegador suele usar como nombre sugerido al exportar a PDF).
function renderPageMeta() {
panelOutput.style.backgroundColor = pageBgColorInput.value;
panelOutput.style.fontFamily = pageFontSelect.value;
pageSizeStyle.textContent = `@page { size: ${pageSizeSelect.value} ${pageOrientationSelect.value}; }`;
document.title = pageTitleInput.value.trim() || 'Crochet';
}
function serializePageSettings() {
return {
title: pageTitleInput.value,
author: pageAuthorInput.value,
bgColor: pageBgColorInput.value,
font: pageFontSelect.value,
pageSize: pageSizeSelect.value,
orientation: pageOrientationSelect.value,
};
}
function applyPageSettings(settings = {}) {
pageTitleInput.value = settings.title || '';
pageAuthorInput.value = settings.author || '';
pageBgColorInput.value = settings.bgColor || '#ffffff';
pageFontSelect.value = settings.font || 'sans-serif';
pageSizeSelect.value = settings.pageSize || 'A4';
pageOrientationSelect.value = settings.orientation || 'portrait';
renderPageMeta();
}
[pageTitleInput, pageAuthorInput].forEach(el => el.addEventListener('input', () => {
renderPageMeta();
renderOutput();
}));
[pageBgColorInput, pageFontSelect, pageSizeSelect, pageOrientationSelect].forEach(el => {
el.addEventListener('input', renderPageMeta);
el.addEventListener('change', renderPageMeta);
});
// ---------------------------------------------------------------------
// Pestañas (solo en móvil, ver css/main.css): alternar entre el panel de
// "Secciones" y el de "Vista previa". A partir del breakpoint md la clase
@@ -826,7 +1040,6 @@ document.addEventListener('DOMContentLoaded', () => {
const tabSections = document.getElementById('tab-sections');
const tabOutput = document.getElementById('tab-output');
const panelOutput = document.getElementById('panel-output');
function showTab(tab) {
const showSections = tab === 'sections';
@@ -877,8 +1090,26 @@ document.addEventListener('DOMContentLoaded', () => {
const subtitleInput = section.querySelector('.subtitle-input');
if (subtitleInput) return { type: 'subtitle', text: getTranslations(subtitleInput) };
const textarea = section.querySelector('textarea');
if (textarea) return { type: 'text', text: getTranslations(textarea) };
const textInput = section.querySelector('.text-input');
if (textInput) return { type: 'text', text: getTranslations(textInput) };
const noteInput = section.querySelector('.note-input');
if (noteInput) {
const textColorInput = section.querySelector('.note-text-color');
const bgColorInput = section.querySelector('.note-bg-color');
return {
type: 'note',
text: getTranslations(noteInput),
textColor: textColorInput ? textColorInput.value : undefined,
bgColor: bgColorInput ? bgColorInput.value : undefined,
};
}
const materialsList = section.querySelector('.materials-list');
if (materialsList) {
const materials = Array.from(materialsList.querySelectorAll('.material-input')).map(getTranslations);
return { type: 'materials', materials };
}
const dndCanvas = section.querySelector('.dnd-canvas');
if (dndCanvas) {
@@ -916,6 +1147,11 @@ document.addEventListener('DOMContentLoaded', () => {
if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text, container);
if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text, container);
if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text, container);
if (sectionData.type === 'note') {
addNoteSection(addNoteBtn.dataset, sectionData.text, container,
{ text: sectionData.textColor, bg: sectionData.bgColor });
}
if (sectionData.type === 'materials') addMaterialsSection(addMaterialsBtn.dataset, sectionData.materials, container);
if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl, container);
if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items, container);
if (sectionData.type === 'group') addGroupSection(addGroupBtn.dataset, sectionData.children, container);
@@ -952,14 +1188,19 @@ document.addEventListener('DOMContentLoaded', () => {
const SECTIONS_STORAGE_KEY = 'sections-data';
document.getElementById('save-pattern').addEventListener('click', () => {
localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(serializeSections()));
const data = { sections: serializeSections(), pageSettings: serializePageSettings() };
localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(data));
});
document.getElementById('load-pattern').addEventListener('click', () => {
const raw = localStorage.getItem(SECTIONS_STORAGE_KEY);
if (!raw) return;
buildSectionsFrom(JSON.parse(raw));
const data = JSON.parse(raw);
buildSectionsFrom(data.sections || []);
applyPageSettings(data.pageSettings);
renderOutput();
});
renderPageMeta();
renderOutput(); // Pinta el mensaje de "sin secciones" nada más cargar la página.
});