@cplieger/web-terminal-engine 5.3.0 → 6.0.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/NOTICE +3 -0
- package/README.md +121 -61
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/jsr.json +3 -1
- package/package.json +4 -2
- package/src/connection.ts +1205 -1676
- package/src/index.ts +20 -100
- package/src/keyboard.ts +56 -133
- package/src/modes.ts +92 -166
- package/src/mouse.ts +380 -413
- package/src/render.ts +1894 -2355
- package/src/scroll.ts +225 -476
- package/src/store.ts +1 -1
- package/src/terminal.ts +135 -0
- package/src/toolbar.ts +29 -43
package/src/mouse.ts
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
|
-
// Mouse event encoding for terminal input
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// +4=shift, +8=alt, +16=ctrl, +32=motion
|
|
9
|
-
//
|
|
10
|
-
// Mouse reports are BEST-EFFORT: a report carries no sequence number and
|
|
11
|
-
// describes a screen a resume may since have repainted, so `sendReport` may
|
|
12
|
-
// refuse one and a refused report is forgotten rather than retried. Focus is not
|
|
13
|
-
// mouse input at all — it is transport state the server derives — and lives in
|
|
14
|
-
// `connection.setClientFocus`.
|
|
15
|
-
//
|
|
16
|
-
// Out-of-scope (consumed but not implemented):
|
|
17
|
-
// - X10 mouse mode (mode 9): not supported.
|
|
18
|
-
// - urxvt encoding (mode 1015): not supported.
|
|
19
|
-
// - DEFAULT encoding (raw bytes): not supported; only SGR 1006.
|
|
1
|
+
// Mouse event encoding for terminal input, SGR 1006 only (no X10, urxvt or raw
|
|
2
|
+
// encoding): ESC[<code;col;rowM for press/move, ESC[<code;col;rowm for release,
|
|
3
|
+
// 1-based coordinates, xterm button codes (0/1/2 buttons, 64/65 wheel, +4
|
|
4
|
+
// shift, +8 alt, +16 ctrl, +32 motion). Reports are BEST-EFFORT: one carries no
|
|
5
|
+
// sequence number and describes a screen a resume may since have repainted, so
|
|
6
|
+
// a refused report is forgotten rather than retried. Focus is transport state
|
|
7
|
+
// the server derives and lives in `connection.setClientFocus`.
|
|
20
8
|
|
|
21
|
-
import {
|
|
9
|
+
import type { ModeState } from "./modes.js";
|
|
22
10
|
|
|
23
11
|
const ESC = "\x1b";
|
|
24
12
|
|
|
@@ -47,14 +35,9 @@ function buttonCode(
|
|
|
47
35
|
}
|
|
48
36
|
|
|
49
37
|
/**
|
|
50
|
-
* Encode a mouse event into an SGR 1006 escape sequence
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* @param code Button code returned from `buttonCode` (button + modifier bits).
|
|
55
|
-
* @param col 1-based column.
|
|
56
|
-
* @param row 1-based row.
|
|
57
|
-
* @param release `true` for release events (terminator `m`), `false` otherwise.
|
|
38
|
+
* Encode a mouse event into an SGR 1006 escape sequence: `code` is the xterm
|
|
39
|
+
* button code plus modifier bits, `col` and `row` are 1-based, and `release`
|
|
40
|
+
* selects the `m` terminator over `M`.
|
|
58
41
|
*/
|
|
59
42
|
export function encodeSGR(code: number, col: number, row: number, release: boolean): string {
|
|
60
43
|
const final = release ? "m" : "M";
|
|
@@ -74,7 +57,7 @@ function domButtonToXterm(button: number): number {
|
|
|
74
57
|
}
|
|
75
58
|
|
|
76
59
|
/**
|
|
77
|
-
* Adapter the consumer
|
|
60
|
+
* Adapter the consumer supplies so the mouse controller can: send encoded
|
|
78
61
|
* input bytes to the server, query current cell pixel size for hit-testing,
|
|
79
62
|
* and access the terminal DOM element to attach listeners to.
|
|
80
63
|
*/
|
|
@@ -107,441 +90,425 @@ export interface MouseInputHandler {
|
|
|
107
90
|
gridSize?: () => { cols: number; rows: number };
|
|
108
91
|
}
|
|
109
92
|
|
|
110
|
-
let handler: MouseInputHandler | null = null;
|
|
111
|
-
// True while a Shift-initiated press is in flight. Shift+click/drag is the
|
|
112
|
-
// xterm convention for "bypass application mouse tracking": the gesture is
|
|
113
|
-
// reserved for the browser's native text selection instead of being reported
|
|
114
|
-
// to (and preventDefault-ed away from) the TUI. Tracked from press to release
|
|
115
|
-
// so the move/up of a bypassed drag stay bypassed even if Shift is lifted
|
|
116
|
-
// mid-drag.
|
|
117
|
-
let shiftBypass = false;
|
|
118
|
-
// Last motion report sent, for same-cell dedup: a drag within one cell fires
|
|
119
|
-
// many DOM mousemove events that would all encode to the identical SGR
|
|
120
|
-
// sequence and flood the PTY (xterm.js applies the same suppression). Cleared
|
|
121
|
-
// on press/release so the first motion of a new gesture always reports.
|
|
122
|
-
let lastMotion = "";
|
|
123
|
-
// The element the current init() attached to, so a re-init or dispose can
|
|
124
|
-
// detach the exact listener set (addEventListener dedups identical
|
|
125
|
-
// registrations on the SAME element, but a re-init on a NEW element would
|
|
126
|
-
// otherwise leak the old one's listeners).
|
|
127
|
-
let attachedEl: HTMLElement | null = null;
|
|
128
|
-
// One warning per wiring, not per event: a non-positive cell size makes every
|
|
129
|
-
// report vanish, which is indistinguishable from mouse-tracking being off, and
|
|
130
|
-
// that silence is why a consumer wiring cellSize to a guess went unnoticed.
|
|
131
|
-
let warnedDegenerateCell = false;
|
|
132
|
-
// The same silence for the other divisor, and it is worse: an empty grid clamps
|
|
133
|
-
// every coordinate to 0, so instead of vanishing the report becomes a confident
|
|
134
|
-
// (1,1) for every pointer position.
|
|
135
|
-
let warnedDegenerateGrid = false;
|
|
136
|
-
// The buttons whose PRESS was actually delivered, with the coordinates that
|
|
137
|
-
// press reported. This is what the APPLICATION believes is held, so it also
|
|
138
|
-
// decides which reports are truthful: a release is emitted only for a recorded
|
|
139
|
-
// button, and a motion report claims a held button only for a recorded one.
|
|
140
|
-
const pressed = new Map<number, { col: number; row: number }>();
|
|
141
93
|
// DOM MouseEvent.button index -> its bit in MouseEvent.buttons. Middle and right
|
|
142
94
|
// are SWAPPED between the two (button 1 = middle = bit 4; button 2 = right =
|
|
143
95
|
// bit 2), so `1 << button` is wrong.
|
|
144
96
|
const BUTTONS_BIT = [1, 4, 2, 8, 16] as const;
|
|
145
|
-
// The freshest `buttons` bitmask any mouse event gave this element, which is the
|
|
146
|
-
// only reading of present button state the DOM offers outside an event.
|
|
147
|
-
let lastButtons = 0;
|
|
148
|
-
// The motion report waiting for the next animation frame, and the frame handle
|
|
149
|
-
// that will flush it. Motion coalesces to one report per frame, latest wins.
|
|
150
|
-
let pendingMotion: string | null = null;
|
|
151
|
-
let motionFrame: number | null = null;
|
|
152
97
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// once tracking is off lands in the PTY as ordinary keystrokes.
|
|
157
|
-
function trackingActive(): boolean {
|
|
158
|
-
return getMouseMode() !== 0 && (isMouseSGR() || isMousePixels());
|
|
98
|
+
/** `MouseInputHandler` plus the mode state the listeners gate on. */
|
|
99
|
+
export interface MouseControllerOptions extends MouseInputHandler {
|
|
100
|
+
modes: Pick<ModeState, "getMouseMode" | "isMouseSGR" | "isMousePixels">;
|
|
159
101
|
}
|
|
160
102
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
103
|
+
/** Pointer and wheel forwarding for one terminal element. */
|
|
104
|
+
export interface MouseController {
|
|
105
|
+
/**
|
|
106
|
+
* Cancels a gesture left in flight once the resume has declared the server's
|
|
107
|
+
* capabilities: emits one release for each recorded button the last reading
|
|
108
|
+
* says is now up, so a drag is not left pressed across a reconnect.
|
|
109
|
+
*/
|
|
110
|
+
resyncGesture(): void;
|
|
111
|
+
/** Forgets the held gesture without reporting anything, for a consumer pointing this terminal at another session. */
|
|
112
|
+
disarmGesture(): void;
|
|
113
|
+
/** Detaches the four listeners, cancels the pending motion frame and forgets the gesture. Idempotent. */
|
|
114
|
+
dispose(): void;
|
|
165
115
|
}
|
|
166
116
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Creates a mouse controller over `opts.termElement()`. The listeners gate on
|
|
119
|
+
* the active mouse mode and SGR 1006 encoding, so they are inert until the
|
|
120
|
+
* application enables tracking. Throws, holding nothing, when a listener cannot
|
|
121
|
+
* be attached.
|
|
122
|
+
*/
|
|
123
|
+
export function createMouseController(opts: MouseControllerOptions): MouseController {
|
|
124
|
+
// Invariant: `modes` and `handler` are read by member call at each call site, never as captured methods.
|
|
125
|
+
const modes = opts.modes;
|
|
126
|
+
let handler: MouseInputHandler | null = opts;
|
|
127
|
+
// True while a Shift-initiated press is in flight. Shift+click/drag is the
|
|
128
|
+
// xterm convention for "bypass application mouse tracking": the gesture is
|
|
129
|
+
// reserved for the browser's native text selection instead of being reported
|
|
130
|
+
// to (and preventDefault-ed away from) the TUI. Tracked from press to release
|
|
131
|
+
// so the move/up of a bypassed drag stay bypassed even if Shift is lifted
|
|
132
|
+
// mid-drag.
|
|
133
|
+
let shiftBypass = false;
|
|
134
|
+
// Last motion report sent, for same-cell dedup: a drag within one cell fires
|
|
135
|
+
// many DOM mousemove events that would all encode to the identical SGR
|
|
136
|
+
// sequence and flood the PTY (xterm.js applies the same suppression). Cleared
|
|
137
|
+
// on press/release so the first motion of a new gesture always reports.
|
|
138
|
+
let lastMotion = "";
|
|
139
|
+
// The element the listeners are attached to, so dispose detaches the exact set.
|
|
140
|
+
let attachedEl: HTMLElement | null = null;
|
|
141
|
+
// One warning per wiring, not per event: a non-positive cell size makes every
|
|
142
|
+
// report vanish, which is indistinguishable from mouse-tracking being off, and
|
|
143
|
+
// that silence is why a consumer wiring cellSize to a guess went unnoticed.
|
|
144
|
+
let warnedDegenerateCell = false;
|
|
145
|
+
// The same silence for the other divisor, and it is worse: an empty grid clamps
|
|
146
|
+
// every coordinate to 0, so instead of vanishing the report becomes a confident
|
|
147
|
+
// (1,1) for every pointer position.
|
|
148
|
+
let warnedDegenerateGrid = false;
|
|
149
|
+
// The buttons whose PRESS was actually delivered, with the coordinates that
|
|
150
|
+
// press reported. This is what the APPLICATION believes is held, so it also
|
|
151
|
+
// decides which reports are truthful: a release is emitted only for a recorded
|
|
152
|
+
// button, and a motion report claims a held button only for a recorded one.
|
|
153
|
+
const pressed = new Map<number, { col: number; row: number }>();
|
|
154
|
+
// The freshest `buttons` bitmask any mouse event gave this element, which is the
|
|
155
|
+
// only reading of present button state the DOM offers outside an event.
|
|
156
|
+
let lastButtons = 0;
|
|
157
|
+
// The motion report waiting for the next animation frame, and the frame handle
|
|
158
|
+
// that will flush it. Motion coalesces to one report per frame, latest wins.
|
|
159
|
+
let pendingMotion: string | null = null;
|
|
160
|
+
let motionFrame: number | null = null;
|
|
181
161
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
function
|
|
187
|
-
|
|
188
|
-
if (motionFrame !== null) {
|
|
189
|
-
cancelAnimationFrame(motionFrame);
|
|
190
|
-
motionFrame = null;
|
|
162
|
+
// The one predicate every read and every write of `pressed` is gated on: the
|
|
163
|
+
// application is asking for SGR-encoded reports right now. A record written
|
|
164
|
+
// under tracking outlives the mode that created it, and an SGR release written
|
|
165
|
+
// once tracking is off lands in the PTY as ordinary keystrokes.
|
|
166
|
+
function trackingActive(): boolean {
|
|
167
|
+
return modes.getMouseMode() !== 0 && (modes.isMouseSGR() || modes.isMousePixels());
|
|
191
168
|
}
|
|
192
|
-
}
|
|
193
169
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (seq === null || !handler) {
|
|
199
|
-
return;
|
|
170
|
+
// Forget the held-gesture record and the button reading it is judged against.
|
|
171
|
+
function clearGesture(): void {
|
|
172
|
+
pressed.clear();
|
|
173
|
+
lastButtons = 0;
|
|
200
174
|
}
|
|
201
|
-
handler.sendReport(seq);
|
|
202
|
-
}
|
|
203
175
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
176
|
+
// True when the application is not tracking, and the shared gate for every
|
|
177
|
+
// handler and for resyncGesture. Forgetting the record here is what closes the
|
|
178
|
+
// stranded-gesture sequence: an SGR release written while nothing asked for one
|
|
179
|
+
// reaches the PTY as keystrokes, and carrying the record into a later re-enable
|
|
180
|
+
// would describe a press that program never saw. tmux answers the same way at
|
|
181
|
+
// attach, disabling every mouse mode and clearing mouse_drag_flag with it
|
|
182
|
+
// (tty.c, tty_start_tty).
|
|
183
|
+
function trackingOff(): boolean {
|
|
184
|
+
if (trackingActive()) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
clearGesture();
|
|
188
|
+
return true;
|
|
207
189
|
}
|
|
208
|
-
attachedEl.removeEventListener("mousedown", onMouseDown);
|
|
209
|
-
attachedEl.removeEventListener("mouseup", onMouseUp);
|
|
210
|
-
attachedEl.removeEventListener("mousemove", onMouseMove);
|
|
211
|
-
attachedEl.removeEventListener("wheel", onWheel);
|
|
212
|
-
attachedEl = null;
|
|
213
|
-
}
|
|
214
190
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
*/
|
|
225
|
-
export function init(h: MouseInputHandler): () => void {
|
|
226
|
-
detach(); // self-heal a re-init on a different element
|
|
227
|
-
handler = h;
|
|
228
|
-
shiftBypass = false;
|
|
229
|
-
lastMotion = "";
|
|
230
|
-
warnedDegenerateCell = false;
|
|
231
|
-
warnedDegenerateGrid = false;
|
|
232
|
-
clearGesture();
|
|
233
|
-
cancelPendingMotion();
|
|
234
|
-
const el = h.termElement();
|
|
235
|
-
attachedEl = el;
|
|
236
|
-
el.addEventListener("mousedown", onMouseDown);
|
|
237
|
-
el.addEventListener("mouseup", onMouseUp);
|
|
238
|
-
el.addEventListener("mousemove", onMouseMove);
|
|
239
|
-
el.addEventListener("wheel", onWheel, { passive: false });
|
|
240
|
-
return function dispose(): void {
|
|
241
|
-
if (attachedEl !== el) {
|
|
242
|
-
return; // superseded by a later init — nothing of ours left to detach
|
|
191
|
+
// Drop the pending motion report and disarm its frame. A press, release or wheel
|
|
192
|
+
// CANCELS rather than flushes: the button event carries a newer position, so
|
|
193
|
+
// ordering is correct by construction and the superseded motion is exactly what
|
|
194
|
+
// coalescing is entitled to drop.
|
|
195
|
+
function cancelPendingMotion(): void {
|
|
196
|
+
pendingMotion = null;
|
|
197
|
+
if (motionFrame !== null) {
|
|
198
|
+
cancelAnimationFrame(motionFrame);
|
|
199
|
+
motionFrame = null;
|
|
243
200
|
}
|
|
244
|
-
|
|
245
|
-
handler = null;
|
|
246
|
-
shiftBypass = false;
|
|
247
|
-
lastMotion = "";
|
|
248
|
-
clearGesture();
|
|
249
|
-
cancelPendingMotion();
|
|
250
|
-
};
|
|
251
|
-
}
|
|
201
|
+
}
|
|
252
202
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
* genuinely is.
|
|
262
|
-
*
|
|
263
|
-
* This reports PRESENT state, which is what RFB and Guacamole do implicitly with
|
|
264
|
-
* their next message, not a replay of the missed release. The synthesis is OUR
|
|
265
|
-
* JUDGEMENT — no reference addresses a producer-side resync (tmux resets its own
|
|
266
|
-
* gesture model at attach, but tmux is the CONSUMER and can) — chosen over
|
|
267
|
-
* emitting nothing and letting the next real click unstick the application. Its
|
|
268
|
-
* bound: the reading is the freshest the browser gave THIS element, so a release
|
|
269
|
-
* that happened off-element is invisible either way.
|
|
270
|
-
*/
|
|
271
|
-
export function resyncGesture(): void {
|
|
272
|
-
if (!handler) {
|
|
273
|
-
return;
|
|
203
|
+
function flushMotion(): void {
|
|
204
|
+
motionFrame = null;
|
|
205
|
+
const seq = pendingMotion;
|
|
206
|
+
pendingMotion = null;
|
|
207
|
+
if (seq === null || !handler) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
handler.sendReport(seq);
|
|
274
211
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
212
|
+
|
|
213
|
+
function detach(): void {
|
|
214
|
+
if (!attachedEl) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
attachedEl.removeEventListener("mousedown", onMouseDown);
|
|
218
|
+
attachedEl.removeEventListener("mouseup", onMouseUp);
|
|
219
|
+
attachedEl.removeEventListener("mousemove", onMouseMove);
|
|
220
|
+
attachedEl.removeEventListener("wheel", onWheel);
|
|
221
|
+
attachedEl = null;
|
|
278
222
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
223
|
+
|
|
224
|
+
// Called on the resumeAck, not the socket's open: the ephemeral channel is
|
|
225
|
+
// not yet known at open, so a release sent there would take the reliable
|
|
226
|
+
// fallback and become the one replayable mouse report. Reports PRESENT state
|
|
227
|
+
// (what RFB and Guacamole do implicitly with their next message), one release
|
|
228
|
+
// per recorded button the last reading says is UP; a release that happened
|
|
229
|
+
// off-element is invisible either way.
|
|
230
|
+
function resyncGesture(): void {
|
|
231
|
+
if (!handler) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
cancelPendingMotion();
|
|
235
|
+
if (trackingOff()) {
|
|
236
|
+
return;
|
|
282
237
|
}
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
238
|
+
for (const [button, pos] of [...pressed]) {
|
|
239
|
+
if ((lastButtons & (BUTTONS_BIT[button] ?? 0)) !== 0) {
|
|
240
|
+
continue; // still held
|
|
241
|
+
}
|
|
242
|
+
const code = buttonCode(domButtonToXterm(button), false, false, false, false);
|
|
243
|
+
if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
|
|
244
|
+
pressed.delete(button); // only a DELIVERED release ends the gesture, as in onMouseUp
|
|
245
|
+
}
|
|
286
246
|
}
|
|
287
247
|
}
|
|
288
|
-
}
|
|
289
248
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
* surviving the switch would reach a session that never saw the press — the
|
|
297
|
-
* desync `pressed` exists to prevent, arriving through resyncGesture. Nothing is
|
|
298
|
-
* emitted instead: the same treatment the consumer gives every other latched
|
|
299
|
-
* input class on a switch.
|
|
300
|
-
*/
|
|
301
|
-
export function disarmGesture(): void {
|
|
302
|
-
cancelPendingMotion();
|
|
303
|
-
clearGesture();
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// pixelToCell returns the coordinate pair to report for a mouse event. Normally
|
|
307
|
-
// that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
|
|
308
|
-
// the 1-based pixel offset within the terminal element (same SGR grammar, so
|
|
309
|
-
// encodeSGR is reused unchanged). The {col,row} field names carry whichever the
|
|
310
|
-
// active mode reports.
|
|
311
|
-
function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
|
|
312
|
-
const h = handler;
|
|
313
|
-
if (!h) {
|
|
314
|
-
return null;
|
|
249
|
+
// The record says what the OUTGOING session's application believes is held,
|
|
250
|
+
// and sessions multiplex over one controller, so a release surviving the
|
|
251
|
+
// switch would reach a session that never saw the press.
|
|
252
|
+
function disarmGesture(): void {
|
|
253
|
+
cancelPendingMotion();
|
|
254
|
+
clearGesture();
|
|
315
255
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
256
|
+
|
|
257
|
+
// pixelToCell returns the coordinate pair to report for a mouse event. Normally
|
|
258
|
+
// that's the 1-based cell column/row; under DEC 1016 (SGR-pixels) it's instead
|
|
259
|
+
// the 1-based pixel offset within the terminal element (same SGR grammar, so
|
|
260
|
+
// encodeSGR is reused unchanged). The {col,row} field names carry whichever the
|
|
261
|
+
// active mode reports.
|
|
262
|
+
function pixelToCell(e: MouseEvent): { col: number; row: number } | null {
|
|
263
|
+
const h = handler;
|
|
264
|
+
if (!h) {
|
|
324
265
|
return null;
|
|
325
266
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (px < 0 || py < 0) {
|
|
267
|
+
const el = (h.gridElement ?? h.termElement)();
|
|
268
|
+
const rect = el.getBoundingClientRect();
|
|
269
|
+
const grid = h.gridSize?.() ?? null;
|
|
270
|
+
if (grid !== null && (grid.cols <= 0 || grid.rows <= 0)) {
|
|
271
|
+
// rows 0 is the documented pre-first-frame answer (render.gridSize): the
|
|
272
|
+
// server has not described the screen yet, so refuse without spending the
|
|
273
|
+
// warning latch that exists to make a genuine mis-wiring loud.
|
|
274
|
+
if (grid.cols > 0 && grid.rows === 0) {
|
|
335
275
|
return null;
|
|
336
276
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
277
|
+
warnUnmeasuredGrid(grid.cols, grid.rows);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
if (modes.isMousePixels()) {
|
|
281
|
+
const box = grid === null ? null : pixelBox(h, grid);
|
|
282
|
+
if (box === null) {
|
|
283
|
+
const px = Math.round(e.clientX - rect.left);
|
|
284
|
+
const py = Math.round(e.clientY - rect.top);
|
|
285
|
+
if (px < 0 || py < 0) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
return { col: px + 1, row: py + 1 }; // 1-based pixel offsets
|
|
289
|
+
}
|
|
290
|
+
// Same origin as the cell branch below — the grid's BOTTOM edge — so one
|
|
291
|
+
// press resolves to the same row under either encoding. Measured from the
|
|
292
|
+
// box's top edge instead, a partial top row shifts every pixel report by up
|
|
293
|
+
// to a cell.
|
|
294
|
+
return {
|
|
295
|
+
col: clampToGrid(Math.round(e.clientX - rect.left), box.width) + 1,
|
|
296
|
+
row: clampToGrid(Math.round(e.clientY - (rect.bottom - box.height)), box.height) + 1,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const { width, height } = h.cellSize();
|
|
300
|
+
if (width <= 0 || height <= 0) {
|
|
301
|
+
warnUnmeasuredCell(width, height);
|
|
357
302
|
return null;
|
|
358
303
|
}
|
|
359
|
-
|
|
304
|
+
const col0 = Math.floor((e.clientX - rect.left) / width);
|
|
305
|
+
if (grid === null) {
|
|
306
|
+
const row0 = Math.floor((e.clientY - rect.top) / height);
|
|
307
|
+
if (col0 < 0 || row0 < 0) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return { col: col0 + 1, row: row0 + 1 };
|
|
311
|
+
}
|
|
312
|
+
// The screen window is the TAIL of the content, so the grid is bottom-anchored:
|
|
313
|
+
// on the primary screen a bottom-pinned view shows a PARTIAL row at the top and
|
|
314
|
+
// row 0 begins below the box's top edge, which top-anchored arithmetic reports
|
|
315
|
+
// one row out for.
|
|
316
|
+
const row0 = grid.rows - 1 - Math.floor((rect.bottom - e.clientY) / height);
|
|
317
|
+
return { col: clampToGrid(col0, grid.cols) + 1, row: clampToGrid(row0, grid.rows) + 1 };
|
|
360
318
|
}
|
|
361
|
-
// The screen window is the TAIL of the content, so the grid is bottom-anchored:
|
|
362
|
-
// on the primary screen a bottom-pinned view shows a PARTIAL row at the top and
|
|
363
|
-
// row 0 begins below the box's top edge, which top-anchored arithmetic reports
|
|
364
|
-
// one row out for.
|
|
365
|
-
const row0 = grid.rows - 1 - Math.floor((rect.bottom - e.clientY) / height);
|
|
366
|
-
return { col: clampToGrid(col0, grid.cols) + 1, row: clampToGrid(row0, grid.rows) + 1 };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Clamp a 0-based grid coordinate into `0..limit-1`, and never below 0. */
|
|
370
|
-
function clampToGrid(value: number, limit: number): number {
|
|
371
|
-
return Math.max(0, Math.min(value, limit - 1));
|
|
372
|
-
}
|
|
373
319
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
function pixelBox(
|
|
378
|
-
h: MouseInputHandler,
|
|
379
|
-
grid: { cols: number; rows: number },
|
|
380
|
-
): { width: number; height: number } | null {
|
|
381
|
-
const { width, height } = h.cellSize();
|
|
382
|
-
if (width <= 0 || height <= 0) {
|
|
383
|
-
return null;
|
|
320
|
+
/** Clamp a 0-based grid coordinate into `0..limit-1`, and never below 0. */
|
|
321
|
+
function clampToGrid(value: number, limit: number): number {
|
|
322
|
+
return Math.max(0, Math.min(value, limit - 1));
|
|
384
323
|
}
|
|
385
|
-
return { width: grid.cols * width, height: grid.rows * height };
|
|
386
|
-
}
|
|
387
324
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
325
|
+
// The grid's own box in pixels, or null while the cell is unmeasured — in which
|
|
326
|
+
// case a pixel report goes out UNCLAMPED rather than being withheld: the offset
|
|
327
|
+
// is still the right offset, only its bound is unknown.
|
|
328
|
+
function pixelBox(
|
|
329
|
+
h: MouseInputHandler,
|
|
330
|
+
grid: { cols: number; rows: number },
|
|
331
|
+
): { width: number; height: number } | null {
|
|
332
|
+
const { width, height } = h.cellSize();
|
|
333
|
+
if (width <= 0 || height <= 0) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return { width: grid.cols * width, height: grid.rows * height };
|
|
391
337
|
}
|
|
392
|
-
warnedDegenerateCell = true;
|
|
393
|
-
console.warn(
|
|
394
|
-
`vterm: mouse reports suppressed, cellSize() answered ${String(width)}x${String(height)}`,
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
338
|
|
|
398
|
-
function
|
|
399
|
-
|
|
400
|
-
|
|
339
|
+
function warnUnmeasuredCell(width: number, height: number): void {
|
|
340
|
+
if (warnedDegenerateCell) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
warnedDegenerateCell = true;
|
|
344
|
+
console.warn(
|
|
345
|
+
`vterm: mouse reports suppressed, cellSize() answered ${String(width)}x${String(height)}`,
|
|
346
|
+
);
|
|
401
347
|
}
|
|
402
|
-
warnedDegenerateGrid = true;
|
|
403
|
-
console.warn(
|
|
404
|
-
`vterm: mouse reports suppressed, gridSize() answered ${String(cols)}x${String(rows)}`,
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
348
|
|
|
408
|
-
function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
shiftBypass = false;
|
|
419
|
-
lastMotion = ""; // new gesture: next motion always reports
|
|
420
|
-
const pos = pixelToCell(e);
|
|
421
|
-
if (!pos) {
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
cancelPendingMotion();
|
|
425
|
-
const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
426
|
-
if (handler.sendReport(encodeSGR(code, pos.col, pos.row, false))) {
|
|
427
|
-
// Only a DELIVERED press starts a gesture, so onMouseUp cannot report a
|
|
428
|
-
// release for a press the application never saw.
|
|
429
|
-
pressed.set(e.button, pos);
|
|
349
|
+
function warnUnmeasuredGrid(cols: number, rows: number): void {
|
|
350
|
+
if (warnedDegenerateGrid) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
warnedDegenerateGrid = true;
|
|
354
|
+
console.warn(
|
|
355
|
+
`vterm: mouse reports suppressed, gridSize() answered ${String(cols)}x${String(rows)}`,
|
|
356
|
+
);
|
|
430
357
|
}
|
|
431
|
-
e.preventDefault();
|
|
432
|
-
}
|
|
433
358
|
|
|
434
|
-
function
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
const bypassed = shiftBypass;
|
|
440
|
-
shiftBypass = false;
|
|
441
|
-
cancelPendingMotion();
|
|
442
|
-
const pos = pixelToCell(e);
|
|
443
|
-
if (!pos) {
|
|
444
|
-
// The record SURVIVES a release that could not be reported. `pressed` is what
|
|
445
|
-
// the application believes is held, and it was told nothing here, so
|
|
446
|
-
// resyncGesture must still find the button and cancel it on the next open.
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
if (pressed.has(e.button)) {
|
|
450
|
-
const btn = domButtonToXterm(e.button);
|
|
451
|
-
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
452
|
-
if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
|
|
453
|
-
pressed.delete(e.button); // only a DELIVERED release ends the gesture
|
|
359
|
+
function onMouseDown(e: MouseEvent): void {
|
|
360
|
+
lastButtons = e.buttons;
|
|
361
|
+
if (trackingOff() || !handler) {
|
|
362
|
+
return;
|
|
454
363
|
}
|
|
364
|
+
if (e.shiftKey) {
|
|
365
|
+
// Shift+press: leave the whole gesture to the browser (native selection).
|
|
366
|
+
shiftBypass = true;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
shiftBypass = false;
|
|
370
|
+
lastMotion = ""; // new gesture: next motion always reports
|
|
371
|
+
const pos = pixelToCell(e);
|
|
372
|
+
if (!pos) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
cancelPendingMotion();
|
|
376
|
+
const code = buttonCode(domButtonToXterm(e.button), false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
377
|
+
if (handler.sendReport(encodeSGR(code, pos.col, pos.row, false))) {
|
|
378
|
+
// Only a DELIVERED press starts a gesture, so onMouseUp cannot report a
|
|
379
|
+
// release for a press the application never saw.
|
|
380
|
+
pressed.set(e.button, pos);
|
|
381
|
+
}
|
|
382
|
+
e.preventDefault();
|
|
455
383
|
}
|
|
456
|
-
if (bypassed) {
|
|
457
|
-
// The Shift-initiated gesture is the browser's (native selection), so its
|
|
458
|
-
// own default is left alone; a button recorded BEFORE the bypass began
|
|
459
|
-
// belongs to a delivered gesture and is still owed the release above.
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
lastMotion = ""; // gesture over: next motion always reports
|
|
463
|
-
e.preventDefault();
|
|
464
|
-
}
|
|
465
384
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
385
|
+
function onMouseUp(e: MouseEvent): void {
|
|
386
|
+
lastButtons = e.buttons;
|
|
387
|
+
if (trackingOff() || !handler) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const bypassed = shiftBypass;
|
|
391
|
+
shiftBypass = false;
|
|
392
|
+
cancelPendingMotion();
|
|
393
|
+
const pos = pixelToCell(e);
|
|
394
|
+
if (!pos) {
|
|
395
|
+
// The record SURVIVES a release that could not be reported. `pressed` is what
|
|
396
|
+
// the application believes is held, and it was told nothing here, so
|
|
397
|
+
// resyncGesture must still find the button and cancel it on the next open.
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (pressed.has(e.button)) {
|
|
401
|
+
const btn = domButtonToXterm(e.button);
|
|
402
|
+
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
403
|
+
if (handler.sendReport(encodeSGR(code, pos.col, pos.row, true))) {
|
|
404
|
+
pressed.delete(e.button); // only a DELIVERED release ends the gesture
|
|
405
|
+
}
|
|
473
406
|
}
|
|
407
|
+
if (bypassed) {
|
|
408
|
+
// The Shift-initiated gesture is the browser's (native selection), so its
|
|
409
|
+
// own default is left alone; a button recorded BEFORE the bypass began
|
|
410
|
+
// belongs to a delivered gesture and is still owed the release above.
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
lastMotion = ""; // gesture over: next motion always reports
|
|
414
|
+
e.preventDefault();
|
|
474
415
|
}
|
|
475
|
-
return null;
|
|
476
|
-
}
|
|
477
416
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
// mode 1002: motion only while button held (drag)
|
|
489
|
-
// mode 1003: all motion events
|
|
490
|
-
if (mode === 1000) {
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
// A button the application never saw pressed is not held for reporting
|
|
494
|
-
// purposes: a drag whose press was refused (or began off the element) would
|
|
495
|
-
// otherwise start a drag the pairing rule above then refuses to end.
|
|
496
|
-
const held = heldButton(e.buttons);
|
|
497
|
-
if (mode === 1002 && held === null) {
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
const pos = pixelToCell(e);
|
|
501
|
-
if (!pos) {
|
|
502
|
-
return;
|
|
417
|
+
// The button to report as held: the highest-priority DOM button that is both
|
|
418
|
+
// down now and recorded as delivered, or null when the application believes none
|
|
419
|
+
// is. Priority matches the DOM's own left/middle/right order.
|
|
420
|
+
function heldButton(buttons: number): number | null {
|
|
421
|
+
for (const button of [0, 1, 2]) {
|
|
422
|
+
if ((buttons & (BUTTONS_BIT[button] ?? 0)) !== 0 && pressed.has(button)) {
|
|
423
|
+
return button;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
503
427
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
428
|
+
|
|
429
|
+
function onMouseMove(e: MouseEvent): void {
|
|
430
|
+
lastButtons = e.buttons;
|
|
431
|
+
if (trackingOff() || !handler) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const mode = modes.getMouseMode();
|
|
435
|
+
if (shiftBypass && e.buttons) {
|
|
436
|
+
return; // a Shift-initiated drag is a native selection; don't report it
|
|
437
|
+
}
|
|
438
|
+
// mode 1000: no motion events
|
|
439
|
+
// mode 1002: motion only while button held (drag)
|
|
440
|
+
// mode 1003: all motion events
|
|
441
|
+
if (mode === 1000) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// A button the application never saw pressed is not held for reporting
|
|
445
|
+
// purposes: a drag whose press was refused (or began off the element) would
|
|
446
|
+
// otherwise start a drag the pairing rule above then refuses to end.
|
|
447
|
+
const held = heldButton(e.buttons);
|
|
448
|
+
if (mode === 1002 && held === null) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const pos = pixelToCell(e);
|
|
452
|
+
if (!pos) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// No button held (bare motion in mode 1003) reports xterm's "no button"
|
|
456
|
+
// code 3, not 0 — code 0 would be read as a left-button drag during hover.
|
|
457
|
+
const code = buttonCode(held ?? 3, true, e.shiftKey, e.altKey, e.ctrlKey);
|
|
458
|
+
const seq = encodeSGR(code, pos.col, pos.row, false);
|
|
459
|
+
if (seq === lastMotion) {
|
|
460
|
+
return; // same cell, same buttons/modifiers: suppress the duplicate report
|
|
461
|
+
}
|
|
462
|
+
lastMotion = seq;
|
|
463
|
+
// Coalesce MOTION to one report per animation frame, latest wins. Under DEC
|
|
464
|
+
// 1016 the cell dedup above does nothing, so pixel mode would otherwise emit
|
|
465
|
+
// one report per DOM mousemove.
|
|
466
|
+
pendingMotion = seq;
|
|
467
|
+
motionFrame ??= requestAnimationFrame(flushMotion);
|
|
510
468
|
}
|
|
511
|
-
lastMotion = seq;
|
|
512
|
-
// Coalesce MOTION to one report per animation frame, latest wins. Under DEC
|
|
513
|
-
// 1016 the cell dedup above does nothing, so pixel mode would otherwise emit
|
|
514
|
-
// one report per DOM mousemove.
|
|
515
|
-
pendingMotion = seq;
|
|
516
|
-
motionFrame ??= requestAnimationFrame(flushMotion);
|
|
517
|
-
}
|
|
518
469
|
|
|
519
|
-
function onWheel(e: WheelEvent): void {
|
|
520
|
-
|
|
521
|
-
|
|
470
|
+
function onWheel(e: WheelEvent): void {
|
|
471
|
+
if (trackingOff() || !handler) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const pos = pixelToCell(e);
|
|
475
|
+
if (!pos) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
cancelPendingMotion();
|
|
479
|
+
// A gesture with NO vertical component (a horizontal swipe, shift-wheel
|
|
480
|
+
// routed to deltaX) has no wheel button: xterm.js reports nothing and still
|
|
481
|
+
// calls preventDefault, since a terminal owns the wheel and a sideways swipe
|
|
482
|
+
// let through would scroll the page. A wheel report is never coalesced: it
|
|
483
|
+
// is a NOTCH COUNT against a screen, so dropping one loses scroll distance.
|
|
484
|
+
if (e.deltaY !== 0) {
|
|
485
|
+
const btn = e.deltaY < 0 ? 64 : 65;
|
|
486
|
+
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
487
|
+
handler.sendReport(encodeSGR(code, pos.col, pos.row, false));
|
|
488
|
+
}
|
|
489
|
+
e.preventDefault();
|
|
522
490
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
491
|
+
|
|
492
|
+
function dispose(): void {
|
|
493
|
+
detach();
|
|
494
|
+
handler = null;
|
|
495
|
+
shiftBypass = false;
|
|
496
|
+
lastMotion = "";
|
|
497
|
+
clearGesture();
|
|
498
|
+
cancelPendingMotion();
|
|
526
499
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// A wheel report is never coalesced: it is a NOTCH COUNT against a screen, so
|
|
539
|
-
// dropping one loses scroll distance — which is why xterm.js expands a single
|
|
540
|
-
// WheelEvent into N discrete reports.
|
|
541
|
-
if (e.deltaY !== 0) {
|
|
542
|
-
const btn = e.deltaY < 0 ? 64 : 65;
|
|
543
|
-
const code = buttonCode(btn, false, e.shiftKey, e.altKey, e.ctrlKey);
|
|
544
|
-
handler.sendReport(encodeSGR(code, pos.col, pos.row, false));
|
|
500
|
+
|
|
501
|
+
const el = opts.termElement();
|
|
502
|
+
attachedEl = el;
|
|
503
|
+
try {
|
|
504
|
+
el.addEventListener("mousedown", onMouseDown);
|
|
505
|
+
el.addEventListener("mouseup", onMouseUp);
|
|
506
|
+
el.addEventListener("mousemove", onMouseMove);
|
|
507
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
508
|
+
} catch (err) {
|
|
509
|
+
dispose();
|
|
510
|
+
throw err;
|
|
545
511
|
}
|
|
546
|
-
|
|
512
|
+
|
|
513
|
+
return { resyncGesture, disarmGesture, dispose };
|
|
547
514
|
}
|