@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.
Files changed (47) hide show
  1. package/client/common/default-setting.js +1 -0
  2. package/client/common/download.jsx +12 -0
  3. package/client/common/resolve.js +20 -14
  4. package/client/components/ai/ai-history-item.jsx +1 -1
  5. package/client/components/ai/ai-history.jsx +1 -0
  6. package/client/components/ai/ai-history.styl +23 -0
  7. package/client/components/bookmark-form/common/color-picker.styl +3 -0
  8. package/client/components/common/remote-pointer.js +49 -0
  9. package/client/components/common/remote-session-shell.jsx +137 -0
  10. package/client/components/common/remote-session-shell.styl +80 -0
  11. package/client/components/common/responsive-tabs.jsx +89 -0
  12. package/client/components/common/responsive-tabs.styl +34 -0
  13. package/client/components/footer/cmd-history.jsx +132 -45
  14. package/client/components/footer/cmd-history.styl +42 -4
  15. package/client/components/footer/multi-tab-run-modal.jsx +83 -0
  16. package/client/components/footer/tab-select.jsx +34 -17
  17. package/client/components/quick-commands/quick-command-create-modal.jsx +60 -0
  18. package/client/components/quick-commands/quick-commands-form-elem.jsx +10 -4
  19. package/client/components/rdp/rdp-session.jsx +94 -124
  20. package/client/components/rdp/rdp.styl +13 -36
  21. package/client/components/session/session.styl +0 -16
  22. package/client/components/setting-panel/setting-modal.jsx +8 -3
  23. package/client/components/setting-panel/setting-terminal.jsx +1 -0
  24. package/client/components/sftp/sftp-entry.jsx +30 -0
  25. package/client/components/sidebar/side-panel.jsx +1 -1
  26. package/client/components/spice/spice-session.jsx +69 -72
  27. package/client/components/spice/spice.styl +10 -19
  28. package/client/components/terminal/mixins/term-content.js +38 -0
  29. package/client/components/terminal/mixins/term-context-menu.jsx +35 -3
  30. package/client/components/terminal/mixins/term-socket.js +5 -5
  31. package/client/components/terminal/mixins/term-touch.js +444 -57
  32. package/client/components/terminal/shortcut-bar-defs.js +30 -4
  33. package/client/components/terminal/shortcut-bar-edit.jsx +40 -9
  34. package/client/components/terminal/shortcut-bar.jsx +13 -4
  35. package/client/components/terminal/ssh-reload-state.js +41 -1
  36. package/client/components/terminal/startup-queue.js +67 -13
  37. package/client/components/terminal/terminal-select-text.jsx +69 -0
  38. package/client/components/terminal/terminal.jsx +72 -4
  39. package/client/components/terminal/terminal.styl +61 -0
  40. package/client/components/theme/theme-form.jsx +18 -21
  41. package/client/components/theme/theme-form.styl +8 -10
  42. package/client/components/vnc/vnc-session.jsx +72 -104
  43. package/client/components/vnc/vnc.styl +25 -16
  44. package/client/css/mobile.styl +20 -7
  45. package/client/store/setting.js +4 -0
  46. package/client/store/tab.js +5 -4
  47. package/package.json +1 -1
@@ -1,15 +1,90 @@
1
1
  /**
2
- * Mobile touch support.
3
- * On touch devices, long-press should (1) select the word under the finger
4
- * and (2) open the context menu — mirroring desktop right-click behaviour.
5
- * xterm.js only uses touch events for scrolling, so we add explicit
6
- * long-press detection here.
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
- if (!this.touchStartPos) {
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
- clearTimeout(this.longPressTimer)
35
- this.longPressTimer = null
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 wasTap = this.touchStartPos && !this.longPressFired
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
- // xterm's own touch (Gesture) handler calls preventDefault()/stopPropagation()
46
- // on touchstart/touchend, which suppresses the synthesised mousedown xterm
47
- // relies on to focus its hidden helper textarea. As a result a tap never
48
- // focuses the terminal on touch devices, so the soft keyboard never opens
49
- // and you cannot type. Focus explicitly on a clean tap (not a long-press,
50
- // not a scroll) so mobile input works. The focus() runs synchronously inside
51
- // this user-gesture handler, so iOS/Android will show the keyboard.
52
- if (wasTap && this.term) {
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
- // Select the word at the touch position (same as desktop right-click word
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
- // Respect pasteWhenContextMenu: when enabled, long-press pastes instead
69
- // of showing the menu (same as desktop right-click).
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
- // Dispatch a synthetic contextmenu event so antd Dropdown's contextMenu
76
- // trigger opens the menu at the exact finger position.
77
- const event = new MouseEvent('contextmenu', {
78
- bubbles: true,
79
- cancelable: true,
254
+ if (!anchor) {
255
+ return
256
+ }
257
+ this.dragSelect = {
258
+ anchor,
259
+ moved: false,
80
260
  clientX,
81
- clientY
82
- })
83
- target.dispatchEvent(event)
261
+ clientY,
262
+ target
263
+ }
264
+ this.lastDragTouch = { clientX, clientY }
84
265
  },
85
266
 
86
- selectWordAt (clientX, clientY) {
87
- if (!this.term) {
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
- const termElement = this.term.element
91
- if (!termElement) {
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
- const rect = termElement.getBoundingClientRect()
95
- const x = clientX - rect.left
96
- const y = clientY - rect.top
97
- if (x < 0 || y < 0 || x > rect.width || y > rect.height) {
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
- const cellWidth = rect.width / this.term.cols
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
- const buffer = this.term.buffer.active
107
- const line = buffer.getLine(row)
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
- // If the touched cell is empty or a separator, nothing to select
117
- if (col >= text.length || wordSeparator.includes(text[col])) {
118
- return
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, row, end - 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 buildComboData (mods, key) {
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
- // human-readable label, e.g. mods ['ctrl','shift'] + key A -> "Ctrl+Shift+A"
122
- function buildComboLabel (mods, key) {
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
- return [...mods.map(m => byId.get(m) || m), key.label].join('+')
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
  }