Files
meshcore-analyzer/public/touch-gestures.js
T
Kpa-clawbot b4f186af19 fix(#1062): gesture system — swipe rows, tabs, slide-over dismiss (#1185)
Red commit: bbb98cf81aae38bff1ef77a7c8a701813b25bb77 (CI run: pending —
see Checks tab)

Fixes #1062. Parent: #1052.

## Gesture system

Adds touch-gesture handling on phones (≤768px):

1. **Swipe-left on a packets/nodes/observers row** → reveals row-action
overlay (trace, filter, copy hash). Threshold: 24% of row width OR 80px.
Sub-threshold = visual peek that snaps back.
2. **Horizontal swipe on the bottom-nav strip** → advances tabs in TAB
order from `bottom-nav.js`. Packets ↔ Live ↔ Map etc.
3. **Swipe-down on a slide-over panel** → calls
`window.SlideOver.close()`.

## Hard constraints met

- **Pointer Events ONLY** — no `touchstart`/`touchend` mixing.
`setPointerCapture` for tracking continuity.
- **Axis-lock** — direction committed in first 8–12px movement. Vertical
scroll is never blocked unless we explicitly committed to a horizontal
swipe. `body { touch-action: pan-y }` so the browser owns vertical
natively.
- **Leaflet exclusion** — handlers early-bail on
`e.target.closest('.leaflet-container')` so pinch/pan on the map tab are
untouched.
- **Singleton pattern** — module-scoped `__touchGestures1062InitCount`
guard. Document-level pointer listeners registered exactly once even if
the script loads multiple times (mirrors the #1180 fix class).
- **prefers-reduced-motion** — animations have `transition-duration: 0s`
under the media query; gestures still trigger, snaps are instant.

## E2E

`test-gestures-1062-e2e.js` — Playwright with synthesized PointerEvents
(page.touchscreen unreliable in headless for axis-locked custom
handlers). Wired into the deploy.yml matrix.

E2E assertion added: test-gestures-1062-e2e.js:120 (overlay-visible
after left-swipe), :201 (tab advance), :219 (Leaflet exclusion), :247
(slide-over dismiss).

---------

Co-authored-by: openclaw-bot <bot@openclaw>
Co-authored-by: OpenClaw Bot <bot@openclaw.dev>
Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-09 18:41:19 -07:00

456 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 812px; 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 (812 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') || '';
o.innerHTML =
'<button type="button" class="row-action-btn" data-row-action="trace">Trace</button>' +
'<button type="button" class="row-action-btn" data-row-action="filter">Filter</button>' +
'<button type="button" class="row-action-btn" data-row-action="copy" data-hash="' +
String(hash).replace(/"/g, '&quot;') + '">Copy hash</button>';
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,
};
})();