xterm-input-panel 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +11 -0
- package/.storybook/preview.ts +15 -0
- package/.storybook/vitest.setup.ts +8 -0
- package/CHANGELOG.md +7 -0
- package/package.json +37 -0
- package/src/brand-icons/claude.png +0 -0
- package/src/brand-icons/codex.png +0 -0
- package/src/brand-icons/gemini.png +0 -0
- package/src/icons.ts +77 -0
- package/src/index.ts +16 -0
- package/src/input-method-tab.stories.ts +131 -0
- package/src/input-method-tab.ts +142 -0
- package/src/input-panel-settings.stories.ts +73 -0
- package/src/input-panel-settings.ts +245 -0
- package/src/input-panel.stories.ts +241 -0
- package/src/input-panel.ts +815 -0
- package/src/pixi-theme.test.ts +58 -0
- package/src/pixi-theme.ts +179 -0
- package/src/platform.ts +14 -0
- package/src/shortcut-pages.ts +204 -0
- package/src/shortcut-tab.ts +543 -0
- package/src/virtual-keyboard-layouts.ts +150 -0
- package/src/virtual-keyboard-tab.stories.ts +390 -0
- package/src/virtual-keyboard-tab.ts +642 -0
- package/src/virtual-trackpad-tab.stories.ts +476 -0
- package/src/virtual-trackpad-tab.ts +556 -0
- package/src/xterm-addon.stories.ts +413 -0
- package/src/xterm-addon.ts +947 -0
- package/tsconfig.json +8 -0
- package/vite.config.ts +13 -0
- package/vitest.storybook.config.ts +23 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import type { ITerminalAddon, Terminal } from '@xterm/xterm'
|
|
2
|
+
import { iconKeyboard, iconMousePointer2 } from './icons.js'
|
|
3
|
+
import type { InputPanelLayout } from './input-panel.js'
|
|
4
|
+
import type { HostPlatform } from './platform.js'
|
|
5
|
+
|
|
6
|
+
const SENSITIVITY = 1.5
|
|
7
|
+
const EDGE_SCROLL_ZONE = 30
|
|
8
|
+
const EDGE_SCROLL_INTERVAL = 50
|
|
9
|
+
const EDGE_SCROLL_OVERSHOOT = 15
|
|
10
|
+
|
|
11
|
+
function isTouchDevice(): boolean {
|
|
12
|
+
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface InputPanelHistoryItem {
|
|
16
|
+
text: string
|
|
17
|
+
time: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InputPanelSettingsPayload {
|
|
21
|
+
fixedHeight: number
|
|
22
|
+
floatingWidth: number
|
|
23
|
+
floatingHeight: number
|
|
24
|
+
vibrationIntensity: number
|
|
25
|
+
historyLimit: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* xterm.js addon that provides full InputPanel integration.
|
|
30
|
+
*
|
|
31
|
+
* **DOM Mounting**: All addon UI (panel, FAB, cursor) mounts into the
|
|
32
|
+
* terminal's own container (`terminal.element.parentElement`) by default.
|
|
33
|
+
* For multi-terminal layouts, set `InputPanelAddon.mountTarget` to a shared
|
|
34
|
+
* ancestor so the singleton panel and FAB live in one place.
|
|
35
|
+
*
|
|
36
|
+
* **Singleton**: Only one `<input-panel>` element exists at a time across all
|
|
37
|
+
* terminal instances. When a different terminal receives focus, the panel
|
|
38
|
+
* migrates automatically (previous addon closes, new one opens).
|
|
39
|
+
*
|
|
40
|
+
* **Native FAB**: On touch devices, a draggable floating action button is
|
|
41
|
+
* created automatically inside the mount target. No React needed.
|
|
42
|
+
*
|
|
43
|
+
* Usage:
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { InputPanelAddon } from 'xterm-input-panel'
|
|
46
|
+
*
|
|
47
|
+
* // Optional: shared container for multi-terminal layouts
|
|
48
|
+
* InputPanelAddon.mountTarget = document.getElementById('app')
|
|
49
|
+
*
|
|
50
|
+
* const addon = new InputPanelAddon({
|
|
51
|
+
* onInput: (data) => pty.write(data),
|
|
52
|
+
* })
|
|
53
|
+
* terminal.loadAddon(addon)
|
|
54
|
+
* // After terminal.open(container):
|
|
55
|
+
* addon.attachListeners()
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export class InputPanelAddon implements ITerminalAddon {
|
|
59
|
+
// ── Singleton state ──
|
|
60
|
+
|
|
61
|
+
/** The currently active (open) addon instance, or null. */
|
|
62
|
+
private static _active: InputPanelAddon | null = null
|
|
63
|
+
|
|
64
|
+
/** The most recently focused terminal's addon (FAB opens this). */
|
|
65
|
+
private static _lastFocused: InputPanelAddon | null = null
|
|
66
|
+
|
|
67
|
+
/** All alive addon instances (for FAB fallback when _lastFocused is null). */
|
|
68
|
+
private static _instances = new Set<InputPanelAddon>()
|
|
69
|
+
|
|
70
|
+
/** Global callback for singleton state changes. */
|
|
71
|
+
private static _onActiveChangeFn: ((addon: InputPanelAddon | null) => void) | null = null
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Shared mount target for multi-terminal scenarios.
|
|
75
|
+
* When set, both `<input-panel>` and FAB mount here instead of each
|
|
76
|
+
* terminal's individual container. Set this to a common ancestor element
|
|
77
|
+
* (e.g. the app shell or terminal panel wrapper).
|
|
78
|
+
*/
|
|
79
|
+
private static _mountTarget: HTMLElement | null = null
|
|
80
|
+
|
|
81
|
+
/** Get the currently active instance (the one with the open panel). */
|
|
82
|
+
static get activeInstance(): InputPanelAddon | null {
|
|
83
|
+
return InputPanelAddon._active
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Subscribe to singleton state changes (open/close/migration). */
|
|
87
|
+
static set onActiveChange(fn: ((addon: InputPanelAddon | null) => void) | null) {
|
|
88
|
+
InputPanelAddon._onActiveChangeFn = fn
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Set a shared mount target for all InputPanelAddon instances.
|
|
93
|
+
* The `<input-panel>` element and FAB will be appended here.
|
|
94
|
+
* If null (default), each addon mounts into its own terminal container.
|
|
95
|
+
*/
|
|
96
|
+
static set mountTarget(el: HTMLElement | null) {
|
|
97
|
+
InputPanelAddon._mountTarget = el
|
|
98
|
+
// Migrate existing FAB to the new target
|
|
99
|
+
if (InputPanelAddon._fabEl && el) {
|
|
100
|
+
el.appendChild(InputPanelAddon._fabEl)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static get mountTarget(): HTMLElement | null {
|
|
105
|
+
return InputPanelAddon._mountTarget
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Native FAB (static singleton) ──
|
|
109
|
+
|
|
110
|
+
private static _fabEl: HTMLButtonElement | null = null
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create the native FAB button and mount it into the given container.
|
|
114
|
+
* The FAB is a static singleton — created once, then moved between
|
|
115
|
+
* containers as terminals gain/lose focus.
|
|
116
|
+
*/
|
|
117
|
+
private static _ensureFab(mountTarget: HTMLElement): void {
|
|
118
|
+
if (InputPanelAddon._fabEl) {
|
|
119
|
+
// FAB already exists — just ensure it's in the right container
|
|
120
|
+
if (InputPanelAddon._fabEl.parentElement !== mountTarget) {
|
|
121
|
+
mountTarget.appendChild(InputPanelAddon._fabEl)
|
|
122
|
+
}
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isTouchDevice()) return
|
|
127
|
+
|
|
128
|
+
const btn = document.createElement('button')
|
|
129
|
+
btn.type = 'button'
|
|
130
|
+
btn.title = 'Open InputPanel'
|
|
131
|
+
btn.replaceChildren(iconKeyboard(24))
|
|
132
|
+
|
|
133
|
+
Object.assign(btn.style, {
|
|
134
|
+
position: 'fixed',
|
|
135
|
+
zIndex: '50',
|
|
136
|
+
width: '56px',
|
|
137
|
+
height: '56px',
|
|
138
|
+
borderRadius: '50%',
|
|
139
|
+
border: '2px solid currentColor',
|
|
140
|
+
display: 'flex',
|
|
141
|
+
alignItems: 'center',
|
|
142
|
+
justifyContent: 'center',
|
|
143
|
+
touchAction: 'none',
|
|
144
|
+
userSelect: 'none',
|
|
145
|
+
webkitUserSelect: 'none',
|
|
146
|
+
webkitTouchCallout: 'none',
|
|
147
|
+
cursor: 'pointer',
|
|
148
|
+
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
149
|
+
background: 'var(--primary, #e04a2f)',
|
|
150
|
+
color: 'var(--primary-foreground, #fff)',
|
|
151
|
+
padding: '0',
|
|
152
|
+
margin: '0',
|
|
153
|
+
outline: 'none',
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Load saved position
|
|
157
|
+
let posX = window.innerWidth - 72
|
|
158
|
+
let posY = window.innerHeight - 140
|
|
159
|
+
try {
|
|
160
|
+
const saved = localStorage.getItem('input-panel-fab-pos')
|
|
161
|
+
if (saved) {
|
|
162
|
+
const p = JSON.parse(saved)
|
|
163
|
+
posX = p.x ?? posX
|
|
164
|
+
posY = p.y ?? posY
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
posX = Math.max(0, Math.min(window.innerWidth - 56, posX))
|
|
170
|
+
posY = Math.max(0, Math.min(window.innerHeight - 56, posY))
|
|
171
|
+
btn.style.left = `${posX}px`
|
|
172
|
+
btn.style.top = `${posY}px`
|
|
173
|
+
|
|
174
|
+
// Drag state
|
|
175
|
+
let dragging = false
|
|
176
|
+
let wasDragged = false
|
|
177
|
+
let startX = 0
|
|
178
|
+
let startY = 0
|
|
179
|
+
let origX = 0
|
|
180
|
+
let origY = 0
|
|
181
|
+
|
|
182
|
+
const doOpen = () => {
|
|
183
|
+
const preferred = InputPanelAddon._lastFocused
|
|
184
|
+
const target =
|
|
185
|
+
(preferred && InputPanelAddon._instances.has(preferred) ? preferred : null) ??
|
|
186
|
+
[...InputPanelAddon._instances].find((instance) => instance._terminal != null) ??
|
|
187
|
+
null
|
|
188
|
+
|
|
189
|
+
if (target) {
|
|
190
|
+
InputPanelAddon._lastFocused = target
|
|
191
|
+
target.open()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
btn.addEventListener('pointerdown', (e) => {
|
|
196
|
+
dragging = true
|
|
197
|
+
wasDragged = false
|
|
198
|
+
startX = e.clientX
|
|
199
|
+
startY = e.clientY
|
|
200
|
+
origX = parseInt(btn.style.left) || 0
|
|
201
|
+
origY = parseInt(btn.style.top) || 0
|
|
202
|
+
btn.setPointerCapture(e.pointerId)
|
|
203
|
+
e.preventDefault()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
btn.addEventListener('pointermove', (e) => {
|
|
207
|
+
if (!dragging) return
|
|
208
|
+
const dx = e.clientX - startX
|
|
209
|
+
const dy = e.clientY - startY
|
|
210
|
+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) wasDragged = true
|
|
211
|
+
const newX = Math.max(0, Math.min(window.innerWidth - 56, origX + dx))
|
|
212
|
+
const newY = Math.max(0, Math.min(window.innerHeight - 56, origY + dy))
|
|
213
|
+
btn.style.left = `${newX}px`
|
|
214
|
+
btn.style.top = `${newY}px`
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
btn.addEventListener('pointerup', () => {
|
|
218
|
+
if (!dragging) return
|
|
219
|
+
dragging = false
|
|
220
|
+
try {
|
|
221
|
+
localStorage.setItem(
|
|
222
|
+
'input-panel-fab-pos',
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
x: parseInt(btn.style.left),
|
|
225
|
+
y: parseInt(btn.style.top),
|
|
226
|
+
})
|
|
227
|
+
)
|
|
228
|
+
} catch {
|
|
229
|
+
/* ignore */
|
|
230
|
+
}
|
|
231
|
+
if (!wasDragged) doOpen()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
btn.addEventListener('pointercancel', () => {
|
|
235
|
+
dragging = false
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Click fallback for environments where pointer events are unreliable
|
|
239
|
+
let pointerDownFired = false
|
|
240
|
+
btn.addEventListener(
|
|
241
|
+
'pointerdown',
|
|
242
|
+
() => {
|
|
243
|
+
pointerDownFired = true
|
|
244
|
+
},
|
|
245
|
+
{ capture: true }
|
|
246
|
+
)
|
|
247
|
+
btn.addEventListener('click', () => {
|
|
248
|
+
if (pointerDownFired) {
|
|
249
|
+
pointerDownFired = false
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
doOpen()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
btn.addEventListener('contextmenu', (e) => {
|
|
256
|
+
e.preventDefault()
|
|
257
|
+
e.stopPropagation()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
btn.addEventListener('dragstart', (e) => {
|
|
261
|
+
e.preventDefault()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// Keep in bounds on resize
|
|
265
|
+
window.addEventListener('resize', () => {
|
|
266
|
+
const x = Math.min(parseInt(btn.style.left) || 0, window.innerWidth - 56)
|
|
267
|
+
const y = Math.min(parseInt(btn.style.top) || 0, window.innerHeight - 56)
|
|
268
|
+
btn.style.left = `${Math.max(0, x)}px`
|
|
269
|
+
btn.style.top = `${Math.max(0, y)}px`
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
mountTarget.appendChild(btn)
|
|
273
|
+
InputPanelAddon._fabEl = btn
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private static _setFabVisible(visible: boolean): void {
|
|
277
|
+
if (InputPanelAddon._fabEl) {
|
|
278
|
+
InputPanelAddon._fabEl.style.display = visible ? 'flex' : 'none'
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Instance state ──
|
|
283
|
+
|
|
284
|
+
private _terminal: Terminal | null = null
|
|
285
|
+
private _panel: HTMLElement | null = null
|
|
286
|
+
private _cursorEl: HTMLElement | null = null
|
|
287
|
+
private _cursorPos = { x: 0, y: 0 }
|
|
288
|
+
private _isDragging = false
|
|
289
|
+
private _isOpen = false
|
|
290
|
+
private _edgeScrollTimer: ReturnType<typeof setInterval> | null = null
|
|
291
|
+
private _cleanups: Array<() => void> = []
|
|
292
|
+
private _persistentCleanups: Array<() => void> = []
|
|
293
|
+
private _listenersAttached = false
|
|
294
|
+
|
|
295
|
+
private _onInput: (data: string) => void
|
|
296
|
+
private _onOpenCb: (() => void) | null
|
|
297
|
+
private _onCloseCb: (() => void) | null
|
|
298
|
+
private _getHistory: (() => Promise<readonly InputPanelHistoryItem[]>) | null
|
|
299
|
+
private _addHistory: ((text: string) => Promise<void> | void) | null
|
|
300
|
+
private _subscribeHistory:
|
|
301
|
+
| ((listener: (items: readonly InputPanelHistoryItem[]) => void) => () => void)
|
|
302
|
+
| null
|
|
303
|
+
private _onSettingsChange: ((settings: InputPanelSettingsPayload) => Promise<void> | void) | null
|
|
304
|
+
private _platform: HostPlatform
|
|
305
|
+
private _defaultLayout: InputPanelLayout
|
|
306
|
+
|
|
307
|
+
constructor(opts?: {
|
|
308
|
+
onInput?: (data: string) => void
|
|
309
|
+
onOpen?: () => void
|
|
310
|
+
onClose?: () => void
|
|
311
|
+
getHistory?: () => Promise<readonly InputPanelHistoryItem[]>
|
|
312
|
+
addHistory?: (text: string) => Promise<void> | void
|
|
313
|
+
subscribeHistory?: (listener: (items: readonly InputPanelHistoryItem[]) => void) => () => void
|
|
314
|
+
onSettingsChange?: (settings: InputPanelSettingsPayload) => Promise<void> | void
|
|
315
|
+
platform?: HostPlatform
|
|
316
|
+
defaultLayout?: InputPanelLayout
|
|
317
|
+
}) {
|
|
318
|
+
this._onInput = opts?.onInput ?? (() => {})
|
|
319
|
+
this._onOpenCb = opts?.onOpen ?? null
|
|
320
|
+
this._onCloseCb = opts?.onClose ?? null
|
|
321
|
+
this._getHistory = opts?.getHistory ?? null
|
|
322
|
+
this._addHistory = opts?.addHistory ?? null
|
|
323
|
+
this._subscribeHistory = opts?.subscribeHistory ?? null
|
|
324
|
+
this._onSettingsChange = opts?.onSettingsChange ?? null
|
|
325
|
+
this._platform = opts?.platform ?? 'common'
|
|
326
|
+
this._defaultLayout = opts?.defaultLayout ?? 'floating'
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
get isOpen(): boolean {
|
|
330
|
+
return this._isOpen
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Allow changing callbacks after construction. */
|
|
334
|
+
set onOpen(fn: (() => void) | null) {
|
|
335
|
+
this._onOpenCb = fn
|
|
336
|
+
}
|
|
337
|
+
set onClose(fn: (() => void) | null) {
|
|
338
|
+
this._onCloseCb = fn
|
|
339
|
+
}
|
|
340
|
+
set onInput(fn: (data: string) => void) {
|
|
341
|
+
this._onInput = fn
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
setPlatform(platform: HostPlatform): void {
|
|
345
|
+
this._platform = platform
|
|
346
|
+
this._applyPlatformToPanel()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
setDefaultLayout(layout: InputPanelLayout): void {
|
|
350
|
+
this._defaultLayout = layout
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Resolve the mount target for this addon instance.
|
|
355
|
+
* Priority: static mountTarget > terminal container > document.body
|
|
356
|
+
*/
|
|
357
|
+
private _getMountTarget(): HTMLElement {
|
|
358
|
+
return InputPanelAddon._mountTarget ?? this._terminal?.element?.parentElement ?? document.body
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
activate(terminal: Terminal): void {
|
|
362
|
+
this._terminal = terminal
|
|
363
|
+
InputPanelAddon._instances.add(this)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
dispose(): void {
|
|
367
|
+
this.close()
|
|
368
|
+
for (const fn of this._persistentCleanups) fn()
|
|
369
|
+
this._persistentCleanups = []
|
|
370
|
+
this._listenersAttached = false
|
|
371
|
+
InputPanelAddon._instances.delete(this)
|
|
372
|
+
if (InputPanelAddon._lastFocused === this) {
|
|
373
|
+
InputPanelAddon._lastFocused = null
|
|
374
|
+
}
|
|
375
|
+
this._terminal = null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── Public API ──
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Set up persistent listeners for auto-open behavior.
|
|
382
|
+
* Must be called after terminal.open(container) so that DOM elements exist.
|
|
383
|
+
*
|
|
384
|
+
* On touch devices:
|
|
385
|
+
* - Permanently sets inputmode='none' to suppress native keyboard
|
|
386
|
+
* - Creates the native FAB inside the mount target
|
|
387
|
+
* - textarea focus → opens InputPanel (migrates from other terminal if needed)
|
|
388
|
+
*/
|
|
389
|
+
attachListeners(): void {
|
|
390
|
+
if (this._listenersAttached || !this._terminal) return
|
|
391
|
+
const textarea = this._terminal.textarea
|
|
392
|
+
if (!textarea) return
|
|
393
|
+
|
|
394
|
+
this._listenersAttached = true
|
|
395
|
+
|
|
396
|
+
if (!isTouchDevice()) return
|
|
397
|
+
|
|
398
|
+
// Permanently suppress native keyboard on touch devices
|
|
399
|
+
textarea.setAttribute('inputmode', 'none')
|
|
400
|
+
|
|
401
|
+
// Ensure native FAB exists in the correct mount target
|
|
402
|
+
InputPanelAddon._ensureFab(this._getMountTarget())
|
|
403
|
+
|
|
404
|
+
// Default FAB target to the first terminal that attaches listeners
|
|
405
|
+
if (!InputPanelAddon._lastFocused) {
|
|
406
|
+
InputPanelAddon._lastFocused = this
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Track last focused terminal for FAB
|
|
410
|
+
// textarea focus → open InputPanel (migration via singleton)
|
|
411
|
+
const onFocus = () => {
|
|
412
|
+
InputPanelAddon._lastFocused = this
|
|
413
|
+
if (!this._isOpen) this.open()
|
|
414
|
+
}
|
|
415
|
+
textarea.addEventListener('focus', onFocus)
|
|
416
|
+
this._persistentCleanups.push(() => textarea.removeEventListener('focus', onFocus))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
open(): void {
|
|
420
|
+
if (this._isOpen || !this._terminal) return
|
|
421
|
+
|
|
422
|
+
// Singleton: close any other active instance (migration)
|
|
423
|
+
if (InputPanelAddon._active && InputPanelAddon._active !== this) {
|
|
424
|
+
InputPanelAddon._active.close()
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
this._isOpen = true
|
|
428
|
+
InputPanelAddon._active = this
|
|
429
|
+
InputPanelAddon._lastFocused = this
|
|
430
|
+
|
|
431
|
+
// Hide FAB while panel is open
|
|
432
|
+
InputPanelAddon._setFabVisible(false)
|
|
433
|
+
|
|
434
|
+
this._suppressKeyboard()
|
|
435
|
+
|
|
436
|
+
// Build the element tree
|
|
437
|
+
const panel = document.createElement('input-panel')
|
|
438
|
+
panel.setAttribute('layout', this._defaultLayout)
|
|
439
|
+
this._applyPanelThemeBindings(panel)
|
|
440
|
+
|
|
441
|
+
const inputTab = document.createElement('input-method-tab')
|
|
442
|
+
inputTab.setAttribute('slot', 'input')
|
|
443
|
+
panel.appendChild(inputTab)
|
|
444
|
+
|
|
445
|
+
const keysTab = document.createElement('virtual-keyboard-tab')
|
|
446
|
+
keysTab.setAttribute('slot', 'keys')
|
|
447
|
+
keysTab.setAttribute('floating', '')
|
|
448
|
+
keysTab.setAttribute('platform', this._platform)
|
|
449
|
+
panel.appendChild(keysTab)
|
|
450
|
+
|
|
451
|
+
const shortcutsTab = document.createElement('shortcut-tab')
|
|
452
|
+
shortcutsTab.setAttribute('slot', 'shortcuts')
|
|
453
|
+
shortcutsTab.setAttribute('platform', this._platform)
|
|
454
|
+
panel.appendChild(shortcutsTab)
|
|
455
|
+
|
|
456
|
+
const trackpadTab = document.createElement('virtual-trackpad-tab')
|
|
457
|
+
trackpadTab.setAttribute('slot', 'trackpad')
|
|
458
|
+
trackpadTab.setAttribute('floating', '')
|
|
459
|
+
panel.appendChild(trackpadTab)
|
|
460
|
+
|
|
461
|
+
this._panel = panel
|
|
462
|
+
|
|
463
|
+
const renderHistory = (items: readonly InputPanelHistoryItem[]) => {
|
|
464
|
+
this._renderHistory(inputTab, items)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (this._getHistory) {
|
|
468
|
+
void this._getHistory()
|
|
469
|
+
.then((items) => renderHistory(items))
|
|
470
|
+
.catch(() => {})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (this._subscribeHistory) {
|
|
474
|
+
try {
|
|
475
|
+
const unsubscribe = this._subscribeHistory((items) => {
|
|
476
|
+
renderHistory(items)
|
|
477
|
+
})
|
|
478
|
+
this._cleanups.push(unsubscribe)
|
|
479
|
+
} catch {
|
|
480
|
+
// ignore history subscription failures
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Wire panel events
|
|
485
|
+
this._on(panel, 'input-panel:close', () => this.close())
|
|
486
|
+
this._on(panel, 'input-panel:send', (e) => {
|
|
487
|
+
const data = (e as CustomEvent).detail?.data
|
|
488
|
+
if (data) this._onInput(data)
|
|
489
|
+
|
|
490
|
+
const source = e.composedPath()[0]
|
|
491
|
+
if (source === inputTab && this._addHistory && data) {
|
|
492
|
+
const normalized = this._normalizeHistoryText(data)
|
|
493
|
+
if (normalized) {
|
|
494
|
+
Promise.resolve(this._addHistory(normalized))
|
|
495
|
+
.then(() => this._getHistory?.())
|
|
496
|
+
.then((items) => {
|
|
497
|
+
if (items) renderHistory(items)
|
|
498
|
+
})
|
|
499
|
+
.catch(() => {})
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
this._on(panel, 'input-panel:tab-change', (e) => {
|
|
504
|
+
const tab = (e as CustomEvent).detail?.tab
|
|
505
|
+
if (tab === 'trackpad') this._showCursor()
|
|
506
|
+
else this._hideCursor()
|
|
507
|
+
})
|
|
508
|
+
this._on(panel, 'input-panel:settings-change', (e) => {
|
|
509
|
+
if (!this._onSettingsChange) return
|
|
510
|
+
const detail = (e as CustomEvent).detail
|
|
511
|
+
if (typeof detail !== 'object' || detail == null) return
|
|
512
|
+
const payload = detail as Partial<InputPanelSettingsPayload>
|
|
513
|
+
if (
|
|
514
|
+
typeof payload.fixedHeight !== 'number' ||
|
|
515
|
+
typeof payload.floatingWidth !== 'number' ||
|
|
516
|
+
typeof payload.floatingHeight !== 'number' ||
|
|
517
|
+
typeof payload.vibrationIntensity !== 'number' ||
|
|
518
|
+
typeof payload.historyLimit !== 'number'
|
|
519
|
+
) {
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
void this._onSettingsChange({
|
|
524
|
+
fixedHeight: payload.fixedHeight,
|
|
525
|
+
floatingWidth: payload.floatingWidth,
|
|
526
|
+
floatingHeight: payload.floatingHeight,
|
|
527
|
+
vibrationIntensity: payload.vibrationIntensity,
|
|
528
|
+
historyLimit: payload.historyLimit,
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// Wire trackpad gesture events
|
|
533
|
+
this._on(panel, 'trackpad:move', (e) => {
|
|
534
|
+
const { dx, dy } = (e as CustomEvent).detail
|
|
535
|
+
this._moveCursor(dx, dy)
|
|
536
|
+
})
|
|
537
|
+
this._on(panel, 'trackpad:tap', () => this._dispatchClick(1))
|
|
538
|
+
this._on(panel, 'trackpad:double-tap', () => this._dispatchDblClick())
|
|
539
|
+
this._on(panel, 'trackpad:long-press', () => this._dispatchRightClick())
|
|
540
|
+
this._on(panel, 'trackpad:two-finger-tap', () => this._dispatchRightClick())
|
|
541
|
+
this._on(panel, 'trackpad:drag-start', () => {
|
|
542
|
+
this._isDragging = true
|
|
543
|
+
this._dispatchMouse('mousedown', { detail: 1 })
|
|
544
|
+
})
|
|
545
|
+
this._on(panel, 'trackpad:drag-move', (e) => {
|
|
546
|
+
const { dx, dy } = (e as CustomEvent).detail
|
|
547
|
+
this._moveCursor(dx, dy)
|
|
548
|
+
this._dispatchMouse('mousemove', { buttons: 1 })
|
|
549
|
+
this._updateEdgeScroll()
|
|
550
|
+
})
|
|
551
|
+
this._on(panel, 'trackpad:drag-end', () => {
|
|
552
|
+
this._isDragging = false
|
|
553
|
+
this._stopEdgeScroll()
|
|
554
|
+
this._dispatchMouse('mouseup', { detail: 1 })
|
|
555
|
+
})
|
|
556
|
+
this._on(panel, 'trackpad:scroll', (e) => {
|
|
557
|
+
const { deltaY } = (e as CustomEvent).detail
|
|
558
|
+
this._dispatchWheel(deltaY)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Mount panel into the terminal's container (or shared mount target)
|
|
562
|
+
this._getMountTarget().appendChild(panel)
|
|
563
|
+
|
|
564
|
+
// Create cursor overlay inside terminal container
|
|
565
|
+
this._createCursor()
|
|
566
|
+
|
|
567
|
+
// Focus terminal textarea to show blinking cursor
|
|
568
|
+
this._focusTerminal()
|
|
569
|
+
|
|
570
|
+
this._onOpenCb?.()
|
|
571
|
+
InputPanelAddon._onActiveChangeFn?.(this)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private _applyPlatformToPanel(): void {
|
|
575
|
+
if (!this._panel) return
|
|
576
|
+
const keysTab = this._panel.querySelector('virtual-keyboard-tab')
|
|
577
|
+
const shortcutsTab = this._panel.querySelector('shortcut-tab')
|
|
578
|
+
keysTab?.setAttribute('platform', this._platform)
|
|
579
|
+
shortcutsTab?.setAttribute('platform', this._platform)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private _readThemeVar(
|
|
583
|
+
style: CSSStyleDeclaration,
|
|
584
|
+
names: readonly string[],
|
|
585
|
+
fallback: string
|
|
586
|
+
): string {
|
|
587
|
+
for (const name of names) {
|
|
588
|
+
const value = style.getPropertyValue(name).trim()
|
|
589
|
+
if (value) return value
|
|
590
|
+
}
|
|
591
|
+
return fallback
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private _applyPanelThemeBindings(panel: HTMLElement): void {
|
|
595
|
+
const scope = this._terminal?.element?.parentElement ?? this._getMountTarget()
|
|
596
|
+
const style = getComputedStyle(scope)
|
|
597
|
+
|
|
598
|
+
const background = this._readThemeVar(
|
|
599
|
+
style,
|
|
600
|
+
['--input-panel-background', '--terminal', '--background'],
|
|
601
|
+
'#1a1a1a'
|
|
602
|
+
)
|
|
603
|
+
const foreground = this._readThemeVar(
|
|
604
|
+
style,
|
|
605
|
+
['--input-panel-foreground', '--terminal-foreground', '--foreground'],
|
|
606
|
+
'#ffffff'
|
|
607
|
+
)
|
|
608
|
+
const primary = this._readThemeVar(style, ['--input-panel-primary', '--primary'], '#e04a2f')
|
|
609
|
+
const primaryForeground = this._readThemeVar(
|
|
610
|
+
style,
|
|
611
|
+
['--input-panel-primary-foreground', '--primary-foreground'],
|
|
612
|
+
'#ffffff'
|
|
613
|
+
)
|
|
614
|
+
const border = this._readThemeVar(
|
|
615
|
+
style,
|
|
616
|
+
['--input-panel-border', '--border'],
|
|
617
|
+
`color-mix(in srgb, ${foreground} 24%, transparent)`
|
|
618
|
+
)
|
|
619
|
+
const muted = this._readThemeVar(
|
|
620
|
+
style,
|
|
621
|
+
['--input-panel-muted', '--muted'],
|
|
622
|
+
`color-mix(in srgb, ${background} 86%, ${foreground} 14%)`
|
|
623
|
+
)
|
|
624
|
+
const mutedForeground = this._readThemeVar(
|
|
625
|
+
style,
|
|
626
|
+
['--input-panel-muted-foreground', '--muted-foreground'],
|
|
627
|
+
`color-mix(in srgb, ${foreground} 62%, transparent)`
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
panel.style.setProperty('--input-panel-background', background)
|
|
631
|
+
panel.style.setProperty('--input-panel-foreground', foreground)
|
|
632
|
+
panel.style.setProperty('--input-panel-primary', primary)
|
|
633
|
+
panel.style.setProperty('--input-panel-primary-foreground', primaryForeground)
|
|
634
|
+
panel.style.setProperty('--input-panel-border', border)
|
|
635
|
+
panel.style.setProperty('--input-panel-muted', muted)
|
|
636
|
+
panel.style.setProperty('--input-panel-muted-foreground', mutedForeground)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
close(): void {
|
|
640
|
+
if (!this._isOpen) return
|
|
641
|
+
this._isOpen = false
|
|
642
|
+
|
|
643
|
+
if (InputPanelAddon._active === this) {
|
|
644
|
+
InputPanelAddon._active = null
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
this._stopEdgeScroll()
|
|
648
|
+
for (const fn of this._cleanups) fn()
|
|
649
|
+
this._cleanups = []
|
|
650
|
+
|
|
651
|
+
this._panel?.remove()
|
|
652
|
+
this._panel = null
|
|
653
|
+
|
|
654
|
+
this._cursorEl?.remove()
|
|
655
|
+
this._cursorEl = null
|
|
656
|
+
|
|
657
|
+
// On touch devices, keep inputmode='none' permanently
|
|
658
|
+
if (!this._listenersAttached) {
|
|
659
|
+
this._restoreKeyboard()
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Show FAB again
|
|
663
|
+
InputPanelAddon._setFabVisible(true)
|
|
664
|
+
|
|
665
|
+
this._onCloseCb?.()
|
|
666
|
+
InputPanelAddon._onActiveChangeFn?.(null)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
toggle(): void {
|
|
670
|
+
this._isOpen ? this.close() : this.open()
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── Terminal focus ──
|
|
674
|
+
|
|
675
|
+
private _focusTerminal(): void {
|
|
676
|
+
const textarea = this._terminal?.textarea
|
|
677
|
+
if (!textarea) return
|
|
678
|
+
textarea.setAttribute('inputmode', 'none')
|
|
679
|
+
textarea.focus()
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Cursor overlay ──
|
|
683
|
+
|
|
684
|
+
private _createCursor(): void {
|
|
685
|
+
const container = this._terminal?.element?.parentElement
|
|
686
|
+
if (!container) return
|
|
687
|
+
|
|
688
|
+
const el = document.createElement('div')
|
|
689
|
+
el.style.cssText =
|
|
690
|
+
'position:absolute;z-index:10;pointer-events:none;opacity:0;transition:opacity 0.15s;color:#fff;'
|
|
691
|
+
const pointer = iconMousePointer2(20)
|
|
692
|
+
pointer.style.filter = 'drop-shadow(0 0 1px rgba(0,0,0,0.9))'
|
|
693
|
+
el.replaceChildren(pointer)
|
|
694
|
+
container.appendChild(el)
|
|
695
|
+
this._cursorEl = el
|
|
696
|
+
|
|
697
|
+
const rect = container.getBoundingClientRect()
|
|
698
|
+
this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
|
|
699
|
+
this._positionCursor()
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private _positionCursor(): void {
|
|
703
|
+
if (!this._cursorEl) return
|
|
704
|
+
this._cursorEl.style.left = `${this._cursorPos.x - 4}px`
|
|
705
|
+
this._cursorEl.style.top = `${this._cursorPos.y - 2}px`
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private _showCursor(): void {
|
|
709
|
+
if (!this._cursorEl) this._createCursor()
|
|
710
|
+
if (this._cursorEl) {
|
|
711
|
+
const container = this._terminal?.element?.parentElement
|
|
712
|
+
if (container) {
|
|
713
|
+
const rect = container.getBoundingClientRect()
|
|
714
|
+
this._cursorPos = { x: rect.width / 2, y: rect.height / 2 }
|
|
715
|
+
this._positionCursor()
|
|
716
|
+
}
|
|
717
|
+
this._cursorEl.style.opacity = '1'
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private _hideCursor(): void {
|
|
722
|
+
if (this._cursorEl) this._cursorEl.style.opacity = '0.3'
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private _moveCursor(dx: number, dy: number): void {
|
|
726
|
+
const container = this._terminal?.element?.parentElement
|
|
727
|
+
if (!container) return
|
|
728
|
+
const rect = container.getBoundingClientRect()
|
|
729
|
+
this._cursorPos.x = Math.max(0, Math.min(rect.width, this._cursorPos.x + dx * SENSITIVITY))
|
|
730
|
+
this._cursorPos.y = Math.max(0, Math.min(rect.height, this._cursorPos.y + dy * SENSITIVITY))
|
|
731
|
+
this._positionCursor()
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── Mouse event dispatch ──
|
|
735
|
+
|
|
736
|
+
private _getClientCoords(): { clientX: number; clientY: number } | null {
|
|
737
|
+
const container = this._terminal?.element?.parentElement
|
|
738
|
+
if (!container) return null
|
|
739
|
+
const rect = container.getBoundingClientRect()
|
|
740
|
+
return {
|
|
741
|
+
clientX: rect.left + this._cursorPos.x,
|
|
742
|
+
clientY: rect.top + this._cursorPos.y,
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private _resolveTarget(clientX: number, clientY: number): Element {
|
|
747
|
+
const container = this._terminal?.element?.parentElement
|
|
748
|
+
if (!container) return document.body
|
|
749
|
+
const el = document.elementFromPoint(clientX, clientY)
|
|
750
|
+
if (el && container.contains(el)) return el
|
|
751
|
+
return container.querySelector('.xterm-screen') ?? container
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private _dispatchMouse(
|
|
755
|
+
type: string,
|
|
756
|
+
opts: { button?: number; detail?: number; buttons?: number } = {}
|
|
757
|
+
): void {
|
|
758
|
+
const coords = this._getClientCoords()
|
|
759
|
+
if (!coords) return
|
|
760
|
+
const button = opts.button ?? 0
|
|
761
|
+
const detail = opts.detail ?? 1
|
|
762
|
+
const buttons =
|
|
763
|
+
opts.buttons ?? (type === 'mousedown' ? (button === 0 ? 1 : button === 2 ? 2 : 4) : 0)
|
|
764
|
+
const target = this._resolveTarget(coords.clientX, coords.clientY)
|
|
765
|
+
target.dispatchEvent(
|
|
766
|
+
new MouseEvent(type, {
|
|
767
|
+
bubbles: true,
|
|
768
|
+
cancelable: true,
|
|
769
|
+
view: window,
|
|
770
|
+
detail,
|
|
771
|
+
clientX: coords.clientX,
|
|
772
|
+
clientY: coords.clientY,
|
|
773
|
+
button,
|
|
774
|
+
buttons,
|
|
775
|
+
})
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private _dispatchClick(detail: number): void {
|
|
780
|
+
this._dispatchMouse('mousedown', { detail })
|
|
781
|
+
this._dispatchMouse('mouseup', { detail })
|
|
782
|
+
this._dispatchMouse('click', { detail })
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private _dispatchDblClick(): void {
|
|
786
|
+
this._dispatchMouse('mousedown', { detail: 2 })
|
|
787
|
+
this._dispatchMouse('mouseup', { detail: 2 })
|
|
788
|
+
this._dispatchMouse('click', { detail: 2 })
|
|
789
|
+
this._dispatchMouse('dblclick', { detail: 2 })
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private _dispatchRightClick(): void {
|
|
793
|
+
this._dispatchMouse('mousedown', { button: 2 })
|
|
794
|
+
this._dispatchMouse('mouseup', { button: 2 })
|
|
795
|
+
this._dispatchMouse('contextmenu', { button: 2 })
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private _dispatchWheel(deltaY: number): void {
|
|
799
|
+
const coords = this._getClientCoords()
|
|
800
|
+
if (!coords) return
|
|
801
|
+
const target = this._resolveTarget(coords.clientX, coords.clientY)
|
|
802
|
+
target.dispatchEvent(
|
|
803
|
+
new WheelEvent('wheel', {
|
|
804
|
+
bubbles: true,
|
|
805
|
+
cancelable: true,
|
|
806
|
+
view: window,
|
|
807
|
+
clientX: coords.clientX,
|
|
808
|
+
clientY: coords.clientY,
|
|
809
|
+
deltaY,
|
|
810
|
+
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
|
811
|
+
})
|
|
812
|
+
)
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── Edge scroll (during drag selection) ──
|
|
816
|
+
|
|
817
|
+
private _updateEdgeScroll(): void {
|
|
818
|
+
const container = this._terminal?.element?.parentElement
|
|
819
|
+
if (!container || !this._isDragging) {
|
|
820
|
+
this._stopEdgeScroll()
|
|
821
|
+
return
|
|
822
|
+
}
|
|
823
|
+
const rect = container.getBoundingClientRect()
|
|
824
|
+
const nearTop = this._cursorPos.y < EDGE_SCROLL_ZONE
|
|
825
|
+
const nearBottom = this._cursorPos.y > rect.height - EDGE_SCROLL_ZONE
|
|
826
|
+
if (!nearTop && !nearBottom) {
|
|
827
|
+
this._stopEdgeScroll()
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
this._stopEdgeScroll()
|
|
832
|
+
this._edgeScrollTimer = setInterval(() => {
|
|
833
|
+
const container = this._terminal?.element?.parentElement
|
|
834
|
+
if (!container || !this._isDragging) {
|
|
835
|
+
this._stopEdgeScroll()
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
const rect = container.getBoundingClientRect()
|
|
839
|
+
const clientX = rect.left + this._cursorPos.x
|
|
840
|
+
const clientY =
|
|
841
|
+
this._cursorPos.y < EDGE_SCROLL_ZONE
|
|
842
|
+
? rect.top - EDGE_SCROLL_OVERSHOOT
|
|
843
|
+
: rect.bottom + EDGE_SCROLL_OVERSHOOT
|
|
844
|
+
const target = this._resolveTarget(
|
|
845
|
+
rect.left + this._cursorPos.x,
|
|
846
|
+
rect.top + this._cursorPos.y
|
|
847
|
+
)
|
|
848
|
+
target.dispatchEvent(
|
|
849
|
+
new MouseEvent('mousemove', {
|
|
850
|
+
bubbles: true,
|
|
851
|
+
cancelable: true,
|
|
852
|
+
view: window,
|
|
853
|
+
clientX,
|
|
854
|
+
clientY,
|
|
855
|
+
button: 0,
|
|
856
|
+
buttons: 1,
|
|
857
|
+
})
|
|
858
|
+
)
|
|
859
|
+
}, EDGE_SCROLL_INTERVAL)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private _stopEdgeScroll(): void {
|
|
863
|
+
if (this._edgeScrollTimer) {
|
|
864
|
+
clearInterval(this._edgeScrollTimer)
|
|
865
|
+
this._edgeScrollTimer = null
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// ── Keyboard suppression ──
|
|
870
|
+
|
|
871
|
+
private _suppressKeyboard(): void {
|
|
872
|
+
const textarea = this._terminal?.textarea
|
|
873
|
+
if (textarea) textarea.setAttribute('inputmode', 'none')
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private _restoreKeyboard(): void {
|
|
877
|
+
const textarea = this._terminal?.textarea
|
|
878
|
+
if (textarea) textarea.removeAttribute('inputmode')
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── Event helper ──
|
|
882
|
+
|
|
883
|
+
private _on(target: EventTarget, event: string, handler: EventListener): void {
|
|
884
|
+
target.addEventListener(event, handler)
|
|
885
|
+
this._cleanups.push(() => target.removeEventListener(event, handler))
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private _normalizeHistoryText(raw: string): string | null {
|
|
889
|
+
const text = raw.replace(/[\r\n]+$/u, '').trim()
|
|
890
|
+
return text ? text : null
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private _renderHistory(inputTab: HTMLElement, items: readonly InputPanelHistoryItem[]): void {
|
|
894
|
+
inputTab.querySelectorAll('[data-input-history-root="true"]').forEach((node) => node.remove())
|
|
895
|
+
if (items.length === 0) return
|
|
896
|
+
|
|
897
|
+
const root = document.createElement('div')
|
|
898
|
+
root.dataset.inputHistoryRoot = 'true'
|
|
899
|
+
root.setAttribute('slot', 'history')
|
|
900
|
+
root.style.display = 'flex'
|
|
901
|
+
root.style.flexDirection = 'column'
|
|
902
|
+
root.style.gap = '4px'
|
|
903
|
+
root.style.padding = '4px 0'
|
|
904
|
+
|
|
905
|
+
for (const item of items) {
|
|
906
|
+
const button = document.createElement('button')
|
|
907
|
+
button.type = 'button'
|
|
908
|
+
button.title = item.text
|
|
909
|
+
button.style.display = 'flex'
|
|
910
|
+
button.style.alignItems = 'center'
|
|
911
|
+
button.style.gap = '8px'
|
|
912
|
+
button.style.width = '100%'
|
|
913
|
+
button.style.padding = '4px 6px'
|
|
914
|
+
button.style.border = '1px solid transparent'
|
|
915
|
+
button.style.borderRadius = '4px'
|
|
916
|
+
button.style.background = 'transparent'
|
|
917
|
+
button.style.color = 'var(--muted-foreground, #888)'
|
|
918
|
+
button.style.fontFamily = 'inherit'
|
|
919
|
+
button.style.fontSize = '12px'
|
|
920
|
+
button.style.textAlign = 'left'
|
|
921
|
+
|
|
922
|
+
const time = document.createElement('span')
|
|
923
|
+
time.textContent = new Date(item.time).toLocaleTimeString([], {
|
|
924
|
+
hour: '2-digit',
|
|
925
|
+
minute: '2-digit',
|
|
926
|
+
})
|
|
927
|
+
time.style.opacity = '0.65'
|
|
928
|
+
time.style.fontSize = '10px'
|
|
929
|
+
time.style.flexShrink = '0'
|
|
930
|
+
|
|
931
|
+
const content = document.createElement('span')
|
|
932
|
+
content.textContent = item.text
|
|
933
|
+
content.style.overflow = 'hidden'
|
|
934
|
+
content.style.textOverflow = 'ellipsis'
|
|
935
|
+
content.style.whiteSpace = 'nowrap'
|
|
936
|
+
|
|
937
|
+
button.appendChild(time)
|
|
938
|
+
button.appendChild(content)
|
|
939
|
+
button.addEventListener('click', () => {
|
|
940
|
+
this._onInput(`${item.text}\n`)
|
|
941
|
+
})
|
|
942
|
+
root.appendChild(button)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
inputTab.appendChild(root)
|
|
946
|
+
}
|
|
947
|
+
}
|