mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-03 20:21:26 +00:00
92df28a569
## Summary
Row-overlay Trace and Filter buttons silently did nothing on touch
swipes. `ensureRowOverlay` stamped `data-hash` only on the Copy button,
while `onClickAction` gates both `trace` and `filter` navigation on
`hash && ...` — so the click handler short-circuited before
`location.hash` was set. Users saw the buttons but tapping them was a
no-op.
## Fix
`public/touch-gestures.js` — in `ensureRowOverlay`, stamp `data-hash` on
all three buttons (Trace, Filter, Copy) from the same source the Copy
button already used (`row.getAttribute('data-hash') ||
row.getAttribute('data-id')`). One-line factoring of the attribute
fragment to avoid duplicating the escape logic.
Behavior after fix:
- Trace → `#/packets/<hash>`
- Filter → `#/packets?hash=<hash>`
- Copy → clipboard (unchanged)
All three match the existing branches in `onClickAction`.
## TDD
- **RED commit** (`dd90f72c`): removes the cov1/cov2 workaround in
`test-touch-gestures-coverage-e2e.js` that artificially stamped
`data-hash` on trace/filter buttons from the test harness. With this
commit alone, cov1/cov2 fail their `location.hash` assertions because
`onClickAction`'s guard short-circuits.
- **GREEN commit** (`a526c30f`): production fix in `ensureRowOverlay`.
cov1/cov2 now pass natively against the real production code path with
no harness-side stamping.
## Browser verified
Coverage E2E (`test-touch-gestures-coverage-e2e.js`) exercises the real
swipe → overlay → button-click → navigation path in headless Chromium
against the running server. cov1 asserts `location.hash ===
#/packets/<hash>`, cov2 asserts `location.hash ===
#/packets?hash=<hash>` — these assertions are the regression gate.
E2E assertion added: test-touch-gestures-coverage-e2e.js:227 (cov1
trace) and test-touch-gestures-coverage-e2e.js:259 (cov2 filter).
## Preflight
All hard gates and warnings pass.
Fixes #1305
---------
Co-authored-by: openclaw <bot@openclaw>
456 lines
18 KiB
JavaScript
456 lines
18 KiB
JavaScript
/* 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 =
|
||
'<button type="button" class="row-action-btn" data-row-action="trace"' + hashAttr + '>Trace</button>' +
|
||
'<button type="button" class="row-action-btn" data-row-action="filter"' + hashAttr + '>Filter</button>' +
|
||
'<button type="button" class="row-action-btn" data-row-action="copy"' + hashAttr + '>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,
|
||
};
|
||
})();
|