Skip to content

Commit 46256e7

Browse files
committed
Add mobile terminal copy mode
1 parent f3c3445 commit 46256e7

8 files changed

Lines changed: 1763 additions & 6 deletions

File tree

packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx

Lines changed: 1086 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { buildTerminalCopyModeSnapshot } from "./copy-mode-snapshot";
4+
5+
function createRect(top: number, left: number, width: number, height: number): DOMRect {
6+
return {
7+
top,
8+
left,
9+
width,
10+
height,
11+
right: left + width,
12+
bottom: top + height,
13+
x: left,
14+
y: top,
15+
toJSON() {},
16+
} as DOMRect;
17+
}
18+
19+
function parseSnapshotHtml(html: string): HTMLDivElement {
20+
const root = document.createElement("div");
21+
root.innerHTML = html;
22+
return root;
23+
}
24+
25+
describe("buildTerminalCopyModeSnapshot", () => {
26+
it("clones visible xterm rows into selectable overlay markup", () => {
27+
const container = document.createElement("div");
28+
const rowsElement = document.createElement("div");
29+
rowsElement.className = "xterm-rows";
30+
rowsElement.innerHTML = `
31+
<div style="position: absolute; top: 12px; transform: translateY(12px); height: 20px; line-height: 20px;">
32+
<span style="color: rgb(255, 0, 0); font-weight: 700; font-style: italic;">hi there</span>
33+
</div>
34+
<div style="position: absolute; top: 32px; transform: translateY(32px); height: 20px; line-height: 20px;">
35+
<span>ab</span>
36+
</div>
37+
`;
38+
container.append(rowsElement);
39+
document.body.append(container);
40+
41+
container.getBoundingClientRect = () => createRect(10, 20, 100, 40);
42+
rowsElement.getBoundingClientRect = () => createRect(30, 35, 80, 40);
43+
44+
const snapshot = buildTerminalCopyModeSnapshot({
45+
rowsElement,
46+
cols: 5,
47+
fontFamily: "JetBrains Mono",
48+
fontSize: 14,
49+
lineHeightPx: 20,
50+
});
51+
const clonedRoot = parseSnapshotHtml(snapshot?.html ?? "");
52+
const clonedRows = Array.from(clonedRoot.children) as HTMLElement[];
53+
const [firstRow, secondRow] = clonedRows;
54+
const firstSpan = firstRow?.querySelector("span");
55+
const paddingSpan = secondRow?.lastElementChild;
56+
57+
expect(snapshot).toEqual({
58+
html: expect.any(String),
59+
text: "hi there\nab ",
60+
fontFamily: "JetBrains Mono",
61+
fontSize: 14,
62+
lineHeightPx: 20,
63+
});
64+
expect(clonedRows).toHaveLength(2);
65+
expect(firstRow.style.position).toBe("static");
66+
expect(firstRow.style.top).toBe("0px");
67+
expect(firstRow.style.left).toBe("0px");
68+
expect(firstRow.style.transform).toBe("none");
69+
expect(firstRow.style.height).toBe("20px");
70+
expect(firstRow.style.lineHeight).toBe("20px");
71+
expect(firstRow.style.whiteSpace).toBe("pre");
72+
expect(firstSpan).not.toBeNull();
73+
expect((firstSpan as HTMLElement).style.color).toBe("rgb(255, 0, 0)");
74+
expect((firstSpan as HTMLElement).style.fontWeight).toBe("700");
75+
expect((firstSpan as HTMLElement).style.fontStyle).toBe("italic");
76+
expect(firstSpan?.textContent).toBe("hi\u00a0there");
77+
expect(paddingSpan?.textContent).toBe("\u00a0\u00a0\u00a0");
78+
expect(paddingSpan?.previousElementSibling?.textContent).toBe("ab");
79+
80+
container.remove();
81+
});
82+
83+
it("pads plain text lines to terminal columns", () => {
84+
const container = document.createElement("div");
85+
const rowsElement = document.createElement("div");
86+
rowsElement.innerHTML = `<div><span>a</span></div>`;
87+
container.append(rowsElement);
88+
89+
container.getBoundingClientRect = () =>
90+
({
91+
top: 0,
92+
left: 0,
93+
width: 10,
94+
height: 10,
95+
right: 10,
96+
bottom: 10,
97+
x: 0,
98+
y: 0,
99+
toJSON() {},
100+
}) as DOMRect;
101+
rowsElement.getBoundingClientRect = () =>
102+
({
103+
top: 0,
104+
left: 0,
105+
width: 10,
106+
height: 10,
107+
right: 10,
108+
bottom: 10,
109+
x: 0,
110+
y: 0,
111+
toJSON() {},
112+
}) as DOMRect;
113+
114+
const snapshot = buildTerminalCopyModeSnapshot({
115+
rowsElement,
116+
cols: 3,
117+
fontFamily: "mono",
118+
fontSize: 12,
119+
lineHeightPx: 18,
120+
});
121+
122+
expect(snapshot?.text).toBe("a ");
123+
});
124+
125+
it("pads full-width CJK text using terminal cell width", () => {
126+
const container = document.createElement("div");
127+
const rowsElement = document.createElement("div");
128+
rowsElement.innerHTML = `<div><span>你</span></div>`;
129+
container.append(rowsElement);
130+
131+
container.getBoundingClientRect = () => createRect(0, 0, 10, 10);
132+
rowsElement.getBoundingClientRect = () => createRect(0, 0, 10, 10);
133+
134+
const snapshot = buildTerminalCopyModeSnapshot({
135+
rowsElement,
136+
cols: 4,
137+
fontFamily: "mono",
138+
fontSize: 12,
139+
lineHeightPx: 18,
140+
});
141+
const clonedRoot = parseSnapshotHtml(snapshot?.html ?? "");
142+
const row = clonedRoot.firstElementChild as HTMLElement | null;
143+
const paddingSpan = row?.lastElementChild;
144+
145+
expect(snapshot?.text).toBe("你 ");
146+
expect(paddingSpan?.textContent).toBe("\u00a0\u00a0");
147+
});
148+
149+
it("pads combining text using terminal cell width", () => {
150+
const container = document.createElement("div");
151+
const rowsElement = document.createElement("div");
152+
rowsElement.innerHTML = `<div><span>e\u0301</span></div>`;
153+
container.append(rowsElement);
154+
155+
container.getBoundingClientRect = () => createRect(0, 0, 10, 10);
156+
rowsElement.getBoundingClientRect = () => createRect(0, 0, 10, 10);
157+
158+
const snapshot = buildTerminalCopyModeSnapshot({
159+
rowsElement,
160+
cols: 3,
161+
fontFamily: "mono",
162+
fontSize: 12,
163+
lineHeightPx: 18,
164+
});
165+
const clonedRoot = parseSnapshotHtml(snapshot?.html ?? "");
166+
const row = clonedRoot.firstElementChild as HTMLElement | null;
167+
const paddingSpan = row?.lastElementChild;
168+
169+
expect(snapshot?.text).toBe("e\u0301 ");
170+
expect(paddingSpan?.textContent).toBe("\u00a0\u00a0");
171+
});
172+
173+
it("returns null when rowsElement is missing", () => {
174+
const snapshot = buildTerminalCopyModeSnapshot({
175+
rowsElement: null,
176+
cols: 80,
177+
fontFamily: "mono",
178+
fontSize: 12,
179+
lineHeightPx: 18,
180+
});
181+
182+
expect(snapshot).toBeNull();
183+
});
184+
});
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
export interface TerminalCopyModeSnapshot {
2+
html: string;
3+
text: string;
4+
fontFamily: string;
5+
fontSize: number;
6+
lineHeightPx: number;
7+
}
8+
9+
export interface BuildTerminalCopyModeSnapshotArgs {
10+
rowsElement: HTMLElement | null;
11+
cols: number;
12+
fontFamily: string;
13+
fontSize: number;
14+
lineHeightPx: number;
15+
}
16+
17+
function toNbspText(value: string): string {
18+
return value.replace(/ /g, "\u00a0");
19+
}
20+
21+
function toPlainText(value: string): string {
22+
return value.replace(/\u00a0/g, " ");
23+
}
24+
25+
function isCombiningCodePoint(codePoint: number): boolean {
26+
return (
27+
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
28+
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
29+
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
30+
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
31+
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
32+
);
33+
}
34+
35+
function isFullWidthCodePoint(codePoint: number): boolean {
36+
return (
37+
codePoint >= 0x1100 &&
38+
(codePoint <= 0x115f ||
39+
codePoint === 0x2329 ||
40+
codePoint === 0x232a ||
41+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
42+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
43+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
44+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
45+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
46+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
47+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
48+
(codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
49+
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
50+
(codePoint >= 0x20000 && codePoint <= 0x3fffd))
51+
);
52+
}
53+
54+
function getCellWidth(text: string): number {
55+
let width = 0;
56+
57+
for (const char of text) {
58+
const codePoint = char.codePointAt(0);
59+
if (codePoint == null || isCombiningCodePoint(codePoint)) {
60+
continue;
61+
}
62+
63+
width += isFullWidthCodePoint(codePoint) ? 2 : 1;
64+
}
65+
66+
return width;
67+
}
68+
69+
function getPaddingWidth(text: string, cols: number): number {
70+
return Math.max(0, cols - getCellWidth(text));
71+
}
72+
73+
function padText(text: string, cols: number): string {
74+
return `${text}${" ".repeat(getPaddingWidth(text, cols))}`;
75+
}
76+
77+
function getRowText(row: HTMLElement): string {
78+
return Array.from(row.querySelectorAll("span"))
79+
.map((span) => span.textContent ?? "")
80+
.join("");
81+
}
82+
83+
function copySpanStyles(source: HTMLElement, target: HTMLElement): void {
84+
const style = window.getComputedStyle(source);
85+
const color = style.color;
86+
const fontWeight = style.fontWeight;
87+
const fontStyle = style.fontStyle;
88+
89+
target.style.color = color;
90+
if (fontWeight && fontWeight !== "normal" && fontWeight !== "400") {
91+
target.style.fontWeight = fontWeight;
92+
}
93+
if (fontStyle && fontStyle !== "normal") {
94+
target.style.fontStyle = fontStyle;
95+
}
96+
}
97+
98+
export function buildTerminalCopyModeSnapshot(
99+
args: BuildTerminalCopyModeSnapshotArgs
100+
): TerminalCopyModeSnapshot | null {
101+
const { rowsElement, cols, fontFamily, fontSize, lineHeightPx } = args;
102+
103+
if (!rowsElement) {
104+
return null;
105+
}
106+
107+
const clonedRows = rowsElement.cloneNode(true) as HTMLElement;
108+
109+
clonedRows.style.position = "static";
110+
clonedRows.style.transform = "none";
111+
clonedRows.style.left = "0";
112+
clonedRows.style.top = "0";
113+
clonedRows.style.pointerEvents = "auto";
114+
clonedRows.style.userSelect = "text";
115+
clonedRows.style.whiteSpace = "pre";
116+
clonedRows.style.fontFamily = fontFamily;
117+
clonedRows.style.fontSize = `${fontSize}px`;
118+
clonedRows.style.lineHeight = `${lineHeightPx}px`;
119+
120+
const originalSpans = rowsElement.querySelectorAll("span");
121+
const clonedSpans = clonedRows.querySelectorAll("span");
122+
clonedSpans.forEach((span, index) => {
123+
const source = originalSpans[index] as HTMLElement | undefined;
124+
if (source) {
125+
copySpanStyles(source, span as HTMLElement);
126+
}
127+
128+
const text = span.textContent ?? "";
129+
span.textContent = toNbspText(text);
130+
});
131+
132+
Array.from(clonedRows.children).forEach((child) => {
133+
const row = child as HTMLElement;
134+
row.style.position = "static";
135+
row.style.top = "0";
136+
row.style.left = "0";
137+
row.style.transform = "none";
138+
row.style.height = `${lineHeightPx}px`;
139+
row.style.lineHeight = `${lineHeightPx}px`;
140+
row.style.whiteSpace = "pre";
141+
const text = getRowText(row);
142+
const plainText = toPlainText(text);
143+
const paddingWidth = getPaddingWidth(plainText, cols);
144+
if (paddingWidth > 0) {
145+
const paddingSpan = document.createElement("span");
146+
paddingSpan.textContent = "\u00a0".repeat(paddingWidth);
147+
row.appendChild(paddingSpan);
148+
}
149+
});
150+
151+
const text = Array.from(clonedRows.children)
152+
.map((child) => padText(toPlainText(getRowText(child as HTMLElement)), cols))
153+
.join("\n");
154+
155+
return {
156+
html: clonedRows.innerHTML,
157+
text,
158+
fontFamily,
159+
fontSize,
160+
lineHeightPx,
161+
};
162+
}

0 commit comments

Comments
 (0)