fix: added mobile support

This commit is contained in:
2026-07-08 14:45:01 +02:00
parent a5b5783556
commit ebb1fc2906
3 changed files with 170 additions and 37 deletions
+11
View File
@@ -9,6 +9,17 @@
width: fit-content; width: fit-content;
} }
/* En pantallas pequeñas, "Secciones" y "Vista previa" se comportan como
pestañas: solo se muestra una a la vez (ver .tabs en index.html). A
partir del breakpoint md ambos paneles se muestran siempre, lado a lado.
Se restringe a "screen" para que nunca afecte a la exportación a PDF
(@media print más abajo), sea cual sea el ancho de pantalla del usuario. */
@media screen and (max-width: 767px) {
.panel-hidden-mobile {
display: none;
}
}
/* Al exportar/imprimir, solo se muestra #panel-output (y no sus propios /* Al exportar/imprimir, solo se muestra #panel-output (y no sus propios
controles, marcados con .no-print), ocultando el resto de la página. */ controles, marcados con .no-print), ocultando el resto de la página. */
@media print { @media print {
+43 -34
View File
@@ -13,42 +13,51 @@
<body> <body>
<main> <main>
<div class="flex gap-4 p-4 items-start"> <header class="flex flex-wrap items-center justify-between gap-2 p-4 no-print">
<h1 class="text-xl font-semibold">Crochet</h1>
<div class="flex gap-2">
<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="export-pdf" type="button" class="btn btn-sm">Exportar a PDF</button>
</div>
</header>
<section id="panel-top" class="w-1/2 border border-base-300 rounded-box p-4"> <div class="p-4 pt-0">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-semibold">Secciones</h2> <div class="tabs tabs-boxed w-fit mb-4 md:hidden no-print">
<div class="flex gap-2"> <button id="tab-sections" type="button" class="tab tab-active">Secciones</button>
<button id="save-pattern" type="button" class="btn btn-sm btn-outline">Guardar</button> <button id="tab-output" type="button" class="tab">Vista previa</button>
<button id="load-pattern" type="button" class="btn btn-sm btn-outline">Cargar</button> </div>
<div class="flex flex-col md:flex-row gap-4">
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4">
<h2 class="text-lg font-semibold mb-2">Secciones</h2>
<div class="flex flex-wrap gap-2 mb-4">
<button id="add-title-section" type="button" class="btn btn-sm"
data-type-label="Título" data-placeholder="Título...">+ Título</button>
<button id="add-subtitle-section" type="button" class="btn btn-sm"
data-type-label="Subtítulo" data-placeholder="Subtítulo...">+ Subtítulo</button>
<button id="add-text-section" type="button" class="btn btn-sm"
data-type-label="Texto" data-placeholder="Escribe aquí...">+ Texto</button>
<button id="add-image-section" type="button" class="btn btn-sm"
data-type-label="Imagen">+ Imagen</button>
<button id="add-dnd-section" type="button" class="btn btn-sm"
data-type-label="Patrón" data-elements-label="Elementos">+ Patrón</button>
</div> </div>
</div> <div id="panel-top-sections" class="flex flex-col gap-4"
<div class="flex gap-2 mb-4"> data-drag-title="Arrastrar para reordenar"
<button id="add-title-section" type="button" class="btn btn-sm" data-collapse-title="Colapsar / expandir"
data-type-label="Título" data-placeholder="Título...">+ Título</button> data-remove-title="Eliminar sección"></div>
<button id="add-subtitle-section" type="button" class="btn btn-sm" </section>
data-type-label="Subtítulo" data-placeholder="Subtítulo...">+ Subtítulo</button>
<button id="add-text-section" type="button" class="btn btn-sm"
data-type-label="Texto" data-placeholder="Escribe aquí...">+ Texto</button>
<button id="add-image-section" type="button" class="btn btn-sm"
data-type-label="Imagen">+ Imagen</button>
<button id="add-dnd-section" type="button" class="btn btn-sm"
data-type-label="Patrón" data-elements-label="Elementos">+ Patrón</button>
</div>
<div id="panel-top-sections" class="flex flex-col gap-4"
data-drag-title="Arrastrar para reordenar"
data-collapse-title="Colapsar / expandir"
data-remove-title="Eliminar sección"></div>
</section>
<section id="panel-output" class="w-1/2 border border-base-300 rounded-box p-4"> <section id="panel-output" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile">
<div class="flex items-center justify-between mb-2 no-print"> <h2 class="text-lg font-semibold mb-2">Vista previa</h2>
<h2 class="text-lg font-semibold">Vista previa</h2> <div id="panel-output-text" class="text-sm"
<button id="export-pdf" type="button" class="btn btn-sm">Exportar a PDF</button> data-empty-message="Añade una sección para ver aquí el resultado."></div>
</div> </section>
<div id="panel-output-text" class="text-sm"
data-empty-message="Añade una sección para ver aquí el resultado."></div> </div>
</section>
</div> </div>
@@ -60,6 +69,6 @@
</ul> </ul>
</main> </main>
</body> </body>
<script src="js/main.js"></script> <script src="js/dnd.js"></script>
</html> </html>
+116 -3
View File
@@ -13,6 +13,16 @@ document.addEventListener('DOMContentLoaded', () => {
// no interfiere con el drag & drop de los elementos arrastrables. // no interfiere con el drag & drop de los elementos arrastrables.
let draggedSection = null; let draggedSection = null;
// La API nativa de Drag and Drop (HTML5) no está implementada en los
// navegadores móviles: no se disparan dragstart/dragover/drop con el
// dedo. Por eso todo el drag & drop de la página (reordenar secciones y
// soltar elementos) también se reimplementa con eventos touch, usando
// este punto como coordenadas comunes.
function getTouchPoint(e) {
const touch = e.touches[0] || e.changedTouches[0];
return { x: touch.clientX, y: touch.clientY };
}
// Dado un punto Y del cursor, devuelve la sección justo debajo de la que // 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). // hay que insertar la sección arrastrada (o null si va al final).
function getSectionAfterY(y) { function getSectionAfterY(y) {
@@ -51,13 +61,58 @@ document.addEventListener('DOMContentLoaded', () => {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en // Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en
// el dataTransfer para poder clonarlo al soltarlo. // el dataTransfer para poder clonarlo al soltarlo. addDndItem() se define
// más abajo; se referencia aquí dentro del listener, no al declarar la
// función, así que el orden no importa.
function makeDraggable(item) { function makeDraggable(item) {
item.setAttribute('draggable', 'true'); item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', (e) => { item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/html', item.outerHTML); e.dataTransfer.setData('text/html', item.outerHTML);
}); });
// Equivalente táctil: crea un clon flotante que sigue al dedo y, al
// soltar, mira qué elemento hay debajo del punto final para saber si
// cayó dentro de una zona de drop.
item.addEventListener('touchstart', (e) => {
e.preventDefault();
const start = getTouchPoint(e);
const ghost = item.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${start.x}px`;
ghost.style.top = `${start.y}px`;
ghost.style.pointerEvents = 'none';
ghost.style.opacity = '0.9';
ghost.style.zIndex = '9999';
// Se agranda y se desplaza por encima del punto de contacto: el dedo
// tapa el elemento original, así que a tamaño normal el usuario no
// vería qué está arrastrando.
ghost.style.transform = 'translate(-50%, -300%) scale(1.8)';
ghost.style.transformOrigin = 'center';
document.body.appendChild(ghost);
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const point = getTouchPoint(moveEvent);
ghost.style.left = `${point.x}px`;
ghost.style.top = `${point.y}px`;
};
const onTouchEnd = (endEvent) => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
ghost.remove();
const point = getTouchPoint(endEvent);
const target = document.elementFromPoint(point.x, point.y);
const dropCanvas = target && target.closest('.dnd-canvas');
if (dropCanvas) addDndItem(dropCanvas, item.textContent.trim(), 1);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
} }
// Añade a una zona de drop un grupo: cantidad (input numérico), el // Añade a una zona de drop un grupo: cantidad (input numérico), el
@@ -243,6 +298,36 @@ document.addEventListener('DOMContentLoaded', () => {
draggedSection = null; draggedSection = null;
}); });
// Equivalente táctil del reordenado por dragover/drop de más arriba:
// mientras el dedo se mueve, se reutiliza getSectionAfterY() para ir
// desplazando la sección en vivo dentro de #panel-top-sections.
dragHandle.addEventListener('touchstart', (e) => {
e.preventDefault();
draggedSection = section;
section.classList.add('dragging', 'opacity-50');
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const { y } = getTouchPoint(moveEvent);
const afterElement = getSectionAfterY(y);
if (afterElement == null) {
sectionsContainer.appendChild(draggedSection);
} else if (afterElement !== draggedSection) {
sectionsContainer.insertBefore(draggedSection, afterElement);
}
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
section.classList.remove('dragging', 'opacity-50');
draggedSection = null;
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
// Botón para colapsar/expandir el cuerpo de la sección, útil cuando hay // 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. // muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio.
const collapseBtn = document.createElement('button'); const collapseBtn = document.createElement('button');
@@ -375,13 +460,13 @@ document.addEventListener('DOMContentLoaded', () => {
const { section, body } = createSectionShell(data.typeLabel); const { section, body } = createSectionShell(data.typeLabel);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'flex gap-4'; row.className = 'flex flex-col sm:flex-row gap-4';
const dndCanvas = document.createElement('div'); const dndCanvas = document.createElement('div');
dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap items-center gap-2 min-h-24'; dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap items-center gap-2 min-h-24';
const elementsPanel = document.createElement('aside'); const elementsPanel = document.createElement('aside');
elementsPanel.className = 'w-48 shrink-0 border border-base-300 rounded-box p-4'; elementsPanel.className = 'w-full sm:w-48 sm:shrink-0 border border-base-300 rounded-box p-4';
const elementsTitle = document.createElement('h3'); const elementsTitle = document.createElement('h3');
elementsTitle.className = 'font-semibold mb-2'; elementsTitle.className = 'font-semibold mb-2';
@@ -419,12 +504,40 @@ document.addEventListener('DOMContentLoaded', () => {
addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset)); addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset));
addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset)); addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset));
// ---------------------------------------------------------------------
// 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
// `panel-hidden-mobile` no tiene efecto y ambos paneles quedan visibles
// permanentemente, lado a lado.
// ---------------------------------------------------------------------
const tabSections = document.getElementById('tab-sections');
const tabOutput = document.getElementById('tab-output');
const panelTop = document.getElementById('panel-top');
const panelOutput = document.getElementById('panel-output');
function showTab(tab) {
const showSections = tab === 'sections';
panelTop.classList.toggle('panel-hidden-mobile', !showSections);
panelOutput.classList.toggle('panel-hidden-mobile', showSections);
tabSections.classList.toggle('tab-active', showSections);
tabOutput.classList.toggle('tab-active', !showSections);
}
tabSections.addEventListener('click', () => showTab('sections'));
tabOutput.addEventListener('click', () => showTab('output'));
// Exportar #panel-output a PDF: se usa el diálogo de impresión del propio // 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 // 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 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) // 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. // aísla #panel-output para que solo se exporte el resultado.
document.getElementById('export-pdf').addEventListener('click', () => { document.getElementById('export-pdf').addEventListener('click', () => {
// `panel-hidden-mobile` está restringida a "screen" en el CSS, así que
// nunca debería afectar a la impresión, pero por si algún navegador
// calcula el ancho de la vista de impresión igual al de pantalla en
// móvil, se cambia de pestaña antes de imprimir para curarse en salud.
showTab('output');
window.print(); window.print();
}); });