feat: port parse-keypress (semantic mapper) from Ink
Port Ink's parse-keypress.ts to map raw input event strings to semantic key objects with name, ctrl, shift, meta, and kitty protocol support. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect } from "vite-plus/test";
|
||||
import { parseKeypress } from "./parse-keypress.ts";
|
||||
|
||||
describe("parse-keypress", () => {
|
||||
test("parses regular character", () => {
|
||||
const key = parseKeypress("a");
|
||||
expect(key.name).toBe("a");
|
||||
expect(key.ctrl).toBe(false);
|
||||
expect(key.shift).toBe(false);
|
||||
expect(key.meta).toBe(false);
|
||||
});
|
||||
|
||||
test("parses ctrl+c", () => {
|
||||
const key = parseKeypress("\x03");
|
||||
expect(key.name).toBe("c");
|
||||
expect(key.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
test("parses arrow up", () => {
|
||||
const key = parseKeypress("\x1b[A");
|
||||
expect(key.name).toBe("up");
|
||||
});
|
||||
|
||||
test("parses arrow down", () => {
|
||||
const key = parseKeypress("\x1b[B");
|
||||
expect(key.name).toBe("down");
|
||||
});
|
||||
|
||||
test("parses meta+a", () => {
|
||||
const key = parseKeypress("\x1ba");
|
||||
expect(key.name).toBe("a");
|
||||
expect(key.meta).toBe(true);
|
||||
});
|
||||
|
||||
test("parses backspace", () => {
|
||||
const key = parseKeypress("\x7F");
|
||||
expect(key.name).toBe("backspace");
|
||||
});
|
||||
|
||||
test("parses return", () => {
|
||||
const key = parseKeypress("\r");
|
||||
expect(key.name).toBe("return");
|
||||
});
|
||||
|
||||
test("parses tab", () => {
|
||||
const key = parseKeypress("\t");
|
||||
expect(key.name).toBe("tab");
|
||||
});
|
||||
|
||||
test("parses shift+tab", () => {
|
||||
const key = parseKeypress("\x1b[Z");
|
||||
expect(key.name).toBe("tab");
|
||||
expect(key.shift).toBe(true);
|
||||
});
|
||||
|
||||
test("parses escape", () => {
|
||||
const key = parseKeypress("\x1b");
|
||||
expect(key.name).toBe("escape");
|
||||
});
|
||||
|
||||
test("parses F1", () => {
|
||||
const key = parseKeypress("\x1bOP");
|
||||
expect(key.name).toBe("f1");
|
||||
});
|
||||
|
||||
test("parses home", () => {
|
||||
const key = parseKeypress("\x1b[H");
|
||||
expect(key.name).toBe("home");
|
||||
});
|
||||
|
||||
test("parses end", () => {
|
||||
const key = parseKeypress("\x1b[F");
|
||||
expect(key.name).toBe("end");
|
||||
});
|
||||
|
||||
test("parses delete", () => {
|
||||
const key = parseKeypress("\x1b[3~");
|
||||
expect(key.name).toBe("delete");
|
||||
});
|
||||
|
||||
test("parses page up", () => {
|
||||
const key = parseKeypress("\x1b[5~");
|
||||
expect(key.name).toBe("pageup");
|
||||
});
|
||||
|
||||
test("uppercase letter sets shift", () => {
|
||||
const key = parseKeypress("A");
|
||||
expect(key.name).toBe("a");
|
||||
expect(key.shift).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
// Ported from Ink's parse-keypress.ts
|
||||
// @see https://github.com/vadimdemedes/ink
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;
|
||||
|
||||
const fnKeyRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;
|
||||
|
||||
const keyName: Record<string, string> = {
|
||||
/* xterm/gnome ESC O letter */
|
||||
OP: "f1",
|
||||
OQ: "f2",
|
||||
OR: "f3",
|
||||
OS: "f4",
|
||||
/* vt220-style ESC [ letter (e.g. Ctrl+F1 sends ESC [ 1 ; 5 P) */
|
||||
"[P": "f1",
|
||||
"[Q": "f2",
|
||||
"[R": "f3",
|
||||
"[S": "f4",
|
||||
/* xterm/rxvt ESC [ number ~ */
|
||||
"[11~": "f1",
|
||||
"[12~": "f2",
|
||||
"[13~": "f3",
|
||||
"[14~": "f4",
|
||||
/* from Cygwin and used in libuv */
|
||||
"[[A": "f1",
|
||||
"[[B": "f2",
|
||||
"[[C": "f3",
|
||||
"[[D": "f4",
|
||||
"[[E": "f5",
|
||||
/* common */
|
||||
"[15~": "f5",
|
||||
"[17~": "f6",
|
||||
"[18~": "f7",
|
||||
"[19~": "f8",
|
||||
"[20~": "f9",
|
||||
"[21~": "f10",
|
||||
"[23~": "f11",
|
||||
"[24~": "f12",
|
||||
/* xterm ESC [ letter */
|
||||
"[A": "up",
|
||||
"[B": "down",
|
||||
"[C": "right",
|
||||
"[D": "left",
|
||||
"[E": "clear",
|
||||
"[F": "end",
|
||||
"[H": "home",
|
||||
/* xterm/gnome ESC O letter */
|
||||
OA: "up",
|
||||
OB: "down",
|
||||
OC: "right",
|
||||
OD: "left",
|
||||
OE: "clear",
|
||||
OF: "end",
|
||||
OH: "home",
|
||||
/* xterm/rxvt ESC [ number ~ */
|
||||
"[1~": "home",
|
||||
"[2~": "insert",
|
||||
"[3~": "delete",
|
||||
"[4~": "end",
|
||||
"[5~": "pageup",
|
||||
"[6~": "pagedown",
|
||||
/* putty */
|
||||
"[[5~": "pageup",
|
||||
"[[6~": "pagedown",
|
||||
/* rxvt */
|
||||
"[7~": "home",
|
||||
"[8~": "end",
|
||||
/* rxvt keys with modifiers */
|
||||
"[a": "up",
|
||||
"[b": "down",
|
||||
"[c": "right",
|
||||
"[d": "left",
|
||||
"[e": "clear",
|
||||
|
||||
"[2$": "insert",
|
||||
"[3$": "delete",
|
||||
"[5$": "pageup",
|
||||
"[6$": "pagedown",
|
||||
"[7$": "home",
|
||||
"[8$": "end",
|
||||
|
||||
Oa: "up",
|
||||
Ob: "down",
|
||||
Oc: "right",
|
||||
Od: "left",
|
||||
Oe: "clear",
|
||||
|
||||
"[2^": "insert",
|
||||
"[3^": "delete",
|
||||
"[5^": "pageup",
|
||||
"[6^": "pagedown",
|
||||
"[7^": "home",
|
||||
"[8^": "end",
|
||||
/* misc. */
|
||||
"[Z": "tab",
|
||||
};
|
||||
|
||||
export const nonAlphanumericKeys = [...Object.values(keyName), "backspace"];
|
||||
|
||||
const isShiftKey = (code: string) => {
|
||||
return ["[a", "[b", "[c", "[d", "[e", "[2$", "[3$", "[5$", "[6$", "[7$", "[8$", "[Z"].includes(
|
||||
code,
|
||||
);
|
||||
};
|
||||
|
||||
const isCtrlKey = (code: string) => {
|
||||
return ["Oa", "Ob", "Oc", "Od", "Oe", "[2^", "[3^", "[5^", "[6^", "[7^", "[8^"].includes(code);
|
||||
};
|
||||
|
||||
export interface Keypress {
|
||||
name: string;
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
sequence: string;
|
||||
raw: string | undefined;
|
||||
code?: string;
|
||||
super?: boolean;
|
||||
hyper?: boolean;
|
||||
capsLock?: boolean;
|
||||
numLock?: boolean;
|
||||
eventType?: "press" | "repeat" | "release";
|
||||
isKittyProtocol?: boolean;
|
||||
text?: string;
|
||||
/**
|
||||
* Whether this key represents printable text input.
|
||||
* When false, the key is a control/function/modifier key that should not
|
||||
* produce text input (e.g., arrows, function keys, capslock, media keys).
|
||||
* Only set by the kitty protocol parser.
|
||||
*/
|
||||
isPrintable?: boolean;
|
||||
}
|
||||
|
||||
// Kitty keyboard protocol modifier bits.
|
||||
// The actual modifier value in CSI sequences is (modifiers - 1).
|
||||
const kittyModifiers = {
|
||||
shift: 1,
|
||||
alt: 2,
|
||||
ctrl: 4,
|
||||
super: 8,
|
||||
hyper: 16,
|
||||
meta: 32,
|
||||
capsLock: 64,
|
||||
numLock: 128,
|
||||
} as const;
|
||||
|
||||
// Kitty keyboard protocol: CSI codepoint ; modifiers [: eventType] [; text-as-codepoints] u
|
||||
const kittyKeyRe = /^\x1b\[(\d+)(?:;(\d+)(?::(\d+))?(?:;([\d:]+))?)?u$/;
|
||||
|
||||
// Kitty-enhanced special keys: CSI number ; modifiers : eventType {letter|~}
|
||||
// These are legacy CSI sequences enhanced with the :eventType field.
|
||||
// Examples: \x1b[1;1:1A (up arrow press), \x1b[3;1:3~ (delete release)
|
||||
const kittySpecialKeyRe = /^\x1b\[(\d+);(\d+):(\d+)([A-Za-z~])$/;
|
||||
|
||||
// Letter-terminated special key names (CSI 1 ; mods letter)
|
||||
const kittySpecialLetterKeys: Record<string, string> = {
|
||||
A: "up",
|
||||
B: "down",
|
||||
C: "right",
|
||||
D: "left",
|
||||
E: "clear",
|
||||
F: "end",
|
||||
H: "home",
|
||||
P: "f1",
|
||||
Q: "f2",
|
||||
R: "f3",
|
||||
S: "f4",
|
||||
};
|
||||
|
||||
// Number-terminated special key names (CSI number ; mods ~)
|
||||
const kittySpecialNumberKeys: Record<number, string> = {
|
||||
2: "insert",
|
||||
3: "delete",
|
||||
5: "pageup",
|
||||
6: "pagedown",
|
||||
7: "home",
|
||||
8: "end",
|
||||
11: "f1",
|
||||
12: "f2",
|
||||
13: "f3",
|
||||
14: "f4",
|
||||
15: "f5",
|
||||
17: "f6",
|
||||
18: "f7",
|
||||
19: "f8",
|
||||
20: "f9",
|
||||
21: "f10",
|
||||
23: "f11",
|
||||
24: "f12",
|
||||
};
|
||||
|
||||
// Map of special codepoints to key names in kitty protocol
|
||||
const kittyCodepointNames: Record<number, string> = {
|
||||
27: "escape",
|
||||
// 13 (return) and 32 (space) are handled before this lookup
|
||||
// in parseKittyKeypress so they can be marked as printable.
|
||||
9: "tab",
|
||||
127: "backspace",
|
||||
8: "backspace",
|
||||
57358: "capslock",
|
||||
57359: "scrolllock",
|
||||
57360: "numlock",
|
||||
57361: "printscreen",
|
||||
57362: "pause",
|
||||
57363: "menu",
|
||||
57376: "f13",
|
||||
57377: "f14",
|
||||
57378: "f15",
|
||||
57379: "f16",
|
||||
57380: "f17",
|
||||
57381: "f18",
|
||||
57382: "f19",
|
||||
57383: "f20",
|
||||
57384: "f21",
|
||||
57385: "f22",
|
||||
57386: "f23",
|
||||
57387: "f24",
|
||||
57388: "f25",
|
||||
57389: "f26",
|
||||
57390: "f27",
|
||||
57391: "f28",
|
||||
57392: "f29",
|
||||
57393: "f30",
|
||||
57394: "f31",
|
||||
57395: "f32",
|
||||
57396: "f33",
|
||||
57397: "f34",
|
||||
57398: "f35",
|
||||
57399: "kp0",
|
||||
57400: "kp1",
|
||||
57401: "kp2",
|
||||
57402: "kp3",
|
||||
57403: "kp4",
|
||||
57404: "kp5",
|
||||
57405: "kp6",
|
||||
57406: "kp7",
|
||||
57407: "kp8",
|
||||
57408: "kp9",
|
||||
57409: "kpdecimal",
|
||||
57410: "kpdivide",
|
||||
57411: "kpmultiply",
|
||||
57412: "kpsubtract",
|
||||
57413: "kpadd",
|
||||
57414: "kpenter",
|
||||
57415: "kpequal",
|
||||
57416: "kpseparator",
|
||||
57417: "kpleft",
|
||||
57418: "kpright",
|
||||
57419: "kpup",
|
||||
57420: "kpdown",
|
||||
57421: "kppageup",
|
||||
57422: "kppagedown",
|
||||
57423: "kphome",
|
||||
57424: "kpend",
|
||||
57425: "kpinsert",
|
||||
57426: "kpdelete",
|
||||
57427: "kpbegin",
|
||||
57428: "mediaplay",
|
||||
57429: "mediapause",
|
||||
57430: "mediaplaypause",
|
||||
57431: "mediareverse",
|
||||
57432: "mediastop",
|
||||
57433: "mediafastforward",
|
||||
57434: "mediarewind",
|
||||
57435: "mediatracknext",
|
||||
57436: "mediatrackprevious",
|
||||
57437: "mediarecord",
|
||||
57438: "lowervolume",
|
||||
57439: "raisevolume",
|
||||
57440: "mutevolume",
|
||||
57441: "leftshift",
|
||||
57442: "leftcontrol",
|
||||
57443: "leftalt",
|
||||
57444: "leftsuper",
|
||||
57445: "lefthyper",
|
||||
57446: "leftmeta",
|
||||
57447: "rightshift",
|
||||
57448: "rightcontrol",
|
||||
57449: "rightalt",
|
||||
57450: "rightsuper",
|
||||
57451: "righthyper",
|
||||
57452: "rightmeta",
|
||||
57453: "isoLevel3Shift",
|
||||
57454: "isoLevel5Shift",
|
||||
};
|
||||
|
||||
// Valid Unicode codepoint range, excluding surrogates
|
||||
const isValidCodepoint = (cp: number): boolean =>
|
||||
cp >= 0 && cp <= 0x10_ffff && !(cp >= 0xd8_00 && cp <= 0xdf_ff);
|
||||
|
||||
const safeFromCodePoint = (cp: number): string =>
|
||||
isValidCodepoint(cp) ? String.fromCodePoint(cp) : "?";
|
||||
|
||||
type EventType = "press" | "repeat" | "release";
|
||||
|
||||
function resolveEventType(value: number): EventType {
|
||||
if (value === 3) return "release";
|
||||
if (value === 2) return "repeat";
|
||||
return "press";
|
||||
}
|
||||
|
||||
function parseKittyModifiers(
|
||||
modifiers: number,
|
||||
): Pick<Keypress, "ctrl" | "shift" | "meta" | "super" | "hyper" | "capsLock" | "numLock"> {
|
||||
return {
|
||||
ctrl: !!(modifiers & kittyModifiers.ctrl),
|
||||
shift: !!(modifiers & kittyModifiers.shift),
|
||||
meta: !!(modifiers & (kittyModifiers.meta | kittyModifiers.alt)),
|
||||
super: !!(modifiers & kittyModifiers.super),
|
||||
hyper: !!(modifiers & kittyModifiers.hyper),
|
||||
capsLock: !!(modifiers & kittyModifiers.capsLock),
|
||||
numLock: !!(modifiers & kittyModifiers.numLock),
|
||||
};
|
||||
}
|
||||
|
||||
const parseKittyKeypress = (s: string): Keypress | null => {
|
||||
const match = kittyKeyRe.exec(s);
|
||||
if (!match) return null;
|
||||
|
||||
const codepoint = parseInt(match[1]!, 10);
|
||||
const modifiers = match[2] ? Math.max(0, parseInt(match[2], 10) - 1) : 0;
|
||||
const eventType = match[3] ? parseInt(match[3], 10) : 1;
|
||||
const textField = match[4];
|
||||
|
||||
// Bail on invalid primary codepoint
|
||||
if (!isValidCodepoint(codepoint)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse text-as-codepoints field (colon-separated Unicode codepoints)
|
||||
let text: string | undefined;
|
||||
if (textField) {
|
||||
text = textField
|
||||
.split(":")
|
||||
.map((cp) => safeFromCodePoint(parseInt(cp, 10)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Determine key name from codepoint
|
||||
let name: string;
|
||||
let isPrintable: boolean;
|
||||
if (codepoint === 32) {
|
||||
name = "space";
|
||||
isPrintable = true;
|
||||
} else if (codepoint === 13) {
|
||||
name = "return";
|
||||
isPrintable = true;
|
||||
} else if (kittyCodepointNames[codepoint]) {
|
||||
name = kittyCodepointNames[codepoint]!;
|
||||
isPrintable = false;
|
||||
} else if (codepoint >= 1 && codepoint <= 26) {
|
||||
// Ctrl+letter comes as codepoint 1-26
|
||||
name = String.fromCodePoint(codepoint + 96); // 'a' is 97
|
||||
isPrintable = false;
|
||||
} else {
|
||||
name = safeFromCodePoint(codepoint).toLowerCase();
|
||||
isPrintable = true;
|
||||
}
|
||||
|
||||
// Default text to the character from the codepoint when not explicitly
|
||||
// provided by the protocol, so keys like space and return produce their
|
||||
// expected text input (' ' and '\r' respectively).
|
||||
if (isPrintable && !text) {
|
||||
text = safeFromCodePoint(codepoint);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
...parseKittyModifiers(modifiers),
|
||||
eventType: resolveEventType(eventType),
|
||||
sequence: s,
|
||||
raw: s,
|
||||
isKittyProtocol: true,
|
||||
isPrintable,
|
||||
text,
|
||||
};
|
||||
};
|
||||
|
||||
// Parse kitty-enhanced special key sequences (arrow keys, function keys, etc.)
|
||||
// These use the legacy CSI format but with an added :eventType field.
|
||||
const parseKittySpecialKey = (s: string): Keypress | null => {
|
||||
const match = kittySpecialKeyRe.exec(s);
|
||||
if (!match) return null;
|
||||
|
||||
const number = parseInt(match[1]!, 10);
|
||||
const modifiers = Math.max(0, parseInt(match[2]!, 10) - 1);
|
||||
const eventType = parseInt(match[3]!, 10);
|
||||
const terminator = match[4]!;
|
||||
|
||||
const name =
|
||||
terminator === "~" ? kittySpecialNumberKeys[number] : kittySpecialLetterKeys[terminator];
|
||||
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
...parseKittyModifiers(modifiers),
|
||||
eventType: resolveEventType(eventType),
|
||||
sequence: s,
|
||||
raw: s,
|
||||
isKittyProtocol: true,
|
||||
isPrintable: false,
|
||||
};
|
||||
};
|
||||
|
||||
export function parseKeypress(s: Uint8Array | string = ""): Keypress {
|
||||
let parts;
|
||||
|
||||
if (s instanceof Uint8Array) {
|
||||
if (s[0]! > 127 && s[1] === undefined) {
|
||||
(s[0] as unknown as number) -= 128;
|
||||
s = "\x1b" + textDecoder.decode(s);
|
||||
} else {
|
||||
s = textDecoder.decode(s);
|
||||
}
|
||||
} else if (s !== undefined && typeof s !== "string") {
|
||||
s = String(s);
|
||||
} else if (!s) {
|
||||
s = "";
|
||||
}
|
||||
|
||||
// Try kitty keyboard protocol parsers first
|
||||
const kittyResult = parseKittyKeypress(s);
|
||||
if (kittyResult) return kittyResult;
|
||||
|
||||
const kittySpecialResult = parseKittySpecialKey(s);
|
||||
if (kittySpecialResult) return kittySpecialResult;
|
||||
|
||||
// If the input matched the kitty CSI-u pattern but was rejected (e.g.,
|
||||
// invalid codepoint), return a safe empty keypress instead of falling
|
||||
// through to legacy parsing which can produce unsafe states (undefined name)
|
||||
if (kittyKeyRe.test(s)) {
|
||||
return {
|
||||
name: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
sequence: s,
|
||||
raw: s,
|
||||
isKittyProtocol: true,
|
||||
isPrintable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const key: Keypress = {
|
||||
name: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
sequence: s,
|
||||
raw: s,
|
||||
};
|
||||
|
||||
key.sequence = key.sequence || s || key.name;
|
||||
|
||||
if (s === "\r" || s === "\x1b\r") {
|
||||
// carriage return (or meta+return on macOS)
|
||||
key.raw = undefined;
|
||||
key.name = "return";
|
||||
key.meta = s.length === 2;
|
||||
} else if (s === "\n") {
|
||||
// enter, should have been called linefeed
|
||||
key.name = "enter";
|
||||
} else if (s === "\t") {
|
||||
// tab
|
||||
key.name = "tab";
|
||||
} else if (s === "\b" || s === "\x1b\b") {
|
||||
// backspace or ctrl+h
|
||||
key.name = "backspace";
|
||||
key.meta = s.charAt(0) === "\x1b";
|
||||
} else if (s === "\x7f" || s === "\x1b\x7f") {
|
||||
// backspace
|
||||
key.name = "backspace";
|
||||
key.meta = s.charAt(0) === "\x1b";
|
||||
} else if (s === "\x1b" || s === "\x1b\x1b") {
|
||||
// escape key
|
||||
key.name = "escape";
|
||||
key.meta = s.length === 2;
|
||||
} else if (s === " " || s === "\x1b ") {
|
||||
key.name = "space";
|
||||
key.meta = s.length === 2;
|
||||
} else if (s.length === 1 && s <= "\x1a") {
|
||||
// ctrl+letter
|
||||
key.name = String.fromCharCode(s.charCodeAt(0) + "a".charCodeAt(0) - 1);
|
||||
key.ctrl = true;
|
||||
} else if (s.length === 1 && s >= "0" && s <= "9") {
|
||||
// number
|
||||
key.name = "number";
|
||||
} else if (s.length === 1 && s >= "a" && s <= "z") {
|
||||
// lowercase letter
|
||||
key.name = s;
|
||||
} else if (s.length === 1 && s >= "A" && s <= "Z") {
|
||||
// shift+letter
|
||||
key.name = s.toLowerCase();
|
||||
key.shift = true;
|
||||
} else if ((parts = metaKeyCodeRe.exec(s))) {
|
||||
// meta+character key
|
||||
key.name = parts[1]!.toLowerCase();
|
||||
key.meta = true;
|
||||
key.shift = /^[A-Z]$/.test(parts[1]!);
|
||||
} else if ((parts = fnKeyRe.exec(s))) {
|
||||
const segs = [...s];
|
||||
|
||||
if (segs[0] === "" && segs[1] === "") {
|
||||
key.meta = true;
|
||||
}
|
||||
|
||||
// ansi escape sequence
|
||||
// reassemble the key code leaving out leading \x1b's,
|
||||
// the modifier key bitflag and any meaningless "1;" sequence
|
||||
const code = [parts[1], parts[2], parts[4], parts[6]].filter(Boolean).join("");
|
||||
|
||||
const modifier = ((parts[3] || parts[5] || 1) as number) - 1;
|
||||
|
||||
// Parse the key modifier
|
||||
key.ctrl = !!(modifier & 4);
|
||||
key.meta = key.meta || !!(modifier & 10);
|
||||
key.shift = !!(modifier & 1);
|
||||
key.code = code;
|
||||
|
||||
key.name = keyName[code] ?? "";
|
||||
key.shift = isShiftKey(code) || key.shift;
|
||||
key.ctrl = isCtrlKey(code) || key.ctrl;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
Reference in New Issue
Block a user