Repository Public
1/**2 * Touch gestures for the terminal on phones/tablets:3 * - one-finger drag scrolls the scrollback with momentum (or sends wheel reports when the4 * application — e.g. tmux with mouse mode — tracks the mouse)5 * - long-press starts an xterm text selection, dragging extends it6 * - two-finger pinch changes the font size7 * A plain tap is left alone so that xterm focuses its input and the keyboard opens.8 */9import type { TerminalController } from './controller';1011export interface TouchCallbacks {12 /** Selection finished (viewport coordinates of the finger) or cleared (null). */13 onSelection: (at: { x: number; y: number } | null) => void;14 onFontSize: (size: number, final: boolean) => void;15}16const LONG_PRESS_MS = 420;17const MOVE_TOLERANCE = 9;1819export function attachTouch(ctrl: TerminalController, host: HTMLElement, cb: TouchCallbacks): () => void {20 const term = ctrl.term;21 let mode: 'idle' | 'pending' | 'scroll' | 'select' | 'pinch' = 'idle';22 let startX = 0, startY = 0, lastY = 0, lastT = 0, acc = 0, velocity = 0;23 let longPress: ReturnType<typeof setTimeout> | null = null;24 let inertia = 0;25 let anchor: { col: number; row: number } | null = null;26 let pinchDist = 0, pinchFont = 0, font = 0;2728 const cell = () => {29 const screen = host.querySelector<HTMLElement>('.xterm-screen');30 const r = (screen ?? host).getBoundingClientRect();31 return { r, w: r.width / term.cols, h: r.height / term.rows };32 };33 const cellAt = (x: number, y: number) => {34 const { r, w, h } = cell();35 return {36 col: Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / w))),37 row: Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / h))) + term.buffer.active.viewportY,38 };39 };40 const scrollBy = (lines: number, x: number, y: number) => {41 if (!lines) return;42 if (term.modes.mouseTrackingMode !== 'none') {43 // The application handles scrolling (tmux copy-mode, pagers): SGR wheel reports.44 const { r, w, h } = cell();45 const col = Math.max(1, Math.min(term.cols, Math.floor((x - r.left) / w) + 1));46 const row = Math.max(1, Math.min(term.rows, Math.floor((y - r.top) / h) + 1));47 const btn = lines < 0 ? 64 : 65;48 ctrl.send(`\x1b[<${btn};${col};${row}M`.repeat(Math.min(Math.abs(lines), 6)));49 } else if (term.buffer.active.type === 'normal') term.scrollLines(lines);50 };51 const stopInertia = () => {52 if (inertia) cancelAnimationFrame(inertia);53 inertia = 0;54 };55 const clearLong = () => {56 if (longPress) clearTimeout(longPress);57 longPress = null;58 };59 const dist = (t: TouchList) => Math.hypot((t[0]?.clientX ?? 0) - (t[1]?.clientX ?? 0), (t[0]?.clientY ?? 0) - (t[1]?.clientY ?? 0));60 const selectWordAt = (x: number, y: number) => {61 const p = cellAt(x, y);62 const line = term.buffer.active.getLine(p.row)?.translateToString(false) ?? '';63 const isWord = (c: string | undefined) => !!c && !/[\s"'`()[\]{}<>|;,]/.test(c);64 let a = p.col, b = p.col;65 if (isWord(line[p.col])) {66 while (a > 0 && isWord(line[a - 1])) a--;67 while (b < line.length - 1 && isWord(line[b + 1])) b++;68 }69 anchor = { col: a, row: p.row };70 term.select(a, p.row, b - a + 1);71 };72 const extendTo = (x: number, y: number) => {73 if (!anchor) return;74 const p = cellAt(x, y);75 const a = anchor.row * term.cols + anchor.col;76 const b = p.row * term.cols + p.col;77 const from = Math.min(a, b), to = Math.max(a, b);78 term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);79 };8081 const onStart = (e: TouchEvent) => {82 stopInertia();83 if (e.touches.length === 2) {84 clearLong();85 mode = 'pinch';86 pinchDist = dist(e.touches);87 pinchFont = font = term.options.fontSize ?? 12;88 e.preventDefault();89 return;90 }91 const t = e.touches[0];92 if (!t || e.touches.length > 2) return;93 mode = 'pending';94 startX = t.clientX;95 startY = lastY = t.clientY;96 lastT = e.timeStamp;97 acc = velocity = 0;98 clearLong();99 longPress = setTimeout(() => {100 if (mode !== 'pending') return;101 mode = 'select';102 selectWordAt(startX, startY);103 try {104 navigator.vibrate?.(8);105 } catch {106 /* unsupported */107 }108 }, LONG_PRESS_MS);109 };110 const onMove = (e: TouchEvent) => {111 if (mode === 'pinch' && e.touches.length === 2) {112 e.preventDefault();113 e.stopPropagation();114 const next = Math.max(8, Math.min(28, Math.round(pinchFont * (dist(e.touches) / (pinchDist || 1)))));115 if (next !== font) {116 font = next;117 cb.onFontSize(next, false);118 }119 return;120 }121 const t = e.touches[0];122 if (!t) return;123 if (mode === 'select') {124 e.preventDefault();125 e.stopPropagation();126 extendTo(t.clientX, t.clientY);127 return;128 }129 if (mode === 'pending' && Math.hypot(t.clientX - startX, t.clientY - startY) > MOVE_TOLERANCE) {130 clearLong();131 mode = Math.abs(t.clientY - startY) >= Math.abs(t.clientX - startX) ? 'scroll' : 'idle';132 }133 if (mode !== 'scroll') return;134 e.preventDefault();135 e.stopPropagation();136 const dy = t.clientY - lastY;137 const dt = Math.max(1, e.timeStamp - lastT);138 velocity = 0.8 * (dy / dt) + 0.2 * velocity;139 lastY = t.clientY;140 lastT = e.timeStamp;141 acc += dy;142 const h = cell().h || 16;143 const lines = Math.trunc(acc / h);144 if (lines) {145 acc -= lines * h;146 scrollBy(-lines, t.clientX, t.clientY);147 }148 };149 const onEnd = (e: TouchEvent) => {150 clearLong();151 const t = e.changedTouches[0];152 if (mode === 'pinch') {153 if (e.touches.length < 2) {154 cb.onFontSize(font, true);155 mode = 'idle';156 }157 e.preventDefault();158 return;159 }160 if (mode === 'select') {161 e.preventDefault(); // no synthetic click: keep the selection162 cb.onSelection(term.hasSelection() && t ? { x: t.clientX, y: t.clientY } : null);163 } else if (mode === 'scroll') {164 e.preventDefault();165 const h = cell().h || 16;166 let v = velocity; // px per ms167 let prev = performance.now();168 let carry = 0;169 const x = t?.clientX ?? 0, y = t?.clientY ?? 0;170 const step = (now: number) => {171 const dt = now - prev;172 prev = now;173 carry += v * dt;174 const lines = Math.trunc(carry / h);175 if (lines) {176 carry -= lines * h;177 scrollBy(-lines, x, y);178 }179 v *= Math.pow(0.996, dt);180 inertia = Math.abs(v) > 0.03 ? requestAnimationFrame(step) : 0;181 };182 if (Math.abs(v) > 0.25 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) inertia = requestAnimationFrame(step);183 } else if (mode === 'pending') {184 // Plain tap: drop any previous selection; the synthetic click focuses xterm (keyboard opens).185 if (term.hasSelection()) {186 term.clearSelection();187 cb.onSelection(null);188 }189 ctrl.focus();190 }191 mode = 'idle';192 };193194 const opts: AddEventListenerOptions = { passive: false, capture: true };195 host.addEventListener('touchstart', onStart, opts);196 host.addEventListener('touchmove', onMove, opts);197 host.addEventListener('touchend', onEnd, opts);198 host.addEventListener('touchcancel', onEnd, opts);199 return () => {200 clearLong();201 stopInertia();202 host.removeEventListener('touchstart', onStart, opts);203 host.removeEventListener('touchmove', onMove, opts);204 host.removeEventListener('touchend', onEnd, opts);205 host.removeEventListener('touchcancel', onEnd, opts);206 };207}208