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:
co-authored by
Claude Sonnet 4.6
parent
1385c25b30
commit
daa9827d93
+159
-9
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user