xterm-input-panel 1.2.4 → 1.2.5
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/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/input-panel.stories.ts +37 -0
- package/src/input-panel.ts +42 -3
- package/src/shortcut-tab.ts +10 -30
- package/src/virtual-keyboard-tab.stories.ts +73 -0
- package/src/virtual-keyboard-tab.ts +52 -8
- package/src/xterm-addon.stories.ts +90 -3
- package/src/xterm-addon.ts +79 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# xterm-input-panel
|
|
2
2
|
|
|
3
|
+
## 1.2.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c265719: Add shared terminal keybindings for OS copy/paste behavior, preserve terminal selection when switching InputPanel tabs, and translate terminal touch gestures into mouse events for mobile terminal interaction.
|
|
8
|
+
|
|
3
9
|
## 1.2.4
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { blendHex, cssColorToHex, onThemeChange, resolvePixiTheme } from './pixi
|
|
|
11
11
|
export type { PixiTheme } from './pixi-theme.js'
|
|
12
12
|
export {
|
|
13
13
|
InputPanelAddon,
|
|
14
|
+
type InputPanelCommand,
|
|
15
|
+
type InputPanelCommandOptions,
|
|
14
16
|
type InputPanelHistoryItem,
|
|
15
17
|
type InputPanelSettingsPayload,
|
|
16
18
|
} from './xterm-addon.js'
|
|
@@ -21,6 +21,7 @@ function pointer(target: Element, type: string, x: number, y: number, id = 1) {
|
|
|
21
21
|
pointerId: id,
|
|
22
22
|
pointerType: 'mouse',
|
|
23
23
|
bubbles: true,
|
|
24
|
+
composed: true,
|
|
24
25
|
cancelable: true,
|
|
25
26
|
})
|
|
26
27
|
)
|
|
@@ -174,6 +175,42 @@ export const TabSwitching: StoryObj = {
|
|
|
174
175
|
},
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Toolbar controls must not leak pointer events to the terminal host. Terminal
|
|
180
|
+
* renderers treat outside pointerdown as a selection boundary, so tab switching
|
|
181
|
+
* must stay inside the panel.
|
|
182
|
+
*/
|
|
183
|
+
export const ToolbarEventBoundary: StoryObj = {
|
|
184
|
+
render: () => html`
|
|
185
|
+
<div data-terminal-boundary style="height: 100%;">
|
|
186
|
+
<input-panel layout="fixed" style="height: 100%;">
|
|
187
|
+
<input-method-tab slot="input"></input-method-tab>
|
|
188
|
+
<virtual-keyboard-tab slot="keys"></virtual-keyboard-tab>
|
|
189
|
+
<shortcut-tab slot="shortcuts"></shortcut-tab>
|
|
190
|
+
<virtual-trackpad-tab slot="trackpad"></virtual-trackpad-tab>
|
|
191
|
+
</input-panel>
|
|
192
|
+
</div>
|
|
193
|
+
`,
|
|
194
|
+
play: async ({ canvasElement }) => {
|
|
195
|
+
const panel = await getLitElement(canvasElement, 'input-panel')
|
|
196
|
+
const boundary = canvasElement.querySelector('[data-terminal-boundary]') as HTMLElement
|
|
197
|
+
const shadow = panel.shadowRoot!
|
|
198
|
+
const tabButtons = shadow.querySelectorAll('.tab-btn')
|
|
199
|
+
const keysTab = tabButtons[1] as HTMLButtonElement
|
|
200
|
+
const boundaryPointerDown = fn()
|
|
201
|
+
|
|
202
|
+
boundary.addEventListener('pointerdown', boundaryPointerDown)
|
|
203
|
+
pointer(keysTab, 'pointerdown', 20, 20)
|
|
204
|
+
keysTab.click()
|
|
205
|
+
await panel.updateComplete
|
|
206
|
+
|
|
207
|
+
expect(boundaryPointerDown).not.toHaveBeenCalled()
|
|
208
|
+
expect(keysTab.hasAttribute('data-active')).toBe(true)
|
|
209
|
+
|
|
210
|
+
boundary.removeEventListener('pointerdown', boundaryPointerDown)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
/**
|
|
178
215
|
* Verifies that the close button dispatches the `input-panel:close` event.
|
|
179
216
|
*/
|
package/src/input-panel.ts
CHANGED
|
@@ -568,6 +568,30 @@ export class InputPanel extends LitElement {
|
|
|
568
568
|
this.requestUpdate()
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
private _stopToolbarControlPointer(e: Event) {
|
|
572
|
+
e.stopPropagation()
|
|
573
|
+
e.preventDefault()
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private _stopToolbarControlEvent(e: Event) {
|
|
577
|
+
e.stopPropagation()
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private _switchTabFromToolbar(e: Event, tab: InputPanelTab) {
|
|
581
|
+
this._stopToolbarControlEvent(e)
|
|
582
|
+
this._switchTab(tab)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private _toggleLayoutFromToolbar(e: Event) {
|
|
586
|
+
this._stopToolbarControlEvent(e)
|
|
587
|
+
this._toggleLayout()
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private _closeFromToolbar(e: Event) {
|
|
591
|
+
this._stopToolbarControlEvent(e)
|
|
592
|
+
this._close()
|
|
593
|
+
}
|
|
594
|
+
|
|
571
595
|
private _toggleLayout() {
|
|
572
596
|
this.layout = this.layout === 'fixed' ? 'floating' : 'fixed'
|
|
573
597
|
if (this.layout === 'fixed') {
|
|
@@ -861,7 +885,9 @@ export class InputPanel extends LitElement {
|
|
|
861
885
|
class="tab-btn"
|
|
862
886
|
part="tab-btn"
|
|
863
887
|
?data-active=${this.activeTab === t.id}
|
|
864
|
-
@
|
|
888
|
+
@pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
|
|
889
|
+
@mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
|
|
890
|
+
@click=${(e: Event) => this._switchTabFromToolbar(e, t.id)}
|
|
865
891
|
>
|
|
866
892
|
${t.icon} ${this.activeTab === t.id ? t.label : ''}
|
|
867
893
|
</button>
|
|
@@ -869,10 +895,23 @@ export class InputPanel extends LitElement {
|
|
|
869
895
|
)}
|
|
870
896
|
</div>
|
|
871
897
|
<div class="action-group">
|
|
872
|
-
<button
|
|
898
|
+
<button
|
|
899
|
+
class="icon-btn"
|
|
900
|
+
@pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
|
|
901
|
+
@mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
|
|
902
|
+
@click=${(e: Event) => this._toggleLayoutFromToolbar(e)}
|
|
903
|
+
title="Toggle layout mode"
|
|
904
|
+
>
|
|
873
905
|
${this.layout === 'fixed' ? iconPin(14) : iconPinOff(14)}
|
|
874
906
|
</button>
|
|
875
|
-
<button
|
|
907
|
+
<button
|
|
908
|
+
class="icon-btn"
|
|
909
|
+
part="close-btn"
|
|
910
|
+
@pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
|
|
911
|
+
@mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
|
|
912
|
+
@click=${(e: Event) => this._closeFromToolbar(e)}
|
|
913
|
+
title="Close panel"
|
|
914
|
+
>
|
|
876
915
|
${iconX(14)}
|
|
877
916
|
</button>
|
|
878
917
|
</div>
|
package/src/shortcut-tab.ts
CHANGED
|
@@ -427,34 +427,14 @@ export class ShortcutTab extends LitElement {
|
|
|
427
427
|
)
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
-
private
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
return
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (command === 'paste') {
|
|
443
|
-
try {
|
|
444
|
-
const text = await navigator.clipboard.readText()
|
|
445
|
-
if (text) this._send(text)
|
|
446
|
-
} catch {
|
|
447
|
-
// ignore clipboard permission failures
|
|
448
|
-
}
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const active = document.activeElement
|
|
453
|
-
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
|
|
454
|
-
active.select()
|
|
455
|
-
return
|
|
456
|
-
}
|
|
457
|
-
document.execCommand('selectAll')
|
|
430
|
+
private _handleCommand(command: 'copy' | 'paste' | 'select-all') {
|
|
431
|
+
this.dispatchEvent(
|
|
432
|
+
new CustomEvent('input-panel:command', {
|
|
433
|
+
detail: { command },
|
|
434
|
+
bubbles: true,
|
|
435
|
+
composed: true,
|
|
436
|
+
})
|
|
437
|
+
)
|
|
458
438
|
}
|
|
459
439
|
|
|
460
440
|
private _dpadData(
|
|
@@ -477,7 +457,7 @@ export class ShortcutTab extends LitElement {
|
|
|
477
457
|
return dy < 0 ? '\x1b[A' : '\x1b[B'
|
|
478
458
|
}
|
|
479
459
|
|
|
480
|
-
private
|
|
460
|
+
private _activateShortcut(
|
|
481
461
|
item: ShortcutItem,
|
|
482
462
|
event: FederatedPointerEvent,
|
|
483
463
|
width: number,
|
|
@@ -502,7 +482,7 @@ export class ShortcutTab extends LitElement {
|
|
|
502
482
|
return
|
|
503
483
|
}
|
|
504
484
|
|
|
505
|
-
|
|
485
|
+
this._handleCommand(action.command)
|
|
506
486
|
}
|
|
507
487
|
|
|
508
488
|
private _setActivePage(pageId: string) {
|
|
@@ -362,6 +362,79 @@ export const MacosCapsRow: StoryObj = {
|
|
|
362
362
|
},
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
/**
|
|
366
|
+
* macOS Command+C is an OS clipboard command, not terminal text input.
|
|
367
|
+
*/
|
|
368
|
+
export const MacosCommandCopyCommand: StoryObj = {
|
|
369
|
+
render: () =>
|
|
370
|
+
html`<virtual-keyboard-tab platform="macos" style="height: 100%;"></virtual-keyboard-tab>`,
|
|
371
|
+
play: async ({ canvasElement }) => {
|
|
372
|
+
const el = await setup(canvasElement)
|
|
373
|
+
const commandKey = findKey(el, 'Command')
|
|
374
|
+
expect(commandKey).toBeDefined()
|
|
375
|
+
|
|
376
|
+
const commandHandler = fn()
|
|
377
|
+
const sendHandler = fn()
|
|
378
|
+
el.addEventListener('input-panel:command', commandHandler)
|
|
379
|
+
el.addEventListener('input-panel:send', sendHandler)
|
|
380
|
+
|
|
381
|
+
emitDown(commandKey!.container)
|
|
382
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
383
|
+
|
|
384
|
+
const cKey = el._keys.find((k) => k.def.data === 'c')
|
|
385
|
+
expect(cKey).toBeDefined()
|
|
386
|
+
emitDown(cKey!.container)
|
|
387
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
388
|
+
emitUp(cKey!.container)
|
|
389
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
390
|
+
|
|
391
|
+
expect(sendHandler).toHaveBeenCalledTimes(0)
|
|
392
|
+
expect(commandHandler).toHaveBeenCalledTimes(1)
|
|
393
|
+
const detail = (commandHandler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
394
|
+
expect(detail.detail).toEqual({ command: 'copy', fallbackData: undefined })
|
|
395
|
+
|
|
396
|
+
el.removeEventListener('input-panel:command', commandHandler)
|
|
397
|
+
el.removeEventListener('input-panel:send', sendHandler)
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Windows/Linux Ctrl+C uses the same command path, with terminal interrupt as
|
|
403
|
+
* fallback when no terminal selection can be copied.
|
|
404
|
+
*/
|
|
405
|
+
export const CommonCtrlCopyCommandWithFallback: StoryObj = {
|
|
406
|
+
render: () =>
|
|
407
|
+
html`<virtual-keyboard-tab platform="common" style="height: 100%;"></virtual-keyboard-tab>`,
|
|
408
|
+
play: async ({ canvasElement }) => {
|
|
409
|
+
const el = await setup(canvasElement)
|
|
410
|
+
const ctrlKey = findKey(el, 'Ctrl')
|
|
411
|
+
expect(ctrlKey).toBeDefined()
|
|
412
|
+
|
|
413
|
+
const commandHandler = fn()
|
|
414
|
+
const sendHandler = fn()
|
|
415
|
+
el.addEventListener('input-panel:command', commandHandler)
|
|
416
|
+
el.addEventListener('input-panel:send', sendHandler)
|
|
417
|
+
|
|
418
|
+
emitDown(ctrlKey!.container)
|
|
419
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
420
|
+
|
|
421
|
+
const cKey = el._keys.find((k) => k.def.data === 'c')
|
|
422
|
+
expect(cKey).toBeDefined()
|
|
423
|
+
emitDown(cKey!.container)
|
|
424
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
425
|
+
emitUp(cKey!.container)
|
|
426
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
427
|
+
|
|
428
|
+
expect(sendHandler).toHaveBeenCalledTimes(0)
|
|
429
|
+
expect(commandHandler).toHaveBeenCalledTimes(1)
|
|
430
|
+
const detail = (commandHandler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
431
|
+
expect(detail.detail).toEqual({ command: 'copy', fallbackData: '\x03' })
|
|
432
|
+
|
|
433
|
+
el.removeEventListener('input-panel:command', commandHandler)
|
|
434
|
+
el.removeEventListener('input-panel:send', sendHandler)
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
|
|
365
438
|
/**
|
|
366
439
|
* Swipe up on a key sends shifted value without pressing Shift key.
|
|
367
440
|
*/
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
TextStyle,
|
|
10
10
|
} from 'pixi.js'
|
|
11
11
|
import { onThemeChange, resolvePixiTheme, type PixiTheme } from './pixi-theme.js'
|
|
12
|
-
import { detectHostPlatform, type PlatformMode } from './platform.js'
|
|
12
|
+
import { detectHostPlatform, type HostPlatform, type PlatformMode } from './platform.js'
|
|
13
13
|
import { LAYOUTS, type KeyDef, type ModifierKey } from './virtual-keyboard-layouts.js'
|
|
14
|
+
import type { InputPanelCommand } from './xterm-addon.js'
|
|
14
15
|
|
|
15
16
|
const KEY_PADDING = 3
|
|
16
17
|
const KEY_RADIUS = 4
|
|
@@ -112,12 +113,14 @@ export class VirtualKeyboardTab extends LitElement {
|
|
|
112
113
|
this._activePointerId = null
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
private _hostPlatform(): HostPlatform {
|
|
117
|
+
return this.platform === 'windows' || this.platform === 'macos' || this.platform === 'common'
|
|
118
|
+
? this.platform
|
|
119
|
+
: detectHostPlatform()
|
|
120
|
+
}
|
|
121
|
+
|
|
115
122
|
private getRows(): KeyDef[][] {
|
|
116
|
-
|
|
117
|
-
this.platform === 'windows' || this.platform === 'macos' || this.platform === 'common'
|
|
118
|
-
? this.platform
|
|
119
|
-
: detectHostPlatform()
|
|
120
|
-
return LAYOUTS[hostPlatform]
|
|
123
|
+
return LAYOUTS[this._hostPlatform()]
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
private async _initPixi() {
|
|
@@ -566,9 +569,9 @@ export class VirtualKeyboardTab extends LitElement {
|
|
|
566
569
|
return def.data
|
|
567
570
|
}
|
|
568
571
|
|
|
569
|
-
private
|
|
572
|
+
private _resolveTerminalData(def: KeyDef, forceShift: boolean): string {
|
|
570
573
|
let data = this._resolveOutputData(def, forceShift)
|
|
571
|
-
if (!data) return
|
|
574
|
+
if (!data) return ''
|
|
572
575
|
|
|
573
576
|
if (this._modifiers.ctrl && data.length === 1) {
|
|
574
577
|
const code = data.toUpperCase().charCodeAt(0) - 64
|
|
@@ -581,6 +584,47 @@ export class VirtualKeyboardTab extends LitElement {
|
|
|
581
584
|
data = `\x1b${data}`
|
|
582
585
|
}
|
|
583
586
|
|
|
587
|
+
return data
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private _resolvePrimaryCommand(def: KeyDef): InputPanelCommand | null {
|
|
591
|
+
if (this._modifiers.alt || this._modifiers.shift) return null
|
|
592
|
+
|
|
593
|
+
const platform = this._hostPlatform()
|
|
594
|
+
const primaryModifierActive =
|
|
595
|
+
platform === 'macos'
|
|
596
|
+
? this._modifiers.meta && !this._modifiers.ctrl
|
|
597
|
+
: this._modifiers.ctrl && !this._modifiers.meta
|
|
598
|
+
if (!primaryModifierActive) return null
|
|
599
|
+
|
|
600
|
+
const key = def.data.toLowerCase()
|
|
601
|
+
if (key === 'c') return 'copy'
|
|
602
|
+
if (key === 'v') return 'paste'
|
|
603
|
+
if (key === 'a') return 'select-all'
|
|
604
|
+
return null
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private _sendCommand(command: InputPanelCommand, fallbackData?: string) {
|
|
608
|
+
this.dispatchEvent(
|
|
609
|
+
new CustomEvent('input-panel:command', {
|
|
610
|
+
detail: { command, fallbackData },
|
|
611
|
+
bubbles: true,
|
|
612
|
+
composed: true,
|
|
613
|
+
})
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private _sendKey(def: KeyDef, forceShift: boolean) {
|
|
618
|
+
const data = this._resolveTerminalData(def, forceShift)
|
|
619
|
+
if (!data) return
|
|
620
|
+
|
|
621
|
+
const command = this._resolvePrimaryCommand(def)
|
|
622
|
+
if (command) {
|
|
623
|
+
const fallbackData = this._hostPlatform() === 'macos' ? undefined : data
|
|
624
|
+
this._sendCommand(command, fallbackData)
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
584
628
|
this.dispatchEvent(
|
|
585
629
|
new CustomEvent('input-panel:send', {
|
|
586
630
|
detail: { data },
|
|
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'
|
|
|
2
2
|
import { Terminal } from '@xterm/xterm'
|
|
3
3
|
import { html } from 'lit'
|
|
4
4
|
import { expect, fn, waitFor } from 'storybook/test'
|
|
5
|
-
import { InputPanelAddon } from './xterm-addon.js'
|
|
5
|
+
import { InputPanelAddon, resolveTerminalPointerTarget } from './xterm-addon.js'
|
|
6
6
|
|
|
7
7
|
// Register all custom elements (critical — xterm-addon.ts does NOT import these)
|
|
8
8
|
import './index.js'
|
|
@@ -32,7 +32,13 @@ function resetAddonState() {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Create a real xterm Terminal + InputPanelAddon, mount into container */
|
|
35
|
-
function setupTerminal(
|
|
35
|
+
function setupTerminal(
|
|
36
|
+
container: HTMLElement,
|
|
37
|
+
opts?: {
|
|
38
|
+
stateKey?: string
|
|
39
|
+
onCommand?: (command: 'copy' | 'paste' | 'select-all') => boolean | Promise<boolean>
|
|
40
|
+
}
|
|
41
|
+
) {
|
|
36
42
|
const terminal = new Terminal({
|
|
37
43
|
cols: 80,
|
|
38
44
|
rows: 10,
|
|
@@ -41,7 +47,11 @@ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
|
|
|
41
47
|
})
|
|
42
48
|
|
|
43
49
|
const inputHandler = fn()
|
|
44
|
-
const addon = new InputPanelAddon({
|
|
50
|
+
const addon = new InputPanelAddon({
|
|
51
|
+
onInput: inputHandler,
|
|
52
|
+
onCommand: opts?.onCommand,
|
|
53
|
+
stateKey: opts?.stateKey,
|
|
54
|
+
})
|
|
45
55
|
terminal.loadAddon(addon)
|
|
46
56
|
terminal.open(container)
|
|
47
57
|
storyCleanups.add(() => {
|
|
@@ -278,6 +288,83 @@ export const InputForwarding: StoryObj = {
|
|
|
278
288
|
},
|
|
279
289
|
}
|
|
280
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Command forwarding: input-panel:command events are delegated to the host.
|
|
293
|
+
* If the host allows the command, the addon writes fallback terminal data.
|
|
294
|
+
*/
|
|
295
|
+
export const CommandForwardingWithFallback: StoryObj = {
|
|
296
|
+
render: () => html`<div id="term-container" style="width:100%;height:100%;"></div>`,
|
|
297
|
+
play: async ({ canvasElement }) => {
|
|
298
|
+
resetAddonState()
|
|
299
|
+
const container = canvasElement.querySelector('#term-container') as HTMLElement
|
|
300
|
+
const commandHandler = fn(() => false)
|
|
301
|
+
const { addon, inputHandler } = setupTerminal(container, { onCommand: commandHandler })
|
|
302
|
+
|
|
303
|
+
addon.open()
|
|
304
|
+
expect(addon.isOpen).toBe(true)
|
|
305
|
+
|
|
306
|
+
const panel = container.querySelector('input-panel')!
|
|
307
|
+
await (panel as any).updateComplete
|
|
308
|
+
|
|
309
|
+
panel.dispatchEvent(
|
|
310
|
+
new CustomEvent('input-panel:command', {
|
|
311
|
+
detail: { command: 'copy', fallbackData: '\x03' },
|
|
312
|
+
bubbles: true,
|
|
313
|
+
composed: true,
|
|
314
|
+
})
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
await waitFor(() => {
|
|
318
|
+
expect(commandHandler).toHaveBeenCalledWith('copy', { fallbackData: '\x03' })
|
|
319
|
+
expect(inputHandler).toHaveBeenCalledWith('\x03')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
addon.close()
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Mouse target resolution ignores the transparent touch overlay so the virtual
|
|
328
|
+
* trackpad can still deliver down/move/up events to the terminal screen.
|
|
329
|
+
*/
|
|
330
|
+
export const PointerTargetIgnoresTouchOverlay: StoryObj = {
|
|
331
|
+
render: () => html`
|
|
332
|
+
<div id="term-container" style="width:100%;height:100%;position:relative;">
|
|
333
|
+
<div class="xterm-screen" id="screen" style="position:absolute;inset:0;"></div>
|
|
334
|
+
<div
|
|
335
|
+
class="terminal-touch-mouse-overlay"
|
|
336
|
+
id="overlay"
|
|
337
|
+
style="position:absolute;inset:0;z-index:20;pointer-events:auto;"
|
|
338
|
+
></div>
|
|
339
|
+
</div>
|
|
340
|
+
`,
|
|
341
|
+
play: async ({ canvasElement }) => {
|
|
342
|
+
const container = canvasElement.querySelector('#term-container') as HTMLElement
|
|
343
|
+
const screen = canvasElement.querySelector('#screen') as HTMLElement
|
|
344
|
+
const overlay = canvasElement.querySelector('#overlay') as HTMLElement
|
|
345
|
+
|
|
346
|
+
const descriptor = Object.getOwnPropertyDescriptor(document, 'elementFromPoint')
|
|
347
|
+
let overlayEnabled = true
|
|
348
|
+
Object.defineProperty(document, 'elementFromPoint', {
|
|
349
|
+
configurable: true,
|
|
350
|
+
value: () => (overlayEnabled && overlay.style.pointerEvents !== 'none' ? overlay : screen),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const target = resolveTerminalPointerTarget(container, 24, 32)
|
|
355
|
+
overlayEnabled = false
|
|
356
|
+
expect(target).toBe(screen)
|
|
357
|
+
expect(overlay.style.pointerEvents).toBe('auto')
|
|
358
|
+
} finally {
|
|
359
|
+
if (descriptor) {
|
|
360
|
+
Object.defineProperty(document, 'elementFromPoint', descriptor)
|
|
361
|
+
} else {
|
|
362
|
+
Reflect.deleteProperty(document, 'elementFromPoint')
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
}
|
|
367
|
+
|
|
281
368
|
/**
|
|
282
369
|
* Custom element registration: document.createElement('input-panel')
|
|
283
370
|
* must produce a real InputPanel instance with shadow DOM, not a generic HTMLElement.
|
package/src/xterm-addon.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ITerminalAddon, Terminal } from '@xterm/xterm'
|
|
|
2
2
|
import { iconKeyboard, iconMousePointer2 } from './icons.js'
|
|
3
3
|
import type { InputPanelLayout, InputPanelTab } from './input-panel.js'
|
|
4
4
|
import type { HostPlatform } from './platform.js'
|
|
5
|
+
import type { ShortcutCommand } from './shortcut-pages.js'
|
|
5
6
|
import { getSessionScopedStorageKey } from './storage-namespace.js'
|
|
6
7
|
|
|
7
8
|
const SENSITIVITY = 1.5
|
|
@@ -13,6 +14,32 @@ function isTouchDevice(): boolean {
|
|
|
13
14
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
function isTerminalTouchMouseOverlay(element: Element): element is HTMLElement {
|
|
18
|
+
return (
|
|
19
|
+
element instanceof HTMLElement && element.classList.contains('terminal-touch-mouse-overlay')
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveTerminalPointerTarget(
|
|
24
|
+
container: HTMLElement,
|
|
25
|
+
clientX: number,
|
|
26
|
+
clientY: number
|
|
27
|
+
): Element {
|
|
28
|
+
let element = document.elementFromPoint(clientX, clientY)
|
|
29
|
+
if (element && isTerminalTouchMouseOverlay(element)) {
|
|
30
|
+
const overlayElement = element
|
|
31
|
+
const previousPointerEvents = overlayElement.style.pointerEvents
|
|
32
|
+
overlayElement.style.pointerEvents = 'none'
|
|
33
|
+
element = document.elementFromPoint(clientX, clientY)
|
|
34
|
+
overlayElement.style.pointerEvents = previousPointerEvents
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (element && container.contains(element) && !isTerminalTouchMouseOverlay(element)) {
|
|
38
|
+
return element
|
|
39
|
+
}
|
|
40
|
+
return container.querySelector('.xterm-screen') ?? container
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
export interface InputPanelHistoryItem {
|
|
17
44
|
text: string
|
|
18
45
|
time: number
|
|
@@ -26,6 +53,12 @@ export interface InputPanelSettingsPayload {
|
|
|
26
53
|
historyLimit: number
|
|
27
54
|
}
|
|
28
55
|
|
|
56
|
+
export type InputPanelCommand = ShortcutCommand
|
|
57
|
+
|
|
58
|
+
export interface InputPanelCommandOptions {
|
|
59
|
+
fallbackData?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
interface InputPanelSessionState {
|
|
30
63
|
activeTab: InputPanelTab
|
|
31
64
|
inputDraft: string
|
|
@@ -49,6 +82,10 @@ function isInputPanelTab(value: unknown): value is InputPanelTab {
|
|
|
49
82
|
)
|
|
50
83
|
}
|
|
51
84
|
|
|
85
|
+
function isInputPanelCommand(value: unknown): value is InputPanelCommand {
|
|
86
|
+
return value === 'copy' || value === 'paste' || value === 'select-all'
|
|
87
|
+
}
|
|
88
|
+
|
|
52
89
|
interface InputPanelStateStore {
|
|
53
90
|
lastActiveTab?: InputPanelTab
|
|
54
91
|
sessions?: Record<string, Partial<InputPanelSessionState>>
|
|
@@ -373,6 +410,12 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
373
410
|
private _listenersAttached = false
|
|
374
411
|
|
|
375
412
|
private _onInput: (data: string) => void
|
|
413
|
+
private _onCommand:
|
|
414
|
+
| ((
|
|
415
|
+
command: InputPanelCommand,
|
|
416
|
+
options: InputPanelCommandOptions
|
|
417
|
+
) => boolean | Promise<boolean>)
|
|
418
|
+
| null
|
|
376
419
|
private _onOpenCb: (() => void) | null
|
|
377
420
|
private _onCloseCb: (() => void) | null
|
|
378
421
|
private _getHistory: (() => Promise<readonly InputPanelHistoryItem[]>) | null
|
|
@@ -391,6 +434,10 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
391
434
|
|
|
392
435
|
constructor(opts?: {
|
|
393
436
|
onInput?: (data: string) => void
|
|
437
|
+
onCommand?: (
|
|
438
|
+
command: InputPanelCommand,
|
|
439
|
+
options: InputPanelCommandOptions
|
|
440
|
+
) => boolean | Promise<boolean>
|
|
394
441
|
onOpen?: () => void
|
|
395
442
|
onClose?: () => void
|
|
396
443
|
getHistory?: () => Promise<readonly InputPanelHistoryItem[]>
|
|
@@ -403,6 +450,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
403
450
|
stateKey?: string
|
|
404
451
|
}) {
|
|
405
452
|
this._onInput = opts?.onInput ?? (() => {})
|
|
453
|
+
this._onCommand = opts?.onCommand ?? null
|
|
406
454
|
this._onOpenCb = opts?.onOpen ?? null
|
|
407
455
|
this._onCloseCb = opts?.onClose ?? null
|
|
408
456
|
this._getHistory = opts?.getHistory ?? null
|
|
@@ -443,6 +491,17 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
443
491
|
this._onInput = fn
|
|
444
492
|
}
|
|
445
493
|
|
|
494
|
+
set onCommand(
|
|
495
|
+
fn:
|
|
496
|
+
| ((
|
|
497
|
+
command: InputPanelCommand,
|
|
498
|
+
options: InputPanelCommandOptions
|
|
499
|
+
) => boolean | Promise<boolean>)
|
|
500
|
+
| null
|
|
501
|
+
) {
|
|
502
|
+
this._onCommand = fn
|
|
503
|
+
}
|
|
504
|
+
|
|
446
505
|
setPlatform(platform: HostPlatform): void {
|
|
447
506
|
this._platform = platform
|
|
448
507
|
this._applyPlatformToPanel()
|
|
@@ -677,6 +736,25 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
677
736
|
}
|
|
678
737
|
}
|
|
679
738
|
})
|
|
739
|
+
this._on(panel, 'input-panel:command', (e) => {
|
|
740
|
+
const detail = (e as CustomEvent).detail
|
|
741
|
+
if (!isRecord(detail)) return
|
|
742
|
+
const command = detail.command
|
|
743
|
+
if (!isInputPanelCommand(command)) return
|
|
744
|
+
const fallbackData = typeof detail.fallbackData === 'string' ? detail.fallbackData : undefined
|
|
745
|
+
|
|
746
|
+
void Promise.resolve(this._onCommand?.(command, { fallbackData }) ?? false)
|
|
747
|
+
.then((handled) => {
|
|
748
|
+
if (!handled && fallbackData) {
|
|
749
|
+
this._onInput(fallbackData)
|
|
750
|
+
}
|
|
751
|
+
})
|
|
752
|
+
.catch(() => {
|
|
753
|
+
if (fallbackData) {
|
|
754
|
+
this._onInput(fallbackData)
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
})
|
|
680
758
|
this._on(inputTab, 'input-panel:input-change', (e) => {
|
|
681
759
|
const value = (e as CustomEvent).detail?.value
|
|
682
760
|
if (typeof value === 'string') {
|
|
@@ -972,9 +1050,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
972
1050
|
private _resolveTarget(clientX: number, clientY: number): Element {
|
|
973
1051
|
const container = this._getTerminalHostElement()
|
|
974
1052
|
if (!container) return document.body
|
|
975
|
-
|
|
976
|
-
if (el && container.contains(el)) return el
|
|
977
|
-
return container.querySelector('.xterm-screen') ?? container
|
|
1053
|
+
return resolveTerminalPointerTarget(container, clientX, clientY)
|
|
978
1054
|
}
|
|
979
1055
|
|
|
980
1056
|
private _dispatchMouse(
|