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,556 @@
|
|
|
1
|
+
import { LitElement, css, html } from 'lit'
|
|
2
|
+
import { Application, Container, Graphics, Text, TextStyle } from 'pixi.js'
|
|
3
|
+
import { onThemeChange, resolvePixiTheme, type PixiTheme } from './pixi-theme.js'
|
|
4
|
+
|
|
5
|
+
// Gesture thresholds
|
|
6
|
+
const DRAG_THRESHOLD = 8
|
|
7
|
+
const LONG_PRESS_MS = 500
|
|
8
|
+
const DOUBLE_TAP_MS = 300
|
|
9
|
+
const SCROLL_STEP = 20
|
|
10
|
+
|
|
11
|
+
// Edge zone infinite sliding
|
|
12
|
+
const EDGE_TICK_MS = 16 // ~60fps emission rate during infinite slide
|
|
13
|
+
const EDGE_MIN_SPEED = 1 // px/tick at zone boundary (depth=0)
|
|
14
|
+
const EDGE_MAX_SPEED = 12 // px/tick at actual edge (depth=1)
|
|
15
|
+
|
|
16
|
+
// Edge zone adaptive sizing: clamp(minPx, pct * min(w,h), maxPx)
|
|
17
|
+
const EDGE_MIN_PX = 20
|
|
18
|
+
const EDGE_PCT = 0.15
|
|
19
|
+
const EDGE_MAX_PX = 60
|
|
20
|
+
|
|
21
|
+
// Base glow alpha when dragging (before edge proximity boosts it)
|
|
22
|
+
const GLOW_BASE = 0.15
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Virtual trackpad — pure gesture input device.
|
|
26
|
+
*
|
|
27
|
+
* Gesture model mirrors a real laptop trackpad:
|
|
28
|
+
*
|
|
29
|
+
* Single-finger gestures:
|
|
30
|
+
* - Slide → `trackpad:move` { dx, dy } Move the cursor (no button pressed)
|
|
31
|
+
* - Tap (touch + release, no movement) → `trackpad:tap` Left click
|
|
32
|
+
* - Tap, then tap-and-hold, then slide → drag gesture:
|
|
33
|
+
* `trackpad:drag-start` → `trackpad:drag-move` { dx, dy } → `trackpad:drag-end`
|
|
34
|
+
* This is mousedown + mousemove(buttons=1) + mouseup — used for text selection.
|
|
35
|
+
* - Double-tap (two quick taps) → `trackpad:double-tap` Double-click (select word)
|
|
36
|
+
* - Long press (>500ms) → `trackpad:long-press` Right-click
|
|
37
|
+
*
|
|
38
|
+
* Two-finger gestures:
|
|
39
|
+
* - Vertical slide → `trackpad:scroll` { deltaY } Scroll
|
|
40
|
+
* - Tap (two fingers touch + release, little movement) → `trackpad:two-finger-tap` Right-click
|
|
41
|
+
*/
|
|
42
|
+
export class VirtualTrackpadTab extends LitElement {
|
|
43
|
+
static get properties() {
|
|
44
|
+
return {
|
|
45
|
+
floating: { type: Boolean },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static styles = css`
|
|
50
|
+
:host {
|
|
51
|
+
display: block;
|
|
52
|
+
width: 100%;
|
|
53
|
+
height: 100%;
|
|
54
|
+
touch-action: none;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.pixi-host {
|
|
58
|
+
position: relative;
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 100%;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Single overlay — box-shadow: inset provides a natural inner-shadow halo */
|
|
64
|
+
.edge-glow {
|
|
65
|
+
position: absolute;
|
|
66
|
+
inset: 8px;
|
|
67
|
+
border-radius: 8px;
|
|
68
|
+
pointer-events: none;
|
|
69
|
+
z-index: 1;
|
|
70
|
+
}
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
declare floating: boolean
|
|
74
|
+
|
|
75
|
+
constructor() {
|
|
76
|
+
super()
|
|
77
|
+
this.floating = false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private _app: Application | null = null
|
|
81
|
+
private _container: Container | null = null
|
|
82
|
+
private _feedbackGfx: Graphics | null = null
|
|
83
|
+
private _resizeObserver: ResizeObserver | null = null
|
|
84
|
+
private _theme: PixiTheme = resolvePixiTheme(this)
|
|
85
|
+
private _unsubTheme: (() => void) | null = null
|
|
86
|
+
|
|
87
|
+
// Gesture state
|
|
88
|
+
private _touchStart: { x: number; y: number; time: number } | null = null
|
|
89
|
+
private _isDragging = false
|
|
90
|
+
private _longPressTimer: ReturnType<typeof setTimeout> | null = null
|
|
91
|
+
private _lastTapTime = 0
|
|
92
|
+
private _lastDragPos = { x: 0, y: 0 }
|
|
93
|
+
|
|
94
|
+
// Second-touch detection (tap-then-drag / double-tap)
|
|
95
|
+
private _isSecondTouch = false
|
|
96
|
+
|
|
97
|
+
// Two-finger state
|
|
98
|
+
private _twoFingerStart: { y: number; time: number } | null = null
|
|
99
|
+
private _twoFingerMoved = false
|
|
100
|
+
|
|
101
|
+
// Edge glow overlay (single element, driven by box-shadow)
|
|
102
|
+
private _edgeOverlay: HTMLElement | null = null
|
|
103
|
+
private _accentRgb = '224,74,47'
|
|
104
|
+
private _edgeSlideInterval: ReturnType<typeof setInterval> | null = null
|
|
105
|
+
|
|
106
|
+
async connectedCallback() {
|
|
107
|
+
super.connectedCallback()
|
|
108
|
+
this._theme = resolvePixiTheme(this)
|
|
109
|
+
this._syncAccentRgb()
|
|
110
|
+
this._unsubTheme = onThemeChange((theme) => {
|
|
111
|
+
this._theme = theme
|
|
112
|
+
this._syncAccentRgb()
|
|
113
|
+
this._drawSurface()
|
|
114
|
+
}, this)
|
|
115
|
+
await this.updateComplete
|
|
116
|
+
await this._initPixi()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
disconnectedCallback() {
|
|
120
|
+
super.disconnectedCallback()
|
|
121
|
+
if (this._longPressTimer) clearTimeout(this._longPressTimer)
|
|
122
|
+
this._stopEdgeSlide()
|
|
123
|
+
this._resizeObserver?.disconnect()
|
|
124
|
+
this._resizeObserver = null
|
|
125
|
+
this._unsubTheme?.()
|
|
126
|
+
this._unsubTheme = null
|
|
127
|
+
this._app?.destroy()
|
|
128
|
+
this._app = null
|
|
129
|
+
this._container = null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Cache accent color as "r,g,b" string for box-shadow rgba(). */
|
|
133
|
+
private _syncAccentRgb() {
|
|
134
|
+
const hex = this._theme.accent
|
|
135
|
+
const r = (hex >> 16) & 0xff
|
|
136
|
+
const g = (hex >> 8) & 0xff
|
|
137
|
+
const b = hex & 0xff
|
|
138
|
+
this._accentRgb = `${r},${g},${b}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async _initPixi() {
|
|
142
|
+
const host = this.shadowRoot?.querySelector('.pixi-host') as HTMLElement
|
|
143
|
+
if (!host) return
|
|
144
|
+
|
|
145
|
+
this._edgeOverlay = this.shadowRoot?.querySelector('.edge-glow') as HTMLElement
|
|
146
|
+
|
|
147
|
+
const app = new Application()
|
|
148
|
+
await app.init({
|
|
149
|
+
background: this._theme.background,
|
|
150
|
+
backgroundAlpha: this.floating ? 0 : 1,
|
|
151
|
+
antialias: true,
|
|
152
|
+
resolution: window.devicePixelRatio || 1,
|
|
153
|
+
autoDensity: false,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
host.appendChild(app.canvas as HTMLCanvasElement)
|
|
157
|
+
this._app = app
|
|
158
|
+
|
|
159
|
+
// CSS sizing: canvas fills host
|
|
160
|
+
const canvas = app.canvas as HTMLCanvasElement
|
|
161
|
+
canvas.style.width = '100%'
|
|
162
|
+
canvas.style.height = '100%'
|
|
163
|
+
canvas.style.display = 'block'
|
|
164
|
+
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
|
|
165
|
+
|
|
166
|
+
// Initial size
|
|
167
|
+
const w = host.clientWidth
|
|
168
|
+
const h = host.clientHeight
|
|
169
|
+
if (w > 0 && h > 0) {
|
|
170
|
+
app.renderer.resize(w, h)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this._container = new Container()
|
|
174
|
+
app.stage.addChild(this._container)
|
|
175
|
+
|
|
176
|
+
this._drawSurface()
|
|
177
|
+
|
|
178
|
+
canvas.addEventListener('touchstart', (e) => this._onTouchStart(e), { passive: false })
|
|
179
|
+
canvas.addEventListener('touchmove', (e) => this._onTouchMove(e), { passive: false })
|
|
180
|
+
canvas.addEventListener('touchend', (e) => this._onTouchEnd(e), { passive: false })
|
|
181
|
+
canvas.addEventListener('touchcancel', () => this._onTouchCancel(), { passive: false })
|
|
182
|
+
|
|
183
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
184
|
+
if (e.pointerType === 'touch') return
|
|
185
|
+
this._handlePointerStart(e.offsetX, e.offsetY)
|
|
186
|
+
})
|
|
187
|
+
canvas.addEventListener('pointermove', (e) => {
|
|
188
|
+
if (e.pointerType === 'touch') return
|
|
189
|
+
this._handlePointerMove(e.offsetX, e.offsetY)
|
|
190
|
+
})
|
|
191
|
+
canvas.addEventListener('pointerup', (e) => {
|
|
192
|
+
if (e.pointerType === 'touch') return
|
|
193
|
+
this._handlePointerEnd()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
this._resizeObserver = new ResizeObserver(() => {
|
|
197
|
+
const w = host.clientWidth
|
|
198
|
+
const h = host.clientHeight
|
|
199
|
+
if (w > 0 && h > 0) {
|
|
200
|
+
app.renderer.resize(w, h)
|
|
201
|
+
this._drawSurface()
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
this._resizeObserver.observe(host)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Compute uniform edge zone depth: clamp(EDGE_MIN_PX, EDGE_PCT * min(w,h), EDGE_MAX_PX). */
|
|
208
|
+
private _getEdgeZoneSize(): number {
|
|
209
|
+
const app = this._app
|
|
210
|
+
if (!app) return 40
|
|
211
|
+
const surfaceW = app.screen.width - 16
|
|
212
|
+
const surfaceH = app.screen.height - 16
|
|
213
|
+
return Math.max(EDGE_MIN_PX, Math.min(EDGE_MAX_PX, Math.min(surfaceW, surfaceH) * EDGE_PCT))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private _drawSurface() {
|
|
217
|
+
const app = this._app
|
|
218
|
+
const container = this._container
|
|
219
|
+
if (!app || !container) return
|
|
220
|
+
|
|
221
|
+
container.removeChildren()
|
|
222
|
+
this._feedbackGfx = null
|
|
223
|
+
|
|
224
|
+
const theme = this._theme
|
|
225
|
+
const w = app.screen.width
|
|
226
|
+
const h = app.screen.height
|
|
227
|
+
|
|
228
|
+
// Background surface
|
|
229
|
+
const surface = new Graphics()
|
|
230
|
+
surface.roundRect(8, 8, w - 16, h - 16, 8)
|
|
231
|
+
surface.fill({ color: theme.surface })
|
|
232
|
+
surface.stroke({ color: theme.surfaceBorder, width: 1 })
|
|
233
|
+
container.addChild(surface)
|
|
234
|
+
|
|
235
|
+
// Center hint label
|
|
236
|
+
const hintText = 'Drag: move | Tap: click | 2-finger: scroll'
|
|
237
|
+
|
|
238
|
+
const style = new TextStyle({
|
|
239
|
+
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
|
|
240
|
+
fontSize: 11,
|
|
241
|
+
fill: theme.hintText,
|
|
242
|
+
align: 'center',
|
|
243
|
+
})
|
|
244
|
+
const label = new Text({ text: hintText, style })
|
|
245
|
+
label.anchor.set(0.5)
|
|
246
|
+
label.x = w / 2
|
|
247
|
+
label.y = h / 2
|
|
248
|
+
container.addChild(label)
|
|
249
|
+
|
|
250
|
+
// Feedback circle (hidden initially)
|
|
251
|
+
const fb = new Graphics()
|
|
252
|
+
fb.circle(0, 0, 20)
|
|
253
|
+
fb.fill({ color: theme.feedbackColor, alpha: 0 })
|
|
254
|
+
this._feedbackGfx = fb
|
|
255
|
+
container.addChild(fb)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Touch handlers ---
|
|
259
|
+
|
|
260
|
+
private _onTouchStart(e: TouchEvent) {
|
|
261
|
+
e.preventDefault()
|
|
262
|
+
if (e.touches.length === 2) {
|
|
263
|
+
const y = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
|
264
|
+
this._twoFingerStart = { y, time: Date.now() }
|
|
265
|
+
this._twoFingerMoved = false
|
|
266
|
+
this._cancelLongPress()
|
|
267
|
+
// Cancel any single-finger gesture in progress
|
|
268
|
+
this._touchStart = null
|
|
269
|
+
this._isDragging = false
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if (e.touches.length === 1) {
|
|
273
|
+
const t = e.touches[0]
|
|
274
|
+
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
|
275
|
+
this._handlePointerStart(t.clientX - rect.left, t.clientY - rect.top)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private _onTouchMove(e: TouchEvent) {
|
|
280
|
+
e.preventDefault()
|
|
281
|
+
if (e.touches.length === 2 && this._twoFingerStart) {
|
|
282
|
+
const y = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
|
283
|
+
const dy = y - this._twoFingerStart.y
|
|
284
|
+
|
|
285
|
+
if (Math.abs(dy) > SCROLL_STEP) {
|
|
286
|
+
this._twoFingerMoved = true
|
|
287
|
+
this._dispatch('trackpad:scroll', { deltaY: dy })
|
|
288
|
+
this._twoFingerStart = { y, time: this._twoFingerStart.time }
|
|
289
|
+
this._vibrate(5)
|
|
290
|
+
}
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
if (e.touches.length === 1) {
|
|
294
|
+
const t = e.touches[0]
|
|
295
|
+
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
|
296
|
+
this._handlePointerMove(t.clientX - rect.left, t.clientY - rect.top)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private _onTouchEnd(e: TouchEvent) {
|
|
301
|
+
e.preventDefault()
|
|
302
|
+
if (this._twoFingerStart && e.touches.length < 2) {
|
|
303
|
+
// Two-finger tap: both fingers touched and lifted with little/no vertical movement
|
|
304
|
+
if (!this._twoFingerMoved) {
|
|
305
|
+
this._dispatch('trackpad:two-finger-tap', { button: 'right' })
|
|
306
|
+
this._vibrate(15)
|
|
307
|
+
}
|
|
308
|
+
this._twoFingerStart = null
|
|
309
|
+
this._twoFingerMoved = false
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
if (e.touches.length === 0) {
|
|
313
|
+
this._handlePointerEnd()
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private _onTouchCancel() {
|
|
318
|
+
this._touchStart = null
|
|
319
|
+
this._isDragging = false
|
|
320
|
+
this._isSecondTouch = false
|
|
321
|
+
this._twoFingerStart = null
|
|
322
|
+
this._twoFingerMoved = false
|
|
323
|
+
this._cancelLongPress()
|
|
324
|
+
this._stopEdgeSlide()
|
|
325
|
+
this._hideGlow()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- Unified pointer logic ---
|
|
329
|
+
|
|
330
|
+
private _handlePointerStart(x: number, y: number) {
|
|
331
|
+
const now = Date.now()
|
|
332
|
+
this._touchStart = { x, y, time: now }
|
|
333
|
+
this._lastDragPos = { x, y }
|
|
334
|
+
this._isDragging = false
|
|
335
|
+
|
|
336
|
+
// Is this the second touch of a double-tap sequence?
|
|
337
|
+
this._isSecondTouch = now - this._lastTapTime < DOUBLE_TAP_MS
|
|
338
|
+
|
|
339
|
+
this._showFeedback(x, y)
|
|
340
|
+
|
|
341
|
+
// Long press → right-click (only on fresh touches, not second-touch)
|
|
342
|
+
if (!this._isSecondTouch) {
|
|
343
|
+
this._longPressTimer = setTimeout(() => {
|
|
344
|
+
this._longPressTimer = null
|
|
345
|
+
this._dispatch('trackpad:long-press', { button: 'right' })
|
|
346
|
+
this._vibrate(30)
|
|
347
|
+
this._touchStart = null
|
|
348
|
+
}, LONG_PRESS_MS)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private _handlePointerMove(x: number, y: number) {
|
|
353
|
+
if (!this._touchStart) return
|
|
354
|
+
|
|
355
|
+
const dx = x - this._touchStart.x
|
|
356
|
+
const dy = y - this._touchStart.y
|
|
357
|
+
|
|
358
|
+
if (!this._isDragging && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
|
|
359
|
+
this._isDragging = true
|
|
360
|
+
this._lastDragPos = { x, y }
|
|
361
|
+
this._cancelLongPress()
|
|
362
|
+
|
|
363
|
+
// Show uniform base inner-shadow glow immediately
|
|
364
|
+
this._applyGlow({ left: 0, right: 0, top: 0, bottom: 0 })
|
|
365
|
+
|
|
366
|
+
// Second touch starts dragging → click-drag (for text selection)
|
|
367
|
+
if (this._isSecondTouch) {
|
|
368
|
+
this._dispatch('trackpad:drag-start', {})
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (this._isDragging) {
|
|
373
|
+
const moveDx = x - this._lastDragPos.x
|
|
374
|
+
const moveDy = y - this._lastDragPos.y
|
|
375
|
+
this._lastDragPos = { x, y }
|
|
376
|
+
|
|
377
|
+
if (this._isSecondTouch) {
|
|
378
|
+
// Drag with "button held" — for text selection
|
|
379
|
+
this._dispatch('trackpad:drag-move', { dx: moveDx, dy: moveDy })
|
|
380
|
+
} else {
|
|
381
|
+
// Normal cursor movement
|
|
382
|
+
this._dispatch('trackpad:move', { dx: moveDx, dy: moveDy })
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this._showFeedback(x, y)
|
|
386
|
+
|
|
387
|
+
// Edge zone: update glow and start/stop infinite slide
|
|
388
|
+
const depths = this._getEdgeDepths(x, y)
|
|
389
|
+
this._applyGlow(depths)
|
|
390
|
+
const inEdge = depths.left > 0 || depths.right > 0 || depths.top > 0 || depths.bottom > 0
|
|
391
|
+
if (inEdge) {
|
|
392
|
+
this._startEdgeSlide()
|
|
393
|
+
} else {
|
|
394
|
+
this._stopEdgeSlide()
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private _handlePointerEnd() {
|
|
400
|
+
this._cancelLongPress()
|
|
401
|
+
this._hideFeedback()
|
|
402
|
+
this._stopEdgeSlide()
|
|
403
|
+
this._hideGlow()
|
|
404
|
+
|
|
405
|
+
if (!this._touchStart) return
|
|
406
|
+
const now = Date.now()
|
|
407
|
+
|
|
408
|
+
if (this._isDragging) {
|
|
409
|
+
if (this._isSecondTouch) {
|
|
410
|
+
// End of click-drag
|
|
411
|
+
this._dispatch('trackpad:drag-end', {})
|
|
412
|
+
}
|
|
413
|
+
// End of normal cursor move — no event needed
|
|
414
|
+
} else {
|
|
415
|
+
// No drag happened — it was a tap
|
|
416
|
+
if (this._isSecondTouch) {
|
|
417
|
+
// Quick second tap → double-tap
|
|
418
|
+
this._dispatch('trackpad:double-tap', { button: 'left' })
|
|
419
|
+
this._vibrate(15)
|
|
420
|
+
this._lastTapTime = 0
|
|
421
|
+
} else {
|
|
422
|
+
// Single tap
|
|
423
|
+
this._dispatch('trackpad:tap', { button: 'left' })
|
|
424
|
+
this._vibrate(5)
|
|
425
|
+
this._lastTapTime = now
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this._touchStart = null
|
|
430
|
+
this._isDragging = false
|
|
431
|
+
this._isSecondTouch = false
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- Edge zone helpers ---
|
|
435
|
+
|
|
436
|
+
private _getEdgeDepths(
|
|
437
|
+
x: number,
|
|
438
|
+
y: number
|
|
439
|
+
): { left: number; right: number; top: number; bottom: number } {
|
|
440
|
+
const app = this._app
|
|
441
|
+
if (!app) return { left: 0, right: 0, top: 0, bottom: 0 }
|
|
442
|
+
|
|
443
|
+
const w = app.screen.width
|
|
444
|
+
const h = app.screen.height
|
|
445
|
+
const pad = 8 // surface padding
|
|
446
|
+
const edge = this._getEdgeZoneSize()
|
|
447
|
+
|
|
448
|
+
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
left: clamp01((edge - (x - pad)) / edge),
|
|
452
|
+
right: clamp01((edge - (w - pad - x)) / edge),
|
|
453
|
+
top: clamp01((edge - (y - pad)) / edge),
|
|
454
|
+
bottom: clamp01((edge - (h - pad - y)) / edge),
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Set the inner-shadow glow via 4 directional inset box-shadows on a single overlay.
|
|
460
|
+
* Each edge gets: base alpha + depth * (max - base).
|
|
461
|
+
* box-shadow composites naturally at corners — no hard seams.
|
|
462
|
+
*/
|
|
463
|
+
private _applyGlow(depths: { left: number; right: number; top: number; bottom: number }) {
|
|
464
|
+
if (!this._edgeOverlay) return
|
|
465
|
+
const edge = Math.round(this._getEdgeZoneSize())
|
|
466
|
+
const blur = Math.round(edge * 0.8)
|
|
467
|
+
const spread = Math.round(edge * 0.4)
|
|
468
|
+
const rgb = this._accentRgb
|
|
469
|
+
const a = (depth: number) => (GLOW_BASE + depth * (0.55 - GLOW_BASE)).toFixed(2)
|
|
470
|
+
|
|
471
|
+
this._edgeOverlay.style.boxShadow = [
|
|
472
|
+
`inset ${edge}px 0 ${blur}px -${spread}px rgba(${rgb},${a(depths.left)})`,
|
|
473
|
+
`inset -${edge}px 0 ${blur}px -${spread}px rgba(${rgb},${a(depths.right)})`,
|
|
474
|
+
`inset 0 ${edge}px ${blur}px -${spread}px rgba(${rgb},${a(depths.top)})`,
|
|
475
|
+
`inset 0 -${edge}px ${blur}px -${spread}px rgba(${rgb},${a(depths.bottom)})`,
|
|
476
|
+
].join(',')
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Remove all glow. */
|
|
480
|
+
private _hideGlow() {
|
|
481
|
+
if (!this._edgeOverlay) return
|
|
482
|
+
this._edgeOverlay.style.boxShadow = 'none'
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private _startEdgeSlide() {
|
|
486
|
+
if (this._edgeSlideInterval) return // already running
|
|
487
|
+
this._edgeSlideInterval = setInterval(() => {
|
|
488
|
+
this._emitEdgeSlide()
|
|
489
|
+
}, EDGE_TICK_MS)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private _stopEdgeSlide() {
|
|
493
|
+
if (this._edgeSlideInterval) {
|
|
494
|
+
clearInterval(this._edgeSlideInterval)
|
|
495
|
+
this._edgeSlideInterval = null
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private _emitEdgeSlide() {
|
|
500
|
+
const { x, y } = this._lastDragPos
|
|
501
|
+
const depths = this._getEdgeDepths(x, y)
|
|
502
|
+
|
|
503
|
+
// Per-axis speed: proportional to depth into edge zone
|
|
504
|
+
const speed = (depth: number) => EDGE_MIN_SPEED + depth * (EDGE_MAX_SPEED - EDGE_MIN_SPEED)
|
|
505
|
+
const dx = depths.right * speed(depths.right) - depths.left * speed(depths.left)
|
|
506
|
+
const dy = depths.bottom * speed(depths.bottom) - depths.top * speed(depths.top)
|
|
507
|
+
|
|
508
|
+
if (dx === 0 && dy === 0) return // not in any edge zone
|
|
509
|
+
|
|
510
|
+
const eventName = this._isSecondTouch ? 'trackpad:drag-move' : 'trackpad:move'
|
|
511
|
+
this._dispatch(eventName, { dx, dy })
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// --- General helpers ---
|
|
515
|
+
|
|
516
|
+
private _dispatch(name: string, detail: Record<string, unknown>) {
|
|
517
|
+
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private _cancelLongPress() {
|
|
521
|
+
if (this._longPressTimer) {
|
|
522
|
+
clearTimeout(this._longPressTimer)
|
|
523
|
+
this._longPressTimer = null
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private _showFeedback(x: number, y: number) {
|
|
528
|
+
if (this._feedbackGfx) {
|
|
529
|
+
this._feedbackGfx.x = x
|
|
530
|
+
this._feedbackGfx.y = y
|
|
531
|
+
this._feedbackGfx.alpha = 0.4
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private _hideFeedback() {
|
|
536
|
+
if (this._feedbackGfx) this._feedbackGfx.alpha = 0
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private _vibrate(ms: number) {
|
|
540
|
+
try {
|
|
541
|
+
navigator?.vibrate?.(ms)
|
|
542
|
+
} catch {
|
|
543
|
+
// ignore
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
render() {
|
|
548
|
+
return html`
|
|
549
|
+
<div class="pixi-host">
|
|
550
|
+
<div class="edge-glow"></div>
|
|
551
|
+
</div>
|
|
552
|
+
`
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
customElements.define('virtual-trackpad-tab', VirtualTrackpadTab)
|