diff --git a/background_scripts/exclusions.js b/background_scripts/exclusions.js index aaa2444fe..c529ec3da 100644 --- a/background_scripts/exclusions.js +++ b/background_scripts/exclusions.js @@ -1,5 +1,5 @@ -// This module manages manages the exclusion rule setting. An exclusion is an object with two -// attributes: pattern and passKeys. The exclusion rules are an array of such objects. +// This module manages manages the exclusion rule setting. An exclusion is an object with three +// attributes: pattern, passKeys and blockedKeys. The exclusion rules are an array of such objects. const ExclusionRegexpCache = { cache: {}, @@ -46,10 +46,16 @@ function getRule(url, rules) { } // Strip whitespace from all matching passKeys strings, and join them together. const passKeys = matchingRules.map((r) => r.passKeys.split(/\s+/).join("")).join(""); + const blockedKeys = matchingRules.map((r) => (r.blockedKeys ?? "").split(/\s+/).join("")).join( + "", + ); // TODO(philc): Remove this commented out code. // passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join "" if (matchingRules.length > 0) { - return { passKeys: Utils.distinctCharacters(passKeys) }; + return { + passKeys: Utils.distinctCharacters(passKeys), + blockedKeys: Utils.distinctCharacters(blockedKeys), + }; } else { return null; } @@ -60,6 +66,7 @@ export function isEnabledForUrl(url) { return { isEnabledForUrl: !rule || (rule.passKeys.length > 0), passKeys: rule ? rule.passKeys : "", + blockedKeys: rule ? rule.blockedKeys ?? "" : "", }; } diff --git a/content_scripts/mode_key_handler.js b/content_scripts/mode_key_handler.js index 21209c980..4a6f27fcf 100644 --- a/content_scripts/mode_key_handler.js +++ b/content_scripts/mode_key_handler.js @@ -20,6 +20,10 @@ class KeyHandlerMode extends Mode { this.passKeys = passKeys; this.reset(); } + setBlockedKeys(blockedKeys) { + this.blockedKeys = blockedKeys; + this.reset(); + } // Only for tests. setCommandHandler(commandHandler) { @@ -71,6 +75,8 @@ class KeyHandlerMode extends Mode { // preview popups. If the user types escape, issue a mouseout event here. See #3073. HintCoordinator.mouseOutOfLastClickedElement(); return this.continueBubbling; + } else if (this.isBlockedKey(keyChar)) { + return this.suppressEvent; } else if (this.isMappedKey(keyChar)) { this.handleKeyChar(keyChar); return this.suppressEvent; @@ -92,6 +98,13 @@ class KeyHandlerMode extends Mode { !this.isPassKey(keyChar); } + isBlockedKey(keyChar) { + return this.isInResetState() && + keyChar && + this.blockedKeys && + this.blockedKeys.includes(keyChar); + } + // This tests whether keyChar is a digit (and accounts for pass keys). isCountKey(keyChar) { return keyChar && diff --git a/content_scripts/vimium_frontend.js b/content_scripts/vimium_frontend.js index c4d5331ec..c78b538e6 100644 --- a/content_scripts/vimium_frontend.js +++ b/content_scripts/vimium_frontend.js @@ -445,6 +445,7 @@ async function checkIfEnabledForUrl() { if (normalMode == null) installModes(); normalMode.setPassKeys(response.passKeys); + normalMode.setBlockedKeys(response.blockedKeys); // Hide the HUD if we're not enabled. if (!isEnabledForUrl) HUD.hide(true, false); } diff --git a/lib/keyboard_utils.js b/lib/keyboard_utils.js index 69f82af1c..837df45e6 100644 --- a/lib/keyboard_utils.js +++ b/lib/keyboard_utils.js @@ -31,6 +31,36 @@ const KeyboardUtils = { } }, + getKeyFromCode(event) { + if (!event.code) { + return event.key != null ? event.key : ""; // Fall back to event.key (see #3099). + } else if (event.code.slice(0, 6) === "Numpad") { + // We cannot correctly emulate the numpad, so fall back to event.key; see #2626. + return event.key; + } + + // The logic here is from the vim-like-key-notation project + // (https://github.com/lydell/vim-like-key-notation). + let key = event.code; + if (key.slice(0, 3) === "Key") key = key.slice(3); + // Translate some special keys to event.key-like strings and handle . + if (this.enUsTranslations[key]) { + key = event.shiftKey ? this.enUsTranslations[key][1] : this.enUsTranslations[key][0]; + } else if ((key.length === 1) && !event.shiftKey) { + key = key.toLowerCase(); + } + return key; + }, + + shouldUseEventCodeFallback(event, key) { + if (event.code == null) return false; + if (event.isComposing || (event.keyCode === 229)) return true; + return (key != null) && + (key.length === 1) && + /^\p{Letter}$/u.test(key) && + !/^\p{Script=Latin}$/u.test(key); + }, + getKeyChar(event) { let key; const canUseEventKey = !Settings.get("ignoreKeyboardLayout") && @@ -41,22 +71,14 @@ const KeyboardUtils = { if (canUseEventKey) { key = event.key; - } else if (!event.code) { - key = event.key != null ? event.key : ""; // Fall back to event.key (see #3099). - } else if (event.code.slice(0, 6) === "Numpad") { - // We cannot correctly emulate the numpad, so fall back to event.key; see #2626. - key = event.key; - } else { - // The logic here is from the vim-like-key-notation project - // (https://github.com/lydell/vim-like-key-notation). - key = event.code; - if (key.slice(0, 3) === "Key") key = key.slice(3); - // Translate some special keys to event.key-like strings and handle . - if (this.enUsTranslations[key]) { - key = event.shiftKey ? this.enUsTranslations[key][1] : this.enUsTranslations[key][0]; - } else if ((key.length === 1) && !event.shiftKey) { - key = key.toLowerCase(); + // IMEs can emit non-Latin letters (for example Korean Hangul) even while normal mode expects + // physical-key mappings. Use the existing event.code translation in those cases, but keep + // non-English Latin layouts like "é" working via event.key. + if (this.shouldUseEventCodeFallback(event, key)) { + key = this.getKeyFromCode(event); } + } else { + key = this.getKeyFromCode(event); } // It appears that key is not always defined (see #2453). diff --git a/lib/settings.js b/lib/settings.js index 93dca9d1f..8e2b75557 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -38,6 +38,7 @@ div > .vimiumHintMarker > .matchingCharacter { exclusionRules: [ // Disable Vimium on Gmail. { + blockedKeys: "", passKeys: "", pattern: "https?://mail.google.com/*", }, diff --git a/pages/action.html b/pages/action.html index 2e1fae6cf..fc720c7a4 100644 --- a/pages/action.html +++ b/pages/action.html @@ -64,6 +64,7 @@

Patterns matching the current page Keys to exclude + Keys to block @@ -92,6 +93,9 @@

+ + + diff --git a/pages/exclusion_rules_editor.js b/pages/exclusion_rules_editor.js index 95cf9acd8..3f556726f 100644 --- a/pages/exclusion_rules_editor.js +++ b/pages/exclusion_rules_editor.js @@ -11,7 +11,8 @@ const ExclusionRulesEditor = { }); }, - // - exclusionRules: the value obtained from settings, with the shape [{pattern, passKeys}]. + // - exclusionRules: the value obtained from settings, with the shape + // [{pattern, passKeys, blockedKeys}]. setForm(exclusionRules = []) { const rulesTable = document.querySelector("#exclusion-rules"); // Remove any previous rows. @@ -20,14 +21,13 @@ const ExclusionRulesEditor = { el.remove(); } - const rowTemplate = document.querySelector("#exclusion-rule-template").content; for (const rule of exclusionRules) { - this.addRow(rule.pattern, rule.passKeys); + this.addRow(rule.pattern, rule.passKeys, rule.blockedKeys); } }, - // `pattern` and `passKeys` are optional. - addRow(pattern, passKeys) { + // `pattern`, `passKeys`, and `blockedKeys` are optional. + addRow(pattern, passKeys, blockedKeys) { const rulesTable = document.querySelector("#exclusion-rules"); const rowTemplate = document.querySelector("#exclusion-rule-template").content; const rowEl = rowTemplate.cloneNode(true); @@ -40,6 +40,10 @@ const ExclusionRulesEditor = { keysEl.value = passKeys ?? ""; keysEl.addEventListener("input", () => this.dispatchEvent("input")); + const blockedKeysEl = rowEl.querySelector("[name=blockedKeys]"); + blockedKeysEl.value = blockedKeys ?? ""; + blockedKeysEl.addEventListener("input", () => this.dispatchEvent("input")); + rowEl.querySelector(".remove").addEventListener("click", (e) => { e.target.closest("tr").remove(); this.dispatchEvent("input"); @@ -53,6 +57,7 @@ const ExclusionRulesEditor = { const rules = rows .map((el) => { return { + blockedKeys: el.querySelector("[name=blockedKeys]").value.trim(), // The ordering of these keys should match the order in defaultOptions in Settings.js. passKeys: el.querySelector("[name=passKeys]").value.trim(), pattern: el.querySelector("[name=pattern]").value.trim(), diff --git a/pages/options.css b/pages/options.css index 13f1a67f2..37a24b1e9 100644 --- a/pages/options.css +++ b/pages/options.css @@ -300,6 +300,7 @@ textarea:focus { input[name="pattern"], input[name="passKeys"], +input[name="blockedKeys"], .exclusion-header-text { width: 100%; font-family: Consolas, "Liberation Mono", Courier, monospace; diff --git a/pages/options.html b/pages/options.html index 1cc84fa90..a94682208 100644 --- a/pages/options.html +++ b/pages/options.html @@ -22,6 +22,7 @@

Excluded URLs and keys

Patterns Keys to exclude + Keys to block @@ -32,6 +33,8 @@

Excluded URLs and keys

"Patterns" are URL regular expressions. * will match zero or more characters.
"Keys": Vimium will exclude these keys and pass them through to the page. +
+ "Keys to block": Vimium will suppress these keys so the page never receives them.

Custom key mappings

@@ -273,6 +276,9 @@

Restore

+ + + diff --git a/tests/unit_tests/exclusion_test.js b/tests/unit_tests/exclusion_test.js index c1e56d234..39938f2c2 100644 --- a/tests/unit_tests/exclusion_test.js +++ b/tests/unit_tests/exclusion_test.js @@ -11,14 +11,14 @@ context("Excluded URLs and pass keys", () => { setup(async () => { await Settings.onLoaded(); await Settings.set("exclusionRules", [ - { pattern: "http*://mail.google.com/*", passKeys: "" }, - { pattern: "http*://www.facebook.com/*", passKeys: "abab" }, - { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" }, - { pattern: "http*://www.bbc.com/*", passKeys: "" }, - { pattern: "http*://www.bbc.com/*", passKeys: "ab" }, - { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a" }, - { pattern: "http*://www.duplicate.com/*", passKeys: "ace" }, - { pattern: "http*://www.duplicate.com/*", passKeys: "bdf" }, + { pattern: "http*://mail.google.com/*", passKeys: "", blockedKeys: "" }, + { pattern: "http*://www.facebook.com/*", passKeys: "abab", blockedKeys: "" }, + { pattern: "http*://www.facebook.com/*", passKeys: "cdcd", blockedKeys: " ff " }, + { pattern: "http*://www.bbc.com/*", passKeys: "", blockedKeys: "" }, + { pattern: "http*://www.bbc.com/*", passKeys: "ab", blockedKeys: "c" }, + { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a", blockedKeys: " ff " }, + { pattern: "http*://www.duplicate.com/*", passKeys: "ace", blockedKeys: "xz" }, + { pattern: "http*://www.duplicate.com/*", passKeys: "bdf", blockedKeys: "zy" }, ]); }); @@ -30,41 +30,47 @@ context("Excluded URLs and pass keys", () => { const rule = isEnabledForUrl({ url: "http://mail.google.com/calendar/page" }); assert.isFalse(rule.isEnabledForUrl); assert.isFalse(rule.passKeys); + assert.equal("", rule.blockedKeys); }); should("be disabled for excluded sites, one exclusion", () => { const rule = isEnabledForUrl({ url: "http://www.bbc.com/calendar/page" }); assert.isFalse(rule.isEnabledForUrl); assert.isFalse(rule.passKeys); + assert.equal("", rule.blockedKeys); }); should("be enabled, but with pass keys", () => { const rule = isEnabledForUrl({ url: "https://www.facebook.com/something" }); assert.isTrue(rule.isEnabledForUrl); assert.equal(rule.passKeys, "abcd"); + assert.equal(rule.blockedKeys, "f"); }); should("be enabled", () => { const rule = isEnabledForUrl({ url: "http://www.twitter.com/pages" }); assert.isTrue(rule.isEnabledForUrl); assert.isFalse(rule.passKeys); + assert.equal(rule.blockedKeys, ""); }); should("handle spaces and duplicates in passkeys", () => { const rule = isEnabledForUrl({ url: "http://www.example.com/pages" }); assert.isTrue(rule.isEnabledForUrl); assert.equal("abc", rule.passKeys); + assert.equal("f", rule.blockedKeys); }); should("handle multiple passkeys rules", () => { const rule = isEnabledForUrl({ url: "http://www.duplicate.com/pages" }); assert.isTrue(rule.isEnabledForUrl); assert.equal("abcdef", rule.passKeys); + assert.equal("xyz", rule.blockedKeys); }); should("be enabled when given malformed regular expressions", async () => { await Settings.set("exclusionRules", [ - { pattern: "http*://www.bad-regexp.com/*[a-", passKeys: "" }, + { pattern: "http*://www.bad-regexp.com/*[a-", passKeys: "", blockedKeys: "" }, ]); const rule = isEnabledForUrl({ url: "http://www.bad-regexp.com/pages" }); assert.isTrue(rule.isEnabledForUrl); diff --git a/tests/unit_tests/keyboard_utils_test.js b/tests/unit_tests/keyboard_utils_test.js new file mode 100644 index 000000000..c800e8554 --- /dev/null +++ b/tests/unit_tests/keyboard_utils_test.js @@ -0,0 +1,54 @@ +import "./test_helper.js"; +import "../../lib/settings.js"; +import "../../lib/keyboard_utils.js"; + +context("KeyboardUtils", () => { + setup(async () => { + await Settings.load(); + }); + + teardown(async () => { + await chrome.storage.sync.clear(); + Settings._settings = null; + }); + + should("use event.code for IME composition events", () => { + const keyChar = KeyboardUtils.getKeyChar({ + key: "ㄹ", + code: "KeyF", + isComposing: true, + keyCode: 229, + }); + assert.equal("f", keyChar); + }); + + should("use event.code for IME-style keydown events without isComposing", () => { + const keyChar = KeyboardUtils.getKeyChar({ + key: "ㄹ", + code: "KeyF", + isComposing: false, + keyCode: 229, + }); + assert.equal("f", keyChar); + }); + + should("use event.code for non-Latin letters even without composition flags", () => { + const keyChar = KeyboardUtils.getKeyChar({ + key: "ㄹ", + code: "KeyF", + isComposing: false, + keyCode: 70, + }); + assert.equal("f", keyChar); + }); + + should("preserve non-English Latin layouts outside IME composition", () => { + const keyChar = KeyboardUtils.getKeyChar({ + key: "é", + code: "Digit2", + isComposing: false, + keyCode: 50, + }); + assert.equal("é", keyChar); + }); +}); diff --git a/tests/unit_tests/options_page_test.js b/tests/unit_tests/options_page_test.js index a36e5cc07..0416e93c1 100644 --- a/tests/unit_tests/options_page_test.js +++ b/tests/unit_tests/options_page_test.js @@ -36,13 +36,17 @@ context("options page", () => { should("show exclusion rule editor for exclusion rules", async () => { const rule = { + blockedKeys: "f", passKeys: "", pattern: "example.com", }; await Settings.set("exclusionRules", [rule]); await optionsPage.init(); - const el = document.querySelector("#exclusion-rules input[name=pattern]"); - assert.equal("example.com", el.value); + assert.equal( + "example.com", + document.querySelector("#exclusion-rules input[name=pattern]").value, + ); + assert.equal("f", document.querySelector("#exclusion-rules input[name=blockedKeys]").value); }); context("backup", () => { @@ -68,6 +72,7 @@ context("options page", () => { should("include exclusion rules", async () => { const rule = { + blockedKeys: "f", passKeys: "", pattern: "example.com", };