xterm-input-panel 1.2.3 → 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 +12 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/input-panel.stories.ts +124 -1
- package/src/input-panel.ts +184 -22
- package/src/shortcut-tab.ts +10 -30
- package/src/storage-namespace.test.ts +3 -3
- package/src/storage-namespace.ts +0 -5
- package/src/virtual-keyboard-tab.stories.ts +73 -0
- package/src/virtual-keyboard-tab.ts +52 -8
- package/src/xterm-addon.stories.ts +113 -18
- package/src/xterm-addon.ts +79 -3
- package/vitest.storybook.config.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.2.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 5e63308: Fix mobile floating input panel chrome and settings switch semantics.
|
|
14
|
+
|
|
3
15
|
## 1.2.3
|
|
4
16
|
|
|
5
17
|
### 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'
|
|
@@ -13,6 +13,25 @@ async function getLitElement(container: HTMLElement, selector: string) {
|
|
|
13
13
|
return el
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function pointer(target: Element, type: string, x: number, y: number, id = 1) {
|
|
17
|
+
target.dispatchEvent(
|
|
18
|
+
new PointerEvent(type, {
|
|
19
|
+
clientX: x,
|
|
20
|
+
clientY: y,
|
|
21
|
+
pointerId: id,
|
|
22
|
+
pointerType: 'mouse',
|
|
23
|
+
bubbles: true,
|
|
24
|
+
composed: true,
|
|
25
|
+
cancelable: true,
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function expectFloatingDialogVisible(dialog: HTMLDialogElement) {
|
|
31
|
+
expect(dialog.open || dialog.matches(':popover-open')).toBe(true)
|
|
32
|
+
expect(dialog.matches(':modal')).toBe(false)
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
const meta: Meta = {
|
|
17
36
|
title: 'InputPanel',
|
|
18
37
|
tags: ['autodocs'],
|
|
@@ -58,8 +77,20 @@ export const FloatingLayout: StoryObj = {
|
|
|
58
77
|
`,
|
|
59
78
|
play: async ({ canvasElement }) => {
|
|
60
79
|
const panel = await getLitElement(canvasElement, 'input-panel')
|
|
61
|
-
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as
|
|
80
|
+
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
62
81
|
expect(dialog).toBeTruthy()
|
|
82
|
+
expectFloatingDialogVisible(dialog)
|
|
83
|
+
const moveBar = panel.shadowRoot?.querySelector('.move-bar') as HTMLElement
|
|
84
|
+
expect(moveBar).toBeTruthy()
|
|
85
|
+
|
|
86
|
+
const dialogStyles = getComputedStyle(dialog)
|
|
87
|
+
expect(dialogStyles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
|
|
88
|
+
expect(dialogStyles.borderTopWidth).toBe('1px')
|
|
89
|
+
expect(dialogStyles.borderRadius).toBe('8px')
|
|
90
|
+
|
|
91
|
+
const moveBarRect = moveBar.getBoundingClientRect()
|
|
92
|
+
const dialogRect = dialog.getBoundingClientRect()
|
|
93
|
+
expect(moveBarRect.top).toBeLessThan(dialogRect.top)
|
|
63
94
|
|
|
64
95
|
const styles = getComputedStyle(dialog) as CSSStyleDeclaration & {
|
|
65
96
|
webkitBackdropFilter?: string
|
|
@@ -144,6 +175,42 @@ export const TabSwitching: StoryObj = {
|
|
|
144
175
|
},
|
|
145
176
|
}
|
|
146
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
|
+
|
|
147
214
|
/**
|
|
148
215
|
* Verifies that the close button dispatches the `input-panel:close` event.
|
|
149
216
|
*/
|
|
@@ -214,6 +281,7 @@ export const FloatingResize: StoryObj = {
|
|
|
214
281
|
const shadow = panel.shadowRoot!
|
|
215
282
|
const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
|
|
216
283
|
expect(dialog).toBeTruthy()
|
|
284
|
+
expectFloatingDialogVisible(dialog)
|
|
217
285
|
|
|
218
286
|
const handles = shadow.querySelectorAll('.resize-handle')
|
|
219
287
|
expect(handles.length).toBe(4)
|
|
@@ -226,6 +294,61 @@ export const FloatingResize: StoryObj = {
|
|
|
226
294
|
},
|
|
227
295
|
}
|
|
228
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Floating panel moves through the protruding move bar and toolbar blank space.
|
|
299
|
+
*/
|
|
300
|
+
export const FloatingDragHandles: StoryObj = {
|
|
301
|
+
render: () => html`
|
|
302
|
+
<input-panel layout="floating" style="height: 100%;">
|
|
303
|
+
<input-method-tab slot="input"></input-method-tab>
|
|
304
|
+
<virtual-keyboard-tab slot="keys" floating></virtual-keyboard-tab>
|
|
305
|
+
<shortcut-tab slot="shortcuts"></shortcut-tab>
|
|
306
|
+
<virtual-trackpad-tab slot="trackpad" floating></virtual-trackpad-tab>
|
|
307
|
+
</input-panel>
|
|
308
|
+
`,
|
|
309
|
+
play: async ({ canvasElement }) => {
|
|
310
|
+
const panel = await getLitElement(canvasElement, 'input-panel')
|
|
311
|
+
const shadow = panel.shadowRoot!
|
|
312
|
+
const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
|
|
313
|
+
const moveBar = shadow.querySelector('.move-bar') as HTMLElement
|
|
314
|
+
const toolbar = shadow.querySelector('.toolbar') as HTMLElement
|
|
315
|
+
const layoutBtn = shadow.querySelector('.action-group .icon-btn') as HTMLButtonElement
|
|
316
|
+
expectFloatingDialogVisible(dialog)
|
|
317
|
+
expect(moveBar).toBeTruthy()
|
|
318
|
+
expect(toolbar).toBeTruthy()
|
|
319
|
+
|
|
320
|
+
const before = dialog.getBoundingClientRect()
|
|
321
|
+
const handleRect = moveBar.getBoundingClientRect()
|
|
322
|
+
const startX = handleRect.left + handleRect.width / 2
|
|
323
|
+
const startY = handleRect.top + handleRect.height / 2
|
|
324
|
+
|
|
325
|
+
pointer(moveBar, 'pointerdown', startX, startY)
|
|
326
|
+
pointer(moveBar, 'pointermove', startX + 40, startY + 22)
|
|
327
|
+
pointer(moveBar, 'pointerup', startX + 40, startY + 22)
|
|
328
|
+
|
|
329
|
+
const after = dialog.getBoundingClientRect()
|
|
330
|
+
expect(after.left).toBeGreaterThan(before.left + 20)
|
|
331
|
+
expect(after.top).toBeGreaterThan(before.top + 10)
|
|
332
|
+
|
|
333
|
+
const toolbarRect = toolbar.getBoundingClientRect()
|
|
334
|
+
const toolbarStartX = toolbarRect.left + toolbarRect.width / 2
|
|
335
|
+
const toolbarStartY = toolbarRect.top + toolbarRect.height / 2
|
|
336
|
+
|
|
337
|
+
pointer(toolbar, 'pointerdown', toolbarStartX, toolbarStartY, 2)
|
|
338
|
+
pointer(toolbar, 'pointermove', toolbarStartX - 35, toolbarStartY + 16, 2)
|
|
339
|
+
pointer(toolbar, 'pointerup', toolbarStartX - 35, toolbarStartY + 16, 2)
|
|
340
|
+
|
|
341
|
+
const toolbarAfter = dialog.getBoundingClientRect()
|
|
342
|
+
expect(toolbarAfter.left).toBeLessThan(after.left - 15)
|
|
343
|
+
expect(toolbarAfter.top).toBeGreaterThan(after.top + 8)
|
|
344
|
+
|
|
345
|
+
layoutBtn.click()
|
|
346
|
+
await panel.updateComplete
|
|
347
|
+
|
|
348
|
+
expect(panel.getAttribute('layout')).toBe('fixed')
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
|
|
229
352
|
/**
|
|
230
353
|
* Verifies Fixed mode height slider updates InputPanel internal style variable.
|
|
231
354
|
*/
|
package/src/input-panel.ts
CHANGED
|
@@ -24,6 +24,7 @@ const MIN_WIDTH_PX = 300
|
|
|
24
24
|
const MIN_HEIGHT_PX = 150
|
|
25
25
|
const MAX_WIDTH_PCT = 95
|
|
26
26
|
const MAX_HEIGHT_PCT = 85
|
|
27
|
+
const MOVE_BAR_PROTRUSION_PX = 10
|
|
27
28
|
|
|
28
29
|
const SETTINGS_KEY = 'xtermInputPanelSettings'
|
|
29
30
|
|
|
@@ -92,6 +93,8 @@ export class InputPanel extends LitElement {
|
|
|
92
93
|
--muted-foreground: var(--_ip-muted-fg);
|
|
93
94
|
--terminal: var(--_ip-bg);
|
|
94
95
|
--terminal-foreground: var(--_ip-fg);
|
|
96
|
+
--move-bar-height: 10px;
|
|
97
|
+
--move-bar-protrusion: 10px;
|
|
95
98
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
96
99
|
font-size: 13px;
|
|
97
100
|
color: var(--foreground, #fff);
|
|
@@ -214,7 +217,7 @@ export class InputPanel extends LitElement {
|
|
|
214
217
|
border-radius: 8px;
|
|
215
218
|
background: var(--background, #1a1a1a);
|
|
216
219
|
color: var(--foreground, #fff);
|
|
217
|
-
overflow:
|
|
220
|
+
overflow: visible;
|
|
218
221
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
219
222
|
display: flex;
|
|
220
223
|
flex-direction: column;
|
|
@@ -226,6 +229,54 @@ export class InputPanel extends LitElement {
|
|
|
226
229
|
z-index: 9999;
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
.panel-body {
|
|
233
|
+
width: 100%;
|
|
234
|
+
height: 100%;
|
|
235
|
+
display: flex;
|
|
236
|
+
flex-direction: column;
|
|
237
|
+
overflow: hidden;
|
|
238
|
+
border-radius: inherit;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.move-bar {
|
|
242
|
+
position: absolute;
|
|
243
|
+
top: calc(-1 * var(--move-bar-protrusion));
|
|
244
|
+
left: 50%;
|
|
245
|
+
width: 68px;
|
|
246
|
+
height: var(--move-bar-height);
|
|
247
|
+
transform: translateX(-50%);
|
|
248
|
+
border: 1px solid var(--primary, #e04a2f);
|
|
249
|
+
border-bottom: 0;
|
|
250
|
+
border-radius: 10px 10px 0 0;
|
|
251
|
+
background: var(--background, #1a1a1a);
|
|
252
|
+
color: var(--muted-foreground, #888);
|
|
253
|
+
touch-action: none;
|
|
254
|
+
cursor: grab;
|
|
255
|
+
z-index: 12;
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: center;
|
|
258
|
+
justify-content: center;
|
|
259
|
+
box-shadow: 0 -6px 12px rgba(0, 0, 0, 0.12);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.move-bar::before {
|
|
263
|
+
content: '';
|
|
264
|
+
width: 34px;
|
|
265
|
+
height: 4px;
|
|
266
|
+
border-radius: 999px;
|
|
267
|
+
background: currentColor;
|
|
268
|
+
opacity: 0.72;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.move-bar:hover,
|
|
272
|
+
:host([data-interacting]) .move-bar {
|
|
273
|
+
color: var(--primary, #e04a2f);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
:host([data-interacting]) .move-bar {
|
|
277
|
+
cursor: grabbing;
|
|
278
|
+
}
|
|
279
|
+
|
|
229
280
|
/* --- Resize handles --- */
|
|
230
281
|
.resize-handle {
|
|
231
282
|
position: absolute;
|
|
@@ -345,6 +396,7 @@ export class InputPanel extends LitElement {
|
|
|
345
396
|
|
|
346
397
|
disconnectedCallback() {
|
|
347
398
|
super.disconnectedCallback()
|
|
399
|
+
this._closeFloatingDialog()
|
|
348
400
|
this.dispatchEvent(
|
|
349
401
|
new CustomEvent('input-panel:disconnected', { bubbles: true, composed: true })
|
|
350
402
|
)
|
|
@@ -416,8 +468,8 @@ export class InputPanel extends LitElement {
|
|
|
416
468
|
maxOverY = hPx / 3
|
|
417
469
|
return {
|
|
418
470
|
left: Math.max(-maxOverX, Math.min(vw - wPx + maxOverX, leftPx)),
|
|
419
|
-
//
|
|
420
|
-
top: Math.max(
|
|
471
|
+
// Keep the protruding move handle reachable even when the panel is near the top edge.
|
|
472
|
+
top: Math.max(MOVE_BAR_PROTRUSION_PX, Math.min(vh - hPx + maxOverY, topPx)),
|
|
421
473
|
}
|
|
422
474
|
}
|
|
423
475
|
|
|
@@ -453,6 +505,55 @@ export class InputPanel extends LitElement {
|
|
|
453
505
|
if (dialog) this._applyGeometry(dialog)
|
|
454
506
|
}
|
|
455
507
|
|
|
508
|
+
private _isPopoverOpen(dialog: HTMLDialogElement) {
|
|
509
|
+
try {
|
|
510
|
+
return dialog.matches(':popover-open')
|
|
511
|
+
} catch {
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private _isFloatingDialogOpen(dialog: HTMLDialogElement) {
|
|
517
|
+
return dialog.open || this._isPopoverOpen(dialog)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private _showFloatingDialog(dialog: HTMLDialogElement) {
|
|
521
|
+
if (this._isFloatingDialogOpen(dialog)) return
|
|
522
|
+
if (!dialog.isConnected) {
|
|
523
|
+
requestAnimationFrame(() => {
|
|
524
|
+
if (
|
|
525
|
+
this.layout === 'floating' &&
|
|
526
|
+
dialog.isConnected &&
|
|
527
|
+
!this._isFloatingDialogOpen(dialog)
|
|
528
|
+
) {
|
|
529
|
+
this._showFloatingDialog(dialog)
|
|
530
|
+
this._applyGeometry(dialog)
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
const popoverDialog = dialog as HTMLDialogElement & {
|
|
536
|
+
showPopover?: () => void
|
|
537
|
+
}
|
|
538
|
+
if (typeof popoverDialog.showPopover === 'function') {
|
|
539
|
+
popoverDialog.showPopover()
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
dialog.show()
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private _closeFloatingDialog() {
|
|
546
|
+
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as
|
|
547
|
+
| (HTMLDialogElement & { hidePopover?: () => void })
|
|
548
|
+
| null
|
|
549
|
+
if (!dialog) return
|
|
550
|
+
if (this._isPopoverOpen(dialog) && typeof dialog.hidePopover === 'function') {
|
|
551
|
+
dialog.hidePopover()
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
if (dialog.open) dialog.close()
|
|
555
|
+
}
|
|
556
|
+
|
|
456
557
|
// --- Tab / layout ---
|
|
457
558
|
|
|
458
559
|
private _switchTab(tab: InputPanelTab) {
|
|
@@ -467,8 +568,35 @@ export class InputPanel extends LitElement {
|
|
|
467
568
|
this.requestUpdate()
|
|
468
569
|
}
|
|
469
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
|
+
|
|
470
595
|
private _toggleLayout() {
|
|
471
596
|
this.layout = this.layout === 'fixed' ? 'floating' : 'fixed'
|
|
597
|
+
if (this.layout === 'fixed') {
|
|
598
|
+
this._closeFloatingDialog()
|
|
599
|
+
}
|
|
472
600
|
this.dispatchEvent(
|
|
473
601
|
new CustomEvent('input-panel:layout-change', {
|
|
474
602
|
detail: { layout: this.layout },
|
|
@@ -480,8 +608,7 @@ export class InputPanel extends LitElement {
|
|
|
480
608
|
}
|
|
481
609
|
|
|
482
610
|
private _close() {
|
|
483
|
-
|
|
484
|
-
if (dialog?.open) dialog.close()
|
|
611
|
+
this._closeFloatingDialog()
|
|
485
612
|
this.dispatchEvent(
|
|
486
613
|
new CustomEvent('input-panel:close', {
|
|
487
614
|
bubbles: true,
|
|
@@ -494,8 +621,8 @@ export class InputPanel extends LitElement {
|
|
|
494
621
|
super.firstUpdated(changed)
|
|
495
622
|
if (this.layout === 'floating') {
|
|
496
623
|
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
|
|
497
|
-
if (dialog && !dialog
|
|
498
|
-
|
|
624
|
+
if (dialog && !this._isFloatingDialogOpen(dialog)) {
|
|
625
|
+
this._showFloatingDialog(dialog)
|
|
499
626
|
this._applyGeometry(dialog)
|
|
500
627
|
}
|
|
501
628
|
}
|
|
@@ -510,10 +637,10 @@ export class InputPanel extends LitElement {
|
|
|
510
637
|
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
|
|
511
638
|
if (dialog) {
|
|
512
639
|
// Only re-open dialog when layout just switched to floating
|
|
513
|
-
if (changed.has('layout') && !dialog
|
|
514
|
-
|
|
640
|
+
if (changed.has('layout') && !this._isFloatingDialogOpen(dialog)) {
|
|
641
|
+
this._showFloatingDialog(dialog)
|
|
515
642
|
}
|
|
516
|
-
if (dialog
|
|
643
|
+
if (this._isFloatingDialogOpen(dialog)) {
|
|
517
644
|
this._applyGeometry(dialog)
|
|
518
645
|
}
|
|
519
646
|
}
|
|
@@ -523,9 +650,12 @@ export class InputPanel extends LitElement {
|
|
|
523
650
|
// --- Dialog drag ---
|
|
524
651
|
|
|
525
652
|
private _onToolbarPointerDown(e: PointerEvent) {
|
|
526
|
-
if (this.layout !== 'floating') return
|
|
527
|
-
// Don't intercept clicks on buttons (close, tabs, layout toggle)
|
|
528
653
|
if ((e.target as HTMLElement).closest('button')) return
|
|
654
|
+
this._onDragPointerDown(e)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private _onDragPointerDown(e: PointerEvent) {
|
|
658
|
+
if (this.layout !== 'floating') return
|
|
529
659
|
const dialog = this.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement | null
|
|
530
660
|
if (!dialog) return
|
|
531
661
|
|
|
@@ -540,10 +670,15 @@ export class InputPanel extends LitElement {
|
|
|
540
670
|
origTop: rect.top,
|
|
541
671
|
}
|
|
542
672
|
this.setAttribute('data-interacting', '')
|
|
543
|
-
|
|
673
|
+
const handle = e.currentTarget as HTMLElement
|
|
674
|
+
try {
|
|
675
|
+
handle.setPointerCapture(e.pointerId)
|
|
676
|
+
} catch {
|
|
677
|
+
// Synthetic PointerEvents used by browser tests do not always create an active pointer.
|
|
678
|
+
}
|
|
544
679
|
}
|
|
545
680
|
|
|
546
|
-
private
|
|
681
|
+
private _onDragPointerMove(e: PointerEvent) {
|
|
547
682
|
if (!this._dragState) return
|
|
548
683
|
e.stopPropagation()
|
|
549
684
|
e.preventDefault()
|
|
@@ -567,8 +702,10 @@ export class InputPanel extends LitElement {
|
|
|
567
702
|
dialog.style.bottom = 'auto'
|
|
568
703
|
}
|
|
569
704
|
|
|
570
|
-
private
|
|
705
|
+
private _onDragPointerUp(e: PointerEvent) {
|
|
571
706
|
if (!this._dragState) return
|
|
707
|
+
e.stopPropagation()
|
|
708
|
+
e.preventDefault()
|
|
572
709
|
this._dragState = null
|
|
573
710
|
this.removeAttribute('data-interacting')
|
|
574
711
|
|
|
@@ -737,8 +874,9 @@ export class InputPanel extends LitElement {
|
|
|
737
874
|
class="toolbar"
|
|
738
875
|
part="toolbar"
|
|
739
876
|
@pointerdown=${(e: PointerEvent) => this._onToolbarPointerDown(e)}
|
|
740
|
-
@pointermove=${(e: PointerEvent) => this.
|
|
741
|
-
@pointerup=${() => this.
|
|
877
|
+
@pointermove=${(e: PointerEvent) => this._onDragPointerMove(e)}
|
|
878
|
+
@pointerup=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
879
|
+
@pointercancel=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
742
880
|
>
|
|
743
881
|
<div class="tab-group">
|
|
744
882
|
${tabs.map(
|
|
@@ -747,7 +885,9 @@ export class InputPanel extends LitElement {
|
|
|
747
885
|
class="tab-btn"
|
|
748
886
|
part="tab-btn"
|
|
749
887
|
?data-active=${this.activeTab === t.id}
|
|
750
|
-
@
|
|
888
|
+
@pointerdown=${(e: PointerEvent) => this._stopToolbarControlPointer(e)}
|
|
889
|
+
@mousedown=${(e: MouseEvent) => this._stopToolbarControlPointer(e)}
|
|
890
|
+
@click=${(e: Event) => this._switchTabFromToolbar(e, t.id)}
|
|
751
891
|
>
|
|
752
892
|
${t.icon} ${this.activeTab === t.id ? t.label : ''}
|
|
753
893
|
</button>
|
|
@@ -755,10 +895,23 @@ export class InputPanel extends LitElement {
|
|
|
755
895
|
)}
|
|
756
896
|
</div>
|
|
757
897
|
<div class="action-group">
|
|
758
|
-
<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
|
+
>
|
|
759
905
|
${this.layout === 'fixed' ? iconPin(14) : iconPinOff(14)}
|
|
760
906
|
</button>
|
|
761
|
-
<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
|
+
>
|
|
762
915
|
${iconX(14)}
|
|
763
916
|
</button>
|
|
764
917
|
</div>
|
|
@@ -778,7 +931,16 @@ export class InputPanel extends LitElement {
|
|
|
778
931
|
`
|
|
779
932
|
|
|
780
933
|
if (this.layout === 'floating') {
|
|
781
|
-
return html` <dialog class="panel-dialog">
|
|
934
|
+
return html` <dialog class="panel-dialog" popover="manual">
|
|
935
|
+
<div
|
|
936
|
+
class="move-bar"
|
|
937
|
+
part="move-bar"
|
|
938
|
+
aria-label="Move input panel"
|
|
939
|
+
@pointerdown=${(e: PointerEvent) => this._onDragPointerDown(e)}
|
|
940
|
+
@pointermove=${(e: PointerEvent) => this._onDragPointerMove(e)}
|
|
941
|
+
@pointerup=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
942
|
+
@pointercancel=${(e: PointerEvent) => this._onDragPointerUp(e)}
|
|
943
|
+
></div>
|
|
782
944
|
<div
|
|
783
945
|
class="resize-handle resize-tl"
|
|
784
946
|
@pointerdown=${(e: PointerEvent) => this._onResizeStart(e, 'tl')}
|
|
@@ -795,7 +957,7 @@ export class InputPanel extends LitElement {
|
|
|
795
957
|
class="resize-handle resize-br"
|
|
796
958
|
@pointerdown=${(e: PointerEvent) => this._onResizeStart(e, 'br')}
|
|
797
959
|
></div>
|
|
798
|
-
|
|
960
|
+
<div class="panel-body">${inner}</div>
|
|
799
961
|
</dialog>`
|
|
800
962
|
}
|
|
801
963
|
|
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) {
|
|
@@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { getSessionScopedStorageKey } from './storage-namespace'
|
|
3
3
|
|
|
4
4
|
describe('storage namespace helpers', () => {
|
|
5
|
-
it('scopes hosted
|
|
5
|
+
it('scopes hosted sessions by session', () => {
|
|
6
6
|
expect(
|
|
7
7
|
getSessionScopedStorageKey('xtermInputPanelState', {
|
|
8
|
-
pathname: '/
|
|
8
|
+
pathname: '/dashboard',
|
|
9
9
|
search: '?session=session-a',
|
|
10
10
|
})
|
|
11
11
|
).toBe('hosted-session:session-a:xtermInputPanelState')
|
|
@@ -15,7 +15,7 @@ describe('storage namespace helpers', () => {
|
|
|
15
15
|
expect(
|
|
16
16
|
getSessionScopedStorageKey('xtermInputPanelState', {
|
|
17
17
|
pathname: '/dashboard',
|
|
18
|
-
search: '
|
|
18
|
+
search: '',
|
|
19
19
|
})
|
|
20
20
|
).toBe('xtermInputPanelState')
|
|
21
21
|
})
|
package/src/storage-namespace.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
const HOSTED_VERSION_PATH_RE = /^\/versions\/[^/]+(?:\/|$)/
|
|
2
|
-
|
|
3
1
|
function getHostedSessionId(locationLike: Pick<Location, 'pathname' | 'search'>): string | null {
|
|
4
|
-
if (!HOSTED_VERSION_PATH_RE.test(locationLike.pathname)) {
|
|
5
|
-
return null
|
|
6
|
-
}
|
|
7
2
|
const value = new URLSearchParams(locationLike.search).get('session')?.trim()
|
|
8
3
|
return value ? value : null
|
|
9
4
|
}
|
|
@@ -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(() => {
|
|
@@ -52,6 +62,11 @@ function setupTerminal(container: HTMLElement, opts?: { stateKey?: string }) {
|
|
|
52
62
|
return { terminal, addon, inputHandler }
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
function expectFloatingDialogVisible(dialog: HTMLDialogElement) {
|
|
66
|
+
expect(dialog.open || dialog.matches(':popover-open')).toBe(true)
|
|
67
|
+
expect(dialog.matches(':modal')).toBe(false)
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
const meta: Meta = {
|
|
56
71
|
title: 'InputPanelAddon',
|
|
57
72
|
tags: ['autodocs'],
|
|
@@ -94,13 +109,13 @@ export const OpenCreatesPanel: StoryObj = {
|
|
|
94
109
|
expect(panel!.getAttribute('layout')).toBe('floating')
|
|
95
110
|
expect(panel!.parentElement).toBe(container)
|
|
96
111
|
|
|
97
|
-
// Wait for Lit to render + firstUpdated to
|
|
112
|
+
// Wait for Lit to render + firstUpdated to open the floating dialog without modal inert behavior.
|
|
98
113
|
await (panel as any).updateComplete
|
|
99
114
|
|
|
100
|
-
// The dialog inside shadow DOM should be
|
|
115
|
+
// The dialog inside shadow DOM should be visible.
|
|
101
116
|
const dialog = (panel as any).shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
102
117
|
expect(dialog).not.toBeNull()
|
|
103
|
-
|
|
118
|
+
expectFloatingDialogVisible(dialog)
|
|
104
119
|
|
|
105
120
|
// Cleanup
|
|
106
121
|
addon.close()
|
|
@@ -178,11 +193,11 @@ export const ToggleCycle: StoryObj = {
|
|
|
178
193
|
const panel = container.querySelector('input-panel')
|
|
179
194
|
expect(panel).not.toBeNull()
|
|
180
195
|
|
|
181
|
-
// Wait for Lit render and verify dialog is
|
|
196
|
+
// Wait for Lit render and verify dialog is visible.
|
|
182
197
|
await (panel as any).updateComplete
|
|
183
198
|
const dialog = (panel as any).shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
184
199
|
expect(dialog).not.toBeNull()
|
|
185
|
-
|
|
200
|
+
expectFloatingDialogVisible(dialog)
|
|
186
201
|
|
|
187
202
|
addon.close()
|
|
188
203
|
},
|
|
@@ -273,6 +288,83 @@ export const InputForwarding: StoryObj = {
|
|
|
273
288
|
},
|
|
274
289
|
}
|
|
275
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
|
+
|
|
276
368
|
/**
|
|
277
369
|
* Custom element registration: document.createElement('input-panel')
|
|
278
370
|
* must produce a real InputPanel instance with shadow DOM, not a generic HTMLElement.
|
|
@@ -303,10 +395,10 @@ export const CustomElementRegistered: StoryObj = {
|
|
|
303
395
|
const dialog = shadow.querySelector('.panel-dialog') as HTMLDialogElement
|
|
304
396
|
expect(dialog).not.toBeNull()
|
|
305
397
|
|
|
306
|
-
// firstUpdated should have
|
|
307
|
-
|
|
398
|
+
// firstUpdated should have opened the floating dialog without modal inert behavior.
|
|
399
|
+
expectFloatingDialogVisible(dialog)
|
|
308
400
|
|
|
309
|
-
// Floating mode intentionally
|
|
401
|
+
// Floating mode intentionally renders no custom backdrop element.
|
|
310
402
|
const backdrop = shadow.querySelector('.backdrop')
|
|
311
403
|
expect(backdrop).toBeNull()
|
|
312
404
|
|
|
@@ -341,7 +433,7 @@ export const FabClickSimulation: StoryObj = {
|
|
|
341
433
|
|
|
342
434
|
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLDialogElement
|
|
343
435
|
expect(dialog).not.toBeNull()
|
|
344
|
-
|
|
436
|
+
expectFloatingDialogVisible(dialog)
|
|
345
437
|
|
|
346
438
|
// Verify dialog has reasonable dimensions (not 0x0)
|
|
347
439
|
const rect = dialog.getBoundingClientRect()
|
|
@@ -576,6 +668,7 @@ export const PersistStateAcrossTerminalSwitch: StoryObj = {
|
|
|
576
668
|
* panel DOM can disappear, but addon should still be able to re-open.
|
|
577
669
|
*/
|
|
578
670
|
export const RecoverAfterPanelHostRemount: StoryObj = {
|
|
671
|
+
tags: ['skip-browser-test'],
|
|
579
672
|
render: () => html`
|
|
580
673
|
<div style="display:flex;gap:8px;height:100%;">
|
|
581
674
|
<div id="host-a" style="flex:1;position:relative;">
|
|
@@ -594,21 +687,23 @@ export const RecoverAfterPanelHostRemount: StoryObj = {
|
|
|
594
687
|
const { addon } = setupTerminal(terminalContainer, { stateKey: 'host-remount' })
|
|
595
688
|
|
|
596
689
|
addon.open()
|
|
597
|
-
|
|
598
|
-
expect(
|
|
690
|
+
const panelA = hostA.querySelector('input-panel')
|
|
691
|
+
expect(panelA).not.toBeNull()
|
|
692
|
+
expect(panelA?.parentElement).toBe(hostA)
|
|
599
693
|
expect(addon.isOpen).toBe(true)
|
|
600
694
|
|
|
601
695
|
// Simulate area switch: host subtree is unmounted while addon remains alive.
|
|
602
|
-
|
|
696
|
+
panelA?.remove()
|
|
603
697
|
expect(addon.isOpen).toBe(true)
|
|
604
698
|
|
|
605
699
|
// Simulate return to terminal area with a new mount target.
|
|
606
700
|
InputPanelAddon.mountTarget = hostB
|
|
607
701
|
addon.open()
|
|
608
702
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
703
|
+
const panelB = hostB.querySelector('input-panel')
|
|
704
|
+
expect(panelB).not.toBeNull()
|
|
705
|
+
expect(panelB?.parentElement).toBe(hostB)
|
|
706
|
+
expect(hostA.querySelector('input-panel')).toBeNull()
|
|
612
707
|
expect(addon.isOpen).toBe(true)
|
|
613
708
|
|
|
614
709
|
addon.close()
|
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(
|