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 { background: #c1c9d4; border-radius: 3px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #8fa0b5; }
|
::-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) {
|
@media (max-width: 900px) {
|
||||||
#panel-palette { width: 170px; min-width: 170px; }
|
#panel-palette { width: 170px; min-width: 170px; }
|
||||||
#panel-preview { width: 260px; min-width: 260px; }
|
#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 ─────────────────────────────── */
|
/* ── Pantalla de éxito ─────────────────────────────── */
|
||||||
#success-screen {
|
#success-screen {
|
||||||
@@ -530,14 +567,16 @@ function renderPalette() {
|
|||||||
$('palette').innerHTML = TIPOS_CAMPO.map(t => `
|
$('palette').innerHTML = TIPOS_CAMPO.map(t => `
|
||||||
<div class="palette-item" draggable="true"
|
<div class="palette-item" draggable="true"
|
||||||
data-tipo="${t.tipo}"
|
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}
|
<i class="fas ${t.icon}"></i>${t.label}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
|
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
|
||||||
<div class="palette-item linked" draggable="true"
|
<div class="palette-item linked" draggable="true"
|
||||||
data-linked="${t.key}"
|
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}
|
<i class="fas ${t.icon}"></i>${t.label}
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
@@ -611,7 +650,8 @@ const TIPO_ICON = {
|
|||||||
texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
|
texto:'fa-font', textarea:'fa-align-left', numero:'fa-hashtag',
|
||||||
fecha:'fa-calendar', hora:'fa-clock', select:'fa-list', radio:'fa-dot-circle',
|
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',
|
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() {
|
function renderCanvas() {
|
||||||
@@ -648,29 +688,139 @@ function renderCanvas() {
|
|||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Reordenar drag
|
// Reordenar — HTML5 drag (mouse desktop)
|
||||||
|
div.setAttribute('draggable', 'true');
|
||||||
div.addEventListener('dragstart', e => {
|
div.addEventListener('dragstart', e => {
|
||||||
|
if (e.target.closest('.fi-btn')) { e.preventDefault(); return; }
|
||||||
_dragSrc = idx; _dragTipo = null;
|
_dragSrc = idx; _dragTipo = null;
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
div.classList.add('dragging');
|
div.classList.add('dragging');
|
||||||
});
|
});
|
||||||
div.addEventListener('dragend', () => div.classList.remove('dragging'));
|
div.addEventListener('dragend', () => {
|
||||||
div.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; });
|
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 => {
|
div.addEventListener('drop', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
$('drop-zone').querySelectorAll('.field-item').forEach(fi =>
|
||||||
|
fi.classList.remove('drop-above','drop-below'));
|
||||||
if (_dragSrc !== null && _dragSrc !== idx) {
|
if (_dragSrc !== null && _dragSrc !== idx) {
|
||||||
|
const r = div.getBoundingClientRect();
|
||||||
|
const before = e.clientY < r.top + r.height / 2;
|
||||||
const [moved] = _campos.splice(_dragSrc, 1);
|
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;
|
_dragSrc = null;
|
||||||
renderCanvas();
|
renderCanvas(); renderPreview();
|
||||||
renderPreview();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Reordenar — Pointer Events (touch / tablet)
|
||||||
|
initPointerDrag(div, idx);
|
||||||
|
|
||||||
zone.appendChild(div);
|
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) {
|
function eliminarCampo(idx) {
|
||||||
_campos.splice(idx, 1);
|
_campos.splice(idx, 1);
|
||||||
renderCanvas();
|
renderCanvas();
|
||||||
|
|||||||
Reference in New Issue
Block a user