Skip to content

Commit ca2e6eb

Browse files
committed
fix(web): resolve mobile terminal line copy by touch coordinates
1 parent 8413966 commit ca2e6eb

6 files changed

Lines changed: 587 additions & 98 deletions

File tree

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

Lines changed: 261 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,57 @@ function dispatchTouchEvent(
181181
target.dispatchEvent(event);
182182
}
183183

184+
function createMockDomRect({
185+
x,
186+
y,
187+
width,
188+
height,
189+
}: {
190+
x: number;
191+
y: number;
192+
width: number;
193+
height: number;
194+
}): DOMRect {
195+
return {
196+
x,
197+
y,
198+
width,
199+
height,
200+
top: y,
201+
left: x,
202+
right: x + width,
203+
bottom: y + height,
204+
toJSON: () => ({}),
205+
} as DOMRect;
206+
}
207+
208+
function stubRowsGeometry(
209+
host: HTMLDivElement,
210+
rowsElement: HTMLDivElement,
211+
rowElements: HTMLDivElement[],
212+
options: {
213+
hostRect?: { x: number; y: number; width: number; height: number };
214+
rowsRect: { x: number; y: number; width: number; height: number };
215+
rowHeight: number;
216+
}
217+
) {
218+
vi.spyOn(host, "getBoundingClientRect").mockReturnValue(
219+
createMockDomRect(
220+
options.hostRect ?? { x: 0, y: 0, width: options.rowsRect.width, height: 240 }
221+
)
222+
);
223+
rowsElement.getBoundingClientRect = () => createMockDomRect(options.rowsRect);
224+
rowElements.forEach((rowElement, index) => {
225+
rowElement.getBoundingClientRect = () =>
226+
createMockDomRect({
227+
x: options.rowsRect.x,
228+
y: options.rowsRect.y + index * options.rowHeight,
229+
width: options.rowsRect.width,
230+
height: options.rowHeight,
231+
});
232+
});
233+
}
234+
184235
const mockTerminal = {
185236
open: vi.fn(),
186237
onData: vi.fn(() => vi.fn()), // Return dispose function
@@ -7006,9 +7057,12 @@ describe("XtermHost", () => {
70067057

70077058
rowsElement.append(firstRow, secondRow, thirdRow);
70087059
host!.appendChild(rowsElement);
7060+
stubRowsGeometry(host!, rowsElement, [firstRow, secondRow, thirdRow], {
7061+
rowsRect: { x: 0, y: 100, width: 320, height: 60 },
7062+
rowHeight: 20,
7063+
});
70097064

7010-
const target = thirdRow.querySelector("span span") as HTMLSpanElement;
7011-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120, target }]);
7065+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 150 }]);
70127066

70137067
await act(async () => {
70147068
vi.advanceTimersByTime(500);
@@ -7086,9 +7140,12 @@ describe("XtermHost", () => {
70867140
rowsElement.className = "xterm-rows";
70877141
rowsElement.innerHTML = "<div><span>&nbsp;</span></div>";
70887142
host!.appendChild(rowsElement);
7143+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
7144+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7145+
rowHeight: 20,
7146+
});
70897147

7090-
const target = rowsElement.querySelector("span") as HTMLSpanElement;
7091-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120, target }]);
7148+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
70927149

70937150
await act(async () => {
70947151
vi.advanceTimersByTime(500);
@@ -7166,9 +7223,12 @@ describe("XtermHost", () => {
71667223
rowsElement.className = "xterm-rows";
71677224
rowsElement.innerHTML = "<div><span>disabled line</span></div>";
71687225
host!.appendChild(rowsElement);
7226+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
7227+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7228+
rowHeight: 20,
7229+
});
71697230

7170-
const target = rowsElement.querySelector("span") as HTMLSpanElement;
7171-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120, target }]);
7231+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
71727232

71737233
await act(async () => {
71747234
vi.advanceTimersByTime(500);
@@ -7226,10 +7286,18 @@ describe("XtermHost", () => {
72267286
rowsElement.className = "xterm-rows";
72277287
rowsElement.innerHTML = "<div><span>scroll line</span></div>";
72287288
(host as HTMLDivElement).appendChild(rowsElement);
7289+
stubRowsGeometry(
7290+
host as HTMLDivElement,
7291+
rowsElement,
7292+
[rowsElement.firstElementChild as HTMLDivElement],
7293+
{
7294+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7295+
rowHeight: 20,
7296+
}
7297+
);
72297298

7230-
const target = rowsElement.querySelector("span") as HTMLSpanElement;
7231-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120, target }]);
7232-
dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 40, clientY: 88, target }]);
7299+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7300+
dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 40, clientY: 88 }]);
72337301

72347302
await act(async () => {
72357303
vi.advanceTimersByTime(500);
@@ -7295,10 +7363,18 @@ describe("XtermHost", () => {
72957363
rowsElement.className = "xterm-rows";
72967364
rowsElement.innerHTML = "<div><span>drift line</span></div>";
72977365
(host as HTMLDivElement).appendChild(rowsElement);
7366+
stubRowsGeometry(
7367+
host as HTMLDivElement,
7368+
rowsElement,
7369+
[rowsElement.firstElementChild as HTMLDivElement],
7370+
{
7371+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7372+
rowHeight: 20,
7373+
}
7374+
);
72987375

7299-
const target = rowsElement.querySelector("span") as HTMLSpanElement;
7300-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120, target }]);
7301-
dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 56, clientY: 120, target }]);
7376+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
7377+
dispatchTouchEvent(host!, "touchmove", [{ identifier: 1, clientX: 56, clientY: 110 }]);
73027378

73037379
await act(async () => {
73047380
vi.advanceTimersByTime(500);
@@ -7392,7 +7468,7 @@ describe("XtermHost", () => {
73927468
vi.useRealTimers();
73937469
});
73947470

7395-
it("mobile line copy shows the existing copy-on-select failure toast when clipboard write fails", async () => {
7471+
it("mobile line copy shows a mobile-specific failure toast when clipboard write fails", async () => {
73967472
vi.useFakeTimers();
73977473
viewportMocks.viewport = "mobile";
73987474

@@ -7447,9 +7523,12 @@ describe("XtermHost", () => {
74477523
rowsElement.className = "xterm-rows";
74487524
rowsElement.innerHTML = "<div><span>toast line</span></div>";
74497525
host!.appendChild(rowsElement);
7526+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
7527+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7528+
rowHeight: 20,
7529+
});
74507530

7451-
const target = rowsElement.querySelector("span") as HTMLSpanElement;
7452-
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 120, target }]);
7531+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 40, clientY: 110 }]);
74537532

74547533
await act(async () => {
74557534
vi.advanceTimersByTime(500);
@@ -7462,6 +7541,7 @@ describe("XtermHost", () => {
74627541
expect.objectContaining({
74637542
kind: "error",
74647543
title: "自动复制失败",
7544+
body: "请重试长按当前行",
74657545
}),
74667546
])
74677547
);
@@ -7471,6 +7551,172 @@ describe("XtermHost", () => {
74717551
vi.useRealTimers();
74727552
});
74737553

7554+
it("mobile line copy still succeeds when the touch target is only the host element", async () => {
7555+
vi.useFakeTimers();
7556+
viewportMocks.viewport = "mobile";
7557+
7558+
const originalMatchMedia = window.matchMedia;
7559+
const writeText = vi.fn().mockResolvedValue(undefined);
7560+
const vibrate = vi.fn();
7561+
const store = createStore();
7562+
7563+
mockTerminal.buffer.active.viewportY = 9;
7564+
setMockBufferLines([[10, "from coords", false]]);
7565+
7566+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7567+
matches: query === "(pointer: coarse)",
7568+
media: query,
7569+
onchange: null,
7570+
addListener: vi.fn(),
7571+
removeListener: vi.fn(),
7572+
addEventListener: vi.fn(),
7573+
removeEventListener: vi.fn(),
7574+
dispatchEvent: vi.fn(),
7575+
})) as typeof window.matchMedia;
7576+
7577+
Object.defineProperty(navigator, "clipboard", {
7578+
configurable: true,
7579+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
7580+
});
7581+
Object.defineProperty(navigator, "vibrate", {
7582+
configurable: true,
7583+
value: vibrate,
7584+
});
7585+
7586+
store.set(localeAtom, "en");
7587+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7588+
store.set(wsClientAtom, {
7589+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
7590+
subscribe: vi.fn(() => () => {}),
7591+
getStatus: vi.fn(() => "connected"),
7592+
onStatus: vi.fn(() => () => {}),
7593+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
7594+
} as never);
7595+
7596+
const { container } = render(
7597+
<Provider store={store}>
7598+
<XtermHost
7599+
terminalId="mobile-line-copy-host-target-terminal"
7600+
workspaceId="test-workspace"
7601+
/>
7602+
</Provider>
7603+
);
7604+
7605+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
7606+
expect(host).toBeTruthy();
7607+
7608+
const rowsElement = document.createElement("div");
7609+
rowsElement.className = "xterm-rows";
7610+
const firstRow = document.createElement("div");
7611+
firstRow.innerHTML = "<span>ignored</span>";
7612+
const secondRow = document.createElement("div");
7613+
secondRow.innerHTML = "<span>from coords</span>";
7614+
rowsElement.append(firstRow, secondRow);
7615+
host!.appendChild(rowsElement);
7616+
stubRowsGeometry(host!, rowsElement, [firstRow, secondRow], {
7617+
rowsRect: { x: 0, y: 100, width: 320, height: 40 },
7618+
rowHeight: 20,
7619+
});
7620+
7621+
dispatchTouchEvent(host!, "touchstart", [
7622+
{ identifier: 1, clientX: 40, clientY: 130, target: host },
7623+
]);
7624+
7625+
await act(async () => {
7626+
vi.advanceTimersByTime(500);
7627+
await Promise.resolve();
7628+
await Promise.resolve();
7629+
});
7630+
7631+
expect(writeText).toHaveBeenCalledWith("from coords");
7632+
expect(vibrate).toHaveBeenCalledWith(10);
7633+
7634+
window.matchMedia = originalMatchMedia;
7635+
vi.useRealTimers();
7636+
});
7637+
7638+
it("mobile line copy still succeeds after the row DOM is replaced before long press matures", async () => {
7639+
vi.useFakeTimers();
7640+
viewportMocks.viewport = "mobile";
7641+
7642+
const originalMatchMedia = window.matchMedia;
7643+
const writeText = vi.fn().mockResolvedValue(undefined);
7644+
const store = createStore();
7645+
7646+
mockTerminal.buffer.active.viewportY = 4;
7647+
setMockBufferLines([[5, "stable row text", false]]);
7648+
7649+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7650+
matches: query === "(pointer: coarse)",
7651+
media: query,
7652+
onchange: null,
7653+
addListener: vi.fn(),
7654+
removeListener: vi.fn(),
7655+
addEventListener: vi.fn(),
7656+
removeEventListener: vi.fn(),
7657+
dispatchEvent: vi.fn(),
7658+
})) as typeof window.matchMedia;
7659+
7660+
Object.defineProperty(navigator, "clipboard", {
7661+
configurable: true,
7662+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
7663+
});
7664+
7665+
store.set(localeAtom, "en");
7666+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7667+
store.set(wsClientAtom, {
7668+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
7669+
subscribe: vi.fn(() => () => {}),
7670+
getStatus: vi.fn(() => "connected"),
7671+
onStatus: vi.fn(() => () => {}),
7672+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
7673+
} as never);
7674+
7675+
const { container } = render(
7676+
<Provider store={store}>
7677+
<XtermHost terminalId="mobile-line-copy-redraw-terminal" workspaceId="test-workspace" />
7678+
</Provider>
7679+
);
7680+
7681+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
7682+
expect(host).toBeTruthy();
7683+
7684+
const rowsElement = document.createElement("div");
7685+
rowsElement.className = "xterm-rows";
7686+
const firstRow = document.createElement("div");
7687+
firstRow.innerHTML = "<span>ignored</span>";
7688+
const secondRow = document.createElement("div");
7689+
secondRow.innerHTML = "<span><span>stable row text</span></span>";
7690+
rowsElement.append(firstRow, secondRow);
7691+
host!.appendChild(rowsElement);
7692+
stubRowsGeometry(host!, rowsElement, [firstRow, secondRow], {
7693+
rowsRect: { x: 0, y: 100, width: 320, height: 40 },
7694+
rowHeight: 20,
7695+
});
7696+
7697+
dispatchTouchEvent(host!, "touchstart", [
7698+
{
7699+
identifier: 1,
7700+
clientX: 40,
7701+
clientY: 130,
7702+
target: secondRow.querySelector("span span") as HTMLSpanElement,
7703+
},
7704+
]);
7705+
7706+
secondRow.replaceChildren(document.createElement("span"));
7707+
7708+
await act(async () => {
7709+
vi.advanceTimersByTime(500);
7710+
await Promise.resolve();
7711+
await Promise.resolve();
7712+
});
7713+
7714+
expect(writeText).toHaveBeenCalledWith("stable row text");
7715+
7716+
window.matchMedia = originalMatchMedia;
7717+
vi.useRealTimers();
7718+
});
7719+
74747720
it("syncs xterm resize events back to the server PTY", async () => {
74757721
const store = createStore();
74767722
const dispatchCommand = vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } });

0 commit comments

Comments
 (0)