feat(mobile): a long press on the pane opens Select text (#1098)

Held still for half a second, a finger opens the plain-text page the
Select text menu item opens, with the run of non-blanks under it already
selected and brought into view, as a long press selects text anywhere on
the phone. A swipe, a pinch or a lift before then is what it was.
This commit is contained in:
l0ng-ai
2026-10-04 20:42:53 +08:00
committed by GitHub
parent 41a247a14f
commit dc441be048
+82 -13
View File
@@ -1368,6 +1368,8 @@ const STATUS_WORD: Record<AgentStatus, string> = {
/** Which machine a pane or workspace is on: a remote the desktop is linked
* to, or null for the paired machine itself. */
type Place = { key: string; name: string } | null;
/** A cell of a terminal: its row in the whole buffer, scrollback included, and its column. */
type Cell = { row: number; col: number };
/** Agents on a machine that are waiting for a reply. */
function waitingCount(tree: Tree) {
@@ -3485,22 +3487,38 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
// mostly vertical, it scrolls the buffer here, a row at a time, and
// coasts after the finger lifts; mostly sideways, it is left to the
// native pan of a pane wider than the phone.
let touch: { x: number; y: number; axis: "x" | "y" | null; samples: [number, number][] } | null = null;
let touch: {
x: number;
y: number;
axis: "x" | "y" | null;
samples: [number, number][];
held?: boolean;
} | null = null;
// Held still, a finger opens the page to select from, with what is under
// it already selected, as a long press selects text anywhere on the phone.
let holding = 0;
let coast = 0;
// Finger movement not yet applied, and the frame that will apply it.
let pending = 0;
let frame = 0;
// The web address under a point on the screen, if any: the cell it falls
// in, then the line that cell is part of, wrapped rows joined.
const linkAt = (x: number, y: number) => {
// The cell under a point on the screen: its row in the whole buffer,
// scrollback included, and its column.
const cellAt = (x: number, y: number): Cell | null => {
const drawn = screenEl.querySelector<HTMLElement>(".xterm-screen");
if (!drawn || !term.cols || !term.rows) return null;
const box = drawn.getBoundingClientRect();
const col = Math.floor(((x - box.left) / box.width) * term.cols);
const row = Math.floor(((y - box.top) / box.height) * term.rows);
if (col < 0 || row < 0 || col >= term.cols || row >= term.rows) return null;
return { row: term.buffer.active.viewportY + row, col };
};
// The web address under a point on the screen, if any: the cell it falls
// in, then the line that cell is part of, wrapped rows joined.
const linkAt = (x: number, y: number) => {
const cell = cellAt(x, y);
if (!cell) return null;
const buf = term.buffer.active;
const at = buf.viewportY + row;
const at = cell.row;
let start = at;
while (start > 0 && buf.getLine(start)?.isWrapped) start--;
let text = "";
@@ -3509,7 +3527,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
if (!line) break;
text += line.translateToString(false);
}
const offset = (at - start) * term.cols + col;
const offset = (at - start) * term.cols + cell.col;
for (const m of text.matchAll(/https?:\/\/[^\s<>"'`]+/g)) {
const url = m[0].replace(/[.,;:!?)\]}'"]+$/, "");
if (offset >= m.index && offset < m.index + url.length) return url;
@@ -3646,6 +3664,7 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
(e) => {
cancelAnimationFrame(coast);
cancelAnimationFrame(frame);
clearTimeout(holding);
frame = 0;
pending = 0;
if (e.touches.length !== 1) return (touch = null);
@@ -3656,19 +3675,26 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
touch = { x: t.clientX, y: t.clientY, axis: null, samples: [[t.clientY, e.timeStamp]] };
finger = { x: t.clientX, y: t.clientY };
wheelPx = 0;
holding = window.setTimeout(() => {
if (!touch || touch.axis || !copyView.hidden) return;
touch.held = true;
feel("tick");
selecting(true, cellAt(t.clientX, t.clientY));
}, 500);
},
{ passive: true },
);
screenEl.addEventListener(
"touchmove",
(e) => {
if (!touch || e.touches.length !== 1) return;
if (!touch || touch.held || e.touches.length !== 1) return;
const t = e.touches[0];
if (!touch.axis) {
const dx = Math.abs(t.clientX - touch.x);
const dy = Math.abs(t.clientY - touch.y);
if (Math.max(dx, dy) < 6) return;
touch.axis = dy > dx ? "y" : "x";
clearTimeout(holding);
}
if (touch.axis !== "y") return;
e.preventDefault();
@@ -3693,6 +3719,12 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
(e) => {
const lifted = touch;
touch = null;
clearTimeout(holding);
// A long press has done its work; the lift is not a tap as well.
if (lifted?.held) {
if (e.cancelable) e.preventDefault();
return;
}
// A tap: the keyboard comes up for typing into the pane. The mouse
// events the tap would turn into are cancelled, or xterm would move
// focus to its own textarea.
@@ -3714,6 +3746,14 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
},
{ passive: false },
);
screenEl.addEventListener(
"touchcancel",
() => {
clearTimeout(holding);
touch = null;
},
{ passive: true },
);
// Pinching: out to read closer, in to see more. A step at a time, on
// lifting — from the whole width to the readable size, then through the
// text sizes Settings offers; the chosen size is kept as the setting.
@@ -3888,21 +3928,32 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
});
// The whole buffer, scrollback included, as lines of text. A row the
// terminal wrapped joins the one before it, so a long URL or path comes
// out whole; its trailing blanks are real and kept.
const bufferText = () => {
// out whole; its trailing blanks are real and kept. With a cell, also
// where its character lands in that text (-1 when it is past the end).
const bufferText = (cell?: Cell | null) => {
const buf = term.buffer.active;
const lines: string[] = [];
let offset = -1;
for (let y = 0; y < buf.length; y++) {
const line = buf.getLine(y);
if (!line) continue;
const text = line.translateToString(!buf.getLine(y + 1)?.isWrapped);
if (line.isWrapped && lines.length) lines[lines.length - 1] += text;
const joined = line.isWrapped && lines.length > 0;
if (cell && y === cell.row) {
const before = joined ? lines.slice(0, -1) : lines;
// A wide character takes two columns but one place in the text.
const into = Math.min(line.translateToString(false, 0, cell.col).length, text.length);
offset =
before.reduce((n, l) => n + l.length + 1, 0) + (joined ? lines[lines.length - 1].length : 0) + into;
}
if (joined) lines[lines.length - 1] += text;
else lines.push(text);
}
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
return lines.join("\n");
const text = lines.join("\n");
return { text, offset: offset < text.length ? offset : -1 };
};
const selecting = (on: boolean) => {
const selecting = (on: boolean, cell?: Cell | null) => {
copyView.hidden = !on;
if (!on) {
getSelection()?.removeAllRanges();
@@ -3911,8 +3962,26 @@ function terminalScreen(host: Host, place: Place, pane: PaneView, title: string,
}
typing.blur();
field.blur();
copyText.textContent = bufferText();
const { text, offset } = bufferText(cell);
copyText.textContent = text;
copyText.scrollTop = copyText.scrollHeight;
const node = copyText.firstChild;
if (!node || offset < 0 || /\s/.test(text[offset])) return;
// The run of non-blanks under the finger, so a path or a URL comes
// whole; the handles take it from there. Brought into the middle.
let start = offset;
let end = offset + 1;
while (start > 0 && !/\s/.test(text[start - 1])) start--;
while (end < text.length && !/\s/.test(text[end])) end++;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
getSelection()?.removeAllRanges();
getSelection()?.addRange(range);
const at = range.getBoundingClientRect();
const box = copyText.getBoundingClientRect();
copyText.scrollTop += at.top - box.top - (box.height - at.height) / 2;
copyText.scrollLeft += at.left - box.left - (box.width - at.width) / 2;
};
copyDone.onclick = () => selecting(false);