SPB Git

Repository Public

touch.ts 7.4 KB 208 lines typescript

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
Files main
Menu

Theme