commit cc1fab88ec426a659c23ac4ea80bbc59378330f1 Author: Pablo Moreno Date: Wed Jul 8 13:24:48 2026 +0200 feat: initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..19959df --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(echo \"exit: $?\")" + ] + } +} diff --git a/css/main.css b/css/main.css new file mode 100644 index 0000000..cafbc8f --- /dev/null +++ b/css/main.css @@ -0,0 +1,35 @@ +/* HTML: */ +.breadcrumb { + --s: 20px; /* control the shape */ + + line-height: 1.8; /* control the height */ + padding-inline: calc(var(--s) + .3em); + clip-path: polygon(0 0,calc(100% - var(--s)) 0,100% 50%,calc(100% - var(--s)) 100%,0 100%,var(--s) 50%); + background: #EAEAEA; + width: fit-content; +} + +/* Al exportar/imprimir, solo se muestra #panel-output (y no sus propios + controles, marcados con .no-print), ocultando el resto de la página. */ +@media print { + body * { + visibility: hidden; + } + + #panel-output, + #panel-output * { + visibility: visible; + } + + #panel-output .no-print { + display: none; + } + + #panel-output { + position: absolute; + top: 0; + left: 0; + width: 100%; + border: none; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3495cae --- /dev/null +++ b/index.html @@ -0,0 +1,65 @@ + + + + + + + Crochet + + + + + + +
+ +
+ +
+
+

Secciones

+
+ + +
+
+
+ + + + + +
+
+
+ +
+
+

Vista previa

+ +
+
+
+ +
+ + + +
+ + + + diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..2696de1 --- /dev/null +++ b/js/main.js @@ -0,0 +1,500 @@ +document.addEventListener('DOMContentLoaded', () => { + // Referencias a los elementos fijos del documento. + const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final. + 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". + + // --------------------------------------------------------------------- + // Reordenar secciones (arrastrando el asa "⠿" de cada una) + // --------------------------------------------------------------------- + + // Sección que se está arrastrando actualmente para reordenar. Se usa como + // "memoria" compartida entre los listeners en lugar de dataTransfer, así + // no interfiere con el drag & drop de los elementos arrastrables. + let draggedSection = null; + + // Dado un punto Y del cursor, devuelve la sección justo debajo de la que + // hay que insertar la sección arrastrada (o null si va al final). + function getSectionAfterY(y) { + const sections = Array.from(sectionsContainer.querySelectorAll(':scope > .top-section:not(.dragging)')); + return sections.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset, element: child }; + } + return closest; + }, { offset: -Infinity, element: null }).element; + } + + sectionsContainer.addEventListener('dragover', (e) => { + if (!draggedSection) return; // No es un arrastre de reordenación, ignorar. + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + + // Mueve en vivo la sección arrastrada a su nueva posición según el cursor. + const afterElement = getSectionAfterY(e.clientY); + if (afterElement == null) { + sectionsContainer.appendChild(draggedSection); + } else if (afterElement !== draggedSection) { + sectionsContainer.insertBefore(draggedSection, afterElement); + } + }); + + sectionsContainer.addEventListener('drop', (e) => { + if (!draggedSection) return; + e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador. + }); + + // --------------------------------------------------------------------- + // Drag & drop de elementos arrastrables hacia una zona de destino + // --------------------------------------------------------------------- + + // Hace arrastrable un
  • de la lista de "Elementos" y mete su HTML en + // el dataTransfer para poder clonarlo al soltarlo. + function makeDraggable(item) { + item.setAttribute('draggable', 'true'); + item.addEventListener('dragstart', (e) => { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('text/html', item.outerHTML); + }); + } + + // Añade a una zona de drop un grupo: cantidad (input numérico), el + // elemento (buscado en draggableItemsSource por su texto) y un botón para + // eliminarlo. Se usa tanto al soltar un elemento como al restaurar un + // patrón guardado previamente (ver buildSectionsFrom más abajo). + function addDndItem(dropCanvas, label, count) { + const sourceItem = Array.from(draggableItemsSource.children) + .find(li => li.textContent.trim() === label); + if (!sourceItem) return; + + const clone = sourceItem.cloneNode(true); + clone.removeAttribute('draggable'); + clone.removeAttribute('id'); + clone.classList.add('inline-block'); + + const countInput = document.createElement('input'); + countInput.type = 'number'; + countInput.min = '1'; + countInput.value = count; + countInput.className = 'input input-bordered input-xs w-16'; + + const group = document.createElement('div'); + group.className = 'inline-flex items-center gap-1'; + + // Botón para poder quitar el elemento si el usuario se equivoca. + const removeItemBtn = document.createElement('button'); + removeItemBtn.type = 'button'; + removeItemBtn.className = 'btn btn-xs btn-circle'; + removeItemBtn.textContent = '✕'; + removeItemBtn.addEventListener('click', () => group.remove()); + + group.appendChild(countInput); + group.appendChild(clone); + group.appendChild(removeItemBtn); + + dropCanvas.appendChild(group); + } + + // Convierte cualquier contenedor en una zona donde soltar elementos + // arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el + // contenido del canvas (al soltar un elemento o cambiar la cantidad). + function setupDropZone(dropCanvas, onChange) { + dropCanvas.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }); + + dropCanvas.addEventListener('drop', (e) => { + e.preventDefault(); + const html = e.dataTransfer.getData('text/html'); + if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada). + + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const clone = wrapper.firstElementChild; + if (!clone) return; + + addDndItem(dropCanvas, clone.textContent.trim(), 1); + }); + + if (onChange) { + dropCanvas.addEventListener('input', onChange); // Cambios en la cantidad. + new MutationObserver(onChange).observe(dropCanvas, { childList: true }); // Añadir/quitar elementos. + } + } + + // --------------------------------------------------------------------- + // Generación del resultado (#panel-output) + // --------------------------------------------------------------------- + + // Convierte el contenido de una zona de drop en texto tipo + // "3 Elemento A, 1 Elemento B". + function getDndCanvasText(dndCanvas) { + const parts = Array.from(dndCanvas.children).map(group => { + const input = group.querySelector('input'); + const label = group.querySelector('li'); + if (!input || !label) return ''; + return `${input.value} ${label.textContent}`; + }); + return parts.filter(Boolean).join(', '); + } + + // Añade una línea de texto al resultado (si no está vacía). + function appendOutputLine(tag, text, className) { + if (!text) return; + const el = document.createElement(tag); + el.className = className; + el.textContent = text; + outputText.appendChild(el); + } + + // Recorre todas las secciones (en su orden actual) y reconstruye + // #panel-output desde cero: cada sección aporta una línea/elemento según + // su tipo (título, texto, drag&drop o imagen). + function renderOutput() { + outputText.innerHTML = ''; + + // 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) { + appendOutputLine('p', outputText.dataset.emptyMessage, 'text-base-content/50 italic'); + return; + } + + Array.from(sectionsContainer.children).forEach(section => { + const titleInput = section.querySelector('.title-input'); + if (titleInput) { + appendOutputLine('h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1'); + return; + } + + const subtitleInput = section.querySelector('.subtitle-input'); + if (subtitleInput) { + appendOutputLine('h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1'); + return; + } + + const textarea = section.querySelector('textarea'); + if (textarea) { + appendOutputLine('p', textarea.value.trim(), ''); + return; + } + + const dndCanvas = section.querySelector('.dnd-canvas'); + if (dndCanvas) { + appendOutputLine('p', getDndCanvasText(dndCanvas), ''); + return; + } + + // Sección de imagen: si ya hay una imagen cargada, se copia (con el + // tamaño limitado) al resultado. + const previewImg = section.querySelector('img'); + if (previewImg) { + if (previewImg.src && !previewImg.classList.contains('hidden')) { + const img = document.createElement('img'); + img.src = previewImg.src; + img.className = 'max-w-full max-h-48 object-contain rounded-box my-2'; + outputText.appendChild(img); + } + } + }); + } + + // El resultado se regenera con cualquier cambio dentro de las secciones: + // escribir texto/título ('input'), elegir una imagen ('change'), o + // añadir/quitar/reordenar secciones o elementos soltados (MutationObserver). + sectionsContainer.addEventListener('input', renderOutput); + sectionsContainer.addEventListener('change', renderOutput); + new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true }); + + // --------------------------------------------------------------------- + // Creación de secciones (título, texto, imagen, drag & drop) + // --------------------------------------------------------------------- + + // 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 y de eliminar) y el + // cuerpo donde cada función addXxxSection() mete su contenido específico. + // Devuelve { section, body }: `section` se añade a #panel-top-sections, + // `body` es donde hay que appendear el contenido propio de cada tipo. + function createSectionShell(typeLabel) { + const section = document.createElement('div'); + section.className = 'top-section border border-base-300 rounded-box p-4 flex flex-col gap-2'; + + const header = document.createElement('div'); + header.className = 'flex items-center gap-2'; + + const dragHandle = document.createElement('span'); + dragHandle.className = 'cursor-move select-none text-base-content/50 px-1'; + dragHandle.textContent = '⠿'; + dragHandle.title = sectionsContainer.dataset.dragTitle; + dragHandle.setAttribute('draggable', 'true'); + + dragHandle.addEventListener('dragstart', (e) => { + draggedSection = section; + 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', 'opacity-50'); + }); + + dragHandle.addEventListener('dragend', () => { + section.classList.remove('dragging', 'opacity-50'); + draggedSection = null; + }); + + // 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 = '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...). + const badge = document.createElement('span'); + badge.className = 'badge badge-sm badge-outline'; + badge.textContent = typeLabel; + + const spacer = document.createElement('div'); + spacer.className = 'flex-1'; + + 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', () => section.remove()); + + header.appendChild(dragHandle); + header.appendChild(collapseBtn); + header.appendChild(badge); + header.appendChild(spacer); + header.appendChild(removeBtn); + + const body = document.createElement('div'); + body.className = '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

    en el resultado. + // `data` es el dataset del botón "+ Título" (data-type-label, data-placeholder). + // `text`, si se pasa, precarga el valor (se usa al restaurar un patrón guardado). + function addTitleSection(data, text = '') { + 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.value = text; + + body.appendChild(input); + sectionsContainer.appendChild(section); + } + + // Sección de subtítulo: igual que la de título pero se muestra como

    + // (más pequeño) en el resultado, para marcar un encabezado secundario. + function addSubtitleSection(data, text = '') { + 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.value = text; + + body.appendChild(input); + sectionsContainer.appendChild(section); + } + + // Sección de texto libre. + function addTextSection(data, text = '') { + const { section, body } = createSectionShell(data.typeLabel); + + const textarea = document.createElement('textarea'); + textarea.className = 'textarea textarea-bordered w-full'; + textarea.placeholder = data.placeholder; + textarea.value = text; + + body.appendChild(textarea); + sectionsContainer.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. + function addImageSection(data, dataUrl = '') { + 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-48 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'); + }); + reader.readAsDataURL(file); + }); + + body.appendChild(fileInput); + body.appendChild(img); + sectionsContainer.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 }]. + function addDndSection(data, items = []) { + const { section, body } = createSectionShell(data.typeLabel); + + const row = document.createElement('div'); + row.className = 'flex 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-48 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); + sectionsContainer.appendChild(section); + setupDropZone(dndCanvas); + + items.forEach(item => addDndItem(dndCanvas, item.label, item.count)); + } + + // Botones de la barra superior para añadir cada tipo de sección. Los textos + // (etiqueta de tipo, placeholders...) se leen de sus data-* en el HTML, no + // están escritos aquí, para que la plantilla Django pueda traducirlos. + const addTitleBtn = document.getElementById('add-title-section'); + const addSubtitleBtn = document.getElementById('add-subtitle-section'); + const addTextBtn = document.getElementById('add-text-section'); + const addImageBtn = document.getElementById('add-image-section'); + const addDndBtn = document.getElementById('add-dnd-section'); + + addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset)); + addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset)); + addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset)); + addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset)); + addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset)); + + // Exportar #panel-output a PDF: se usa el diálogo de impresión del propio + // navegador (con "Guardar como PDF") en vez de una librería externa, ya + // que las librerías tipo html2canvas no renderizan bien los colores oklch + // que usa Tailwind v4. La hoja de estilos de impresión (css/main.css) + // aísla #panel-output para que solo se exporte el resultado. + document.getElementById('export-pdf').addEventListener('click', () => { + window.print(); + }); + + // --------------------------------------------------------------------- + // Guardar / cargar las secciones (serializarlas a datos planos) + // --------------------------------------------------------------------- + + // Recorre #panel-top-sections y lo convierte en un array de objetos planos + // (uno por sección), listo para guardarse como JSON en cualquier sitio + // (localStorage, o enviado a un backend Django). + function serializeSections() { + return Array.from(sectionsContainer.children).map(section => { + const titleInput = section.querySelector('.title-input'); + if (titleInput) return { type: 'title', text: titleInput.value }; + + const subtitleInput = section.querySelector('.subtitle-input'); + if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value }; + + const textarea = section.querySelector('textarea'); + if (textarea) return { type: 'text', text: textarea.value }; + + const dndCanvas = section.querySelector('.dnd-canvas'); + if (dndCanvas) { + const items = Array.from(dndCanvas.children).map(group => { + const input = group.querySelector('input'); + const label = group.querySelector('li'); + if (!input || !label) return null; + return { label: label.textContent.trim(), count: Number(input.value) }; + }).filter(Boolean); + return { type: 'dnd', items }; + } + + const img = section.querySelector('img'); + if (img) { + const dataUrl = !img.classList.contains('hidden') ? img.src : ''; + return { type: 'image', dataUrl }; + } + + return null; + }).filter(Boolean); + } + + // Reconstruye #panel-top-sections a partir del array que genera + // serializeSections(), sustituyendo las secciones actuales. + function buildSectionsFrom(sectionsData) { + sectionsContainer.innerHTML = ''; + + sectionsData.forEach(sectionData => { + if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text); + if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text); + if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text); + if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl); + if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items); + }); + } + + // Ejemplo de uso: guardar/cargar todas las secciones en localStorage. + // Para integrarlo con Django, bastaría con sustituir estos dos listeners + // por una llamada a la API (POST del JSON de serializeSections() / GET + // para pasarle el resultado a buildSectionsFrom()). + const SECTIONS_STORAGE_KEY = 'sections-data'; + + document.getElementById('save-pattern').addEventListener('click', () => { + localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(serializeSections())); + }); + + document.getElementById('load-pattern').addEventListener('click', () => { + const raw = localStorage.getItem(SECTIONS_STORAGE_KEY); + if (!raw) return; + buildSectionsFrom(JSON.parse(raw)); + }); + + renderOutput(); // Pinta el mensaje de "sin secciones" nada más cargar la página. +});