vojo/apps/widget-telegram/src/swipe-forward.ts
2026-06-13 12:50:55 +03:00

121 lines
3.9 KiB
TypeScript

// Forwards the widget's raw touch stream to the Vojo host so the
// swipe-back-from-widget gesture works across the iframe boundary. An
// iframe is a separate browsing context — touches inside it NEVER bubble
// to the host document, so without this the host's interactive-pop
// gesture (src/app/components/swipe-back) is dead over the widget body.
//
// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch',
// data: { phase, x, y } }` posted to the parent with the pinned
// `parentOrigin` (same side-channel + origin discipline as
// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local
// clientX/Y; the host offsets them by the iframe's viewport rect.
//
// The host owns the real gesture state machine (dead-zone axis resolve,
// edge guard, distance commit). The ONLY logic duplicated here is the
// axis resolution needed to call preventDefault locally — the host
// cannot cancel this document's scroll, so once a single-finger drag
// resolves as horizontal-rightward we must suppress our own default
// handling or the widget's vertical scroll would fight the card slide.
// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync.
const DEAD_ZONE_PX = 12;
type Phase = 'start' | 'move' | 'end' | 'cancel';
export function installSwipeForwarder(parentOrigin: string): void {
const post = (phase: Phase, x: number, y: number): void => {
window.parent.postMessage(
{ api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } },
parentOrigin
);
};
let tracking = false;
let bailed = false;
let engaged = false;
let startX = 0;
let startY = 0;
const cancel = (x: number, y: number): void => {
if (tracking && !bailed) post('cancel', x, y);
tracking = false;
bailed = true;
};
document.addEventListener(
'touchstart',
(e) => {
if (e.touches.length !== 1) {
cancel(0, 0);
return;
}
const t = e.touches[0];
tracking = true;
bailed = false;
engaged = false;
startX = t.clientX;
startY = t.clientY;
post('start', t.clientX, t.clientY);
},
{ passive: true }
);
document.addEventListener(
'touchmove',
(e) => {
if (!tracking || bailed) return;
if (e.touches.length !== 1) {
const t = e.touches[0];
cancel(t.clientX, t.clientY);
return;
}
const t = e.touches[0];
if (!engaged) {
const dx = t.clientX - startX;
const dy = t.clientY - startY;
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) {
// Still inside the dead-zone — keep feeding the host (its own
// machine waits the same way) but make no local decision yet.
post('move', t.clientX, t.clientY);
return;
}
// Vertical-dominant or leftward: the gesture is the widget's own
// (scroll / horizontal UI). Stop forwarding — the host's machine
// bails identically from the same data; the cancel is belt and
// braces against threshold drift.
if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) {
cancel(t.clientX, t.clientY);
return;
}
engaged = true;
}
// Horizontal-rightward drag — the host owns it now. Suppress the
// widget's own scroll for the rest of the touch.
if (e.cancelable) e.preventDefault();
post('move', t.clientX, t.clientY);
},
{ passive: false }
);
document.addEventListener(
'touchend',
(e) => {
if (!tracking || bailed) {
tracking = false;
return;
}
tracking = false;
const t = e.changedTouches[0];
post('end', t?.clientX ?? startX, t?.clientY ?? startY);
},
{ passive: true }
);
document.addEventListener(
'touchcancel',
(e) => {
const t = e.changedTouches[0];
cancel(t?.clientX ?? startX, t?.clientY ?? startY);
},
{ passive: true }
);
}