diff --git a/lab_formulario_builder.php b/lab_formulario_builder.php
index ea059ca..46ad85b 100644
--- a/lab_formulario_builder.php
+++ b/lab_formulario_builder.php
@@ -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 => `
+ ondragstart="palDragStart(event,'${t.tipo}')"
+ onclick="agregarCampo({tipo:'${t.tipo}',linked:null})">
${t.label}
`).join('');
$('palette-linked').innerHTML = TIPOS_LINKED.map(t => `
+ ondragstart="palDragStart(event,'linked','${t.key}')"
+ onclick="agregarCampo({tipo:'linked',linked:'${t.key}'})"
${t.label}
`).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() {
`;
- // 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 +
+ '' + (div.querySelector('.fi-label')?.textContent || '') + '';
+ 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();