Skip to content

Commit 839f5a7

Browse files
committed
fix(terminal): restore native copy menu
1 parent 7481f50 commit 839f5a7

3 files changed

Lines changed: 195 additions & 7 deletions

File tree

packages/terminal/src/web/terminal-copy-interaction.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
clearNativeBrowserCopyMenu,
3+
prepareNativeBrowserCopyMenu,
4+
type TerminalCopyTextarea,
5+
type TerminalNativeCopyMenuHost
6+
} from "./terminal-copy-native-menu.js"
17
import {
28
createTerminalSelectionDragController,
39
forceTerminalSelectionModifier,
@@ -37,6 +43,7 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
3743
readonly mouseTrackingMode: TerminalMouseTrackingMode
3844
}
3945
readonly onSelectionChange?: (handler: () => void) => TerminalDisposable
46+
readonly textarea?: TerminalCopyTextarea | undefined
4047
}
4148

4249
type TerminalCopyClipboardData = {
@@ -54,7 +61,7 @@ type TerminalCopyListenerRegistration = {
5461
(type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void
5562
}
5663

57-
type TerminalCopyInteractionHost = {
64+
type TerminalCopyInteractionHost = TerminalNativeCopyMenuHost & {
5865
readonly ownerDocument?: TerminalSelectionDragTarget | null
5966
readonly addEventListener: TerminalCopyListenerRegistration
6067
readonly removeEventListener: TerminalCopyListenerRegistration
@@ -176,6 +183,8 @@ class TerminalSelectionContextSnapshot {
176183

177184
readonly has = (): boolean => this.selection.length > 0
178185

186+
readonly read = (): string => this.selection
187+
179188
readonly refresh = (): boolean => {
180189
const selection = this.terminal.getSelection()
181190
if (selection.length === 0) {
@@ -246,6 +255,18 @@ class TerminalCopyInteractionController {
246255
hasActiveMouseTracking(this.args.terminal) &&
247256
(this.selectionContext.has() || this.args.terminal.hasSelection())
248257

258+
private readonly prepareNativeBrowserCopyMenu = (event: TerminalCopyMouseEvent): boolean =>
259+
prepareNativeBrowserCopyMenu({
260+
event,
261+
host: this.args.host,
262+
selection: this.selectionContext.read(),
263+
textarea: this.args.terminal.textarea
264+
})
265+
266+
private readonly clearNativeBrowserCopyMenu = (): void => {
267+
clearNativeBrowserCopyMenu(this.args.terminal.textarea)
268+
}
269+
249270
private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean =>
250271
isSecondaryMouseButton(event) && this.hasProtectedSelectionContext()
251272

@@ -263,18 +284,23 @@ class TerminalCopyInteractionController {
263284
private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => {
264285
if (isPrimaryMouseButton(event)) {
265286
this.selectionContext.clear()
287+
this.clearNativeBrowserCopyMenu()
266288
}
267289
const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal)
268-
const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal)
290+
const forceSelectionContext = this.shouldProtectSelectionContext(event)
269291
if (!forceBrowserSelection && !forceSelectionContext) {
270292
if (isSecondaryMouseButton(event)) {
271293
this.selectionContext.clear()
294+
this.clearNativeBrowserCopyMenu()
272295
}
273296
return
274297
}
275298
forceTerminalSelectionModifier(event)
276299
if (forceSelectionContext) {
277-
this.selectionContext.refresh()
300+
if (this.args.terminal.hasSelection()) {
301+
this.selectionContext.refresh()
302+
}
303+
this.prepareNativeBrowserCopyMenu(event)
278304
suppressTerminalMouseReport(event)
279305
return
280306
}
@@ -307,6 +333,7 @@ class TerminalCopyInteractionController {
307333
if (this.args.terminal.hasSelection()) {
308334
this.selectionContext.refresh()
309335
}
336+
this.prepareNativeBrowserCopyMenu(event)
310337
suppressTerminalMouseReport(event)
311338
}
312339

@@ -317,6 +344,7 @@ class TerminalCopyInteractionController {
317344
return
318345
}
319346
this.selectionContext.clear()
347+
this.clearNativeBrowserCopyMenu()
320348
event.preventDefault()
321349
event.stopPropagation()
322350
}
@@ -326,6 +354,7 @@ class TerminalCopyInteractionController {
326354
this.selectionChangeDisposable = null
327355
this.selectionDrag.dispose()
328356
this.selectionContext.clear()
357+
this.clearNativeBrowserCopyMenu()
329358
this.args.host.removeEventListener("mousedown", this.onMouseDown, true)
330359
this.args.host.removeEventListener("mouseup", this.onMouseUp, true)
331360
this.args.host.removeEventListener("contextmenu", this.onContextMenu, true)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { TerminalCopyMouseEvent } from "./terminal-copy-selection-drag.js"
2+
3+
export type TerminalCopyTextarea = {
4+
readonly focus: () => void
5+
readonly select: () => void
6+
readonly style: {
7+
height: string
8+
left: string
9+
top: string
10+
width: string
11+
zIndex: string
12+
}
13+
value: string
14+
}
15+
16+
type TerminalCopyScreenElement = {
17+
readonly getBoundingClientRect: () => {
18+
readonly left: number
19+
readonly top: number
20+
}
21+
}
22+
23+
export type TerminalNativeCopyMenuHost = {
24+
readonly getBoundingClientRect?: TerminalCopyScreenElement["getBoundingClientRect"]
25+
readonly querySelector?: (selector: string) => TerminalCopyScreenElement | null
26+
}
27+
28+
type PrepareNativeBrowserCopyMenuArgs = {
29+
readonly event: TerminalCopyMouseEvent
30+
readonly host: TerminalNativeCopyMenuHost
31+
readonly selection: string
32+
readonly textarea: TerminalCopyTextarea | undefined
33+
}
34+
35+
const terminalContextMenuTextareaOffsetPx = 10
36+
const terminalContextMenuTextareaSizePx = 20
37+
const xtermScreenSelector = ".xterm-screen"
38+
39+
const optionalNumber = (value: number | undefined): number => value ?? 0
40+
41+
const resolveContextMenuHostScreenElement = (
42+
host: TerminalNativeCopyMenuHost
43+
): TerminalCopyScreenElement | null => {
44+
const getBoundingClientRect = host.getBoundingClientRect
45+
if (getBoundingClientRect === undefined) {
46+
return null
47+
}
48+
return {
49+
getBoundingClientRect: () => getBoundingClientRect.call(host)
50+
}
51+
}
52+
53+
const resolveContextMenuScreenElement = (
54+
host: TerminalNativeCopyMenuHost
55+
): TerminalCopyScreenElement | null =>
56+
host.querySelector?.(xtermScreenSelector) ?? resolveContextMenuHostScreenElement(host)
57+
58+
export const prepareNativeBrowserCopyMenu = (
59+
{ event, host, selection, textarea }: PrepareNativeBrowserCopyMenuArgs
60+
): boolean => {
61+
const screenElement = resolveContextMenuScreenElement(host)
62+
if (selection.length === 0 || textarea === undefined || screenElement === null) {
63+
return false
64+
}
65+
const screenPosition = screenElement.getBoundingClientRect()
66+
textarea.style.width = `${terminalContextMenuTextareaSizePx}px`
67+
textarea.style.height = `${terminalContextMenuTextareaSizePx}px`
68+
textarea.style.left = `${optionalNumber(event.clientX) - screenPosition.left - terminalContextMenuTextareaOffsetPx}px`
69+
textarea.style.top = `${optionalNumber(event.clientY) - screenPosition.top - terminalContextMenuTextareaOffsetPx}px`
70+
textarea.style.zIndex = "1000"
71+
textarea.focus()
72+
textarea.value = selection
73+
textarea.select()
74+
return true
75+
}
76+
77+
export const clearNativeBrowserCopyMenu = (
78+
textarea: TerminalCopyTextarea | undefined
79+
): void => {
80+
if (textarea !== undefined) {
81+
textarea.value = ""
82+
}
83+
}

packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,58 @@ const keyboardCopyEvent: TerminalCopyKeyboardEvent = {
1515
type: "keydown"
1616
}
1717

18+
class FakeTerminalCopyTextarea {
19+
focusCalls = 0
20+
selectCalls = 0
21+
readonly style = {
22+
height: "",
23+
left: "",
24+
top: "",
25+
width: "",
26+
zIndex: ""
27+
}
28+
value = ""
29+
30+
focus(): void {
31+
this.focusCalls += 1
32+
}
33+
34+
select(): void {
35+
this.selectCalls += 1
36+
}
37+
}
38+
39+
class FakeTerminalCopyScreenHost extends FakeTerminalCopyHost {
40+
constructor(
41+
readonly screenLeft: number,
42+
readonly screenTop: number
43+
) {
44+
super(null)
45+
}
46+
47+
querySelector(
48+
selector: string
49+
): { readonly getBoundingClientRect: () => { readonly left: number; readonly top: number } } | null {
50+
if (selector !== ".xterm-screen") {
51+
return null
52+
}
53+
return {
54+
getBoundingClientRect: () => ({
55+
left: this.screenLeft,
56+
top: this.screenTop
57+
})
58+
}
59+
}
60+
}
61+
1862
describe("terminal copy redraw interaction", () => {
1963
it("keeps selection snapshot copyable after terminal redraw clears live selection", () => {
2064
let terminalSelection = ""
2165
const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = []
2266
const selectionChangeHandlers: Array<() => void> = []
2367
const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = []
24-
const host = new FakeTerminalCopyHost(null)
68+
const host = new FakeTerminalCopyScreenHost(100, 200)
69+
const textarea = new FakeTerminalCopyTextarea()
2570
const terminal: TerminalCopyInteractionTerminal = {
2671
attachCustomKeyEventHandler: (handler) => {
2772
keyHandlers.push(handler)
@@ -39,7 +84,8 @@ describe("terminal copy redraw interaction", () => {
3984
}
4085
}
4186
}
42-
}
87+
},
88+
textarea
4389
}
4490
const disposable = attachTerminalCopyInteraction({ host, terminal })
4591

@@ -53,21 +99,51 @@ describe("terminal copy redraw interaction", () => {
5399
const handleKey = keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.")
54100
expect(handleKey(keyboardCopyEvent)).toBe(false)
55101

56-
const contextMenu = mouseEvent(0, "contextmenu")
102+
const rightClick = mouseEvent(2, "mousedown", {
103+
clientX: 150,
104+
clientY: 260
105+
})
106+
const contextMenu = mouseEvent(0, "contextmenu", {
107+
clientX: 155,
108+
clientY: 265
109+
})
57110
const copy = copyEvent({
58111
setData: (format: string, data: string) => {
59112
clipboardWrites.push({ data, format })
60113
}
61114
})
115+
host.dispatchMouse("mousedown", rightClick)
116+
117+
expect(rightClick.shiftKey).toBe(true)
118+
expect(rightClick.preventDefaultCalls).toBe(0)
119+
expect(rightClick.stopImmediatePropagationCalls).toBe(1)
120+
expect(textarea.value).toBe("selected before redraw")
121+
expect(textarea.focusCalls).toBe(1)
122+
expect(textarea.selectCalls).toBe(1)
123+
62124
host.dispatchMouse("contextmenu", contextMenu)
63-
host.dispatchCopy(copy)
64125

65126
expect(contextMenu.shiftKey).toBe(true)
127+
expect(contextMenu.preventDefaultCalls).toBe(0)
66128
expect(contextMenu.stopImmediatePropagationCalls).toBe(1)
67129
expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1)
130+
expect(textarea.value).toBe("selected before redraw")
131+
expect(textarea.focusCalls).toBe(2)
132+
expect(textarea.selectCalls).toBe(2)
133+
expect(textarea.style).toEqual({
134+
height: "20px",
135+
left: "45px",
136+
top: "55px",
137+
width: "20px",
138+
zIndex: "1000"
139+
})
140+
141+
host.dispatchCopy(copy)
142+
68143
expect(clipboardWrites).toEqual([{ data: "selected before redraw", format: "text/plain" }])
69144
expect(copy.preventDefaultCalls).toBe(1)
70145
expect(copy.stopPropagationCalls).toBe(1)
146+
expect(textarea.value).toBe("")
71147
expect(selectionChangeHandlers).toHaveLength(1)
72148
expect(handleKey(keyboardCopyEvent)).toBe(true)
73149

0 commit comments

Comments
 (0)