1+ import {
2+ createTerminalSelectionDragController ,
3+ forceTerminalSelectionModifier ,
4+ suppressTerminalMouseReport ,
5+ type TerminalCopyMouseEvent ,
6+ type TerminalCopyMouseEventType ,
7+ type TerminalMouseButtonEvent ,
8+ type TerminalSelectionDragTarget
9+ } from "./terminal-copy-selection-drag.js"
10+
11+ export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js"
12+
113export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10"
214
315type TerminalSelectionTarget = {
@@ -11,15 +23,6 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
1123 }
1224}
1325
14- type TerminalMouseButtonEvent = {
15- readonly button : number
16- }
17-
18- type TerminalSelectionModifierEvent = {
19- readonly altKey : boolean
20- readonly shiftKey : boolean
21- }
22-
2326type TerminalCopyClipboardData = {
2427 readonly setData : ( format : string , data : string ) => void
2528}
@@ -30,35 +33,6 @@ type TerminalCopyClipboardEvent = {
3033 readonly stopPropagation : ( ) => void
3134}
3235
33- type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & {
34- readonly buttons ?: number | undefined
35- readonly clientX ?: number | undefined
36- readonly clientY ?: number | undefined
37- readonly ctrlKey ?: boolean | undefined
38- readonly detail ?: number | undefined
39- readonly metaKey ?: boolean | undefined
40- readonly preventDefault ?: ( ( ) => void ) | undefined
41- readonly screenX ?: number | undefined
42- readonly screenY ?: number | undefined
43- readonly stopImmediatePropagation ?: ( ( ) => void ) | undefined
44- readonly stopPropagation ?: ( ( ) => void ) | undefined
45- }
46-
47- type TerminalSelectionDragEventType = "mousemove" | "mouseup"
48- type TerminalCopyMouseEventType = "mousedown" | TerminalSelectionDragEventType
49-
50- type TerminalSelectionDragListenerRegistration = (
51- type : TerminalSelectionDragEventType ,
52- listener : ( event : TerminalCopyMouseEvent ) => void ,
53- options : true
54- ) => void
55-
56- type TerminalSelectionDragTarget = {
57- readonly addEventListener : TerminalSelectionDragListenerRegistration
58- readonly dispatchEvent ?: ( ( event : Event ) => boolean ) | undefined
59- readonly removeEventListener : TerminalSelectionDragListenerRegistration
60- }
61-
6236type TerminalCopyListenerRegistration = {
6337 ( type : "copy" , listener : ( event : TerminalCopyClipboardEvent ) => void , options : true ) : void
6438 ( type : TerminalCopyMouseEventType , listener : ( event : TerminalCopyMouseEvent ) => void , options : true ) : void
@@ -75,22 +49,9 @@ type TerminalCopyInteractionArgs = {
7549 readonly terminal : TerminalCopyInteractionTerminal
7650}
7751
78- type TerminalSelectionDragController = {
79- readonly dispose : ( ) => void
80- readonly start : ( ) => void
81- }
82-
8352const primaryMouseButton = 0
8453const secondaryMouseButton = 2
85-
86- const macPlatformNames = new Set ( [ "Mac68K" , "MacIntel" , "Macintosh" , "MacPPC" ] )
87-
88- const currentNavigatorPlatform = ( ) : string => {
89- if ( typeof navigator === "undefined" ) {
90- return ""
91- }
92- return navigator . platform
93- }
54+ const terminalSelectionContextSnapshotTtlMs = 10_000
9455
9556const isPrimaryMouseButton = ( event : TerminalMouseButtonEvent ) : boolean => event . button === primaryMouseButton
9657
@@ -109,18 +70,6 @@ export const shouldForceTerminalSelectionContext = (
10970 terminal : TerminalCopyInteractionTerminal
11071) : boolean => isSecondaryMouseButton ( event ) && terminal . hasSelection ( )
11172
112- const terminalSelectionModifier = ( platform : string ) : keyof TerminalSelectionModifierEvent =>
113- macPlatformNames . has ( platform ) ? "altKey" : "shiftKey"
114-
115- export const forceTerminalSelectionModifier = (
116- event : TerminalSelectionModifierEvent ,
117- platform : string = currentNavigatorPlatform ( )
118- ) : boolean =>
119- Reflect . defineProperty ( event , terminalSelectionModifier ( platform ) , {
120- configurable : true ,
121- value : true
122- } )
123-
12473export const writeTerminalSelectionToClipboardData = (
12574 terminal : TerminalSelectionTarget ,
12675 clipboardData : TerminalCopyClipboardData | null
@@ -136,182 +85,131 @@ export const writeTerminalSelectionToClipboardData = (
13685 return true
13786}
13887
139- const resolveTerminalSelectionDragTarget = (
140- host : TerminalCopyInteractionHost
141- ) : TerminalSelectionDragTarget => host . ownerDocument ?? host
142-
143- const optionalNumber = ( value : number | undefined ) : number => value ?? 0
144-
145- const optionalBoolean = ( value : boolean | undefined ) : boolean => value ?? false
146-
147- const forcedTerminalMouseUpInit = ( event : TerminalCopyMouseEvent ) : MouseEventInit => {
148- const selectionModifier = terminalSelectionModifier ( currentNavigatorPlatform ( ) )
149- return {
150- altKey : selectionModifier === "altKey" ? true : event . altKey ,
151- bubbles : true ,
152- button : event . button ,
153- buttons : 0 ,
154- cancelable : true ,
155- clientX : optionalNumber ( event . clientX ) ,
156- clientY : optionalNumber ( event . clientY ) ,
157- ctrlKey : optionalBoolean ( event . ctrlKey ) ,
158- detail : optionalNumber ( event . detail ) ,
159- metaKey : optionalBoolean ( event . metaKey ) ,
160- screenX : optionalNumber ( event . screenX ) ,
161- screenY : optionalNumber ( event . screenY ) ,
162- shiftKey : selectionModifier === "shiftKey" ? true : event . shiftKey
163- }
164- }
165-
166- const defineMouseEventProperty = (
167- event : Event ,
168- property : string ,
169- value : boolean | number
170- ) : void => {
171- Reflect . defineProperty ( event , property , {
172- configurable : true ,
173- value
174- } )
175- }
88+ class TerminalSelectionContextSnapshot {
89+ private selection = ""
90+ private timer : ReturnType < typeof setTimeout > | null = null
17691
177- const copyMouseEventInitProperties = (
178- event : Event ,
179- init : MouseEventInit
180- ) : void => {
181- defineMouseEventProperty ( event , "altKey" , optionalBoolean ( init . altKey ) )
182- defineMouseEventProperty ( event , "button" , optionalNumber ( init . button ) )
183- defineMouseEventProperty ( event , "buttons" , optionalNumber ( init . buttons ) )
184- defineMouseEventProperty ( event , "clientX" , optionalNumber ( init . clientX ) )
185- defineMouseEventProperty ( event , "clientY" , optionalNumber ( init . clientY ) )
186- defineMouseEventProperty ( event , "ctrlKey" , optionalBoolean ( init . ctrlKey ) )
187- defineMouseEventProperty ( event , "detail" , optionalNumber ( init . detail ) )
188- defineMouseEventProperty ( event , "metaKey" , optionalBoolean ( init . metaKey ) )
189- defineMouseEventProperty ( event , "screenX" , optionalNumber ( init . screenX ) )
190- defineMouseEventProperty ( event , "screenY" , optionalNumber ( init . screenY ) )
191- defineMouseEventProperty ( event , "shiftKey" , optionalBoolean ( init . shiftKey ) )
192- }
92+ constructor ( private readonly terminal : TerminalSelectionTarget ) { }
19393
194- const createForcedTerminalMouseUpEvent = (
195- sourceEvent : TerminalCopyMouseEvent
196- ) : Event => {
197- const init = forcedTerminalMouseUpInit ( sourceEvent )
198- const event = typeof MouseEvent === "function"
199- ? new MouseEvent ( "mouseup" , init )
200- : new Event ( "mouseup" , { bubbles : true , cancelable : true } )
201- copyMouseEventInitProperties ( event , init )
202- return event
203- }
94+ readonly clear = ( ) : void => {
95+ this . selection = ""
96+ if ( this . timer !== null ) {
97+ clearTimeout ( this . timer )
98+ this . timer = null
99+ }
100+ }
204101
205- const suppressOriginalTerminalMouseUp = ( event : TerminalCopyMouseEvent ) : void => {
206- event . preventDefault ?.( )
207- event . stopPropagation ?.( )
208- event . stopImmediatePropagation ?.( )
209- }
102+ readonly has = ( ) : boolean => this . selection . length > 0
210103
211- const suppressTerminalMouseReport = ( event : TerminalCopyMouseEvent ) : void => {
212- event . stopPropagation ?.( )
213- event . stopImmediatePropagation ?.( )
214- }
104+ readonly refresh = ( ) : boolean => {
105+ const selection = this . terminal . getSelection ( )
106+ if ( selection . length === 0 ) {
107+ this . clear ( )
108+ return false
109+ }
110+ this . selection = selection
111+ if ( this . timer !== null ) {
112+ clearTimeout ( this . timer )
113+ }
114+ this . timer = setTimeout ( this . clear , terminalSelectionContextSnapshotTtlMs )
115+ return true
116+ }
215117
216- const replayForcedTerminalMouseUp = (
217- target : TerminalSelectionDragTarget ,
218- event : TerminalCopyMouseEvent
219- ) : void => {
220- target . dispatchEvent ?.( createForcedTerminalMouseUpEvent ( event ) )
118+ readonly writeToClipboardData = ( clipboardData : TerminalCopyClipboardData | null ) : boolean => {
119+ if ( clipboardData === null || this . selection . length === 0 ) {
120+ return false
121+ }
122+ clipboardData . setData ( "text/plain" , this . selection )
123+ return true
124+ }
221125}
222126
223- const createTerminalSelectionDragController = (
224- host : TerminalCopyInteractionHost
225- ) : TerminalSelectionDragController => {
226- let forcedSelectionDrag = false
227- let selectionDragTarget : TerminalSelectionDragTarget | null = null
127+ class TerminalCopyInteractionController {
128+ private readonly selectionContext : TerminalSelectionContextSnapshot
129+ private readonly selectionDrag : ReturnType < typeof createTerminalSelectionDragController >
228130
229- const clearSelectionDrag = ( ) : void => {
230- if ( selectionDragTarget === null ) {
231- forcedSelectionDrag = false
232- return
233- }
234- selectionDragTarget . removeEventListener ( "mousemove" , onMouseMove , true )
235- selectionDragTarget . removeEventListener ( "mouseup" , onMouseUp , true )
236- selectionDragTarget = null
237- forcedSelectionDrag = false
131+ constructor ( private readonly args : TerminalCopyInteractionArgs ) {
132+ this . selectionContext = new TerminalSelectionContextSnapshot ( args . terminal )
133+ this . selectionDrag = createTerminalSelectionDragController ( args . host )
238134 }
239135
240- const onMouseMove = ( event : TerminalCopyMouseEvent ) : void => {
241- if ( ! forcedSelectionDrag ) {
242- return
243- }
244- forceTerminalSelectionModifier ( event )
136+ readonly attach = ( ) : { readonly dispose : ( ) => void } => {
137+ this . args . host . addEventListener ( "mousedown" , this . onMouseDown , true )
138+ this . args . host . addEventListener ( "mouseup" , this . onMouseUp , true )
139+ this . args . host . addEventListener ( "contextmenu" , this . onContextMenu , true )
140+ this . args . host . addEventListener ( "copy" , this . onCopy , true )
141+ return { dispose : this . dispose }
245142 }
246143
247- const onMouseUp = ( event : TerminalCopyMouseEvent ) : void => {
248- if ( ! forcedSelectionDrag ) {
249- return
144+ private readonly shouldProtectSelectionContext = ( event : TerminalCopyMouseEvent ) : boolean =>
145+ isSecondaryMouseButton ( event ) && ( this . selectionContext . has ( ) || this . args . terminal . hasSelection ( ) )
146+
147+ private readonly onSelectionContextMouseEvent = ( event : TerminalCopyMouseEvent ) : boolean => {
148+ if ( ! this . shouldProtectSelectionContext ( event ) ) {
149+ return false
250150 }
251- const target = selectionDragTarget
252151 forceTerminalSelectionModifier ( event )
253- if ( target ?. dispatchEvent !== undefined ) {
254- // CHANGE: replay a clean document mouseup for xterm selection finalization.
255- // WHY: xterm's mouse-report mouseup treats the original release as pty input,
256- // which triggers onUserInput and clears the just-created selection.
257- suppressOriginalTerminalMouseUp ( event )
258- clearSelectionDrag ( )
259- replayForcedTerminalMouseUp ( target , event )
260- return
152+ if ( this . args . terminal . hasSelection ( ) ) {
153+ this . selectionContext . refresh ( )
261154 }
262- clearSelectionDrag ( )
263- }
264-
265- const startSelectionDrag = ( ) : void => {
266- clearSelectionDrag ( )
267- forcedSelectionDrag = true
268- selectionDragTarget = resolveTerminalSelectionDragTarget ( host )
269- selectionDragTarget . addEventListener ( "mousemove" , onMouseMove , true )
270- selectionDragTarget . addEventListener ( "mouseup" , onMouseUp , true )
155+ return true
271156 }
272157
273- return {
274- dispose : clearSelectionDrag ,
275- start : startSelectionDrag
276- }
277- }
278-
279- export const attachTerminalCopyInteraction = (
280- args : TerminalCopyInteractionArgs
281- ) : { readonly dispose : ( ) => void } => {
282- const selectionDrag = createTerminalSelectionDragController ( args . host )
283-
284- const onMouseDown = ( event : TerminalCopyMouseEvent ) : void => {
285- const forceBrowserSelection = shouldForceBrowserTerminalSelection ( event , args . terminal )
286- const forceSelectionContext = shouldForceTerminalSelectionContext ( event , args . terminal )
158+ private readonly onMouseDown = ( event : TerminalCopyMouseEvent ) : void => {
159+ if ( isPrimaryMouseButton ( event ) ) {
160+ this . selectionContext . clear ( )
161+ }
162+ const forceBrowserSelection = shouldForceBrowserTerminalSelection ( event , this . args . terminal )
163+ const forceSelectionContext = shouldForceTerminalSelectionContext ( event , this . args . terminal )
287164 if ( ! forceBrowserSelection && ! forceSelectionContext ) {
165+ if ( isSecondaryMouseButton ( event ) ) {
166+ this . selectionContext . clear ( )
167+ }
288168 return
289169 }
290170 forceTerminalSelectionModifier ( event )
291171 if ( forceSelectionContext ) {
172+ this . selectionContext . refresh ( )
292173 suppressTerminalMouseReport ( event )
293174 return
294175 }
295176 if ( forceBrowserSelection ) {
296- selectionDrag . start ( )
177+ this . selectionDrag . start ( )
297178 }
298179 }
299- const onCopy = ( event : TerminalCopyClipboardEvent ) : void => {
300- if ( ! writeTerminalSelectionToClipboardData ( args . terminal , event . clipboardData ) ) {
180+
181+ private readonly onMouseUp = ( event : TerminalCopyMouseEvent ) : void => {
182+ if ( ! this . onSelectionContextMouseEvent ( event ) ) {
301183 return
302184 }
303- event . preventDefault ( )
304- event . stopPropagation ( )
185+ suppressTerminalMouseReport ( event )
305186 }
306187
307- args . host . addEventListener ( "mousedown" , onMouseDown , true )
308- args . host . addEventListener ( "copy" , onCopy , true )
188+ private readonly onContextMenu = ( event : TerminalCopyMouseEvent ) : void => {
189+ this . onSelectionContextMouseEvent ( event )
190+ }
309191
310- return {
311- dispose : ( ) => {
312- selectionDrag . dispose ( )
313- args . host . removeEventListener ( "mousedown" , onMouseDown , true )
314- args . host . removeEventListener ( "copy" , onCopy , true )
192+ private readonly onCopy = ( event : TerminalCopyClipboardEvent ) : void => {
193+ const wroteSelection = writeTerminalSelectionToClipboardData ( this . args . terminal , event . clipboardData )
194+ const wroteSnapshot = wroteSelection ? false : this . selectionContext . writeToClipboardData ( event . clipboardData )
195+ if ( ! wroteSelection && ! wroteSnapshot ) {
196+ return
315197 }
198+ this . selectionContext . clear ( )
199+ event . preventDefault ( )
200+ event . stopPropagation ( )
201+ }
202+
203+ private readonly dispose = ( ) : void => {
204+ this . selectionDrag . dispose ( )
205+ this . selectionContext . clear ( )
206+ this . args . host . removeEventListener ( "mousedown" , this . onMouseDown , true )
207+ this . args . host . removeEventListener ( "mouseup" , this . onMouseUp , true )
208+ this . args . host . removeEventListener ( "contextmenu" , this . onContextMenu , true )
209+ this . args . host . removeEventListener ( "copy" , this . onCopy , true )
316210 }
317211}
212+
213+ export const attachTerminalCopyInteraction = (
214+ args : TerminalCopyInteractionArgs
215+ ) : { readonly dispose : ( ) => void } => new TerminalCopyInteractionController ( args ) . attach ( )
0 commit comments