xterm-input-panel 1.2.0 → 1.2.1
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 +8 -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/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 +53 -8
- package/vitest.storybook.config.ts +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { beforeAll } from 'vitest'
|
|
2
|
-
import { setProjectAnnotations } from 'storybook/preview-api'
|
|
3
1
|
import * as rendererAnnotations from '@storybook/web-components/entry-preview'
|
|
2
|
+
import { setProjectAnnotations } from 'storybook/preview-api'
|
|
3
|
+
import { beforeAll } from 'vitest'
|
|
4
4
|
import * as previewAnnotations from './preview'
|
|
5
5
|
|
|
6
6
|
const annotations = setProjectAnnotations([rendererAnnotations, previewAnnotations])
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# xterm-input-panel
|
|
2
2
|
|
|
3
|
+
## 1.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- fcfb701: Move terminal InputPanel entry from floating FAB to the terminal toolbar, harden InputPanel remount lifecycle recovery, and improve schema-driven workflow compatibility by removing proposal/tasks/design hard assumptions from dashboard metadata paths.
|
|
8
|
+
|
|
9
|
+
Also evolve `opsx-collab-pr-loop` into dedicated loop artifacts under `loop/*` (intake, research-plan, implementation, checkpoints) with apply tracking on `loop/checkpoints.md`.
|
|
10
|
+
|
|
3
11
|
## 1.2.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenSpecUI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xterm-input-panel",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"dependencies": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@storybook/web-components-vite": "^10.2.8",
|
|
23
23
|
"@vitest/browser": "^4.0.18",
|
|
24
24
|
"@vitest/browser-playwright": "^4.0.18",
|
|
25
|
-
"@xterm/xterm": "6.1.0-beta.
|
|
25
|
+
"@xterm/xterm": "6.1.0-beta.167",
|
|
26
26
|
"playwright": "^1.58.2",
|
|
27
27
|
"storybook": "^10.2.8",
|
|
28
28
|
"typescript": "^5.7.2",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"dev": "storybook dev -p 6007",
|
|
34
|
-
"test:browser": "vitest run --config vitest.storybook.config.ts",
|
|
34
|
+
"test:browser": "vitest run --retry 2 --config vitest.storybook.config.ts",
|
|
35
35
|
"typecheck": "tsc --noEmit"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -16,7 +16,9 @@ const meta: Meta = {
|
|
|
16
16
|
tags: ['autodocs'],
|
|
17
17
|
decorators: [
|
|
18
18
|
(story) => html`
|
|
19
|
-
<div
|
|
19
|
+
<div
|
|
20
|
+
style="width: 400px; height: 250px; background: #1a1a1a; color: #fff; font-family: monospace;"
|
|
21
|
+
>
|
|
20
22
|
${story()}
|
|
21
23
|
</div>
|
|
22
24
|
`,
|
|
@@ -56,7 +58,7 @@ export const SendInput: StoryObj = {
|
|
|
56
58
|
sendBtn.click()
|
|
57
59
|
|
|
58
60
|
// Wait for _send to complete
|
|
59
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
60
62
|
|
|
61
63
|
expect(sendHandler).toHaveBeenCalledTimes(1)
|
|
62
64
|
const detail = (sendHandler.mock.calls[0] as unknown[])[0] as CustomEvent
|
|
@@ -85,13 +87,15 @@ export const CtrlEnterSend: StoryObj = {
|
|
|
85
87
|
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
86
88
|
await tab.updateComplete
|
|
87
89
|
|
|
88
|
-
textarea.dispatchEvent(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
textarea.dispatchEvent(
|
|
91
|
+
new KeyboardEvent('keydown', {
|
|
92
|
+
key: 'Enter',
|
|
93
|
+
ctrlKey: true,
|
|
94
|
+
bubbles: true,
|
|
95
|
+
})
|
|
96
|
+
)
|
|
93
97
|
|
|
94
|
-
await new Promise(resolve => setTimeout(resolve, 100))
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
95
99
|
|
|
96
100
|
expect(sendHandler).toHaveBeenCalledTimes(1)
|
|
97
101
|
const detail = (sendHandler.mock.calls[0] as unknown[])[0] as CustomEvent
|
package/src/input-method-tab.ts
CHANGED
|
@@ -56,6 +56,20 @@ export const FloatingLayout: StoryObj = {
|
|
|
56
56
|
<virtual-trackpad-tab slot="trackpad" floating></virtual-trackpad-tab>
|
|
57
57
|
</input-panel>
|
|
58
58
|
`,
|
|
59
|
+
play: async ({ canvasElement }) => {
|
|
60
|
+
const panel = await getLitElement(canvasElement, 'input-panel')
|
|
61
|
+
const dialog = panel.shadowRoot?.querySelector('.panel-dialog') as HTMLElement
|
|
62
|
+
expect(dialog).toBeTruthy()
|
|
63
|
+
|
|
64
|
+
const styles = getComputedStyle(dialog) as CSSStyleDeclaration & {
|
|
65
|
+
webkitBackdropFilter?: string
|
|
66
|
+
}
|
|
67
|
+
expect(styles.mixBlendMode).toBe('exclusion')
|
|
68
|
+
expect(
|
|
69
|
+
styles.backdropFilter.includes('blur(1px)') ||
|
|
70
|
+
styles.webkitBackdropFilter?.includes('blur(1px)')
|
|
71
|
+
).toBe(true)
|
|
72
|
+
},
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
/**
|
package/src/input-panel.ts
CHANGED
|
@@ -200,19 +200,10 @@ export class InputPanel extends LitElement {
|
|
|
200
200
|
position: relative;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
@keyframes breathing {
|
|
204
|
-
0%,
|
|
205
|
-
100% {
|
|
206
|
-
opacity: 0.5;
|
|
207
|
-
}
|
|
208
|
-
50% {
|
|
209
|
-
opacity: 0.05;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/* Fix 7: breathing animation on entire panel (including toolbar) */
|
|
214
203
|
:host([layout='floating']) .panel-dialog {
|
|
215
|
-
|
|
204
|
+
mix-blend-mode: exclusion;
|
|
205
|
+
backdrop-filter: blur(1px);
|
|
206
|
+
-webkit-backdrop-filter: blur(1px);
|
|
216
207
|
}
|
|
217
208
|
|
|
218
209
|
.panel-dialog {
|
package/src/pixi-theme.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import {
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { blendHex, cssColorToHex, resolvePixiTheme } from './pixi-theme.js'
|
|
3
3
|
|
|
4
4
|
describe('pixi-theme', () => {
|
|
5
5
|
describe('resolvePixiTheme', () => {
|
package/src/platform.ts
CHANGED
|
@@ -3,9 +3,9 @@ export type PlatformMode = HostPlatform | 'auto'
|
|
|
3
3
|
|
|
4
4
|
export function detectHostPlatform(): HostPlatform {
|
|
5
5
|
const platformSource = (
|
|
6
|
-
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
|
|
7
|
+
navigator.platform ??
|
|
8
|
+
navigator.userAgent
|
|
9
9
|
).toLowerCase()
|
|
10
10
|
|
|
11
11
|
if (platformSource.includes('mac')) return 'macos'
|
package/src/shortcut-pages.ts
CHANGED
|
@@ -96,23 +96,135 @@ function terminalPage(): ShortcutPage {
|
|
|
96
96
|
cols: GRID_COLS,
|
|
97
97
|
rows: GRID_ROWS,
|
|
98
98
|
items: [
|
|
99
|
-
{
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
99
|
+
{
|
|
100
|
+
id: 'ctrl-w',
|
|
101
|
+
label: 'Kill Word',
|
|
102
|
+
subLabel: 'Ctrl+W',
|
|
103
|
+
col: 3,
|
|
104
|
+
row: ROW_Q,
|
|
105
|
+
cols: 2,
|
|
106
|
+
action: { type: 'send', data: '\x17' },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'ctrl-r',
|
|
110
|
+
label: 'History Search',
|
|
111
|
+
subLabel: 'Ctrl+R',
|
|
112
|
+
col: 5,
|
|
113
|
+
row: ROW_Q,
|
|
114
|
+
cols: 2,
|
|
115
|
+
action: { type: 'send', data: '\x12' },
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'ctrl-u',
|
|
119
|
+
label: 'Kill Left',
|
|
120
|
+
subLabel: 'Ctrl+U',
|
|
121
|
+
col: 8,
|
|
122
|
+
row: ROW_Q,
|
|
123
|
+
cols: 2,
|
|
124
|
+
action: { type: 'send', data: '\x15' },
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
{
|
|
128
|
+
id: 'ctrl-a',
|
|
129
|
+
label: 'Line Start',
|
|
130
|
+
subLabel: 'Ctrl+A',
|
|
131
|
+
col: 2,
|
|
132
|
+
row: ROW_A,
|
|
133
|
+
cols: 2,
|
|
134
|
+
action: { type: 'send', data: '\x01' },
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'ctrl-d',
|
|
138
|
+
label: 'EOF',
|
|
139
|
+
subLabel: 'Ctrl+D',
|
|
140
|
+
col: 4,
|
|
141
|
+
row: ROW_A,
|
|
142
|
+
cols: 2,
|
|
143
|
+
action: { type: 'send', data: '\x04' },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'ctrl-f',
|
|
147
|
+
label: 'Char Right',
|
|
148
|
+
subLabel: 'Ctrl+F',
|
|
149
|
+
col: 6,
|
|
150
|
+
row: ROW_A,
|
|
151
|
+
cols: 2,
|
|
152
|
+
action: { type: 'send', data: '\x06' },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'ctrl-k',
|
|
156
|
+
label: 'Kill Right',
|
|
157
|
+
subLabel: 'Ctrl+K',
|
|
158
|
+
col: 9,
|
|
159
|
+
row: ROW_A,
|
|
160
|
+
cols: 2,
|
|
161
|
+
action: { type: 'send', data: '\x0b' },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'ctrl-l',
|
|
165
|
+
label: 'Clear',
|
|
166
|
+
subLabel: 'Ctrl+L',
|
|
167
|
+
col: 11,
|
|
168
|
+
row: ROW_A,
|
|
169
|
+
cols: 2,
|
|
170
|
+
action: { type: 'send', data: '\x0c' },
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
id: 'ctrl-z',
|
|
175
|
+
label: 'Suspend',
|
|
176
|
+
subLabel: 'Ctrl+Z',
|
|
177
|
+
col: 3,
|
|
178
|
+
row: ROW_Z,
|
|
179
|
+
cols: 2,
|
|
180
|
+
action: { type: 'send', data: '\x1a' },
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'ctrl-c',
|
|
184
|
+
label: 'Interrupt',
|
|
185
|
+
subLabel: 'Ctrl+C',
|
|
186
|
+
col: 5,
|
|
187
|
+
row: ROW_Z,
|
|
188
|
+
cols: 2,
|
|
189
|
+
action: { type: 'send', data: '\x03' },
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 'ctrl-v',
|
|
193
|
+
label: 'Literal',
|
|
194
|
+
subLabel: 'Ctrl+V',
|
|
195
|
+
col: 7,
|
|
196
|
+
row: ROW_Z,
|
|
197
|
+
cols: 2,
|
|
198
|
+
action: { type: 'send', data: '\x16' },
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'ctrl-y',
|
|
202
|
+
label: 'Yank',
|
|
203
|
+
subLabel: 'Ctrl+Y',
|
|
204
|
+
col: 9,
|
|
205
|
+
row: ROW_Z,
|
|
206
|
+
cols: 2,
|
|
207
|
+
action: { type: 'send', data: '\x19' },
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
id: 'alt-b',
|
|
212
|
+
label: 'Prev Word',
|
|
213
|
+
subLabel: 'Alt+B',
|
|
214
|
+
col: 6,
|
|
215
|
+
row: ROW_BOTTOM,
|
|
216
|
+
cols: 2,
|
|
217
|
+
action: { type: 'send', data: '\x1bb' },
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'alt-f',
|
|
221
|
+
label: 'Next Word',
|
|
222
|
+
subLabel: 'Alt+F',
|
|
223
|
+
col: 8,
|
|
224
|
+
row: ROW_BOTTOM,
|
|
225
|
+
cols: 2,
|
|
226
|
+
action: { type: 'send', data: '\x1bf' },
|
|
227
|
+
},
|
|
116
228
|
],
|
|
117
229
|
}
|
|
118
230
|
}
|
|
@@ -125,20 +237,116 @@ function claudePage(): ShortcutPage {
|
|
|
125
237
|
cols: GRID_COLS,
|
|
126
238
|
rows: GRID_ROWS,
|
|
127
239
|
items: [
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
{
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
240
|
+
{
|
|
241
|
+
id: 'claude-commands',
|
|
242
|
+
label: '/ Commands',
|
|
243
|
+
subLabel: '/',
|
|
244
|
+
col: 1,
|
|
245
|
+
row: ROW_TOP,
|
|
246
|
+
cols: 2,
|
|
247
|
+
action: { type: 'text', text: '/' },
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
id: 'claude-shell',
|
|
251
|
+
label: '! Shell',
|
|
252
|
+
subLabel: '!',
|
|
253
|
+
col: 3,
|
|
254
|
+
row: ROW_TOP,
|
|
255
|
+
cols: 2,
|
|
256
|
+
action: { type: 'text', text: '!' },
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: 'claude-files',
|
|
260
|
+
label: '@ Paths',
|
|
261
|
+
subLabel: '@',
|
|
262
|
+
col: 5,
|
|
263
|
+
row: ROW_TOP,
|
|
264
|
+
cols: 2,
|
|
265
|
+
action: { type: 'text', text: '@' },
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: 'claude-newline',
|
|
269
|
+
label: 'Newline',
|
|
270
|
+
subLabel: '\\ + Enter',
|
|
271
|
+
col: 8,
|
|
272
|
+
row: ROW_TOP,
|
|
273
|
+
cols: 3,
|
|
274
|
+
action: { type: 'text', text: '\\\n' },
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: 'claude-edit-last',
|
|
278
|
+
label: 'Edit Previous',
|
|
279
|
+
subLabel: 'Esc Esc',
|
|
280
|
+
col: 11,
|
|
281
|
+
row: ROW_TOP,
|
|
282
|
+
cols: 3,
|
|
283
|
+
action: { type: 'send', data: '\x1b\x1b' },
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
{
|
|
287
|
+
id: 'claude-editor',
|
|
288
|
+
label: 'External Editor',
|
|
289
|
+
subLabel: 'Ctrl+G',
|
|
290
|
+
col: 2,
|
|
291
|
+
row: ROW_A,
|
|
292
|
+
cols: 3,
|
|
293
|
+
action: { type: 'send', data: '\x07' },
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'claude-reverse-search',
|
|
297
|
+
label: 'History Search',
|
|
298
|
+
subLabel: 'Ctrl+R',
|
|
299
|
+
col: 5,
|
|
300
|
+
row: ROW_A,
|
|
301
|
+
cols: 3,
|
|
302
|
+
action: { type: 'send', data: '\x12' },
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
id: 'claude-bg',
|
|
306
|
+
label: 'Background Task',
|
|
307
|
+
subLabel: 'Ctrl+B',
|
|
308
|
+
col: 8,
|
|
309
|
+
row: ROW_A,
|
|
310
|
+
cols: 3,
|
|
311
|
+
action: { type: 'send', data: '\x02' },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: 'claude-task-list',
|
|
315
|
+
label: 'Task List',
|
|
316
|
+
subLabel: 'Ctrl+T',
|
|
317
|
+
col: 11,
|
|
318
|
+
row: ROW_A,
|
|
319
|
+
cols: 3,
|
|
320
|
+
action: { type: 'send', data: '\x14' },
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
id: 'claude-change-mode',
|
|
325
|
+
label: 'Change Mode',
|
|
326
|
+
subLabel: 'Shift+Tab',
|
|
327
|
+
col: 1,
|
|
328
|
+
row: ROW_BOTTOM,
|
|
329
|
+
cols: 3,
|
|
330
|
+
action: { type: 'send', data: '\x1b[Z' },
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
id: 'claude-cancel',
|
|
334
|
+
label: 'Cancel',
|
|
335
|
+
subLabel: 'Ctrl+C',
|
|
336
|
+
col: 4,
|
|
337
|
+
row: ROW_BOTTOM,
|
|
338
|
+
cols: 3,
|
|
339
|
+
action: { type: 'send', data: '\x03' },
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
id: 'claude-exit',
|
|
343
|
+
label: 'Exit',
|
|
344
|
+
subLabel: 'Ctrl+D',
|
|
345
|
+
col: 7,
|
|
346
|
+
row: ROW_BOTTOM,
|
|
347
|
+
cols: 3,
|
|
348
|
+
action: { type: 'send', data: '\x04' },
|
|
349
|
+
},
|
|
142
350
|
],
|
|
143
351
|
}
|
|
144
352
|
}
|
|
@@ -151,20 +359,108 @@ function codexPage(platform: HostPlatform): ShortcutPage {
|
|
|
151
359
|
cols: GRID_COLS,
|
|
152
360
|
rows: GRID_ROWS,
|
|
153
361
|
items: [
|
|
154
|
-
{
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
362
|
+
{
|
|
363
|
+
id: 'codex-commands',
|
|
364
|
+
label: '/ Commands',
|
|
365
|
+
subLabel: '/',
|
|
366
|
+
col: 1,
|
|
367
|
+
row: ROW_TOP,
|
|
368
|
+
cols: 2,
|
|
369
|
+
action: { type: 'text', text: '/' },
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: 'codex-shell',
|
|
373
|
+
label: '! Shell',
|
|
374
|
+
subLabel: '!',
|
|
375
|
+
col: 3,
|
|
376
|
+
row: ROW_TOP,
|
|
377
|
+
cols: 2,
|
|
378
|
+
action: { type: 'text', text: '!' },
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 'codex-files',
|
|
382
|
+
label: '@ Paths',
|
|
383
|
+
subLabel: '@',
|
|
384
|
+
col: 5,
|
|
385
|
+
row: ROW_TOP,
|
|
386
|
+
cols: 2,
|
|
387
|
+
action: { type: 'text', text: '@' },
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: 'codex-newline',
|
|
391
|
+
label: 'Newline',
|
|
392
|
+
subLabel: 'Shift+Enter',
|
|
393
|
+
col: 8,
|
|
394
|
+
row: ROW_TOP,
|
|
395
|
+
cols: 3,
|
|
396
|
+
action: { type: 'send', data: '\n' },
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: 'codex-queue',
|
|
400
|
+
label: 'Queue Message',
|
|
401
|
+
subLabel: 'Tab',
|
|
402
|
+
col: 11,
|
|
403
|
+
row: ROW_TOP,
|
|
404
|
+
cols: 3,
|
|
405
|
+
action: { type: 'send', data: '\t' },
|
|
406
|
+
},
|
|
159
407
|
|
|
160
|
-
{
|
|
161
|
-
|
|
162
|
-
|
|
408
|
+
{
|
|
409
|
+
id: 'codex-editor',
|
|
410
|
+
label: 'External Editor',
|
|
411
|
+
subLabel: 'Ctrl+G',
|
|
412
|
+
col: 4,
|
|
413
|
+
row: ROW_A,
|
|
414
|
+
cols: 3,
|
|
415
|
+
action: { type: 'send', data: '\x07' },
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
id: 'codex-edit-last',
|
|
419
|
+
label: 'Edit Previous',
|
|
420
|
+
subLabel: 'Esc Esc',
|
|
421
|
+
col: 7,
|
|
422
|
+
row: ROW_A,
|
|
423
|
+
cols: 3,
|
|
424
|
+
action: { type: 'send', data: '\x1b\x1b' },
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
id: 'codex-transcript',
|
|
428
|
+
label: 'Transcript',
|
|
429
|
+
subLabel: 'Ctrl+T',
|
|
430
|
+
col: 10,
|
|
431
|
+
row: ROW_A,
|
|
432
|
+
cols: 3,
|
|
433
|
+
action: { type: 'send', data: '\x14' },
|
|
434
|
+
},
|
|
163
435
|
|
|
164
|
-
{
|
|
436
|
+
{
|
|
437
|
+
id: 'codex-paste-image',
|
|
438
|
+
label: 'Paste Image',
|
|
439
|
+
subLabel: codexPasteImageLabel(platform),
|
|
440
|
+
col: 6,
|
|
441
|
+
row: ROW_Z,
|
|
442
|
+
cols: 3,
|
|
443
|
+
action: { type: 'send', data: '\x16' },
|
|
444
|
+
},
|
|
165
445
|
|
|
166
|
-
{
|
|
167
|
-
|
|
446
|
+
{
|
|
447
|
+
id: 'codex-change-mode',
|
|
448
|
+
label: 'Change Mode',
|
|
449
|
+
subLabel: 'Shift+Tab',
|
|
450
|
+
col: 1,
|
|
451
|
+
row: ROW_BOTTOM,
|
|
452
|
+
cols: 3,
|
|
453
|
+
action: { type: 'send', data: '\x1b[Z' },
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: 'codex-exit',
|
|
457
|
+
label: 'Exit',
|
|
458
|
+
subLabel: 'Ctrl+C',
|
|
459
|
+
col: 4,
|
|
460
|
+
row: ROW_BOTTOM,
|
|
461
|
+
cols: 3,
|
|
462
|
+
action: { type: 'send', data: '\x03' },
|
|
463
|
+
},
|
|
168
464
|
],
|
|
169
465
|
}
|
|
170
466
|
}
|
|
@@ -177,28 +473,102 @@ function geminiPage(): ShortcutPage {
|
|
|
177
473
|
cols: GRID_COLS,
|
|
178
474
|
rows: GRID_ROWS,
|
|
179
475
|
items: [
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
476
|
+
{
|
|
477
|
+
id: 'gemini-shell-mode',
|
|
478
|
+
label: 'Shell Mode',
|
|
479
|
+
subLabel: '!',
|
|
480
|
+
col: 1,
|
|
481
|
+
row: ROW_TOP,
|
|
482
|
+
cols: 2,
|
|
483
|
+
action: { type: 'text', text: '!' },
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
id: 'gemini-shortcuts-panel',
|
|
487
|
+
label: 'Shortcuts',
|
|
488
|
+
subLabel: '?',
|
|
489
|
+
col: 3,
|
|
490
|
+
row: ROW_TOP,
|
|
491
|
+
cols: 2,
|
|
492
|
+
action: { type: 'text', text: '?' },
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
id: 'gemini-editor',
|
|
496
|
+
label: 'External Editor',
|
|
497
|
+
subLabel: 'Ctrl+X',
|
|
498
|
+
col: 5,
|
|
499
|
+
row: ROW_TOP,
|
|
500
|
+
cols: 3,
|
|
501
|
+
action: { type: 'send', data: '\x18' },
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
id: 'gemini-reverse-search',
|
|
505
|
+
label: 'Reverse Search',
|
|
506
|
+
subLabel: 'Ctrl+R',
|
|
507
|
+
col: 8,
|
|
508
|
+
row: ROW_TOP,
|
|
509
|
+
cols: 3,
|
|
510
|
+
action: { type: 'send', data: '\x12' },
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: 'gemini-edit-last',
|
|
514
|
+
label: 'Edit Previous',
|
|
515
|
+
subLabel: 'Esc Esc',
|
|
516
|
+
col: 11,
|
|
517
|
+
row: ROW_TOP,
|
|
518
|
+
cols: 3,
|
|
519
|
+
action: { type: 'send', data: '\x1b\x1b' },
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
{
|
|
523
|
+
id: 'gemini-ide-context',
|
|
524
|
+
label: 'IDE Context',
|
|
525
|
+
subLabel: 'Ctrl+G',
|
|
526
|
+
col: 4,
|
|
527
|
+
row: ROW_A,
|
|
528
|
+
cols: 3,
|
|
529
|
+
action: { type: 'send', data: '\x07' },
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
id: 'gemini-todo-list',
|
|
533
|
+
label: 'TODO List',
|
|
534
|
+
subLabel: 'Ctrl+T',
|
|
535
|
+
col: 7,
|
|
536
|
+
row: ROW_A,
|
|
537
|
+
cols: 3,
|
|
538
|
+
action: { type: 'send', data: '\x14' },
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
id: 'gemini-change-mode',
|
|
542
|
+
label: 'Change Mode',
|
|
543
|
+
subLabel: 'Shift+Tab',
|
|
544
|
+
col: 10,
|
|
545
|
+
row: ROW_A,
|
|
546
|
+
cols: 3,
|
|
547
|
+
action: { type: 'send', data: '\x1b[Z' },
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
{
|
|
551
|
+
id: 'gemini-cancel',
|
|
552
|
+
label: 'Cancel',
|
|
553
|
+
subLabel: 'Ctrl+C',
|
|
554
|
+
col: 4,
|
|
555
|
+
row: ROW_BOTTOM,
|
|
556
|
+
cols: 3,
|
|
557
|
+
action: { type: 'send', data: '\x03' },
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
id: 'gemini-exit',
|
|
561
|
+
label: 'Exit Empty',
|
|
562
|
+
subLabel: 'Ctrl+D',
|
|
563
|
+
col: 7,
|
|
564
|
+
row: ROW_BOTTOM,
|
|
565
|
+
cols: 3,
|
|
566
|
+
action: { type: 'send', data: '\x04' },
|
|
567
|
+
},
|
|
192
568
|
],
|
|
193
569
|
}
|
|
194
570
|
}
|
|
195
571
|
|
|
196
572
|
export function buildShortcutPages(platform: HostPlatform): ShortcutPage[] {
|
|
197
|
-
return [
|
|
198
|
-
systemPage(platform),
|
|
199
|
-
terminalPage(),
|
|
200
|
-
claudePage(),
|
|
201
|
-
codexPage(platform),
|
|
202
|
-
geminiPage(),
|
|
203
|
-
]
|
|
573
|
+
return [systemPage(platform), terminalPage(), claudePage(), codexPage(platform), geminiPage()]
|
|
204
574
|
}
|
package/src/shortcut-tab.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Application, CanvasTextMetrics, Container, FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js'
|
|
1
|
+
import { css, html, LitElement } from 'lit'
|
|
3
2
|
import { createElement, SquareSlash, SquareTerminal } from 'lucide'
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
Application,
|
|
5
|
+
CanvasTextMetrics,
|
|
6
|
+
Container,
|
|
7
|
+
FederatedPointerEvent,
|
|
8
|
+
Graphics,
|
|
9
|
+
Text,
|
|
10
|
+
TextStyle,
|
|
11
|
+
} from 'pixi.js'
|
|
12
|
+
import { onThemeChange, resolvePixiTheme, type PixiTheme } from './pixi-theme.js'
|
|
6
13
|
import { detectHostPlatform, type PlatformMode } from './platform.js'
|
|
14
|
+
import {
|
|
15
|
+
buildShortcutPages,
|
|
16
|
+
type ShortcutAction,
|
|
17
|
+
type ShortcutItem,
|
|
18
|
+
type ShortcutPage,
|
|
19
|
+
} from './shortcut-pages.js'
|
|
7
20
|
|
|
8
21
|
const GRID_GAP = 8
|
|
9
22
|
const GRID_PADDING = 10
|
|
@@ -66,7 +79,10 @@ export class ShortcutTab extends LitElement {
|
|
|
66
79
|
justify-content: center;
|
|
67
80
|
padding: 0;
|
|
68
81
|
cursor: pointer;
|
|
69
|
-
transition:
|
|
82
|
+
transition:
|
|
83
|
+
border-color 0.15s,
|
|
84
|
+
color 0.15s,
|
|
85
|
+
background 0.15s;
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
.page-btn:hover {
|
|
@@ -247,9 +263,7 @@ export class ShortcutTab extends LitElement {
|
|
|
247
263
|
gfx.clear()
|
|
248
264
|
gfx.roundRect(0, 0, width, height, CARD_RADIUS)
|
|
249
265
|
gfx.fill({
|
|
250
|
-
color: pressed
|
|
251
|
-
? this._theme.keyPressed
|
|
252
|
-
: this._theme.keyNormal,
|
|
266
|
+
color: pressed ? this._theme.keyPressed : this._theme.keyNormal,
|
|
253
267
|
})
|
|
254
268
|
gfx.stroke({
|
|
255
269
|
color: pressed ? this._theme.accent : this._theme.surfaceBorder,
|
|
@@ -409,7 +423,7 @@ export class ShortcutTab extends LitElement {
|
|
|
409
423
|
detail: { data },
|
|
410
424
|
bubbles: true,
|
|
411
425
|
composed: true,
|
|
412
|
-
})
|
|
426
|
+
})
|
|
413
427
|
)
|
|
414
428
|
}
|
|
415
429
|
|
|
@@ -443,7 +457,12 @@ export class ShortcutTab extends LitElement {
|
|
|
443
457
|
document.execCommand('selectAll')
|
|
444
458
|
}
|
|
445
459
|
|
|
446
|
-
private _dpadData(
|
|
460
|
+
private _dpadData(
|
|
461
|
+
event: FederatedPointerEvent,
|
|
462
|
+
width: number,
|
|
463
|
+
height: number,
|
|
464
|
+
container: Container
|
|
465
|
+
): string {
|
|
447
466
|
const local = container.toLocal(event.global)
|
|
448
467
|
const dx = local.x - width / 2
|
|
449
468
|
const dy = local.y - height / 2
|
|
@@ -463,7 +482,7 @@ export class ShortcutTab extends LitElement {
|
|
|
463
482
|
event: FederatedPointerEvent,
|
|
464
483
|
width: number,
|
|
465
484
|
height: number,
|
|
466
|
-
container: Container
|
|
485
|
+
container: Container
|
|
467
486
|
) {
|
|
468
487
|
if (item.kind === 'dpad') {
|
|
469
488
|
this._send(this._dpadData(event, width, height, container))
|
|
@@ -518,19 +537,21 @@ export class ShortcutTab extends LitElement {
|
|
|
518
537
|
return html`
|
|
519
538
|
<div class="layout">
|
|
520
539
|
<div class="pages">
|
|
521
|
-
${pages.map(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
540
|
+
${pages.map(
|
|
541
|
+
(page) => html`
|
|
542
|
+
<button
|
|
543
|
+
type="button"
|
|
544
|
+
class="page-btn"
|
|
545
|
+
title=${page.title}
|
|
546
|
+
aria-label=${page.title}
|
|
547
|
+
?data-active=${page.id === activePage.id}
|
|
548
|
+
@click=${() => this._setActivePage(page.id)}
|
|
549
|
+
>
|
|
550
|
+
${this._renderPageIcon(page.id)}
|
|
551
|
+
<span class="sr-only">${page.title}</span>
|
|
552
|
+
</button>
|
|
553
|
+
`
|
|
554
|
+
)}
|
|
534
555
|
</div>
|
|
535
556
|
<div class="canvas-wrap">
|
|
536
557
|
<div class="pixi-host"></div>
|
|
@@ -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
|
@@ -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: [
|