anvilsign in

collin/anvil

1import { isLinkActivationModifier } from "./hyperlink.js";
2const NORMAL_KEYS = {
3 ArrowUp: "\x1b[A",
4 ArrowDown: "\x1b[B",
5 ArrowRight: "\x1b[C",
6 ArrowLeft: "\x1b[D",
7 Home: "\x1b[H",
8 End: "\x1b[F",
9};
10const APP_KEYS = {
11 ArrowUp: "\x1bOA",
12 ArrowDown: "\x1bOB",
13 ArrowRight: "\x1bOC",
14 ArrowLeft: "\x1bOD",
15 Home: "\x1bOH",
16 End: "\x1bOF",
17};
18const FIXED_KEYS = {
19 Enter: "\r",
20 Backspace: "\x7f",
21 Tab: "\t",
22 Escape: "\x1b",
23 Insert: "\x1b[2~",
24 Delete: "\x1b[3~",
25 PageUp: "\x1b[5~",
26 PageDown: "\x1b[6~",
27 F1: "\x1bOP",
28 F2: "\x1bOQ",
29 F3: "\x1bOR",
30 F4: "\x1bOS",
31 F5: "\x1b[15~",
32 F6: "\x1b[17~",
33 F7: "\x1b[18~",
34 F8: "\x1b[19~",
35 F9: "\x1b[20~",
36 F10: "\x1b[21~",
37 F11: "\x1b[23~",
38 F12: "\x1b[24~",
39};
40export class InputHandler {
41 constructor(element, onData, getBridge, getCellSize = () => null) {
42 this.composing = false;
43 this.mouseButtons = 0;
44 this.focused = false;
45 this.element = element;
46 this.onData = onData;
47 this.getBridge = getBridge;
48 this.getCellSize = getCellSize;
49 this.textarea = document.createElement("textarea");
50 this.textarea.setAttribute("autocapitalize", "off");
51 this.textarea.setAttribute("autocomplete", "off");
52 this.textarea.setAttribute("autocorrect", "off");
53 this.textarea.setAttribute("spellcheck", "false");
54 this.textarea.setAttribute("enterkeyhint", "send");
55 this.textarea.setAttribute("tabindex", "0");
56 this.textarea.setAttribute("aria-hidden", "true");
57 const s = this.textarea.style;
58 s.position = "absolute";
59 s.left = "-9999px";
60 s.top = "0";
61 s.width = "1px";
62 s.height = "1px";
63 s.opacity = "0";
64 s.overflow = "hidden";
65 s.border = "0";
66 s.padding = "0";
67 s.margin = "0";
68 s.outline = "none";
69 s.resize = "none";
70 s.pointerEvents = "none";
71 s.caretColor = "transparent";
72 s.color = "transparent";
73 s.background = "transparent";
74 element.appendChild(this.textarea);
75 this._onKeyDown = this.handleKeyDown.bind(this);
76 this._onPaste = this.handlePaste.bind(this);
77 this._onCompositionStart = this.handleCompositionStart.bind(this);
78 this._onCompositionEnd = this.handleCompositionEnd.bind(this);
79 this._onInput = this.handleInput.bind(this);
80 this._onFocus = () => {
81 if (this.focused)
82 return;
83 this.focused = true;
84 this.element.classList.add("focused");
85 if (this.getBridge()?.focusEvents?.())
86 this.onData("\x1b[I");
87 };
88 this._onBlur = () => {
89 this.focused = false;
90 this.element.classList.remove("focused");
91 this.stopMouseCapture();
92 if (this.getBridge()?.focusEvents?.())
93 this.onData("\x1b[O");
94 };
95 this._onMouseDown = (event) => this.handleMouse(event, "press");
96 this._onMouseMove = (event) => {
97 if (this.mouseButtons !== 0)
98 this.handleMouse(event, "move");
99 };
100 this._onMouseUp = (event) => {
101 if (this.mouseButtons === 0)
102 return;
103 this.handleMouse(event, "release");
104 this.mouseButtons = event.buttons & 7;
105 if (this.mouseButtons === 0)
106 this.stopMouseCapture();
107 };
108 this._onWheel = (event) => this.handleMouse(event, "wheel");
109 this.textarea.addEventListener("keydown", this._onKeyDown);
110 this.textarea.addEventListener("paste", this._onPaste);
111 this.textarea.addEventListener("compositionstart", this._onCompositionStart);
112 this.textarea.addEventListener("compositionend", this._onCompositionEnd);
113 this.textarea.addEventListener("input", this._onInput);
114 this.textarea.addEventListener("focus", this._onFocus);
115 this.textarea.addEventListener("blur", this._onBlur);
116 this.element.addEventListener("mousedown", this._onMouseDown);
117 this.element.addEventListener("wheel", this._onWheel, { passive: false });
118 }
119 focus() {
120 this.textarea.focus({ preventScroll: true });
121 }
122 destroy() {
123 this.textarea.removeEventListener("keydown", this._onKeyDown);
124 this.textarea.removeEventListener("paste", this._onPaste);
125 this.textarea.removeEventListener("compositionstart", this._onCompositionStart);
126 this.textarea.removeEventListener("compositionend", this._onCompositionEnd);
127 this.textarea.removeEventListener("input", this._onInput);
128 this.textarea.removeEventListener("focus", this._onFocus);
129 this.textarea.removeEventListener("blur", this._onBlur);
130 this.element.removeEventListener("mousedown", this._onMouseDown);
131 this.stopMouseCapture();
132 this.element.removeEventListener("wheel", this._onWheel);
133 this.element.classList.remove("focused");
134 this.textarea.remove();
135 }
136 handleKeyDown(e) {
137 if (this.composing)
138 return;
139 if ((e.metaKey || e.ctrlKey) && e.key === "c") {
140 const sel = window.getSelection();
141 if (sel && sel.toString().length > 0)
142 return;
143 }
144 if ((e.metaKey || e.ctrlKey) && e.key === "v") {
145 this.textarea.focus();
146 return;
147 }
148 if (e.metaKey && !e.ctrlKey) {
149 if (e.key === "Backspace") {
150 e.preventDefault();
151 this.onData("\x15");
152 }
153 else if (e.key === "a") {
154 e.preventDefault();
155 const sel = window.getSelection();
156 if (sel) {
157 const range = document.createRange();
158 range.selectNodeContents(this.element);
159 sel.removeAllRanges();
160 sel.addRange(range);
161 }
162 }
163 return;
164 }
165 e.preventDefault();
166 const seq = this.keyToSequence(e);
167 if (seq)
168 this.onData(seq);
169 }
170 handlePaste(e) {
171 e.preventDefault();
172 const text = e.clipboardData?.getData("text");
173 if (!text)
174 return;
175 const bridge = this.getBridge();
176 if (bridge && bridge.bracketedPaste()) {
177 // Strip ESC bytes so clipboard payloads cannot inject \x1b[201~ to
178 // break out of bracketed paste mode and smuggle commands to the PTY.
179 const safe = text.replace(/\x1b/g, "");
180 this.onData("\x1b[200~" + safe + "\x1b[201~");
181 }
182 else {
183 this.onData(text);
184 }
185 }
186 handleCompositionStart() {
187 this.composing = true;
188 }
189 handleCompositionEnd(e) {
190 this.composing = false;
191 if (e.data)
192 this.onData(e.data);
193 this.textarea.value = "";
194 }
195 handleInput() {
196 if (this.composing)
197 return;
198 const value = this.textarea.value;
199 if (value) {
200 this.onData(value);
201 this.textarea.value = "";
202 }
203 }
204 handleMouse(event, kind) {
205 const bridge = this.getBridge();
206 const tracking = bridge?.mouseTracking?.() ?? 0;
207 if (!bridge || tracking === 0 || !bridge.mouseSgr?.())
208 return;
209 if (kind === "press" &&
210 isLinkActivationModifier(event, this.element.ownerDocument.defaultView?.navigator ?? navigator) &&
211 event.target instanceof Element &&
212 event.target.closest(".term-link")) {
213 return;
214 }
215 if (kind === "press" && (event.shiftKey || event.button > 2))
216 return;
217 if (kind === "release" && event.button > 2)
218 return;
219 const supportedButtons = event.buttons & 7;
220 if (kind === "move" && (tracking !== 1002 || supportedButtons === 0)) {
221 return;
222 }
223 const view = this.element.ownerDocument.defaultView;
224 if (!view)
225 return;
226 const viewportRow = this.element.querySelector(".term-row:not(.term-scrollback-row)");
227 const hostRect = this.element.getBoundingClientRect();
228 const rowRect = viewportRow?.getBoundingClientRect();
229 const cellSize = this.getCellSize();
230 let left;
231 let top;
232 let charWidth;
233 let rowHeight;
234 if (rowRect && cellSize) {
235 left = rowRect.left;
236 top = rowRect.top;
237 charWidth = cellSize.charWidth;
238 rowHeight = cellSize.rowHeight;
239 }
240 else {
241 const style = view.getComputedStyle(this.element);
242 const borderLeft = parseFloat(style.borderLeftWidth) || 0;
243 const borderRight = parseFloat(style.borderRightWidth) || 0;
244 const borderTop = parseFloat(style.borderTopWidth) || 0;
245 const borderBottom = parseFloat(style.borderBottomWidth) || 0;
246 const paddingLeft = parseFloat(style.paddingLeft) || 0;
247 const paddingRight = parseFloat(style.paddingRight) || 0;
248 const paddingTop = parseFloat(style.paddingTop) || 0;
249 const paddingBottom = parseFloat(style.paddingBottom) || 0;
250 left = rowRect?.left ?? hostRect.left + borderLeft + paddingLeft;
251 top = rowRect?.top ?? hostRect.top + borderTop + paddingTop;
252 charWidth =
253 (hostRect.width -
254 borderLeft -
255 borderRight -
256 paddingLeft -
257 paddingRight) /
258 bridge.getCols();
259 rowHeight =
260 (hostRect.height -
261 borderTop -
262 borderBottom -
263 paddingTop -
264 paddingBottom) /
265 bridge.getRows();
266 }
267 if (charWidth <= 0 || rowHeight <= 0)
268 return;
269 if (kind === "press") {
270 this.textarea.focus({ preventScroll: true });
271 if (!this.focused)
272 this._onFocus();
273 this.mouseButtons =
274 supportedButtons ||
275 (event.button === 1 ? 4 : event.button === 2 ? 2 : 1);
276 view.addEventListener("mousemove", this._onMouseMove);
277 view.addEventListener("mouseup", this._onMouseUp);
278 }
279 const col = Math.max(1, Math.min(bridge.getCols(), Math.floor((event.clientX - left) / charWidth) + 1));
280 const row = Math.max(1, Math.min(bridge.getRows(), Math.floor((event.clientY - top) / rowHeight) + 1));
281 const modifiers = (event.shiftKey ? 4 : 0) |
282 (event.altKey ? 8 : 0) |
283 (event.ctrlKey ? 16 : 0);
284 let code;
285 let final = "M";
286 if (kind === "wheel") {
287 const wheel = event;
288 if (Math.abs(wheel.deltaX) > Math.abs(wheel.deltaY)) {
289 if (wheel.deltaX === 0)
290 return;
291 code = (wheel.deltaX < 0 ? 66 : 67) | modifiers;
292 }
293 else {
294 if (wheel.deltaY === 0)
295 return;
296 code = (wheel.deltaY < 0 ? 64 : 65) | modifiers;
297 }
298 }
299 else {
300 const button = kind === "move"
301 ? supportedButtons & 4
302 ? 1
303 : supportedButtons & 2
304 ? 2
305 : 0
306 : event.button === 1
307 ? 1
308 : event.button === 2
309 ? 2
310 : 0;
311 code = button | modifiers | (kind === "move" ? 32 : 0);
312 if (kind === "release")
313 final = "m";
314 }
315 event.preventDefault();
316 this.onData(`\x1b[<${code};${col};${row}${final}`);
317 }
318 stopMouseCapture() {
319 this.mouseButtons = 0;
320 const view = this.element.ownerDocument.defaultView;
321 view?.removeEventListener("mousemove", this._onMouseMove);
322 view?.removeEventListener("mouseup", this._onMouseUp);
323 }
324 keyToSequence(e) {
325 if (e.ctrlKey && !e.altKey && !e.metaKey) {
326 if (e.key.length === 1) {
327 const code = e.key.toLowerCase().charCodeAt(0);
328 if (code >= 97 && code <= 122)
329 return String.fromCharCode(code - 96);
330 }
331 if (e.key === "[")
332 return "\x1b";
333 if (e.key === "\\")
334 return "\x1c";
335 if (e.key === "]")
336 return "\x1d";
337 if (e.key === "^")
338 return "\x1e";
339 if (e.key === "_")
340 return "\x1f";
341 }
342 if (e.key === "Enter" && e.shiftKey)
343 return "\x1b[13;2u";
344 if (e.key === "Tab" && e.shiftKey)
345 return "\x1b[Z";
346 const fixed = FIXED_KEYS[e.key];
347 if (fixed)
348 return e.altKey ? "\x1b" + fixed : fixed;
349 const bridge = this.getBridge();
350 const appMode = bridge && bridge.cursorKeysApp();
351 const navMap = appMode ? APP_KEYS : NORMAL_KEYS;
352 const nav = navMap[e.key];
353 if (nav)
354 return e.altKey ? "\x1b" + nav : nav;
355 if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
356 return e.altKey ? "\x1b" + e.key : e.key;
357 }
358 return null;
359 }
360}