@electerm/electerm-react 5.5.6 → 5.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/common/default-setting.js +1 -0
- package/client/common/download.jsx +12 -0
- package/client/common/resolve.js +20 -14
- package/client/components/ai/ai-history-item.jsx +1 -1
- package/client/components/ai/ai-history.jsx +1 -0
- package/client/components/ai/ai-history.styl +23 -0
- package/client/components/bookmark-form/common/color-picker.styl +3 -0
- package/client/components/common/remote-pointer.js +49 -0
- package/client/components/common/remote-session-shell.jsx +137 -0
- package/client/components/common/remote-session-shell.styl +80 -0
- package/client/components/common/responsive-tabs.jsx +89 -0
- package/client/components/common/responsive-tabs.styl +34 -0
- package/client/components/footer/cmd-history.jsx +132 -45
- package/client/components/footer/cmd-history.styl +42 -4
- package/client/components/footer/multi-tab-run-modal.jsx +83 -0
- package/client/components/footer/tab-select.jsx +34 -17
- package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
- package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
- package/client/components/rdp/rdp-session.jsx +94 -124
- package/client/components/rdp/rdp.styl +13 -36
- package/client/components/session/session.styl +0 -16
- package/client/components/setting-panel/setting-modal.jsx +8 -3
- package/client/components/setting-panel/setting-terminal.jsx +1 -0
- package/client/components/sftp/sftp-entry.jsx +30 -0
- package/client/components/sidebar/side-panel.jsx +1 -1
- package/client/components/spice/spice-session.jsx +69 -72
- package/client/components/spice/spice.styl +10 -19
- package/client/components/terminal/mixins/term-content.js +38 -0
- package/client/components/terminal/mixins/term-context-menu.jsx +35 -3
- package/client/components/terminal/mixins/term-socket.js +5 -5
- package/client/components/terminal/mixins/term-touch.js +444 -57
- package/client/components/terminal/shortcut-bar-defs.js +30 -4
- package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
- package/client/components/terminal/shortcut-bar.jsx +13 -4
- package/client/components/terminal/ssh-reload-state.js +41 -1
- package/client/components/terminal/startup-queue.js +67 -13
- package/client/components/terminal/terminal-select-text.jsx +69 -0
- package/client/components/terminal/terminal.jsx +72 -4
- package/client/components/terminal/terminal.styl +61 -0
- package/client/components/theme/theme-form.jsx +18 -21
- package/client/components/theme/theme-form.styl +8 -10
- package/client/components/vnc/vnc-session.jsx +72 -104
- package/client/components/vnc/vnc.styl +25 -16
- package/client/css/mobile.styl +20 -7
- package/client/store/setting.js +4 -0
- package/client/store/tab.js +5 -4
- package/package.json +1 -1
|
@@ -1,15 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mobile touch support.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Mobile touch support — active only while `store.isTouchDevice` is true.
|
|
3
|
+
*
|
|
4
|
+
* `store.isTouchDevice` is flipped by main.jsx from the real pointer input
|
|
5
|
+
* (mouse → false, touch/pen → true), and a pointerdown always precedes the
|
|
6
|
+
* touch event it stands for, so by the time a touchstart reaches us the flag
|
|
7
|
+
* already reflects the device the user is actually operating. A mouse-driven
|
|
8
|
+
* desktop therefore never enters any of this code, and the whole gesture
|
|
9
|
+
* layer is gated at `onTouchStart` alone: every other handler needs the
|
|
10
|
+
* gesture state that only a touch start can create, so a stray event cannot
|
|
11
|
+
* strand a half-finished gesture when the flag flips mid-gesture.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists at all: xterm.js has **no** touch text selection. Its touch
|
|
14
|
+
* layer maps gestures to scrolling only (`-xterm-gesturestart/-change`, see
|
|
15
|
+
* @xterm/xterm), and `-xterm-gesturetap` has no consumer in core. Every touch
|
|
16
|
+
* terminal therefore has to build selection itself; the previous version here
|
|
17
|
+
* could only ever select a single word, which is what made copying painful.
|
|
18
|
+
*
|
|
19
|
+
* Gesture map (touch mode only):
|
|
20
|
+
* tap → clear any selection, focus (opens the soft keyboard)
|
|
21
|
+
* double tap → select the word under the finger
|
|
22
|
+
* triple tap → select the whole line
|
|
23
|
+
* long press → select the word, then KEEP DRAGGING to extend it,
|
|
24
|
+
* auto-scrolling while the finger is past an edge
|
|
25
|
+
* release, dragged → copy (copyWhenSelect on) or open the menu
|
|
26
|
+
* release, no drag → open the menu, exactly as before
|
|
27
|
+
* two fingers → left alone: xterm's own pinch/scroll handling
|
|
28
|
+
*
|
|
29
|
+
* Dragging out a selection is only ever an approximation of what a phone user
|
|
30
|
+
* expects, so the menu also offers "select text" (see onSelectTextMode): it
|
|
31
|
+
* opens the whole buffer in a full-screen read-only textarea where the OS
|
|
32
|
+
* supplies real selection handles. That overlay is an ordinary DOM surface, so
|
|
33
|
+
* `onTouchStart` bails out of it — see isInsideSelectText().
|
|
34
|
+
*
|
|
35
|
+
* Two things that are easy to get wrong:
|
|
36
|
+
* - `term.select()` takes a BUFFER-ABSOLUTE row, not a viewport row. Passing
|
|
37
|
+
* the raw viewport row selects the wrong text as soon as the user has
|
|
38
|
+
* scrolled back — that was a real bug in the single-word version.
|
|
39
|
+
* - xterm registers its touch listeners on `document`, while React's root
|
|
40
|
+
* container sits below it, so our `onTouchMove` runs first and a plain
|
|
41
|
+
* `stopPropagation()` is enough to veto xterm's scroll for the duration of
|
|
42
|
+
* the drag. `preventDefault()` is not an option: React listens to
|
|
43
|
+
* `touchmove` passively, so it is ignored (with a console warning).
|
|
7
44
|
*/
|
|
45
|
+
|
|
46
|
+
const DEFAULT_WORD_SEPARATOR = './\\()"\'-:,.;<>~!@#$%^&*|+=[]{}`~ ?'
|
|
47
|
+
|
|
48
|
+
// the select-text overlay; its own native selection must stay out of the
|
|
49
|
+
// terminal gesture layer
|
|
50
|
+
const SELECT_TEXT_CLASS = '.terminal-select-text'
|
|
51
|
+
|
|
52
|
+
// tap detection
|
|
53
|
+
const DOUBLE_TAP_MS = 350
|
|
54
|
+
const DOUBLE_TAP_PX = 24
|
|
55
|
+
|
|
56
|
+
// edge auto-scroll: a short delay before it kicks in, then one scroll step
|
|
57
|
+
// per interval
|
|
58
|
+
const AUTOSCROLL_DELAY = 260
|
|
59
|
+
const AUTOSCROLL_INTERVAL = 60
|
|
60
|
+
|
|
8
61
|
export const touchMixin = {
|
|
62
|
+
isTouchMode () {
|
|
63
|
+
const store = window.store
|
|
64
|
+
return !!(store && store.isTouchDevice)
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// ---- gesture entry points, wired up in terminal.jsx ----
|
|
68
|
+
|
|
9
69
|
onTouchStart (e) {
|
|
70
|
+
// the one and only touch-mode gate — see the file header
|
|
71
|
+
if (!this.isTouchMode()) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
// the select-text overlay is a normal DOM surface: its native selection
|
|
75
|
+
// gestures must not be recycled into terminal gestures. Only a touchstart
|
|
76
|
+
// is checked, which is enough — the other handlers need gesture state that
|
|
77
|
+
// only a touchstart can create.
|
|
78
|
+
if (this.isInsideSelectText(e)) {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
// two or more fingers mean zoom/scroll for xterm; abort whatever we were
|
|
82
|
+
// doing and stay out of the way
|
|
10
83
|
if (e.touches.length !== 1) {
|
|
84
|
+
this.cancelTouchGesture()
|
|
11
85
|
return
|
|
12
86
|
}
|
|
87
|
+
this.cancelTouchGesture()
|
|
13
88
|
const touch = e.touches[0]
|
|
14
89
|
this.touchStartPos = {
|
|
15
90
|
clientX: touch.clientX,
|
|
@@ -24,34 +99,138 @@ export const touchMixin = {
|
|
|
24
99
|
},
|
|
25
100
|
|
|
26
101
|
onTouchMove (e) {
|
|
27
|
-
|
|
102
|
+
// nothing in flight → not our gesture (this also covers touch mode being
|
|
103
|
+
// off, since only a gated onTouchStart can put state in flight)
|
|
104
|
+
if (!this.dragSelect && !this.touchStartPos) {
|
|
28
105
|
return
|
|
29
106
|
}
|
|
30
107
|
const touch = e.touches[0]
|
|
108
|
+
if (!touch) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (this.dragSelect) {
|
|
112
|
+
// veto xterm's document-level gesture handler so the drag selects
|
|
113
|
+
// instead of scrolling
|
|
114
|
+
e.stopPropagation()
|
|
115
|
+
this.lastDragTouch = {
|
|
116
|
+
clientX: touch.clientX,
|
|
117
|
+
clientY: touch.clientY
|
|
118
|
+
}
|
|
119
|
+
this.dragSelect.moved = true
|
|
120
|
+
this.scheduleDragUpdate()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
31
123
|
const dx = touch.clientX - this.touchStartPos.clientX
|
|
32
124
|
const dy = touch.clientY - this.touchStartPos.clientY
|
|
33
125
|
if (Math.sqrt(dx * dx + dy * dy) > this.longPressMoveTolerance) {
|
|
34
|
-
|
|
35
|
-
this.
|
|
36
|
-
this.touchStartPos = null
|
|
126
|
+
// it is a scroll/pan, not a long press — hand it back to xterm
|
|
127
|
+
this.cancelLongPress()
|
|
37
128
|
}
|
|
38
129
|
},
|
|
39
130
|
|
|
40
|
-
onTouchEnd () {
|
|
41
|
-
const
|
|
131
|
+
onTouchEnd (e) {
|
|
132
|
+
const gesture = this.dragSelect
|
|
133
|
+
const startPos = this.touchStartPos
|
|
134
|
+
const longPressed = this.longPressFired
|
|
135
|
+
|
|
42
136
|
clearTimeout(this.longPressTimer)
|
|
43
137
|
this.longPressTimer = null
|
|
138
|
+
|
|
139
|
+
// touchmove is coalesced to one update per frame, so flush the pending
|
|
140
|
+
// frame here to make the final selection match where the finger lifted
|
|
141
|
+
if (gesture && gesture.moved && this.lastDragTouch) {
|
|
142
|
+
const coords = this.toBufferCoords(
|
|
143
|
+
this.lastDragTouch.clientX,
|
|
144
|
+
this.lastDragTouch.clientY
|
|
145
|
+
)
|
|
146
|
+
if (coords) {
|
|
147
|
+
this.applySelection(gesture.anchor, coords)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
window.cancelAnimationFrame(this.dragFrame)
|
|
151
|
+
this.dragFrame = 0
|
|
152
|
+
this.stopAutoScroll()
|
|
153
|
+
this.dragSelect = null
|
|
154
|
+
this.lastDragTouch = null
|
|
44
155
|
this.touchStartPos = null
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
156
|
+
this.longPressFired = false
|
|
157
|
+
|
|
158
|
+
if (gesture) {
|
|
159
|
+
if (gesture.moved && this.props.config.copyWhenSelect) {
|
|
160
|
+
// mobile has no Ctrl+C — copy on release and skip the menu; the
|
|
161
|
+
// selection stays visible so the user can still see what was copied
|
|
162
|
+
this.copySelectionToClipboard()
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
// no drag, or auto-copy off: the menu is the way to act on the
|
|
166
|
+
// selection, and its Copy item now covers a real multi-line range
|
|
167
|
+
// instead of a single word. Anchor it under the finger that lifted,
|
|
168
|
+
// which for a long drag is where the user is actually looking.
|
|
169
|
+
const endTouch = gesture.moved && e.changedTouches && e.changedTouches[0]
|
|
170
|
+
this.openContextMenuAt(
|
|
171
|
+
endTouch ? endTouch.clientX : gesture.clientX,
|
|
172
|
+
endTouch ? endTouch.clientY : gesture.clientY,
|
|
173
|
+
gesture.target
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (longPressed) {
|
|
178
|
+
// long pressed on a separator/blank cell, so nothing got selected —
|
|
179
|
+
// still offer the menu, as before
|
|
180
|
+
if (startPos) {
|
|
181
|
+
this.openContextMenuAt(startPos.clientX, startPos.clientY, startPos.target)
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!startPos) {
|
|
186
|
+
// the gesture turned into a scroll, which is xterm's job
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
this.handleTap(e.changedTouches[0])
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A tap that never became a drag: single → focus + drop the selection,
|
|
194
|
+
* double → select the word, triple → select the line.
|
|
195
|
+
*/
|
|
196
|
+
handleTap (touch) {
|
|
197
|
+
if (!touch || !this.term) {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
const { clientX, clientY } = touch
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
const near = this.lastTapPos &&
|
|
203
|
+
Math.abs(clientX - this.lastTapPos.clientX) < DOUBLE_TAP_PX &&
|
|
204
|
+
Math.abs(clientY - this.lastTapPos.clientY) < DOUBLE_TAP_PX
|
|
205
|
+
this.tapCount = (now - this.lastTapTime < DOUBLE_TAP_MS && near)
|
|
206
|
+
? this.tapCount + 1
|
|
207
|
+
: 1
|
|
208
|
+
this.lastTapTime = now
|
|
209
|
+
this.lastTapPos = { clientX, clientY }
|
|
210
|
+
|
|
211
|
+
if (this.tapCount === 2 && this.selectWordAt(clientX, clientY)) {
|
|
53
212
|
this.term.focus()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (this.tapCount >= 3) {
|
|
216
|
+
this.tapCount = 0
|
|
217
|
+
const coords = this.toBufferCoords(clientX, clientY)
|
|
218
|
+
if (coords) {
|
|
219
|
+
// selectLines() takes buffer-absolute line indices, same as select()
|
|
220
|
+
this.term.selectLines(coords.absRow, coords.absRow)
|
|
221
|
+
this.term.focus()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (this.term.hasSelection()) {
|
|
226
|
+
this.term.clearSelection()
|
|
54
227
|
}
|
|
228
|
+
// xterm's own touch handler calls preventDefault()/stopPropagation() on
|
|
229
|
+
// touchstart/touchend, which suppresses the synthesised mousedown xterm
|
|
230
|
+
// relies on to focus its hidden helper textarea — so a tap never focuses
|
|
231
|
+
// the terminal by itself and the soft keyboard would never open. Focusing
|
|
232
|
+
// here runs synchronously inside the user gesture, so iOS/Android show it.
|
|
233
|
+
this.term.focus()
|
|
55
234
|
},
|
|
56
235
|
|
|
57
236
|
handleLongPress () {
|
|
@@ -59,78 +238,286 @@ export const touchMixin = {
|
|
|
59
238
|
return
|
|
60
239
|
}
|
|
61
240
|
this.longPressFired = true
|
|
241
|
+
// a long press is not part of any tap sequence
|
|
242
|
+
this.tapCount = 0
|
|
243
|
+
this.lastTapPos = null
|
|
62
244
|
const { clientX, clientY, target } = this.touchStartPos
|
|
63
245
|
|
|
64
|
-
|
|
65
|
-
// select) so the user can immediately copy or act on it.
|
|
66
|
-
this.selectWordAt(clientX, clientY)
|
|
246
|
+
const anchor = this.selectWordAt(clientX, clientY)
|
|
67
247
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
248
|
+
// pasteWhenContextMenu means "long press pastes" — honour that contract
|
|
249
|
+
// and never take over the gesture with a drag
|
|
70
250
|
if (this.props.config.pasteWhenContextMenu) {
|
|
71
251
|
this.onPaste()
|
|
72
252
|
return
|
|
73
253
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
254
|
+
if (!anchor) {
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
this.dragSelect = {
|
|
258
|
+
anchor,
|
|
259
|
+
moved: false,
|
|
80
260
|
clientX,
|
|
81
|
-
clientY
|
|
82
|
-
|
|
83
|
-
|
|
261
|
+
clientY,
|
|
262
|
+
target
|
|
263
|
+
}
|
|
264
|
+
this.lastDragTouch = { clientX, clientY }
|
|
84
265
|
},
|
|
85
266
|
|
|
86
|
-
|
|
87
|
-
|
|
267
|
+
isInsideSelectText (e) {
|
|
268
|
+
const target = e.target
|
|
269
|
+
return !!(target && target.closest && target.closest(SELECT_TEXT_CLASS))
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// ---- select-text overlay ----
|
|
273
|
+
// The full-screen read-only textarea behind the menu's "select text" item.
|
|
274
|
+
// Dragging a selection out on the canvas is only ever an approximation of a
|
|
275
|
+
// native one, so this hands the whole buffer to the platform instead; the
|
|
276
|
+
// component itself is ./terminal-select-text.jsx.
|
|
277
|
+
|
|
278
|
+
onSelectTextMode () {
|
|
279
|
+
const text = this.getSelectableBufferText()
|
|
280
|
+
if (!text) {
|
|
88
281
|
return
|
|
89
282
|
}
|
|
90
|
-
|
|
91
|
-
|
|
283
|
+
this.setState({
|
|
284
|
+
selectTextVisible: true,
|
|
285
|
+
selectTextContent: text
|
|
286
|
+
})
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
handleSelectTextClose () {
|
|
290
|
+
this.setState({
|
|
291
|
+
selectTextVisible: false,
|
|
292
|
+
selectTextContent: ''
|
|
293
|
+
})
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
handleSelectTextAll () {
|
|
297
|
+
const el = this.selectTextRef.current
|
|
298
|
+
if (!el) {
|
|
92
299
|
return
|
|
93
300
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
301
|
+
// readOnly, so this does not raise the soft keyboard; the element still
|
|
302
|
+
// has to be focused or browsers will not paint the selection
|
|
303
|
+
el.focus({ preventScroll: true })
|
|
304
|
+
el.setSelectionRange(0, el.value.length)
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
handleSelectTextCopy () {
|
|
308
|
+
const el = this.selectTextRef.current
|
|
309
|
+
if (!el) {
|
|
98
310
|
return
|
|
99
311
|
}
|
|
312
|
+
const { value, selectionStart, selectionEnd } = el
|
|
313
|
+
// nothing selected by hand → "copy" means the whole buffer, which is what
|
|
314
|
+
// a phone user means after opening this panel
|
|
315
|
+
this.copyToClipboard(
|
|
316
|
+
selectionEnd > selectionStart
|
|
317
|
+
? value.slice(selectionStart, selectionEnd)
|
|
318
|
+
: value
|
|
319
|
+
)
|
|
320
|
+
},
|
|
100
321
|
|
|
101
|
-
|
|
102
|
-
const cellHeight = rect.height / this.term.rows
|
|
103
|
-
const col = Math.floor(x / cellWidth)
|
|
104
|
-
const row = Math.floor(y / cellHeight)
|
|
322
|
+
// ---- selection plumbing ----
|
|
105
323
|
|
|
106
|
-
|
|
107
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Selects the word under the point and returns the anchor a following drag
|
|
326
|
+
* extends from. The anchor is the word start, so a drag always keeps the
|
|
327
|
+
* whole word. Returns null when there is no usable coordinate mapping.
|
|
328
|
+
*/
|
|
329
|
+
selectWordAt (clientX, clientY) {
|
|
330
|
+
if (!this.term) {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
const coords = this.toBufferCoords(clientX, clientY)
|
|
334
|
+
if (!coords) {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
const { col, absRow } = coords
|
|
338
|
+
const anchor = { col, absRow }
|
|
339
|
+
const line = this.term.buffer.active.getLine(absRow)
|
|
108
340
|
if (!line) {
|
|
109
|
-
return
|
|
341
|
+
return anchor
|
|
110
342
|
}
|
|
111
|
-
|
|
112
343
|
const text = line.translateToString(true)
|
|
113
344
|
const wordSeparator = this.props.config.terminalWordSeparator ||
|
|
114
|
-
|
|
345
|
+
DEFAULT_WORD_SEPARATOR
|
|
115
346
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
347
|
+
// touched a blank/separator cell: nothing to expand, but it is still a
|
|
348
|
+
// valid place to start dragging from
|
|
349
|
+
const char = col < text.length ? text[col] : ''
|
|
350
|
+
if (!char || wordSeparator.includes(char)) {
|
|
351
|
+
return anchor
|
|
119
352
|
}
|
|
120
353
|
|
|
121
|
-
// Find word start
|
|
122
354
|
let start = col
|
|
123
355
|
while (start > 0 && !wordSeparator.includes(text[start - 1])) {
|
|
124
356
|
start--
|
|
125
357
|
}
|
|
126
|
-
// Find word end
|
|
127
358
|
let end = col
|
|
128
359
|
while (end < text.length && !wordSeparator.includes(text[end])) {
|
|
129
360
|
end++
|
|
130
361
|
}
|
|
131
|
-
|
|
132
362
|
if (end > start) {
|
|
133
|
-
this.term.select(start,
|
|
363
|
+
this.term.select(start, absRow, end - start)
|
|
364
|
+
}
|
|
365
|
+
return { col: start, absRow }
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Client point → buffer cell. `beyond` is -1/1 while the finger is above or
|
|
370
|
+
* below the screen area, which is what drives edge auto-scrolling.
|
|
371
|
+
*/
|
|
372
|
+
toBufferCoords (clientX, clientY) {
|
|
373
|
+
const term = this.term
|
|
374
|
+
if (!term || !term.element || !term.cols || !term.rows) {
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
// measure against the screen element (exactly cols × rows cells) rather
|
|
378
|
+
// than the whole `.xterm` box, whose padding and scrollbar would skew the
|
|
379
|
+
// mapping by a growing amount the further the finger moves
|
|
380
|
+
const screen = term.element.querySelector('.xterm-screen') || term.element
|
|
381
|
+
const rect = screen.getBoundingClientRect()
|
|
382
|
+
if (!rect.width || !rect.height) {
|
|
383
|
+
return null
|
|
384
|
+
}
|
|
385
|
+
const cellWidth = rect.width / term.cols
|
|
386
|
+
const cellHeight = rect.height / term.rows
|
|
387
|
+
const col = Math.min(
|
|
388
|
+
term.cols - 1,
|
|
389
|
+
Math.max(0, Math.floor((clientX - rect.left) / cellWidth))
|
|
390
|
+
)
|
|
391
|
+
const viewportRow = Math.min(
|
|
392
|
+
term.rows - 1,
|
|
393
|
+
Math.max(0, Math.floor((clientY - rect.top) / cellHeight))
|
|
394
|
+
)
|
|
395
|
+
return {
|
|
396
|
+
col,
|
|
397
|
+
beyond: clientY < rect.top ? -1 : (clientY > rect.bottom ? 1 : 0),
|
|
398
|
+
// the selection model stores buffer-absolute rows, not viewport rows
|
|
399
|
+
absRow: term.buffer.active.viewportY + viewportRow
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Selects from `a` to `b`, in either direction.
|
|
405
|
+
*
|
|
406
|
+
* `term.select()` only accepts "start + length" — it clears any end point,
|
|
407
|
+
* so a backwards drag has to be normalised to min/max. The length is a
|
|
408
|
+
* linear character count across buffer rows, matching xterm's own
|
|
409
|
+
* `toSelectionLength = cols * (endY - startY) + (endX - startX + 1)` and the
|
|
410
|
+
* inverse in `finalSelectionEnd`, which is why a multi-line drag needs no
|
|
411
|
+
* private API.
|
|
412
|
+
*/
|
|
413
|
+
applySelection (a, b) {
|
|
414
|
+
if (!this.term || !a || !b) {
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const forward = a.absRow < b.absRow ||
|
|
418
|
+
(a.absRow === b.absRow && a.col <= b.col)
|
|
419
|
+
const start = forward ? a : b
|
|
420
|
+
const end = forward ? b : a
|
|
421
|
+
const length = this.term.cols * (end.absRow - start.absRow) +
|
|
422
|
+
(end.col - start.col + 1)
|
|
423
|
+
this.term.select(start.col, start.absRow, Math.max(1, length))
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Coalesces touchmove to one update per frame: every `term.select()` fires
|
|
428
|
+
* `onSelectionChange`, and the terminal re-renders on that, so a raw
|
|
429
|
+
* per-event update makes the drag stutter on slower phones.
|
|
430
|
+
*/
|
|
431
|
+
scheduleDragUpdate () {
|
|
432
|
+
if (this.dragFrame) {
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
this.dragFrame = window.requestAnimationFrame(() => {
|
|
436
|
+
this.dragFrame = 0
|
|
437
|
+
const gesture = this.dragSelect
|
|
438
|
+
const touch = this.lastDragTouch
|
|
439
|
+
if (!gesture || !touch) {
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const coords = this.toBufferCoords(touch.clientX, touch.clientY)
|
|
443
|
+
if (!coords) {
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
this.applySelection(gesture.anchor, coords)
|
|
447
|
+
this.autoScrollFrom(coords.beyond)
|
|
448
|
+
})
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Keeps scrolling while the finger is held past the top/bottom edge, and
|
|
453
|
+
* re-extends the selection as the buffer moves under it. The anchor is in
|
|
454
|
+
* absolute coordinates, so it stays put while the viewport scrolls.
|
|
455
|
+
*/
|
|
456
|
+
autoScrollFrom (beyond) {
|
|
457
|
+
if (!beyond) {
|
|
458
|
+
this.stopAutoScroll()
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
if (this.autoScrollDir === beyond) {
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
this.stopAutoScroll()
|
|
465
|
+
this.autoScrollDir = beyond
|
|
466
|
+
const tick = () => {
|
|
467
|
+
if (!this.autoScrollDir || !this.dragSelect) {
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
this.term.scrollLines(this.autoScrollDir)
|
|
471
|
+
const touch = this.lastDragTouch
|
|
472
|
+
if (touch) {
|
|
473
|
+
const coords = this.toBufferCoords(touch.clientX, touch.clientY)
|
|
474
|
+
if (coords) {
|
|
475
|
+
this.applySelection(this.dragSelect.anchor, coords)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
this.timers.touchAutoScroll = setTimeout(tick, AUTOSCROLL_INTERVAL)
|
|
479
|
+
}
|
|
480
|
+
this.timers.touchAutoScroll = setTimeout(tick, AUTOSCROLL_DELAY)
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
stopAutoScroll () {
|
|
484
|
+
this.autoScrollDir = 0
|
|
485
|
+
clearTimeout(this.timers.touchAutoScroll)
|
|
486
|
+
this.timers.touchAutoScroll = null
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Opens the antd context-menu dropdown at the finger position by
|
|
491
|
+
* dispatching a synthetic `contextmenu` event, so the `contextMenu` trigger
|
|
492
|
+
* (and its positioning) is handled by antd exactly like a desktop
|
|
493
|
+
* right-click.
|
|
494
|
+
*/
|
|
495
|
+
openContextMenuAt (clientX, clientY, target) {
|
|
496
|
+
if (!target || this.props.config.pasteWhenContextMenu) {
|
|
497
|
+
return
|
|
134
498
|
}
|
|
499
|
+
const event = new MouseEvent('contextmenu', {
|
|
500
|
+
bubbles: true,
|
|
501
|
+
cancelable: true,
|
|
502
|
+
clientX,
|
|
503
|
+
clientY
|
|
504
|
+
})
|
|
505
|
+
target.dispatchEvent(event)
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
cancelLongPress () {
|
|
509
|
+
clearTimeout(this.longPressTimer)
|
|
510
|
+
this.longPressTimer = null
|
|
511
|
+
this.touchStartPos = null
|
|
512
|
+
this.longPressFired = false
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
cancelTouchGesture () {
|
|
516
|
+
this.cancelLongPress()
|
|
517
|
+
this.stopAutoScroll()
|
|
518
|
+
window.cancelAnimationFrame(this.dragFrame)
|
|
519
|
+
this.dragFrame = 0
|
|
520
|
+
this.dragSelect = null
|
|
521
|
+
this.lastDragTouch = null
|
|
135
522
|
}
|
|
136
523
|
}
|
|
@@ -100,9 +100,17 @@ const modifierOptions = [
|
|
|
100
100
|
{ id: 'meta', label: 'Meta' }
|
|
101
101
|
]
|
|
102
102
|
|
|
103
|
+
const MODIFIER_IDS = new Set(modifierOptions.map(m => m.id))
|
|
104
|
+
|
|
105
|
+
// the builder's second slot accepts either a modifier (Ctrl/Alt/Shift/Meta)
|
|
106
|
+
// or a bare key, so we need to tell them apart.
|
|
107
|
+
function isModifier (id) {
|
|
108
|
+
return MODIFIER_IDS.has(id)
|
|
109
|
+
}
|
|
110
|
+
|
|
103
111
|
// compose the bytes to send for a (mods, key) combo.
|
|
104
112
|
// Ctrl+letter -> the control byte; Alt -> ESC prefix; Shift+letter -> uppercase
|
|
105
|
-
function
|
|
113
|
+
function applyMods (mods, key) {
|
|
106
114
|
const ctrl = mods.includes('ctrl')
|
|
107
115
|
const alt = mods.includes('alt')
|
|
108
116
|
const shift = mods.includes('shift')
|
|
@@ -118,10 +126,27 @@ function buildComboData (mods, key) {
|
|
|
118
126
|
return data
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
//
|
|
122
|
-
|
|
129
|
+
// `prefix` is an optional first key typed BEFORE `key` — that's how you build
|
|
130
|
+
// two-step combos such as tmux's Ctrl+A then D: the modifiers apply to the
|
|
131
|
+
// prefix only, the trailing key is sent plain.
|
|
132
|
+
// mods [ctrl], prefix A, key D -> Ctrl+A then D
|
|
133
|
+
// mods [ctrl, shift], key D -> Ctrl+Shift+D
|
|
134
|
+
function buildComboData (mods, key, prefix) {
|
|
135
|
+
if (prefix) {
|
|
136
|
+
return applyMods(mods, prefix) + key.data
|
|
137
|
+
}
|
|
138
|
+
return applyMods(mods, key)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// human-readable label, e.g.
|
|
142
|
+
// mods ['ctrl','shift'] + key A -> "Ctrl+Shift+A"
|
|
143
|
+
// mods ['ctrl'] + prefix A + key D -> "Ctrl+A D"
|
|
144
|
+
function buildComboLabel (mods, key, prefix) {
|
|
123
145
|
const byId = new Map(modifierOptions.map(m => [m.id, m.label]))
|
|
124
|
-
|
|
146
|
+
const head = mods.map(m => byId.get(m) || m)
|
|
147
|
+
return prefix
|
|
148
|
+
? [...head, prefix.label].join('+') + ' ' + key.label
|
|
149
|
+
: [...head, key.label].join('+')
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
// full library, de-duplicated by id
|
|
@@ -196,6 +221,7 @@ export {
|
|
|
196
221
|
candidates,
|
|
197
222
|
keyOptions,
|
|
198
223
|
modifierOptions,
|
|
224
|
+
isModifier,
|
|
199
225
|
buildComboData,
|
|
200
226
|
buildComboLabel
|
|
201
227
|
}
|