xterm-input-panel 1.1.0 → 1.2.0
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/input-method-tab.ts +19 -7
- package/src/xterm-addon.stories.ts +150 -6
- package/src/xterm-addon.ts +195 -13
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/input-method-tab.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { iconSend } from './icons.js'
|
|
|
10
10
|
export class InputMethodTab extends LitElement {
|
|
11
11
|
static get properties() {
|
|
12
12
|
return {
|
|
13
|
-
|
|
13
|
+
value: { type: String, attribute: false },
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -84,15 +84,26 @@ export class InputMethodTab extends LitElement {
|
|
|
84
84
|
}
|
|
85
85
|
`
|
|
86
86
|
|
|
87
|
-
declare
|
|
87
|
+
declare value: string
|
|
88
88
|
|
|
89
89
|
constructor() {
|
|
90
90
|
super()
|
|
91
|
-
this.
|
|
91
|
+
this.value = ''
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private _emitInputChange() {
|
|
95
|
+
this.dispatchEvent(
|
|
96
|
+
new CustomEvent('input-panel:input-change', {
|
|
97
|
+
detail: { value: this.value },
|
|
98
|
+
bubbles: true,
|
|
99
|
+
composed: true,
|
|
100
|
+
})
|
|
101
|
+
)
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
private _onInput(e: Event) {
|
|
95
|
-
this.
|
|
105
|
+
this.value = (e.target as HTMLTextAreaElement).value
|
|
106
|
+
this._emitInputChange()
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
private _onKeyDown(e: KeyboardEvent) {
|
|
@@ -104,7 +115,7 @@ export class InputMethodTab extends LitElement {
|
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
private _send() {
|
|
107
|
-
const text = this.
|
|
118
|
+
const text = this.value.trim()
|
|
108
119
|
if (!text) return
|
|
109
120
|
|
|
110
121
|
// Dispatch to terminal
|
|
@@ -116,7 +127,8 @@ export class InputMethodTab extends LitElement {
|
|
|
116
127
|
})
|
|
117
128
|
)
|
|
118
129
|
|
|
119
|
-
this.
|
|
130
|
+
this.value = ''
|
|
131
|
+
this._emitInputChange()
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
render() {
|
|
@@ -124,7 +136,7 @@ export class InputMethodTab extends LitElement {
|
|
|
124
136
|
<div class="input-area">
|
|
125
137
|
<textarea
|
|
126
138
|
placeholder="Type command and press Ctrl+Enter to send..."
|
|
127
|
-
.value=${this.
|
|
139
|
+
.value=${this.value}
|
|
128
140
|
@input=${this._onInput}
|
|
129
141
|
@keydown=${this._onKeyDown}
|
|
130
142
|
></textarea>
|
|
@@ -17,10 +17,11 @@ function resetAddonState() {
|
|
|
17
17
|
|
|
18
18
|
// Remove any <input-panel> elements leaked into body or containers
|
|
19
19
|
document.querySelectorAll('input-panel').forEach((el) => el.remove())
|
|
20
|
+
localStorage.removeItem('xtermInputPanelState')
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/** Create a real xterm Terminal + InputPanelAddon, mount into container */
|
|
23
|
-
function setupTerminal(container: HTMLElement) {
|
|
24
|
+
function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
|
|
24
25
|
const terminal = new Terminal({
|
|
25
26
|
cols: 80,
|
|
26
27
|
rows: 10,
|
|
@@ -29,7 +30,7 @@ function setupTerminal(container: HTMLElement) {
|
|
|
29
30
|
})
|
|
30
31
|
|
|
31
32
|
const inputHandler = fn()
|
|
32
|
-
const addon = new InputPanelAddon({ onInput: inputHandler })
|
|
33
|
+
const addon = new InputPanelAddon({ onInput: inputHandler, stateKey: opts?.stateKey })
|
|
33
34
|
terminal.loadAddon(addon)
|
|
34
35
|
terminal.open(container)
|
|
35
36
|
|
|
@@ -108,8 +109,8 @@ export const SingletonMigration: StoryObj = {
|
|
|
108
109
|
resetAddonState()
|
|
109
110
|
const containerA = canvasElement.querySelector('#term-a') as HTMLElement
|
|
110
111
|
const containerB = canvasElement.querySelector('#term-b') as HTMLElement
|
|
111
|
-
const { addon: addonA } = setupTerminal(containerA)
|
|
112
|
-
const { addon: addonB } = setupTerminal(containerB)
|
|
112
|
+
const { addon: addonA } = setupTerminal(containerA, { stateKey: 'story-term-a' })
|
|
113
|
+
const { addon: addonB } = setupTerminal(containerB, { stateKey: 'story-term-b' })
|
|
113
114
|
|
|
114
115
|
// Open A — panel should be inside container A
|
|
115
116
|
addonA.open()
|
|
@@ -382,8 +383,8 @@ export const SharedMountTarget: StoryObj = {
|
|
|
382
383
|
// Set shared mount target BEFORE creating terminals
|
|
383
384
|
InputPanelAddon.mountTarget = wrapper
|
|
384
385
|
|
|
385
|
-
const { addon: addonA } = setupTerminal(containerA)
|
|
386
|
-
const { addon: addonB } = setupTerminal(containerB)
|
|
386
|
+
const { addon: addonA } = setupTerminal(containerA, { stateKey: 'switch-term-a' })
|
|
387
|
+
const { addon: addonB } = setupTerminal(containerB, { stateKey: 'switch-term-b' })
|
|
387
388
|
|
|
388
389
|
// Open A — panel should be in the shared wrapper, NOT container A
|
|
389
390
|
addonA.open()
|
|
@@ -411,3 +412,146 @@ export const SharedMountTarget: StoryObj = {
|
|
|
411
412
|
InputPanelAddon.mountTarget = null
|
|
412
413
|
},
|
|
413
414
|
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Persist panel runtime state between close/open cycles:
|
|
418
|
+
* - active tab (input mode)
|
|
419
|
+
* - input draft text in Input tab
|
|
420
|
+
*/
|
|
421
|
+
export const PersistPanelSessionState: StoryObj = {
|
|
422
|
+
render: () => html`<div id="term-container" style="width:100%;height:100%;"></div>`,
|
|
423
|
+
play: async ({ canvasElement }) => {
|
|
424
|
+
resetAddonState()
|
|
425
|
+
const container = canvasElement.querySelector('#term-container') as HTMLElement
|
|
426
|
+
const { addon } = setupTerminal(container)
|
|
427
|
+
|
|
428
|
+
addon.open()
|
|
429
|
+
const panel = container.querySelector('input-panel') as HTMLElement & {
|
|
430
|
+
activeTab: string
|
|
431
|
+
updateComplete: Promise<void>
|
|
432
|
+
shadowRoot: ShadowRoot
|
|
433
|
+
}
|
|
434
|
+
await panel.updateComplete
|
|
435
|
+
|
|
436
|
+
const inputTab = panel.querySelector('input-method-tab') as HTMLElement & {
|
|
437
|
+
updateComplete: Promise<void>
|
|
438
|
+
shadowRoot: ShadowRoot
|
|
439
|
+
}
|
|
440
|
+
await inputTab.updateComplete
|
|
441
|
+
const textarea = inputTab.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
|
|
442
|
+
textarea.value = 'echo keep'
|
|
443
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
444
|
+
await inputTab.updateComplete
|
|
445
|
+
|
|
446
|
+
const keysBtn = panel.shadowRoot.querySelectorAll('.tab-btn')[1] as HTMLButtonElement
|
|
447
|
+
keysBtn.click()
|
|
448
|
+
await panel.updateComplete
|
|
449
|
+
expect(panel.activeTab).toBe('keys')
|
|
450
|
+
|
|
451
|
+
addon.close()
|
|
452
|
+
addon.open()
|
|
453
|
+
|
|
454
|
+
const reopened = container.querySelector('input-panel') as HTMLElement & {
|
|
455
|
+
activeTab: string
|
|
456
|
+
updateComplete: Promise<void>
|
|
457
|
+
}
|
|
458
|
+
await reopened.updateComplete
|
|
459
|
+
expect(reopened.activeTab).toBe('keys')
|
|
460
|
+
|
|
461
|
+
const reopenedInputTab = reopened.querySelector('input-method-tab') as HTMLElement & {
|
|
462
|
+
updateComplete: Promise<void>
|
|
463
|
+
shadowRoot: ShadowRoot
|
|
464
|
+
}
|
|
465
|
+
await reopenedInputTab.updateComplete
|
|
466
|
+
const reopenedTextarea = reopenedInputTab.shadowRoot.querySelector(
|
|
467
|
+
'textarea'
|
|
468
|
+
) as HTMLTextAreaElement
|
|
469
|
+
expect(reopenedTextarea.value).toBe('echo keep')
|
|
470
|
+
|
|
471
|
+
addon.close()
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Persist session state across terminal-instance switching:
|
|
477
|
+
* A(input draft + keys) -> B -> A should restore A state.
|
|
478
|
+
*/
|
|
479
|
+
export const PersistStateAcrossTerminalSwitch: StoryObj = {
|
|
480
|
+
render: () => html`
|
|
481
|
+
<div style="display:flex;gap:8px;height:100%;">
|
|
482
|
+
<div id="term-a" style="flex:1;"></div>
|
|
483
|
+
<div id="term-b" style="flex:1;"></div>
|
|
484
|
+
</div>
|
|
485
|
+
`,
|
|
486
|
+
play: async ({ canvasElement }) => {
|
|
487
|
+
type PanelEl = HTMLElement & {
|
|
488
|
+
activeTab: string
|
|
489
|
+
updateComplete: Promise<void>
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
resetAddonState()
|
|
493
|
+
const containerA = canvasElement.querySelector('#term-a') as HTMLElement
|
|
494
|
+
const containerB = canvasElement.querySelector('#term-b') as HTMLElement
|
|
495
|
+
const { addon: addonA } = setupTerminal(containerA, { stateKey: 'switch-term-a' })
|
|
496
|
+
const { addon: addonB } = setupTerminal(containerB, { stateKey: 'switch-term-b' })
|
|
497
|
+
|
|
498
|
+
addonA.open()
|
|
499
|
+
let panelA = containerA.querySelector('input-panel') as PanelEl
|
|
500
|
+
expect(panelA).not.toBeNull()
|
|
501
|
+
await panelA.updateComplete
|
|
502
|
+
|
|
503
|
+
const inputTabA = panelA.querySelector('input-method-tab') as HTMLElement & {
|
|
504
|
+
updateComplete: Promise<void>
|
|
505
|
+
shadowRoot: ShadowRoot
|
|
506
|
+
}
|
|
507
|
+
await inputTabA.updateComplete
|
|
508
|
+
const textareaA = inputTabA.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
|
|
509
|
+
textareaA.value = 'echo keep-on-a'
|
|
510
|
+
textareaA.dispatchEvent(new Event('input', { bubbles: true }))
|
|
511
|
+
await inputTabA.updateComplete
|
|
512
|
+
|
|
513
|
+
const keysBtnA = panelA.shadowRoot!.querySelectorAll('.tab-btn')[1] as HTMLButtonElement
|
|
514
|
+
keysBtnA.click()
|
|
515
|
+
await panelA.updateComplete
|
|
516
|
+
expect(panelA.activeTab).toBe('keys')
|
|
517
|
+
const persistedRaw = localStorage.getItem('xtermInputPanelState')
|
|
518
|
+
expect(persistedRaw).not.toBeNull()
|
|
519
|
+
const persisted = JSON.parse(persistedRaw ?? '{}') as {
|
|
520
|
+
sessions?: Record<string, { activeTab?: string; inputDraft?: string }>
|
|
521
|
+
}
|
|
522
|
+
expect(persisted.sessions?.['switch-term-a']?.activeTab).toBe('keys')
|
|
523
|
+
expect(persisted.sessions?.['switch-term-a']?.inputDraft).toBe('echo keep-on-a')
|
|
524
|
+
|
|
525
|
+
addonB.syncFocusLifecycle()
|
|
526
|
+
const panelB = containerB.querySelector('input-panel') as PanelEl
|
|
527
|
+
expect(panelB).not.toBeNull()
|
|
528
|
+
await panelB.updateComplete
|
|
529
|
+
expect(panelB.activeTab).toBe('keys')
|
|
530
|
+
const inputTabB = panelB.querySelector('input-method-tab') as HTMLElement & {
|
|
531
|
+
updateComplete: Promise<void>
|
|
532
|
+
shadowRoot: ShadowRoot
|
|
533
|
+
}
|
|
534
|
+
await inputTabB.updateComplete
|
|
535
|
+
const textareaB = inputTabB.shadowRoot.querySelector('textarea') as HTMLTextAreaElement
|
|
536
|
+
expect(textareaB.value).toBe('')
|
|
537
|
+
|
|
538
|
+
addonA.syncFocusLifecycle()
|
|
539
|
+
panelA = containerA.querySelector('input-panel') as PanelEl
|
|
540
|
+
expect(panelA).not.toBeNull()
|
|
541
|
+
await panelA.updateComplete
|
|
542
|
+
expect(panelA.activeTab).toBe('keys')
|
|
543
|
+
|
|
544
|
+
const reopenedInputTabA = panelA.querySelector('input-method-tab') as HTMLElement & {
|
|
545
|
+
updateComplete: Promise<void>
|
|
546
|
+
shadowRoot: ShadowRoot
|
|
547
|
+
}
|
|
548
|
+
await reopenedInputTabA.updateComplete
|
|
549
|
+
const reopenedTextareaA = reopenedInputTabA.shadowRoot.querySelector(
|
|
550
|
+
'textarea'
|
|
551
|
+
) as HTMLTextAreaElement
|
|
552
|
+
expect(reopenedTextareaA.value).toBe('echo keep-on-a')
|
|
553
|
+
|
|
554
|
+
addonA.close()
|
|
555
|
+
addonB.close()
|
|
556
|
+
},
|
|
557
|
+
}
|
package/src/xterm-addon.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { ITerminalAddon, Terminal } from '@xterm/xterm'
|
|
2
2
|
import { iconKeyboard, iconMousePointer2 } from './icons.js'
|
|
3
|
-
import type { InputPanelLayout } from './input-panel.js'
|
|
3
|
+
import type { InputPanelLayout, InputPanelTab } from './input-panel.js'
|
|
4
4
|
import type { HostPlatform } from './platform.js'
|
|
5
5
|
|
|
6
6
|
const SENSITIVITY = 1.5
|
|
7
7
|
const EDGE_SCROLL_ZONE = 30
|
|
8
8
|
const EDGE_SCROLL_INTERVAL = 50
|
|
9
9
|
const EDGE_SCROLL_OVERSHOOT = 15
|
|
10
|
+
const STATE_STORAGE_KEY = 'xtermInputPanelState'
|
|
10
11
|
|
|
11
12
|
function isTouchDevice(): boolean {
|
|
12
13
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
|
@@ -25,6 +26,84 @@ export interface InputPanelSettingsPayload {
|
|
|
25
26
|
historyLimit: number
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
interface InputPanelSessionState {
|
|
30
|
+
activeTab: InputPanelTab
|
|
31
|
+
inputDraft: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface InputMethodTabElement extends HTMLElement {
|
|
35
|
+
value: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface InputPanelElement extends HTMLElement {
|
|
39
|
+
activeTab: InputPanelTab
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isInputPanelTab(value: unknown): value is InputPanelTab {
|
|
43
|
+
return (
|
|
44
|
+
value === 'input' ||
|
|
45
|
+
value === 'keys' ||
|
|
46
|
+
value === 'shortcuts' ||
|
|
47
|
+
value === 'trackpad' ||
|
|
48
|
+
value === 'settings'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface InputPanelStateStore {
|
|
53
|
+
lastActiveTab?: InputPanelTab
|
|
54
|
+
sessions?: Record<string, Partial<InputPanelSessionState>>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
58
|
+
return typeof value === 'object' && value !== null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadPanelStateStore(): InputPanelStateStore {
|
|
62
|
+
try {
|
|
63
|
+
const raw = localStorage.getItem(STATE_STORAGE_KEY)
|
|
64
|
+
if (!raw) return {}
|
|
65
|
+
const parsed = JSON.parse(raw)
|
|
66
|
+
return isRecord(parsed) ? (parsed as InputPanelStateStore) : {}
|
|
67
|
+
} catch {
|
|
68
|
+
return {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function savePanelStateStore(store: InputPanelStateStore): void {
|
|
73
|
+
try {
|
|
74
|
+
localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(store))
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadPanelSessionState(sessionKey: string): InputPanelSessionState | null {
|
|
81
|
+
const store = loadPanelStateStore()
|
|
82
|
+
const sessions = store.sessions
|
|
83
|
+
if (!sessions) return null
|
|
84
|
+
const rawState = sessions[sessionKey]
|
|
85
|
+
if (!isRecord(rawState)) return null
|
|
86
|
+
|
|
87
|
+
const state: InputPanelSessionState = {
|
|
88
|
+
activeTab: 'input',
|
|
89
|
+
inputDraft: '',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (isInputPanelTab(rawState.activeTab)) {
|
|
93
|
+
state.activeTab = rawState.activeTab
|
|
94
|
+
}
|
|
95
|
+
if (typeof rawState.inputDraft === 'string') {
|
|
96
|
+
state.inputDraft = rawState.inputDraft
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return state
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function loadLastActiveTab(): InputPanelTab {
|
|
103
|
+
const store = loadPanelStateStore()
|
|
104
|
+
return isInputPanelTab(store.lastActiveTab) ? store.lastActiveTab : 'input'
|
|
105
|
+
}
|
|
106
|
+
|
|
28
107
|
/**
|
|
29
108
|
* xterm.js addon that provides full InputPanel integration.
|
|
30
109
|
*
|
|
@@ -56,6 +135,7 @@ export interface InputPanelSettingsPayload {
|
|
|
56
135
|
* ```
|
|
57
136
|
*/
|
|
58
137
|
export class InputPanelAddon implements ITerminalAddon {
|
|
138
|
+
private static _lastActiveTab: InputPanelTab = 'input'
|
|
59
139
|
// ── Singleton state ──
|
|
60
140
|
|
|
61
141
|
/** The currently active (open) addon instance, or null. */
|
|
@@ -301,6 +381,9 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
301
381
|
private _onSettingsChange: ((settings: InputPanelSettingsPayload) => Promise<void> | void) | null
|
|
302
382
|
private _platform: HostPlatform
|
|
303
383
|
private _defaultLayout: InputPanelLayout
|
|
384
|
+
private _panelSessionState: InputPanelSessionState
|
|
385
|
+
private _stateKey: string
|
|
386
|
+
private _hasOwnPersistedState: boolean
|
|
304
387
|
|
|
305
388
|
constructor(opts?: {
|
|
306
389
|
onInput?: (data: string) => void
|
|
@@ -312,6 +395,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
312
395
|
onSettingsChange?: (settings: InputPanelSettingsPayload) => Promise<void> | void
|
|
313
396
|
platform?: HostPlatform
|
|
314
397
|
defaultLayout?: InputPanelLayout
|
|
398
|
+
stateKey?: string
|
|
315
399
|
}) {
|
|
316
400
|
this._onInput = opts?.onInput ?? (() => {})
|
|
317
401
|
this._onOpenCb = opts?.onOpen ?? null
|
|
@@ -322,6 +406,19 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
322
406
|
this._onSettingsChange = opts?.onSettingsChange ?? null
|
|
323
407
|
this._platform = opts?.platform ?? 'common'
|
|
324
408
|
this._defaultLayout = opts?.defaultLayout ?? 'floating'
|
|
409
|
+
this._stateKey = opts?.stateKey?.trim() ? opts.stateKey : 'default'
|
|
410
|
+
this._hasOwnPersistedState = false
|
|
411
|
+
this._panelSessionState = {
|
|
412
|
+
activeTab: 'input',
|
|
413
|
+
inputDraft: '',
|
|
414
|
+
}
|
|
415
|
+
const persistedState = loadPanelSessionState(this._stateKey)
|
|
416
|
+
if (persistedState) {
|
|
417
|
+
this._panelSessionState = persistedState
|
|
418
|
+
this._hasOwnPersistedState = true
|
|
419
|
+
} else {
|
|
420
|
+
this._panelSessionState.activeTab = InputPanelAddon._lastActiveTab || loadLastActiveTab()
|
|
421
|
+
}
|
|
325
422
|
}
|
|
326
423
|
|
|
327
424
|
get isOpen(): boolean {
|
|
@@ -348,12 +445,52 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
348
445
|
this._defaultLayout = layout
|
|
349
446
|
}
|
|
350
447
|
|
|
448
|
+
private _persistPanelState(): void {
|
|
449
|
+
InputPanelAddon._lastActiveTab = this._panelSessionState.activeTab
|
|
450
|
+
const store = loadPanelStateStore()
|
|
451
|
+
const nextSessions = {
|
|
452
|
+
...(store.sessions ?? {}),
|
|
453
|
+
[this._stateKey]: {
|
|
454
|
+
activeTab: this._panelSessionState.activeTab,
|
|
455
|
+
inputDraft: this._panelSessionState.inputDraft,
|
|
456
|
+
},
|
|
457
|
+
}
|
|
458
|
+
savePanelStateStore({
|
|
459
|
+
...store,
|
|
460
|
+
lastActiveTab: this._panelSessionState.activeTab,
|
|
461
|
+
sessions: nextSessions,
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
351
465
|
/**
|
|
352
466
|
* Resolve the mount target for this addon instance.
|
|
353
467
|
* Priority: static mountTarget > terminal container > document.body
|
|
354
468
|
*/
|
|
355
469
|
private _getMountTarget(): HTMLElement {
|
|
356
|
-
return InputPanelAddon._mountTarget ?? this.
|
|
470
|
+
return InputPanelAddon._mountTarget ?? this._getTerminalHostElement() ?? document.body
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Resolve the visual host element used for overlay UI (cursor/panel).
|
|
475
|
+
*
|
|
476
|
+
* xterm renderer usually exposes `.xterm` as `terminal.element`, while
|
|
477
|
+
* ghostty may expose the mount container itself.
|
|
478
|
+
*/
|
|
479
|
+
private _getTerminalHostElement(): HTMLElement | null {
|
|
480
|
+
const termElement = this._terminal?.element
|
|
481
|
+
if (!(termElement instanceof HTMLElement)) return null
|
|
482
|
+
if (termElement.classList.contains('xterm')) {
|
|
483
|
+
return termElement.parentElement instanceof HTMLElement ? termElement.parentElement : termElement
|
|
484
|
+
}
|
|
485
|
+
return termElement
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private _detectTerminalEngine(): 'xterm' | 'non-xterm' {
|
|
489
|
+
const termElement = this._terminal?.element
|
|
490
|
+
if (termElement instanceof HTMLElement && termElement.classList.contains('xterm')) {
|
|
491
|
+
return 'xterm'
|
|
492
|
+
}
|
|
493
|
+
return 'non-xterm'
|
|
357
494
|
}
|
|
358
495
|
|
|
359
496
|
activate(terminal: Terminal): void {
|
|
@@ -431,13 +568,21 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
431
568
|
|
|
432
569
|
this._suppressKeyboard()
|
|
433
570
|
|
|
571
|
+
if (!this._hasOwnPersistedState) {
|
|
572
|
+
const fallbackActiveTab = loadLastActiveTab()
|
|
573
|
+
this._panelSessionState.activeTab = fallbackActiveTab
|
|
574
|
+
InputPanelAddon._lastActiveTab = fallbackActiveTab
|
|
575
|
+
}
|
|
576
|
+
|
|
434
577
|
// Build the element tree
|
|
435
|
-
const panel = document.createElement('input-panel')
|
|
578
|
+
const panel = document.createElement('input-panel') as InputPanelElement
|
|
436
579
|
panel.setAttribute('layout', this._defaultLayout)
|
|
437
580
|
this._applyPanelThemeBindings(panel)
|
|
581
|
+
panel.activeTab = this._panelSessionState.activeTab
|
|
438
582
|
|
|
439
|
-
const inputTab = document.createElement('input-method-tab')
|
|
583
|
+
const inputTab = document.createElement('input-method-tab') as InputMethodTabElement
|
|
440
584
|
inputTab.setAttribute('slot', 'input')
|
|
585
|
+
inputTab.value = this._panelSessionState.inputDraft
|
|
441
586
|
panel.appendChild(inputTab)
|
|
442
587
|
|
|
443
588
|
const keysTab = document.createElement('virtual-keyboard-tab')
|
|
@@ -498,8 +643,21 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
498
643
|
}
|
|
499
644
|
}
|
|
500
645
|
})
|
|
646
|
+
this._on(inputTab, 'input-panel:input-change', (e) => {
|
|
647
|
+
const value = (e as CustomEvent).detail?.value
|
|
648
|
+
if (typeof value === 'string') {
|
|
649
|
+
this._panelSessionState.inputDraft = value
|
|
650
|
+
this._hasOwnPersistedState = true
|
|
651
|
+
this._persistPanelState()
|
|
652
|
+
}
|
|
653
|
+
})
|
|
501
654
|
this._on(panel, 'input-panel:tab-change', (e) => {
|
|
502
655
|
const tab = (e as CustomEvent).detail?.tab
|
|
656
|
+
if (isInputPanelTab(tab)) {
|
|
657
|
+
this._panelSessionState.activeTab = tab
|
|
658
|
+
this._hasOwnPersistedState = true
|
|
659
|
+
this._persistPanelState()
|
|
660
|
+
}
|
|
503
661
|
if (tab === 'trackpad') this._showCursor()
|
|
504
662
|
else this._hideCursor()
|
|
505
663
|
})
|
|
@@ -590,7 +748,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
590
748
|
}
|
|
591
749
|
|
|
592
750
|
private _applyPanelThemeBindings(panel: HTMLElement): void {
|
|
593
|
-
const scope = this.
|
|
751
|
+
const scope = this._getTerminalHostElement() ?? this._getMountTarget()
|
|
594
752
|
const style = getComputedStyle(scope)
|
|
595
753
|
|
|
596
754
|
const background = this._readThemeVar(
|
|
@@ -667,6 +825,27 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
667
825
|
this._isOpen ? this.close() : this.open()
|
|
668
826
|
}
|
|
669
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Sync addon singleton state when host marks this terminal as active.
|
|
830
|
+
*
|
|
831
|
+
* Lifecycle:
|
|
832
|
+
* 1) Always refresh `_lastFocused` so FAB targets the current terminal.
|
|
833
|
+
* 2) If another terminal owns an open panel, migrate panel ownership here.
|
|
834
|
+
* 3) If this panel is already open, keep terminal focus in sync.
|
|
835
|
+
*/
|
|
836
|
+
syncFocusLifecycle(): void {
|
|
837
|
+
InputPanelAddon._lastFocused = this
|
|
838
|
+
|
|
839
|
+
if (InputPanelAddon._active && InputPanelAddon._active !== this) {
|
|
840
|
+
this.open()
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (this._isOpen) {
|
|
845
|
+
this._focusTerminal()
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
670
849
|
// ── Terminal focus ──
|
|
671
850
|
|
|
672
851
|
private _focusTerminal(): void {
|
|
@@ -681,10 +860,13 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
681
860
|
// ── Cursor overlay ──
|
|
682
861
|
|
|
683
862
|
private _createCursor(): void {
|
|
684
|
-
const container = this.
|
|
863
|
+
const container = this._getTerminalHostElement()
|
|
685
864
|
if (!container) return
|
|
686
865
|
|
|
687
866
|
const el = document.createElement('div')
|
|
867
|
+
el.setAttribute('data-input-panel-cursor', 'virtual-mouse')
|
|
868
|
+
el.setAttribute('data-terminal-engine', this._detectTerminalEngine())
|
|
869
|
+
el.setAttribute('aria-hidden', 'true')
|
|
688
870
|
el.style.cssText =
|
|
689
871
|
'position:absolute;z-index:10;pointer-events:none;opacity:0;transition:opacity 0.15s;color:#fff;'
|
|
690
872
|
const pointer = iconMousePointer2(20)
|
|
@@ -707,7 +889,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
707
889
|
private _showCursor(): void {
|
|
708
890
|
if (!this._cursorEl) this._createCursor()
|
|
709
891
|
if (this._cursorEl) {
|
|
710
|
-
const container = this.
|
|
892
|
+
const container = this._getTerminalHostElement()
|
|
711
893
|
if (container) {
|
|
712
894
|
const rect = container.getBoundingClientRect()
|
|
713
895
|
this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
|
|
@@ -722,7 +904,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
722
904
|
}
|
|
723
905
|
|
|
724
906
|
private _moveCursor(dx: number, dy: number): void {
|
|
725
|
-
const container = this.
|
|
907
|
+
const container = this._getTerminalHostElement()
|
|
726
908
|
if (!container) return
|
|
727
909
|
const rect = container.getBoundingClientRect()
|
|
728
910
|
this._cursorPos.x = Math.max(0, Math.min(rect.width, this._cursorPos.x + dx * SENSITIVITY))
|
|
@@ -733,7 +915,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
733
915
|
// ── Mouse event dispatch ──
|
|
734
916
|
|
|
735
917
|
private _getClientCoords(): { clientX: number; clientY: number } | null {
|
|
736
|
-
const container = this.
|
|
918
|
+
const container = this._getTerminalHostElement()
|
|
737
919
|
if (!container) return null
|
|
738
920
|
const rect = container.getBoundingClientRect()
|
|
739
921
|
return {
|
|
@@ -743,7 +925,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
743
925
|
}
|
|
744
926
|
|
|
745
927
|
private _resolveTarget(clientX: number, clientY: number): Element {
|
|
746
|
-
const container = this.
|
|
928
|
+
const container = this._getTerminalHostElement()
|
|
747
929
|
if (!container) return document.body
|
|
748
930
|
const el = document.elementFromPoint(clientX, clientY)
|
|
749
931
|
if (el && container.contains(el)) return el
|
|
@@ -814,7 +996,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
814
996
|
// ── Edge scroll (during drag selection) ──
|
|
815
997
|
|
|
816
998
|
private _updateEdgeScroll(): void {
|
|
817
|
-
const container = this.
|
|
999
|
+
const container = this._getTerminalHostElement()
|
|
818
1000
|
if (!container || !this._isDragging) {
|
|
819
1001
|
this._stopEdgeScroll()
|
|
820
1002
|
return
|
|
@@ -829,7 +1011,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
829
1011
|
|
|
830
1012
|
this._stopEdgeScroll()
|
|
831
1013
|
this._edgeScrollTimer = setInterval(() => {
|
|
832
|
-
const container = this.
|
|
1014
|
+
const container = this._getTerminalHostElement()
|
|
833
1015
|
if (!container || !this._isDragging) {
|
|
834
1016
|
this._stopEdgeScroll()
|
|
835
1017
|
return
|
|
@@ -914,7 +1096,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
914
1096
|
button.style.border = '1px solid transparent'
|
|
915
1097
|
button.style.borderRadius = '4px'
|
|
916
1098
|
button.style.background = 'transparent'
|
|
917
|
-
button.style.color = 'var(--
|
|
1099
|
+
button.style.color = 'var(--foreground, #fff)'
|
|
918
1100
|
button.style.fontFamily = 'inherit'
|
|
919
1101
|
button.style.fontSize = '12px'
|
|
920
1102
|
button.style.textAlign = 'left'
|