// 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 } ); }