xterm-input-panel 1.2.0 → 1.2.2
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/.storybook/vitest.setup.ts +2 -2
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/package.json +3 -3
- package/src/input-method-tab.stories.ts +12 -8
- package/src/input-method-tab.ts +1 -1
- package/src/input-panel.stories.ts +14 -0
- package/src/input-panel.ts +3 -12
- package/src/pixi-theme.test.ts +2 -2
- package/src/platform.ts +3 -3
- package/src/shortcut-pages.ts +431 -61
- package/src/shortcut-tab.ts +45 -24
- package/src/storage-namespace.test.ts +22 -0
- package/src/storage-namespace.ts +17 -0
- package/src/virtual-keyboard-tab.stories.ts +10 -8
- package/src/virtual-trackpad-tab.stories.ts +51 -40
- package/src/xterm-addon.stories.ts +45 -0
- package/src/xterm-addon.ts +56 -11
- package/vitest.storybook.config.ts +1 -1
|
@@ -153,17 +153,16 @@ export const KeyRepeatOnLongPress: StoryObj = {
|
|
|
153
153
|
emitDown(key!.container)
|
|
154
154
|
|
|
155
155
|
// Wait long enough for initial delay (400ms) + several repeats (80ms each)
|
|
156
|
-
// 400 + 80*3 = 640ms,
|
|
157
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
156
|
+
// 400 + 80*3 = 640ms, add wider CI margin
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 850))
|
|
158
158
|
|
|
159
159
|
emitUp(key!.container)
|
|
160
160
|
|
|
161
161
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
162
162
|
|
|
163
|
-
// 1 send from
|
|
164
|
-
// Total should be > 2 (repeats happen at 80ms intervals after 400ms delay)
|
|
163
|
+
// 1 send from key up + at least 1 repeat event.
|
|
165
164
|
const callCount = handler.mock.calls.length
|
|
166
|
-
expect(callCount).toBeGreaterThanOrEqual(
|
|
165
|
+
expect(callCount).toBeGreaterThanOrEqual(2)
|
|
167
166
|
|
|
168
167
|
// All sends should have data 'a'
|
|
169
168
|
for (const call of handler.mock.calls) {
|
|
@@ -252,8 +251,11 @@ export const PointerLeaveDuringRepeatKeepsRepeating: StoryObj = {
|
|
|
252
251
|
el.addEventListener('input-panel:send', handler)
|
|
253
252
|
|
|
254
253
|
emitDown(key!.container)
|
|
255
|
-
// Wait for repeat to start
|
|
256
|
-
|
|
254
|
+
// Wait for repeat to start (polling is more stable than fixed sleeps in CI)
|
|
255
|
+
const repeatStart = Date.now()
|
|
256
|
+
while (handler.mock.calls.length === 0 && Date.now() - repeatStart < 1600) {
|
|
257
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
258
|
+
}
|
|
257
259
|
|
|
258
260
|
const countBefore = handler.mock.calls.length
|
|
259
261
|
expect(countBefore).toBeGreaterThan(0) // At least one repeat fired
|
|
@@ -262,7 +264,7 @@ export const PointerLeaveDuringRepeatKeepsRepeating: StoryObj = {
|
|
|
262
264
|
emitLeave(key!.container)
|
|
263
265
|
|
|
264
266
|
// Wait — repeats should keep firing until release
|
|
265
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
266
268
|
|
|
267
269
|
const countAfter = handler.mock.calls.length
|
|
268
270
|
expect(countAfter).toBeGreaterThan(countBefore)
|
|
@@ -9,7 +9,9 @@ const meta: Meta = {
|
|
|
9
9
|
tags: ['autodocs'],
|
|
10
10
|
decorators: [
|
|
11
11
|
(story) => html`
|
|
12
|
-
<div
|
|
12
|
+
<div
|
|
13
|
+
style="width: 400px; height: 200px; background: #1a1a1a; color: #fff; font-family: monospace;"
|
|
14
|
+
>
|
|
13
15
|
${story()}
|
|
14
16
|
</div>
|
|
15
17
|
`,
|
|
@@ -20,9 +22,11 @@ export default meta
|
|
|
20
22
|
|
|
21
23
|
/** Helper to get a ready trackpad element and its canvas. */
|
|
22
24
|
async function setup(canvasElement: HTMLElement) {
|
|
23
|
-
const el = canvasElement.querySelector('virtual-trackpad-tab') as HTMLElement & {
|
|
25
|
+
const el = canvasElement.querySelector('virtual-trackpad-tab') as HTMLElement & {
|
|
26
|
+
updateComplete: Promise<boolean>
|
|
27
|
+
}
|
|
24
28
|
await el.updateComplete
|
|
25
|
-
await new Promise(resolve => setTimeout(resolve, 500))
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
26
30
|
const shadow = el.shadowRoot!
|
|
27
31
|
const canvas = shadow.querySelector('canvas')!
|
|
28
32
|
const rect = canvas.getBoundingClientRect()
|
|
@@ -30,10 +34,15 @@ async function setup(canvasElement: HTMLElement) {
|
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function pointer(canvas: HTMLCanvasElement, type: string, x: number, y: number, id = 1) {
|
|
33
|
-
canvas.dispatchEvent(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
canvas.dispatchEvent(
|
|
38
|
+
new PointerEvent(type, {
|
|
39
|
+
clientX: x,
|
|
40
|
+
clientY: y,
|
|
41
|
+
pointerId: id,
|
|
42
|
+
pointerType: 'mouse',
|
|
43
|
+
bubbles: true,
|
|
44
|
+
})
|
|
45
|
+
)
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
|
@@ -64,7 +73,7 @@ export const MoveEvent: StoryObj = {
|
|
|
64
73
|
pointer(canvas, 'pointermove', cx + 20, cy + 10)
|
|
65
74
|
pointer(canvas, 'pointerup', cx + 20, cy + 10)
|
|
66
75
|
|
|
67
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
68
77
|
|
|
69
78
|
expect(handler).toHaveBeenCalled()
|
|
70
79
|
const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
@@ -91,7 +100,7 @@ export const TapEvent: StoryObj = {
|
|
|
91
100
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
92
101
|
pointer(canvas, 'pointerup', cx, cy)
|
|
93
102
|
|
|
94
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
95
104
|
|
|
96
105
|
expect(handler).toHaveBeenCalled()
|
|
97
106
|
const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
@@ -120,13 +129,13 @@ export const DoubleTapEvent: StoryObj = {
|
|
|
120
129
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
121
130
|
pointer(canvas, 'pointerup', cx, cy)
|
|
122
131
|
|
|
123
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
124
133
|
|
|
125
134
|
// Second tap (within 300ms)
|
|
126
135
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
127
136
|
pointer(canvas, 'pointerup', cx, cy)
|
|
128
137
|
|
|
129
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
130
139
|
|
|
131
140
|
expect(tapHandler).toHaveBeenCalledTimes(1)
|
|
132
141
|
expect(doubleTapHandler).toHaveBeenCalledTimes(1)
|
|
@@ -150,7 +159,7 @@ export const LongPressEvent: StoryObj = {
|
|
|
150
159
|
el.addEventListener('trackpad:long-press', handler)
|
|
151
160
|
|
|
152
161
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
153
|
-
await new Promise(resolve => setTimeout(resolve, 600))
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
154
163
|
|
|
155
164
|
expect(handler).toHaveBeenCalled()
|
|
156
165
|
const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
@@ -185,7 +194,7 @@ export const DragEvent: StoryObj = {
|
|
|
185
194
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
186
195
|
pointer(canvas, 'pointerup', cx, cy)
|
|
187
196
|
|
|
188
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
197
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
189
198
|
|
|
190
199
|
// Second: tap-and-hold then drag (within 300ms of first tap)
|
|
191
200
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
@@ -193,12 +202,12 @@ export const DragEvent: StoryObj = {
|
|
|
193
202
|
pointer(canvas, 'pointermove', cx + 40, cy)
|
|
194
203
|
pointer(canvas, 'pointerup', cx + 40, cy)
|
|
195
204
|
|
|
196
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
197
206
|
|
|
198
|
-
expect(tapHandler).toHaveBeenCalledTimes(1)
|
|
199
|
-
expect(dragStartHandler).toHaveBeenCalledTimes(1)
|
|
200
|
-
expect(dragMoveHandler).toHaveBeenCalled()
|
|
201
|
-
expect(dragEndHandler).toHaveBeenCalledTimes(1)
|
|
207
|
+
expect(tapHandler).toHaveBeenCalledTimes(1) // First touch was a tap
|
|
208
|
+
expect(dragStartHandler).toHaveBeenCalledTimes(1) // Drag started on second touch
|
|
209
|
+
expect(dragMoveHandler).toHaveBeenCalled() // At least one drag-move
|
|
210
|
+
expect(dragEndHandler).toHaveBeenCalledTimes(1) // Drag ended
|
|
202
211
|
|
|
203
212
|
el.removeEventListener('trackpad:tap', tapHandler)
|
|
204
213
|
el.removeEventListener('trackpad:drag-start', dragStartHandler)
|
|
@@ -223,14 +232,14 @@ export const DragMoveDeltas: StoryObj = {
|
|
|
223
232
|
// Tap first
|
|
224
233
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
225
234
|
pointer(canvas, 'pointerup', cx, cy)
|
|
226
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
227
236
|
|
|
228
237
|
// Tap-and-drag
|
|
229
238
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
230
|
-
pointer(canvas, 'pointermove', cx +
|
|
231
|
-
pointer(canvas, 'pointermove', cx +
|
|
232
|
-
pointer(canvas, 'pointerup', cx +
|
|
233
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
239
|
+
pointer(canvas, 'pointermove', cx + 20, cy) // Match DragEvent threshold margin
|
|
240
|
+
pointer(canvas, 'pointermove', cx + 40, cy) // Second move
|
|
241
|
+
pointer(canvas, 'pointerup', cx + 40, cy)
|
|
242
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
234
243
|
|
|
235
244
|
expect(dragMoveHandler).toHaveBeenCalled()
|
|
236
245
|
// Check delta detail has numeric dx/dy
|
|
@@ -263,7 +272,7 @@ export const SmallMoveNoEvent: StoryObj = {
|
|
|
263
272
|
pointer(canvas, 'pointermove', cx + 3, cy + 2)
|
|
264
273
|
pointer(canvas, 'pointerup', cx + 3, cy + 2)
|
|
265
274
|
|
|
266
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
267
276
|
|
|
268
277
|
// Should still be interpreted as a tap, not a move
|
|
269
278
|
expect(moveHandler).not.toHaveBeenCalled()
|
|
@@ -294,13 +303,15 @@ export const ScrollEvent: StoryObj = {
|
|
|
294
303
|
|
|
295
304
|
// Programmatically dispatch a trackpad:scroll event to verify
|
|
296
305
|
// the component's event structure works end-to-end through bubbling.
|
|
297
|
-
el.dispatchEvent(
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
306
|
+
el.dispatchEvent(
|
|
307
|
+
new CustomEvent('trackpad:scroll', {
|
|
308
|
+
detail: { deltaY: 40 },
|
|
309
|
+
bubbles: true,
|
|
310
|
+
composed: true,
|
|
311
|
+
})
|
|
312
|
+
)
|
|
302
313
|
|
|
303
|
-
await new Promise(resolve => setTimeout(resolve, 50))
|
|
314
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
304
315
|
|
|
305
316
|
expect(handler).toHaveBeenCalledTimes(1)
|
|
306
317
|
const detail = (handler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
@@ -328,12 +339,12 @@ export const EdgeSlideRight: StoryObj = {
|
|
|
328
339
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
329
340
|
pointer(canvas, 'pointermove', rect.right - 12, cy) // deep into right edge zone
|
|
330
341
|
|
|
331
|
-
// Wait for edge slide interval to fire
|
|
332
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
342
|
+
// Wait for edge slide interval to fire (with CI margin)
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
333
344
|
|
|
334
|
-
// Should have received
|
|
345
|
+
// Should have received at least one interval move in addition to drag move
|
|
335
346
|
const callCount = handler.mock.calls.length
|
|
336
|
-
expect(callCount).toBeGreaterThanOrEqual(
|
|
347
|
+
expect(callCount).toBeGreaterThanOrEqual(2)
|
|
337
348
|
|
|
338
349
|
// Edge slide events (after the initial drag move) should have positive dx (rightward).
|
|
339
350
|
// The first event may have dx=0 from the drag threshold crossing, so check from index 1.
|
|
@@ -363,14 +374,14 @@ export const EdgeSlideStopsOnRelease: StoryObj = {
|
|
|
363
374
|
// Drag to right edge
|
|
364
375
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
365
376
|
pointer(canvas, 'pointermove', rect.right - 12, cy)
|
|
366
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
377
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
367
378
|
|
|
368
379
|
// Release finger
|
|
369
380
|
pointer(canvas, 'pointerup', rect.right - 12, cy)
|
|
370
381
|
const countAtRelease = handler.mock.calls.length
|
|
371
382
|
|
|
372
383
|
// Wait and verify no more events
|
|
373
|
-
await new Promise(resolve => setTimeout(resolve, 150))
|
|
384
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
374
385
|
expect(handler.mock.calls.length).toBe(countAtRelease)
|
|
375
386
|
|
|
376
387
|
el.removeEventListener('trackpad:move', handler)
|
|
@@ -394,10 +405,10 @@ export const EdgeSlideCorner: StoryObj = {
|
|
|
394
405
|
pointer(canvas, 'pointerdown', cx, cy)
|
|
395
406
|
pointer(canvas, 'pointermove', rect.right - 12, rect.top + 12)
|
|
396
407
|
|
|
397
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
408
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
398
409
|
|
|
399
|
-
// Should have interval-emitted events
|
|
400
|
-
expect(handler.mock.calls.length).toBeGreaterThanOrEqual(
|
|
410
|
+
// Should have interval-emitted events beyond the initial move
|
|
411
|
+
expect(handler.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
401
412
|
|
|
402
413
|
// Find events from the edge slide interval (not the initial move)
|
|
403
414
|
// Interval events should have positive dx (rightward) and negative dy (upward)
|
|
@@ -431,7 +442,7 @@ export const NoEdgeSlideInCenter: StoryObj = {
|
|
|
431
442
|
const countAfterMove = handler.mock.calls.length
|
|
432
443
|
|
|
433
444
|
// Wait to see if interval fires
|
|
434
|
-
await new Promise(resolve => setTimeout(resolve, 150))
|
|
445
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
435
446
|
|
|
436
447
|
// No additional events beyond the initial move
|
|
437
448
|
expect(handler.mock.calls.length).toBe(countAfterMove)
|
|
@@ -555,3 +555,48 @@ export const PersistStateAcrossTerminalSwitch: StoryObj = {
|
|
|
555
555
|
addonB.close()
|
|
556
556
|
},
|
|
557
557
|
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Recover lifecycle when host area unmounts/remounts while panel is open:
|
|
561
|
+
* panel DOM can disappear, but addon should still be able to re-open.
|
|
562
|
+
*/
|
|
563
|
+
export const RecoverAfterPanelHostRemount: StoryObj = {
|
|
564
|
+
render: () => html`
|
|
565
|
+
<div style="display:flex;gap:8px;height:100%;">
|
|
566
|
+
<div id="host-a" style="flex:1;position:relative;">
|
|
567
|
+
<div id="term" style="height:100%;"></div>
|
|
568
|
+
</div>
|
|
569
|
+
<div id="host-b" style="flex:1;position:relative;"></div>
|
|
570
|
+
</div>
|
|
571
|
+
`,
|
|
572
|
+
play: async ({ canvasElement }) => {
|
|
573
|
+
resetAddonState()
|
|
574
|
+
const hostA = canvasElement.querySelector('#host-a') as HTMLElement
|
|
575
|
+
const hostB = canvasElement.querySelector('#host-b') as HTMLElement
|
|
576
|
+
const terminalContainer = canvasElement.querySelector('#term') as HTMLElement
|
|
577
|
+
|
|
578
|
+
InputPanelAddon.mountTarget = hostA
|
|
579
|
+
const { addon } = setupTerminal(terminalContainer, { stateKey: 'host-remount' })
|
|
580
|
+
|
|
581
|
+
addon.open()
|
|
582
|
+
let panel = hostA.querySelector('input-panel')
|
|
583
|
+
expect(panel).not.toBeNull()
|
|
584
|
+
expect(addon.isOpen).toBe(true)
|
|
585
|
+
|
|
586
|
+
// Simulate area switch: host subtree is unmounted while addon remains alive.
|
|
587
|
+
panel?.remove()
|
|
588
|
+
expect(addon.isOpen).toBe(true)
|
|
589
|
+
|
|
590
|
+
// Simulate return to terminal area with a new mount target.
|
|
591
|
+
InputPanelAddon.mountTarget = hostB
|
|
592
|
+
addon.open()
|
|
593
|
+
|
|
594
|
+
await waitFor(() => {
|
|
595
|
+
expect(hostB.querySelector('input-panel')).not.toBeNull()
|
|
596
|
+
})
|
|
597
|
+
expect(addon.isOpen).toBe(true)
|
|
598
|
+
|
|
599
|
+
addon.close()
|
|
600
|
+
InputPanelAddon.mountTarget = null
|
|
601
|
+
},
|
|
602
|
+
}
|
package/src/xterm-addon.ts
CHANGED
|
@@ -2,12 +2,12 @@ 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 { getSessionScopedStorageKey } from './storage-namespace.js'
|
|
5
6
|
|
|
6
7
|
const SENSITIVITY = 1.5
|
|
7
8
|
const EDGE_SCROLL_ZONE = 30
|
|
8
9
|
const EDGE_SCROLL_INTERVAL = 50
|
|
9
10
|
const EDGE_SCROLL_OVERSHOOT = 15
|
|
10
|
-
const STATE_STORAGE_KEY = 'xtermInputPanelState'
|
|
11
11
|
|
|
12
12
|
function isTouchDevice(): boolean {
|
|
13
13
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
|
@@ -60,7 +60,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
60
60
|
|
|
61
61
|
function loadPanelStateStore(): InputPanelStateStore {
|
|
62
62
|
try {
|
|
63
|
-
const raw = localStorage.getItem(
|
|
63
|
+
const raw = localStorage.getItem(getSessionScopedStorageKey('xtermInputPanelState'))
|
|
64
64
|
if (!raw) return {}
|
|
65
65
|
const parsed = JSON.parse(raw)
|
|
66
66
|
return isRecord(parsed) ? (parsed as InputPanelStateStore) : {}
|
|
@@ -71,7 +71,7 @@ function loadPanelStateStore(): InputPanelStateStore {
|
|
|
71
71
|
|
|
72
72
|
function savePanelStateStore(store: InputPanelStateStore): void {
|
|
73
73
|
try {
|
|
74
|
-
localStorage.setItem(
|
|
74
|
+
localStorage.setItem(getSessionScopedStorageKey('xtermInputPanelState'), JSON.stringify(store))
|
|
75
75
|
} catch {
|
|
76
76
|
/* ignore */
|
|
77
77
|
}
|
|
@@ -188,6 +188,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
188
188
|
// ── Native FAB (static singleton) ──
|
|
189
189
|
|
|
190
190
|
private static _fabEl: HTMLButtonElement | null = null
|
|
191
|
+
private static _fabSubscriberCount = 0
|
|
191
192
|
|
|
192
193
|
/**
|
|
193
194
|
* Create the native FAB button and mount it into the given container.
|
|
@@ -353,7 +354,8 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
353
354
|
|
|
354
355
|
private static _setFabVisible(visible: boolean): void {
|
|
355
356
|
if (InputPanelAddon._fabEl) {
|
|
356
|
-
InputPanelAddon._fabEl.style.display =
|
|
357
|
+
InputPanelAddon._fabEl.style.display =
|
|
358
|
+
visible && InputPanelAddon._fabSubscriberCount > 0 ? 'flex' : 'none'
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
|
|
@@ -381,6 +383,8 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
381
383
|
private _onSettingsChange: ((settings: InputPanelSettingsPayload) => Promise<void> | void) | null
|
|
382
384
|
private _platform: HostPlatform
|
|
383
385
|
private _defaultLayout: InputPanelLayout
|
|
386
|
+
private _showFab: boolean
|
|
387
|
+
private _fabSubscribed: boolean
|
|
384
388
|
private _panelSessionState: InputPanelSessionState
|
|
385
389
|
private _stateKey: string
|
|
386
390
|
private _hasOwnPersistedState: boolean
|
|
@@ -395,6 +399,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
395
399
|
onSettingsChange?: (settings: InputPanelSettingsPayload) => Promise<void> | void
|
|
396
400
|
platform?: HostPlatform
|
|
397
401
|
defaultLayout?: InputPanelLayout
|
|
402
|
+
showFab?: boolean
|
|
398
403
|
stateKey?: string
|
|
399
404
|
}) {
|
|
400
405
|
this._onInput = opts?.onInput ?? (() => {})
|
|
@@ -406,6 +411,8 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
406
411
|
this._onSettingsChange = opts?.onSettingsChange ?? null
|
|
407
412
|
this._platform = opts?.platform ?? 'common'
|
|
408
413
|
this._defaultLayout = opts?.defaultLayout ?? 'floating'
|
|
414
|
+
this._showFab = opts?.showFab ?? true
|
|
415
|
+
this._fabSubscribed = false
|
|
409
416
|
this._stateKey = opts?.stateKey?.trim() ? opts.stateKey : 'default'
|
|
410
417
|
this._hasOwnPersistedState = false
|
|
411
418
|
this._panelSessionState = {
|
|
@@ -449,7 +456,7 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
449
456
|
InputPanelAddon._lastActiveTab = this._panelSessionState.activeTab
|
|
450
457
|
const store = loadPanelStateStore()
|
|
451
458
|
const nextSessions = {
|
|
452
|
-
...
|
|
459
|
+
...store.sessions,
|
|
453
460
|
[this._stateKey]: {
|
|
454
461
|
activeTab: this._panelSessionState.activeTab,
|
|
455
462
|
inputDraft: this._panelSessionState.inputDraft,
|
|
@@ -480,7 +487,9 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
480
487
|
const termElement = this._terminal?.element
|
|
481
488
|
if (!(termElement instanceof HTMLElement)) return null
|
|
482
489
|
if (termElement.classList.contains('xterm')) {
|
|
483
|
-
return termElement.parentElement instanceof HTMLElement
|
|
490
|
+
return termElement.parentElement instanceof HTMLElement
|
|
491
|
+
? termElement.parentElement
|
|
492
|
+
: termElement
|
|
484
493
|
}
|
|
485
494
|
return termElement
|
|
486
495
|
}
|
|
@@ -503,6 +512,11 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
503
512
|
for (const fn of this._persistentCleanups) fn()
|
|
504
513
|
this._persistentCleanups = []
|
|
505
514
|
this._listenersAttached = false
|
|
515
|
+
if (this._fabSubscribed) {
|
|
516
|
+
InputPanelAddon._fabSubscriberCount = Math.max(0, InputPanelAddon._fabSubscriberCount - 1)
|
|
517
|
+
this._fabSubscribed = false
|
|
518
|
+
InputPanelAddon._setFabVisible(InputPanelAddon._active === null)
|
|
519
|
+
}
|
|
506
520
|
InputPanelAddon._instances.delete(this)
|
|
507
521
|
if (InputPanelAddon._lastFocused === this) {
|
|
508
522
|
InputPanelAddon._lastFocused = null
|
|
@@ -529,7 +543,19 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
529
543
|
this._listenersAttached = true
|
|
530
544
|
|
|
531
545
|
// Ensure native FAB exists in the correct mount target
|
|
532
|
-
|
|
546
|
+
if (this._showFab) {
|
|
547
|
+
InputPanelAddon._ensureFab(this._getMountTarget())
|
|
548
|
+
if (!this._fabSubscribed) {
|
|
549
|
+
InputPanelAddon._fabSubscriberCount += 1
|
|
550
|
+
this._fabSubscribed = true
|
|
551
|
+
}
|
|
552
|
+
InputPanelAddon._setFabVisible(true)
|
|
553
|
+
} else {
|
|
554
|
+
// Hide legacy/stale FAB when current runtime has no FAB subscribers.
|
|
555
|
+
if (InputPanelAddon._fabSubscriberCount === 0) {
|
|
556
|
+
InputPanelAddon._setFabVisible(false)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
533
559
|
|
|
534
560
|
// Default FAB target to the first terminal that attaches listeners
|
|
535
561
|
if (!InputPanelAddon._lastFocused) {
|
|
@@ -552,7 +578,13 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
552
578
|
}
|
|
553
579
|
|
|
554
580
|
open(): void {
|
|
555
|
-
if (
|
|
581
|
+
if (!this._terminal) return
|
|
582
|
+
if (this._isOpen) {
|
|
583
|
+
// Recover from host unmount/remount: panel DOM can be removed while addon
|
|
584
|
+
// still thinks it is open. In that case, close stale state and re-open.
|
|
585
|
+
if (this._panel?.isConnected) return
|
|
586
|
+
this.close()
|
|
587
|
+
}
|
|
556
588
|
|
|
557
589
|
// Singleton: close any other active instance (migration)
|
|
558
590
|
if (InputPanelAddon._active && InputPanelAddon._active !== this) {
|
|
@@ -564,7 +596,9 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
564
596
|
InputPanelAddon._lastFocused = this
|
|
565
597
|
|
|
566
598
|
// Hide FAB while panel is open
|
|
567
|
-
|
|
599
|
+
if (this._showFab) {
|
|
600
|
+
InputPanelAddon._setFabVisible(false)
|
|
601
|
+
}
|
|
568
602
|
|
|
569
603
|
this._suppressKeyboard()
|
|
570
604
|
|
|
@@ -815,14 +849,20 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
815
849
|
}
|
|
816
850
|
|
|
817
851
|
// Show FAB again
|
|
818
|
-
|
|
852
|
+
if (this._showFab) {
|
|
853
|
+
InputPanelAddon._setFabVisible(true)
|
|
854
|
+
}
|
|
819
855
|
|
|
820
856
|
this._onCloseCb?.()
|
|
821
857
|
InputPanelAddon._onActiveChangeFn?.(null)
|
|
822
858
|
}
|
|
823
859
|
|
|
824
860
|
toggle(): void {
|
|
825
|
-
this._isOpen
|
|
861
|
+
if (this._isOpen) {
|
|
862
|
+
this.close()
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
this.open()
|
|
826
866
|
}
|
|
827
867
|
|
|
828
868
|
/**
|
|
@@ -836,6 +876,11 @@ export class InputPanelAddon implements ITerminalAddon {
|
|
|
836
876
|
syncFocusLifecycle(): void {
|
|
837
877
|
InputPanelAddon._lastFocused = this
|
|
838
878
|
|
|
879
|
+
if (this._isOpen && !this._panel?.isConnected) {
|
|
880
|
+
this.open()
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
|
|
839
884
|
if (InputPanelAddon._active && InputPanelAddon._active !== this) {
|
|
840
885
|
this.open()
|
|
841
886
|
return
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
|
|
2
2
|
import { playwright } from '@vitest/browser-playwright'
|
|
3
|
-
import { defineConfig } from 'vitest/config'
|
|
4
3
|
import { resolve } from 'path'
|
|
4
|
+
import { defineConfig } from 'vitest/config'
|
|
5
5
|
|
|
6
6
|
export default defineConfig({
|
|
7
7
|
plugins: [
|