feat(builder): tablet-friendly drag — pointer events + tap-to-add palette

- Palette items: onclick adds field directly (tap on tablet, no drag needed)
- Canvas reorder: PointerEvents drag (touchstart/move/end) with visual ghost
  and blue line indicator; HTML5 drag kept for mouse/desktop
- CSS: drop-above/drop-below indicators, ghost element, larger touch targets
  for handle and action buttons on coarse pointer devices
- Hide preview panel on ≤680px to give more canvas space on small tablets
- calculado added to TIPO_ICON map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-03 11:28:28 -05:00
co-authored by Claude Sonnet 4.6
parent 1385c25b30
commit daa9827d93
+159 -9
View File
@@ -215,10 +215,47 @@ $formId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
::-webkit-scrollbar-thumb { background: #c1c9d4; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #8fa0b5; }
/* ── Drop indicators (touch drag) ─────────────────────── */
.field-item.touch-dragging { opacity: .3; }
.field-item.drop-above::before {
content: ''; position: absolute; top: -4px; left: 0; right: 0;
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
}
.field-item.drop-below::after {
content: ''; position: absolute; bottom: -4px; left: 0; right: 0;
height: 3px; background: #1565c0; border-radius: 2px; pointer-events: none;
}
#drag-ghost {
position: fixed; pointer-events: none; z-index: 9999;
background: #fff; border: 1.5px solid #1565c0; border-radius: 8px;
box-shadow: 0 8px 28px rgba(21,101,192,.25);
padding: 9px 12px; display: flex; align-items: center; gap: 8px;
font-size: 12.5px; font-weight: 600; color: #1565c0;
opacity: .92; transition: none;
}
/* ── Palette tap hint ──────────────────────────────────── */
.palette-item { touch-action: none; }
@media (pointer: coarse) {
.palette-item::after {
content: '+'; margin-left: auto; font-size: 15px;
font-weight: 700; color: #1565c0; opacity: .5;
}
.field-item .fi-handle {
font-size: 18px; padding: 4px 6px;
color: #90a4ae;
}
.field-item .fi-btn { padding: 6px 8px; font-size: 14px; }
}
@media (max-width: 900px) {
#panel-palette { width: 170px; min-width: 170px; }
#panel-preview { width: 260px; min-width: 260px; }
}
@media (max-width: 680px) {
#panel-preview { display: none; }
#panel-palette { width: 140px; min-width: 140px; }
}
/* ── Pantalla de éxito ─────────────────────────────── */
#success-screen {
@@ -530,14 +567,16 @@ function renderPalette() {
$('palette').innerHTML = TIPOS_CAMPO.map(t => `
<div class="palette-item" draggable="true"
data-tipo="${t.tipo}"
ondragstart="palDragStart(event,'${t.tipo}')">
ondragstart="palDragStart(event,'${t.tipo}')"
onclick="agregarCampo({tipo:'${t.tipo}',linked:null})">
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
<div class="palette-item linked" draggable="true"
data-linked="${t.key}"
ondragstart="palDragStart(event,'linked','${t.key}')">
ondragstart="palDragStart(event,'linked','${t.key}')"
onclick="agregarCampo({tipo:'linked',linked:'${t.key}'})"
<i class="fas ${t.icon}"></i>${t.label}
</div>`).join('');
@@ -611,7 +650,8 @@ const TIPO_ICON = {
texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
fecha:'fa-calendar', hora:'fa-clock', select:'fa-list', radio:'fa-dot-circle',
checkbox:'fa-check-square', firma:'fa-signature', firma_profesional:'fa-user-md', separador:'fa-minus', linked:'fa-link',
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol'
parrafo:'fa-paragraph', parrafo_inline:'fa-align-left', lista_marcable:'fa-list-ol',
calculado:'fa-calculator'
};
function renderCanvas() {
@@ -648,29 +688,139 @@ function renderCanvas() {
</button>
</div>`;
// Reordenar drag
// Reordenar — HTML5 drag (mouse desktop)
div.setAttribute('draggable', 'true');
div.addEventListener('dragstart', e => {
if (e.target.closest('.fi-btn')) { e.preventDefault(); return; }
_dragSrc = idx; _dragTipo = null;
e.dataTransfer.effectAllowed = 'move';
div.classList.add('dragging');
});
div.addEventListener('dragend', () => div.classList.remove('dragging'));
div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; });
div.addEventListener('dragend', () => {
div.classList.remove('dragging');
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
});
div.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
const r = div.getBoundingClientRect();
div.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
});
div.addEventListener('drop', e => {
e.stopPropagation();
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
if (_dragSrc !== null && _dragSrc !== idx) {
const r = div.getBoundingClientRect();
const before = e.clientY < r.top + r.height / 2;
const [moved] = _campos.splice(_dragSrc, 1);
_campos.splice(idx, 0, moved);
const dest = _dragSrc < idx ? (before ? idx - 1 : idx) : (before ? idx : idx + 1);
_campos.splice(dest, 0, moved);
_dragSrc = null;
renderCanvas();
renderPreview();
renderCanvas(); renderPreview();
}
});
// Reordenar — Pointer Events (touch / tablet)
initPointerDrag(div, idx);
zone.appendChild(div);
});
}
// ════════════════════════════════════════════════════════════════════
// POINTER DRAG (touch + tablet)
// ════════════════════════════════════════════════════════════════════
let _pDrag = null;
function initPointerDrag(div, idx) {
const handle = div.querySelector('.fi-handle');
if (!handle) return;
handle.addEventListener('pointerdown', e => {
// Solo si es touch o el botón primario del mouse (pero el mouse usa HTML5)
if (e.pointerType === 'mouse') return;
e.preventDefault();
e.stopPropagation();
_dragSrc = idx; _dragTipo = null;
const rect = div.getBoundingClientRect();
// Crear ghost
const ghost = document.createElement('div');
ghost.id = 'drag-ghost';
ghost.innerHTML = div.querySelector('.fi-icon').outerHTML +
'<span>' + (div.querySelector('.fi-label')?.textContent || '') + '</span>';
ghost.style.top = rect.top + 'px';
ghost.style.left = rect.left + 'px';
ghost.style.width = rect.width + 'px';
document.body.appendChild(ghost);
div.classList.add('touch-dragging');
handle.setPointerCapture(e.pointerId);
_pDrag = { idx, div, ghost, offsetY: e.clientY - rect.top };
});
handle.addEventListener('pointermove', e => {
if (!_pDrag || _pDrag.idx !== idx) return;
e.preventDefault();
const { ghost } = _pDrag;
ghost.style.top = (e.clientY - _pDrag.offsetY) + 'px';
// Indicador de posición
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
ghost.style.display = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY);
ghost.style.display = '';
const target = el?.closest('.field-item');
if (target && target !== _pDrag.div) {
const r = target.getBoundingClientRect();
target.classList.add(e.clientY < r.top + r.height / 2 ? 'drop-above' : 'drop-below');
}
});
const endDrag = e => {
if (!_pDrag || _pDrag.idx !== idx) return;
const { div: origDiv, ghost } = _pDrag;
ghost.style.display = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY);
ghost.remove();
origDiv.classList.remove('touch-dragging');
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
const target = el?.closest('.field-item');
if (target && target !== origDiv) {
const targetIdx = parseInt(target.dataset.idx);
const r = target.getBoundingClientRect();
const before = e.clientY < r.top + r.height / 2;
if (!isNaN(targetIdx) && targetIdx !== idx) {
const [moved] = _campos.splice(idx, 1);
const dest = idx < targetIdx
? (before ? targetIdx - 1 : targetIdx)
: (before ? targetIdx : targetIdx + 1);
_campos.splice(dest, 0, moved);
renderCanvas(); renderPreview();
}
}
_pDrag = null; _dragSrc = null;
};
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', e => {
if (!_pDrag || _pDrag.idx !== idx) return;
_pDrag.ghost.remove();
_pDrag.div.classList.remove('touch-dragging');
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
fi.classList.remove('drop-above','drop-below'));
_pDrag = null; _dragSrc = null;
});
}
function eliminarCampo(idx) {
_campos.splice(idx, 1);
renderCanvas();