/* public/touch-gestures.js — Gesture system for #1062. * * Three gestures for narrow viewports (≤768px): * 1. Swipe-LEFT on a packets/nodes/observers row → reveal row-action overlay. * 2. Horizontal swipe on the bottom-nav strip → advance tabs in TAB order. * 3. Swipe-DOWN on a slide-over panel → close it. * * Hard rules (per #1062 brief): * - Pointer Events ONLY (no touchstart/touchend mixing). setPointerCapture. * - Axis-lock: commit to one axis in the first 8–12px; vertical scroll never * blocked unless we explicitly committed to a horizontal swipe. * - Leaflet exclusion: bail if e.target.closest('.leaflet-container'). * - Threshold: row-action triggers only at 24% of row width OR 80px swiped. * - touch-action: body { touch-action: pan-y } so browser owns vertical * scroll natively. [data-bottom-nav] gets touch-action: none. * - Singleton + cleanup: module-scoped guard, document-level listeners * registered ONCE (mirrors the #1180 MQL leak fix class). * - prefers-reduced-motion: animations disabled (CSS handles this), gesture * still works. */ (function () { 'use strict'; if (typeof window === 'undefined' || typeof document === 'undefined') return; // ── Singleton guard (matches #1180 pattern) ── if (typeof window.__touchGestures1062InitCount !== 'number') { window.__touchGestures1062InitCount = 0; } if (window.__touchGestures1062InitCount > 0) { // Already initialized — never re-register document listeners. return; } window.__touchGestures1062InitCount += 1; // ── Tunables ── var AXIS_LOCK_DISTANCE = 10; // px before we commit to an axis (8–12 range) var ROW_ACTION_PX = 80; // absolute px threshold var ROW_ACTION_PCT = 0.24; // OR 24% of row width var SLIDE_OVER_DISMISS_PX = 100; // downward swipe to dismiss slide-over var TAB_SWIPE_PX = 60; // horizontal swipe on bottom-nav strip var NARROW_BP = 768; // gestures only matter on phones // ── Module state ── var pointerActive = false; var pointerId = null; var startX = 0, startY = 0; var lastX = 0, lastY = 0; var axis = null; // 'h' | 'v' | null var startTarget = null; var gestureContext = null; // 'row' | 'bottom-nav' | 'slide-over' | null var activeRow = null; var rowOverlay = null; var capturedEl = null; // PR #1185 mesh-op review: scroll-discriminator for slide-over. // Captured at pointerdown when the slide-over context is selected; if the // panel content is mid-scroll (scrollTop > 0) at gesture start, the gesture // is a normal scroll, NOT a dismiss — we must not close the panel. var slideOverScroller = null; var slideOverStartScrollTop = 0; function isNarrow() { return window.innerWidth <= NARROW_BP; } function inLeaflet(target) { return !!(target && target.closest && target.closest('.leaflet-container')); } function findRow(target) { if (!target || !target.closest) return null; // Packets/nodes/observers tables — generic: any tr inside a tbody whose // table is inside one of the relevant pages. var tr = target.closest('tr[data-hash], tr[data-id]'); if (!tr) return null; var tbody = tr.closest('tbody'); if (!tbody) return null; // Restrict to the three target tables. id="pktBody" for packets, // and we treat any tbody inside .nodes-table / .observers-table as eligible. if (tbody.id === 'pktBody') return tr; var table = tbody.closest('table'); if (table && (table.id === 'nodesTable' || table.id === 'observersTable' || table.classList.contains('nodes-table') || table.classList.contains('observers-table'))) { return tr; } return tr; // permissive — still skip leaflet via inLeaflet(). } function findBottomNav(target) { if (!target || !target.closest) return null; return target.closest('[data-bottom-nav]'); } function findSlideOver(target) { if (!target || !target.closest) return null; return target.closest('.slide-over-panel'); } // Locate the open slide-over panel by querying the DOM (not via target // ancestry). Used as a fallback when the pointerdown's hit-test target // is something outside the panel subtree (e.g. a focused button whose // event was retargeted, or a panel mid-animation where elementFromPoint // returned an unrelated element). Pairs the lookup with a coordinate // check so we don't claim slide-over context for taps elsewhere. function findOpenSlideOverAt(x, y) { if (!window.SlideOver || typeof window.SlideOver.isOpen !== 'function') return null; if (!window.SlideOver.isOpen()) return null; var panel = document.querySelector('.slide-over-panel'); if (!panel || panel.hidden) return null; var r = panel.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return null; if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return panel; return null; } // ── Bottom-nav: read TAB order from bottom-nav.js ── // The TAB list there is module-private; we re-derive order from the rendered // DOM (which IS the source of truth for what the user sees) — primary tabs only, // i.e. excluding "more". function getNavTabsInOrder() { var nodes = document.querySelectorAll('[data-bottom-nav] [data-bottom-nav-tab]'); var out = []; for (var i = 0; i < nodes.length; i++) { var r = nodes[i].getAttribute('data-bottom-nav-tab'); if (r && r !== 'more') out.push(r); } return out; } function currentRouteShort() { var h = (location.hash || '').replace(/^#\//, ''); if (!h) return 'packets'; var slash = h.indexOf('/'); if (slash >= 0) h = h.substring(0, slash); var q = h.indexOf('?'); if (q >= 0) h = h.substring(0, q); return h || 'packets'; } function navigateRelative(delta) { var tabs = getNavTabsInOrder(); if (!tabs.length) return; var cur = currentRouteShort(); var idx = tabs.indexOf(cur); if (idx < 0) return; // current route isn't a primary tab var next = idx + delta; if (next < 0 || next >= tabs.length) return; location.hash = '#/' + tabs[next]; } // ── Row-action overlay ── function ensureRowOverlay(row) { if (rowOverlay && rowOverlay.parentNode) return rowOverlay; var o = document.createElement('div'); o.className = 'row-action-overlay'; o.setAttribute('role', 'group'); o.setAttribute('aria-label', 'Row actions'); var hash = row.getAttribute('data-hash') || row.getAttribute('data-id') || ''; var hashAttr = ' data-hash="' + String(hash).replace(/"/g, '"') + '"'; o.innerHTML = '' + '' + ''; document.body.appendChild(o); rowOverlay = o; return o; } function showRowOverlay(row) { var o = ensureRowOverlay(row); var rect = row.getBoundingClientRect(); o.style.position = 'fixed'; o.style.top = rect.top + 'px'; o.style.left = (rect.right - 240) + 'px'; o.style.height = rect.height + 'px'; o.style.width = '240px'; o.classList.add('row-action-overlay-open'); o.hidden = false; } function dismissRowAction() { if (rowOverlay) { rowOverlay.classList.remove('row-action-overlay-open'); // Remove from DOM after animation; CSS handles instant under reduce. var el = rowOverlay; rowOverlay = null; try { if (el.parentNode) el.parentNode.removeChild(el); } catch (_) {} } if (activeRow) { activeRow.style.transform = ''; activeRow.classList.remove('row-swiping'); activeRow = null; } } // ── Pointer handlers ── function onPointerDown(e) { if (e.pointerType !== 'touch') return; if (pointerActive) return; var t = e.target; if (inLeaflet(t)) return; if (!isNarrow()) return; var row = findRow(t); var nav = findBottomNav(t); var so = findSlideOver(t) || findOpenSlideOverAt(e.clientX, e.clientY); if (so) gestureContext = 'slide-over'; else if (nav) gestureContext = 'bottom-nav'; else if (row) gestureContext = 'row'; else gestureContext = null; if (!gestureContext) return; pointerActive = true; pointerId = e.pointerId; startX = lastX = e.clientX; startY = lastY = e.clientY; axis = null; startTarget = t; activeRow = (gestureContext === 'row') ? row : null; // Slide-over scroll-discriminator (PR #1185): record where the user is // reading from. The slide-over panel itself is the scroller (CSS sets // `.slide-over-panel { overflow-y: auto; }`) — `.slide-over-content` is a // flex child without its own overflow-y, so its scrollTop is always 0. // To be robust against markup/CSS drift, walk every candidate (panel + // any inner `.slide-over-content`) and take the MAX scrollTop. Whichever // element actually scrolls becomes the discriminator source — this // guarantees production reads from the same element a test (or a future // refactor) writes to. if (gestureContext === 'slide-over') { var candidates = []; if (so) candidates.push(so); var inner = so && so.querySelector && so.querySelector('.slide-over-content'); if (inner) candidates.push(inner); slideOverScroller = so || null; slideOverStartScrollTop = 0; for (var i = 0; i < candidates.length; i++) { var st = (candidates[i] && typeof candidates[i].scrollTop === 'number') ? candidates[i].scrollTop : 0; if (st > slideOverStartScrollTop) { slideOverStartScrollTop = st; slideOverScroller = candidates[i]; } } } else { slideOverScroller = null; slideOverStartScrollTop = 0; } // Capture so subsequent move events flow to us regardless of element. try { var capTarget = (gestureContext === 'bottom-nav') ? nav : (gestureContext === 'slide-over') ? so : row || t; if (capTarget && typeof capTarget.setPointerCapture === 'function') { capTarget.setPointerCapture(pointerId); capturedEl = capTarget; } } catch (_) { capturedEl = null; } } function onPointerMove(e) { if (!pointerActive || e.pointerId !== pointerId) return; var dx = e.clientX - startX; var dy = e.clientY - startY; lastX = e.clientX; lastY = e.clientY; if (axis === null) { var adx = Math.abs(dx), ady = Math.abs(dy); if (adx < AXIS_LOCK_DISTANCE && ady < AXIS_LOCK_DISTANCE) return; // For slide-over, dismiss on vertical down swipe; commit accordingly. if (gestureContext === 'slide-over') { axis = (ady > adx) ? 'v' : 'h'; if (axis !== 'v') { // Horizontal on slide-over — release, do nothing. releasePointer(); return; } // Scroll-discriminator (PR #1185): if user started mid-scroll, this // gesture belongs to the browser's native scroll. Release immediately // so we never preventDefault / drag the panel / dismiss. if (slideOverStartScrollTop > 0) { releasePointer(); return; } } else if (gestureContext === 'bottom-nav') { axis = (adx > ady) ? 'h' : 'v'; if (axis !== 'h') { releasePointer(); return; } } else if (gestureContext === 'row') { axis = (adx > ady) ? 'h' : 'v'; if (axis !== 'h') { // Vertical → release; let browser handle scroll. releasePointer(); return; } } } // Apply visual feedback only after axis commit. if (gestureContext === 'row' && axis === 'h' && activeRow) { // Only show the peek for left-swipes (reveal action panel on right side). if (dx < 0) { activeRow.classList.add('row-swiping'); activeRow.style.transform = 'translateX(' + Math.max(dx, -240) + 'px)'; } else { activeRow.style.transform = ''; } // Prevent default so the browser doesn't start a text-selection drag. if (e.cancelable) { try { e.preventDefault(); } catch (_) {} } } else if (gestureContext === 'bottom-nav' && axis === 'h') { if (e.cancelable) { try { e.preventDefault(); } catch (_) {} } } else if (gestureContext === 'slide-over' && axis === 'v') { if (dy > 0) { // Drag panel down with the finger. var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel'); if (so) { so.style.transform = 'translateY(' + dy + 'px)'; } } if (e.cancelable) { try { e.preventDefault(); } catch (_) {} } } } function onPointerUp(e) { if (!pointerActive || e.pointerId !== pointerId) return; var dx = e.clientX - startX; var dy = e.clientY - startY; try { if (gestureContext === 'row' && axis === 'h' && activeRow) { var rowRect = activeRow.getBoundingClientRect(); var threshold = Math.min(ROW_ACTION_PX, rowRect.width * ROW_ACTION_PCT); if (dx < 0 && Math.abs(dx) >= threshold) { // Commit — show overlay, snap row back. activeRow.style.transform = ''; activeRow.classList.remove('row-swiping'); showRowOverlay(activeRow); activeRow = null; // overlay owns lifecycle now } else { // Snap back. activeRow.style.transform = ''; activeRow.classList.remove('row-swiping'); activeRow = null; } } else if (gestureContext === 'bottom-nav' && axis === 'h') { if (dx <= -TAB_SWIPE_PX) { // Drag content leftward → next tab. navigateRelative(+1); } else if (dx >= TAB_SWIPE_PX) { navigateRelative(-1); } } else if (gestureContext === 'slide-over' && axis === 'v') { var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel'); if (so) so.style.transform = ''; // Scroll-discriminator (PR #1185): if the user started mid-scroll, // never dismiss — onPointerMove should already have released, this // is a defense-in-depth guard. if (slideOverStartScrollTop > 0) { // no-op } else if (dy >= SLIDE_OVER_DISMISS_PX && window.SlideOver && typeof window.SlideOver.close === 'function') { try { window.SlideOver.close(); } catch (_) {} } } } finally { releasePointer(); } } function onPointerCancel(e) { if (!pointerActive || e.pointerId !== pointerId) return; if (activeRow) { activeRow.style.transform = ''; activeRow.classList.remove('row-swiping'); activeRow = null; } var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel'); if (so) so.style.transform = ''; releasePointer(); } // Browser may steal pointer capture (e.g. orientation change, parent // scroll start, focus change). When that happens neither pointerup nor // pointercancel are guaranteed — we'd leak state and visuals. Treat // lost-capture identically to cancel. function onPointerLostCapture(e) { if (!pointerActive || e.pointerId !== pointerId) return; if (activeRow) { activeRow.style.transform = ''; activeRow.classList.remove('row-swiping'); activeRow = null; } var so = findSlideOver(startTarget) || document.querySelector('.slide-over-panel'); if (so) so.style.transform = ''; releasePointer(); } function releasePointer() { try { if (capturedEl && pointerId != null && typeof capturedEl.releasePointerCapture === 'function') { capturedEl.releasePointerCapture(pointerId); } } catch (_) {} pointerActive = false; pointerId = null; axis = null; startTarget = null; capturedEl = null; gestureContext = null; slideOverScroller = null; slideOverStartScrollTop = 0; } // ── Row-overlay click delegation ── function onClickAction(e) { var btn = e.target && e.target.closest && e.target.closest('.row-action-btn'); if (!btn) { // Click outside overlay dismisses it. if (rowOverlay && !(e.target.closest && e.target.closest('.row-action-overlay'))) { dismissRowAction(); } return; } var action = btn.getAttribute('data-row-action'); var hash = btn.getAttribute('data-hash') || ''; if (action === 'copy' && hash && navigator.clipboard) { try { navigator.clipboard.writeText(hash); } catch (_) {} } else if (action === 'filter' && hash) { location.hash = '#/packets?hash=' + encodeURIComponent(hash); } else if (action === 'trace' && hash) { location.hash = '#/packets/' + encodeURIComponent(hash); } dismissRowAction(); } // ── Register listeners ONCE at document level ── // passive:false on move/up so we can preventDefault when we own the axis. document.addEventListener('pointerdown', onPointerDown, { passive: true }); document.addEventListener('pointermove', onPointerMove, { passive: false }); document.addEventListener('pointerup', onPointerUp, { passive: true }); document.addEventListener('pointercancel', onPointerCancel, { passive: true }); document.addEventListener('lostpointercapture', onPointerLostCapture, { passive: true }); document.addEventListener('click', onClickAction, true); // Public API used by tests / future callers. window.TouchGestures = { dismissRowAction: dismissRowAction, _navigateRelative: navigateRelative, }; })();