From f130eb255f0d02c1b07b3f3bfc6bc63d6d5d63c3 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Fri, 10 Nov 2023 11:48:12 +0100 Subject: [PATCH 01/23] GH-84: Initial cleanup before implementing onscreen keyboard. --- audit-resolve.json | 24 ++++++++++++++++++++++++ package.json | 13 ++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/audit-resolve.json b/audit-resolve.json index 4b1e95e..b57fb72 100644 --- a/audit-resolve.json +++ b/audit-resolve.json @@ -415,6 +415,30 @@ "1094500|grunt-prompt>lodash": { "decision": "ignore", "madeAt": 1698935257541 + }, + "1094544|fluid-lint-all>eslint-plugin-markdown>remark-parse>trim>markdownlint-config-fluid>markdownlint>markdown-it>stylelint>autoprefixer>postcss": { + "decision": "ignore", + "madeAt": 1699613213481 + }, + "1094544|stylelint-config-fluid>stylelint>autoprefixer>postcss": { + "decision": "ignore", + "madeAt": 1699613213481 + }, + "1094544|stylelint-scss>stylelint>autoprefixer>postcss": { + "decision": "ignore", + "madeAt": 1699613213481 + }, + "1094544|sugarss>postcss": { + "decision": "ignore", + "madeAt": 1699612632490 + }, + "1094544|postcss-less>postcss": { + "decision": "ignore", + "madeAt": 1699613213481 + }, + "1094544|stylelint-order>postcss": { + "decision": "ignore", + "madeAt": 1699613213481 } }, "rules": {}, diff --git a/package.json b/package.json index d9b25b7..1e1a636 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,13 @@ "name": "gamepad-navigator", "version": "0.2.0", "description": "A Chrome extension that allows you to navigate web pages and Chromium-based browsers using a game controller.", - "author": "Divyanshu Mahajan", + "contributors": [ + { "name": "Divyanshu Mahajan" }, + { + "name": "Tony Atkins", + "url": "https://duhrer.github.io/" + } + ], "license": "BSD-3-Clause", "repository": { "type": "git", @@ -19,13 +25,14 @@ }, "dependencies": { "ally.js": "1.4.1", + "fluid-osk": "0.1.0-dev.20220408T124144Z.50e779c.GH-1", "infusion": "4.6.0" }, "devDependencies": { "dotenv": "16.3.1", "eslint": "8.50.0", - "eslint-config-fluid": "2.1.1", - "fluid-lint-all": "1.2.9", + "eslint-config-fluid": "2.1.1-dev.20231104T144458Z.5488e1c.GH-17", + "fluid-lint-all": "1.2.10-dev.20231104T184451Z.53e64ec.GH-64", "grunt": "1.6.1", "grunt-banner": "0.6.0", "grunt-contrib-clean": "2.0.1", From 5c97238544a0be960952a749c5707e91a8ab5b3d Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Tue, 14 Nov 2023 11:11:02 +0100 Subject: [PATCH 02/23] GH-84: Added onscreen keyboard as a modal. GH-110: Refactored to avoid destroying / (re)creating component on page focus change. --- Gruntfile.js | 12 + package.json | 7 +- src/css/common.css | 4 - src/css/modal.css | 11 + src/css/onscreen-keyboard.css | 29 ++ src/js/content_scripts/action-launcher.js | 1 + src/js/content_scripts/gamepad-navigator.js | 1 + src/js/content_scripts/input-mapper-base.js | 135 ++++----- .../input-mapper-content-utils.js | 203 +++++++------ src/js/content_scripts/input-mapper.js | 269 +++++++----------- src/js/content_scripts/modal.js | 110 +++++-- src/js/content_scripts/onscreen-keyboard.js | 107 +++++++ .../templateRenderer.js | 0 src/manifest.json | 17 +- tests/html/text-input.html | 38 +++ utils/bundleCss.js | 56 ++++ 16 files changed, 650 insertions(+), 350 deletions(-) create mode 100644 src/css/onscreen-keyboard.css create mode 100644 src/js/content_scripts/onscreen-keyboard.js rename src/js/{shared => content_scripts}/templateRenderer.js (100%) create mode 100644 tests/html/text-input.html create mode 100644 utils/bundleCss.js diff --git a/Gruntfile.js b/Gruntfile.js index 4b3c3a0..64f0155 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -68,6 +68,18 @@ module.exports = function (grunt) { src: "node_modules/ally.js/ally.min.js", dest: "dist/js/lib/ally/" }, + oskCss: { + expand: true, + flatten: true, + src: "node_modules/fluid-osk/src/css/*", + dest: "dist/css/lib/fluid-osk" + }, + oskJs: { + expand: true, + flatten: true, + src: "node_modules/fluid-osk/src/js/*", + dest: "dist/js/lib/fluid-osk" + }, source: { expand: true, cwd: "src", diff --git a/package.json b/package.json index 1e1a636..9f24ef1 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,15 @@ "homepage": "https://github.com/fluid-lab/gamepad-navigator#readme", "scripts": { "lint": "fluid-lint-all", - "postinstall": "grunt build", + "build": "npm run build:grunt && npm run build:css", + "build:grunt": "grunt build", + "build:css": "node ./utils/bundleCss.js", + "postinstall": "npm run build", "test": "testem ci --file tests/testem.json" }, "dependencies": { "ally.js": "1.4.1", - "fluid-osk": "0.1.0-dev.20220408T124144Z.50e779c.GH-1", + "fluid-osk": "0.1.0-dev.20231110T145341Z.c8aee65.GH-1", "infusion": "4.6.0" }, "devDependencies": { diff --git a/src/css/common.css b/src/css/common.css index a052358..c0bb9bb 100644 --- a/src/css/common.css +++ b/src/css/common.css @@ -6,7 +6,3 @@ * { font-family: ubuntu, sans-serif; } - -.hidden { - display: none; -} diff --git a/src/css/modal.css b/src/css/modal.css index d516aeb..f4e5905 100644 --- a/src/css/modal.css +++ b/src/css/modal.css @@ -1,6 +1,11 @@ +.hidden { + display: none; +} + .modal-outer-container { align-content: center; background-color: #000c; + font-family: inherit; height: 100%; left: 0; position: fixed; @@ -12,6 +17,7 @@ .modal-inner-container { background-color: white; border-radius: 1rem; + color: black; display: flex; flex-direction: column; height: 75%; @@ -41,6 +47,11 @@ } .modal-footer button { + background-color: #ccc; + border: 1px solid #999; + border-radius: 0.5rem; + color: black; font-size: 2rem; margin: 1rem; + padding: 0.5rem; } diff --git a/src/css/onscreen-keyboard.css b/src/css/onscreen-keyboard.css new file mode 100644 index 0000000..89a11c1 --- /dev/null +++ b/src/css/onscreen-keyboard.css @@ -0,0 +1,29 @@ +.osk-keyboard { + margin: auto; + padding-bottom: 2rem; + padding-top: 2rem; + width: 80%; +} + +.osk-text-input { + margin: auto; + margin-top: 2rem; + width: 80%; +} + +button.osk-key { + color: black; +} + +.osk-key { + background-color: #ccc; +} + +.osk-key:focus { + border: 3px solid #339; +} + +.onscreen-keyboard-modal .modal-inner-container { + height: 40rem; + top: 25%; +} diff --git a/src/js/content_scripts/action-launcher.js b/src/js/content_scripts/action-launcher.js index 438d8a9..adb8512 100644 --- a/src/js/content_scripts/action-launcher.js +++ b/src/js/content_scripts/action-launcher.js @@ -18,6 +18,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE fluid.defaults("gamepad.actionLauncher", { gradeNames: ["gamepad.modal"], model: { + classNames: " actionLauncher-modal", label: "Gamepad Navigator: Launch Action" }, components: { diff --git a/src/js/content_scripts/gamepad-navigator.js b/src/js/content_scripts/gamepad-navigator.js index 4059b71..f421372 100644 --- a/src/js/content_scripts/gamepad-navigator.js +++ b/src/js/content_scripts/gamepad-navigator.js @@ -19,6 +19,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE fluid.defaults("gamepad.navigator", { gradeNames: ["fluid.modelComponent"], model: { + // TODO: Figure out how this is used and how it differs from "in view"; connected: false, axes: {}, buttons: {} diff --git a/src/js/content_scripts/input-mapper-base.js b/src/js/content_scripts/input-mapper-base.js index 39bd52d..18586da 100644 --- a/src/js/content_scripts/input-mapper-base.js +++ b/src/js/content_scripts/input-mapper-base.js @@ -22,6 +22,9 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE fluid.defaults("gamepad.inputMapper.base", { gradeNames: ["gamepad.configMaps", "gamepad.navigator"], + model: { + pageInView: true + }, modelListeners: { "axes.*": { funcName: "{that}.produceNavigation", @@ -70,6 +73,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE cutoffValue: 0.40, scrollInputMultiplier: 50, invokers: { + updateTabbables: { + funcName: "gamepad.inputMapper.base.updateTabbables", + args: ["{that}"] + }, produceNavigation: { funcName: "gamepad.inputMapper.base.produceNavigation", args: ["{that}", "{arguments}.0"] @@ -86,11 +93,6 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE "this": "{that}.mutationObserverInstance", method: "disconnect" }, - // TODO: Investigate, identify, and fix tab navigation issues. - tabindexSortFilter: { - funcName: "gamepad.inputMapper.base.tabindexSortFilter", - args: ["{arguments}.0", "{arguments}.1"] - }, /** * TODO: Add tests for links and other elements that involve navigation * between pages. @@ -98,7 +100,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Actions, these are called with: value, speedFactor, invert, background, oldValue, homepageURL click: { funcName: "gamepad.inputMapperUtils.content.click", - args: ["{arguments}.0"] + args: ["{that}", "{arguments}.0"] }, previousPageInHistory: { funcName: "gamepad.inputMapperUtils.content.previousPageInHistory", @@ -152,19 +154,19 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Arrow actions for buttons sendArrowLeft: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{arguments}.0", "ArrowLeft"] // value, key + args: ["{that}", "{arguments}.0", "ArrowLeft"] // value, key }, sendArrowRight: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{arguments}.0", "ArrowRight"] // value, key + args: ["{that}", "{arguments}.0", "ArrowRight"] // value, key }, sendArrowUp: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{arguments}.0", "ArrowUp"] // value, key + args: ["{that}", "{arguments}.0", "ArrowUp"] // value, key }, sendArrowDown: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{arguments}.0", "ArrowDown"] // value, key + args: ["{that}", "{arguments}.0", "ArrowDown"] // value, key }, // Arrow actions for axes thumbstickHorizontalArrows: { @@ -193,42 +195,45 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapper.base.produceNavigation = function (that, change) { - /** - * Check if input is generated by axis or button and which button/axes was - * disturbed. - */ - var inputType = change.path[0], - index = change.path[1], - inputValue = change.value, - oldInputValue = change.oldValue || 0; + // Only respond to gamepad input if we are "in view". + if (that.model.pageInView) { + /** + * Check if input is generated by axis or button and which button/axes was + * disturbed. + */ + var inputType = change.path[0], + index = change.path[1], + inputValue = change.value, + oldInputValue = change.oldValue || 0; - var inputProperties = that.model.map[inputType][index], - actionLabel = fluid.get(inputProperties, "currentAction") || fluid.get(inputProperties, "defaultAction"), - homepageURL = that.model.commonConfiguration.homepageURL; + var inputProperties = that.model.map[inputType][index], + actionLabel = fluid.get(inputProperties, "currentAction") || fluid.get(inputProperties, "defaultAction"), + homepageURL = that.model.commonConfiguration.homepageURL; - /** - * TODO: Modify the action call in such a manner that the action gets triggered - * when the inputs are released. - * (To gain shortpress and longpress actions) - * - * Refer: - * https://github.com/fluid-lab/gamepad-navigator/pull/21#discussion_r453507050 - */ + /** + * TODO: Modify the action call in such a manner that the action gets triggered + * when the inputs are released. + * (To gain shortpress and longpress actions) + * + * Refer: + * https://github.com/fluid-lab/gamepad-navigator/pull/21#discussion_r453507050 + */ - // Execute the actions only if the action label is available. - if (actionLabel) { - var action = fluid.get(that, actionLabel); + // Execute the actions only if the action label is available. + if (actionLabel) { + var action = fluid.get(that, actionLabel); - // Trigger the action only if a valid function is found. - if (action) { - action( - inputValue, - inputProperties.speedFactor, - inputProperties.invert, - inputProperties.background, - oldInputValue, - homepageURL - ); + // Trigger the action only if a valid function is found. + if (action) { + action( + inputValue, + inputProperties.speedFactor, + inputProperties.invert, + inputProperties.background, + oldInputValue, + homepageURL + ); + } } } }; @@ -247,6 +252,11 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }); }; + gamepad.inputMapper.base.updateTabbables = function (that) { + that.tabbableElements = ally.query.tabbable({ strategy: "strict" }); + }; + + /** * * A listener to track DOM elements and update the list when the DOM is updated. @@ -259,13 +269,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE MutationObserver = that.options.windowObject.MutationObserver; // Record the tabbable elements when the component is created. - that.tabbableElements = ally.query.tabbable({ strategy: "strict" }).sort(that.tabindexSortFilter); + that.updateTabbables(); // Create an instance of the mutation observer. - that.mutationObserverInstance = new MutationObserver(function () { - // Record the tabbable elements when the DOM mutates. - that.tabbableElements = ally.query.tabbable({ strategy: "strict" }).sort(that.tabindexSortFilter); - }); + that.mutationObserverInstance = new MutationObserver(that.updateTabbables); // Specify the mutations to be observed. var observerConfiguration = { @@ -280,38 +287,4 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Start observing the DOM mutations. that.mutationObserverInstance.observe(body, observerConfiguration); }; - - /** - * - * Filter for sorting the elements; to be used inside JavaScript's sort() method. - * - * @param {Object} elementOne - The DOM element. - * @param {Object} elementTwo - The DOM element. - * @return {Integer} - The value which will decide the order of the two elements. - * - */ - gamepad.inputMapper.base.tabindexSortFilter = function (elementOne, elementTwo) { - var tabindexOne = parseInt(elementOne.getAttribute("tabindex")), - tabindexTwo = parseInt(elementTwo.getAttribute("tabindex")); - - /** - * If both elements have tabindex greater than 0, arrange them in ascending order - * of the tabindex. Otherwise if only one of the elements have tabindex greater - * than 0, place it before the other element. And in case, no element has a - * tabindex attribute or both of them posses tabindex value equal to 0, keep them - * in the same order. - */ - if (tabindexOne > 0 && tabindexTwo > 0) { - return tabindexOne - tabindexTwo; - } - else if (tabindexOne > 0) { - return -1; - } - else if (tabindexTwo > 0) { - return 1; - } - else { - return 0; - } - }; })(fluid); diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 94bc08e..2951427 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -35,20 +35,22 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.scrollHorizontally = function (that, value, speedFactor, invert) { - // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1; - value = value * inversionFactor; - if (value > 0) { - clearInterval(that.intervalRecords.leftScroll); - that.scrollRight(value, speedFactor); - } - else if (value < 0) { - clearInterval(that.intervalRecords.rightScroll); - that.scrollLeft(-1 * value, speedFactor); - } - else { - clearInterval(that.intervalRecords.leftScroll); - clearInterval(that.intervalRecords.rightScroll); + if (that.model.pageInView) { + // Get the updated input value according to the configuration. + var inversionFactor = invert ? -1 : 1; + value = value * inversionFactor; + if (value > 0) { + clearInterval(that.intervalRecords.leftScroll); + that.scrollRight(value, speedFactor); + } + else if (value < 0) { + clearInterval(that.intervalRecords.rightScroll); + that.scrollLeft(-1 * value, speedFactor); + } + else { + clearInterval(that.intervalRecords.leftScroll); + clearInterval(that.intervalRecords.rightScroll); + } } }; @@ -72,7 +74,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage towards the left only if the input value is more than the * cutoff value. */ - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll to the left according to the new input value. that.intervalRecords.leftScroll = setInterval(function () { var xOffset = $(that.options.windowObject).scrollLeft(); @@ -101,7 +103,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage towards the right only if the input value is more than the * cutoff value. */ - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll to the right according to the new input value. that.intervalRecords.rightScroll = setInterval(function () { var xOffset = $(that.options.windowObject).scrollLeft(); @@ -121,20 +123,22 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.scrollVertically = function (that, value, speedFactor, invert) { - // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1; - value = value * inversionFactor; - if (value > 0) { - clearInterval(that.intervalRecords.upwardScroll); - that.scrollDown(value, speedFactor); - } - else if (value < 0) { - clearInterval(that.intervalRecords.downwardScroll); - that.scrollUp(-1 * value, speedFactor); - } - else { - clearInterval(that.intervalRecords.upwardScroll); - clearInterval(that.intervalRecords.downwardScroll); + if (that.model.pageInView) { + // Get the updated input value according to the configuration. + var inversionFactor = invert ? -1 : 1; + value = value * inversionFactor; + if (value > 0) { + clearInterval(that.intervalRecords.upwardScroll); + that.scrollDown(value, speedFactor); + } + else if (value < 0) { + clearInterval(that.intervalRecords.downwardScroll); + that.scrollUp(-1 * value, speedFactor); + } + else { + clearInterval(that.intervalRecords.upwardScroll); + clearInterval(that.intervalRecords.downwardScroll); + } } }; @@ -158,7 +162,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage upward only if the input value is more than the cutoff * value. */ - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll upward according to the new input value. that.intervalRecords.upwardScroll = setInterval(function () { var yOffset = $(that.options.windowObject).scrollTop(); @@ -187,7 +191,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage downward only if the input value is more than the cutoff * value. */ - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll upward according to the new input value. that.intervalRecords.downwardScroll = setInterval(function () { var yOffset = $(that.options.windowObject).scrollTop(); @@ -207,23 +211,25 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.thumbstickTabbing = function (that, value, speedFactor, invert) { - var inversionFactor = invert ? -1 : 1; - value = value * inversionFactor; - clearInterval(that.intervalRecords.forwardTab); - clearInterval(that.intervalRecords.reverseTab); - if (value > 0) { - that.intervalRecords.forwardTab = setInterval( - that.forwardTab, - that.options.frequency * speedFactor, - value - ); - } - else if (value < 0) { - that.intervalRecords.reverseTab = setInterval( - that.reverseTab, - that.options.frequency * speedFactor, - -1 * value - ); + if (that.model.pageInView) { + var inversionFactor = invert ? -1 : 1; + value = value * inversionFactor; + clearInterval(that.intervalRecords.forwardTab); + clearInterval(that.intervalRecords.reverseTab); + if (value > 0) { + that.intervalRecords.forwardTab = setInterval( + that.forwardTab, + that.options.frequency * speedFactor, + value + ); + } + else if (value < 0) { + that.intervalRecords.reverseTab = setInterval( + that.reverseTab, + that.options.frequency * speedFactor, + -1 * value + ); + } } }; @@ -237,7 +243,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.buttonTabNavigation = function (that, value, direction) { - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { var length = that.tabbableElements.length; // Tab only if at least one tabbable element is available. @@ -247,7 +253,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * currently focused, shift the focus to the first element. Otherwise * shift the focus to the next element. */ - var activeElement = document.activeElement; + var activeElement = that.model.activeModal ? fluid.get(that, "model.shadowElement.activeElement") : document.activeElement; if (activeElement.nodeName === "BODY" || !activeElement) { that.tabbableElements[0].focus(); } @@ -285,34 +291,45 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * * Click on the currently focused element. * + * @param {Object} that - The inputMapper component. * @param {Integer} value - The value of the gamepad input. * */ - gamepad.inputMapperUtils.content.click = function (value) { - if (value > 0) { + gamepad.inputMapperUtils.content.click = function (that, value) { + if (that.model.pageInView && (value > 0)) { + var activeElement = that.model.activeModal ? fluid.get(that, "model.shadowElement.activeElement") : document.activeElement; + var isTextInput = gamepad.inputMapperUtils.content.isTextInput(activeElement); + + // Open the new onscreen keyboard to input text. + if (isTextInput) { + var lastExternalFocused = activeElement; + that.applier.change("lastExternalFocused", lastExternalFocused); + that.applier.change("textInputValue", lastExternalFocused.value); + that.applier.change("activeModal", "onscreenKeyboard"); + } /** * If SELECT element is currently focused, toggle its state. Otherwise perform * the regular click operation. */ - if (document.activeElement.nodeName === "SELECT") { + else if (activeElement.nodeName === "SELECT") { var optionsLength = 0; // Compute the number of options and store it. - document.activeElement.childNodes.forEach(function (childNode) { + activeElement.childNodes.forEach(function (childNode) { if (childNode.nodeName === "OPTION") { optionsLength++; } }); // Toggle the SELECT dropdown. - if (!document.activeElement.getAttribute("size") || document.activeElement.getAttribute("size") === "1") { + if (!activeElement.getAttribute("size") || activeElement.getAttribute("size") === "1") { /** * Store the initial size of the dropdown in a separate attribute * (if specified already). */ - var initialSizeString = document.activeElement.getAttribute("size"); + var initialSizeString = activeElement.getAttribute("size"); if (initialSizeString) { - document.activeElement.setAttribute("initialSize", parseInt(initialSizeString)); + activeElement.setAttribute("initialSize", parseInt(initialSizeString)); } /** @@ -321,23 +338,39 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * countries). */ var length = Math.min(15, optionsLength); - document.activeElement.setAttribute("size", length); + activeElement.setAttribute("size", length); } else { // Obtain the initial size of the dropdown. - var sizeString = document.activeElement.getAttribute("initialSize") || "1"; + var sizeString = activeElement.getAttribute("initialSize") || "1"; // Restore the size of the dropdown. - document.activeElement.setAttribute("size", parseInt(sizeString)); + activeElement.setAttribute("size", parseInt(sizeString)); } } else { // Click on the focused element. - document.activeElement.click(); + activeElement.click(); } } }; + gamepad.inputMapperUtils.content.isTextInput = function (element) { + if (element.nodeName === "INPUT") { + var allowedTypes = ["text", "search", "email", "password", "tel", "text", "url"]; + var inputType = element.getAttribute("type"); + if (!inputType || allowedTypes.includes(inputType)) { + return true; + } + } + // TODO: Add support for this in the future. + // else if (element.nodeName === "TEXTAREA") { + // return true; + // } + + return false; + }; + /** * * Navigate to the previous/next page in history using thumbsticks. @@ -349,14 +382,16 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.thumbstickHistoryNavigation = function (that, value, invert) { - // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1; - value = value * inversionFactor; - if (value > 0) { - that.nextPageInHistory(value); - } - else if (value < 0) { - that.previousPageInHistory(-1 * value); + if (that.model.pageInView) { + // Get the updated input value according to the configuration. + var inversionFactor = invert ? -1 : 1; + value = value * inversionFactor; + if (value > 0) { + that.nextPageInHistory(value); + } + else if (value < 0) { + that.previousPageInHistory(-1 * value); + } } }; @@ -374,12 +409,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.previousPageInHistory = function (that, value) { - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { var activeElementIndex = null; // Get the index of the currently active element, if available. if (fluid.get(document, "activeElement")) { - var tabbableElements = ally.query.tabbable({ strategy: "strict" }).sort(that.tabindexSortFilter); + var tabbableElements = ally.query.tabbable({ strategy: "strict" }); activeElementIndex = tabbableElements.indexOf(document.activeElement); } @@ -407,12 +442,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.inputMapperUtils.content.nextPageInHistory = function (that, value) { - if (value > that.options.cutoffValue) { + if (that.model.pageInView && (value > that.options.cutoffValue)) { var activeElementIndex = null; // Get the index of the currently active element, if available. if (fluid.get(document, "activeElement")) { - var tabbableElements = ally.query.tabbable({ strategy: "strict" }).sort(that.tabindexSortFilter); + var tabbableElements = ally.query.tabbable({ strategy: "strict" }); activeElementIndex = tabbableElements.indexOf(document.activeElement); } @@ -434,20 +469,22 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE /** * * Simulate a key press (down and up) on the current focused element. + * @param {Object} that - The inputMapper component. * @param {Number} value - The current value of the input (from 0 to 1). * @param {String} key - The key (ex: `ArrowLeft`) to simulate. * */ - gamepad.inputMapperUtils.content.sendKey = function (value, key) { - if (value > 0) { + gamepad.inputMapperUtils.content.sendKey = function (that, value, key) { + if (that.model.pageInView && (value > 0)) { var keyDownEvent = new KeyboardEvent("keydown", { key: key, code: key, bubbles: true }); - document.activeElement.dispatchEvent(keyDownEvent); + var activeElement = that.model.activeModal ? fluid.get(that, "model.shadowElement.activeElement") : document.activeElement; + activeElement.dispatchEvent(keyDownEvent); // TODO: Test with text inputs and textarea fields to see if // beforeinput and input are needed. var keyUpEvent = new KeyboardEvent("keyup", { key: key, code: key, bubbles: true }); - document.activeElement.dispatchEvent(keyUpEvent); + activeElement.dispatchEvent(keyUpEvent); } }; @@ -470,16 +507,18 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE clearInterval(that.intervalRecords[backwardKey]); if (value > that.options.cutoffValue) { that.intervalRecords[forwardKey] = setInterval( - gamepad.inputMapperUtils.content.sendKey, - that.options.frequency * speedFactor, - value, - forwardKey + gamepad.inputMapperUtils.content.sendKey, // func + that.options.frequency * speedFactor, // delay + that, // arg 0 + value, //arg 1 + forwardKey // arg 2 ); } else if (value < (-1 * that.options.cutoffValue)) { that.intervalRecords[backwardKey] = setInterval( gamepad.inputMapperUtils.content.sendKey, that.options.frequency * speedFactor, + that, -1 * value, backwardKey ); diff --git a/src/js/content_scripts/input-mapper.js b/src/js/content_scripts/input-mapper.js index 397c348..cfc1518 100644 --- a/src/js/content_scripts/input-mapper.js +++ b/src/js/content_scripts/input-mapper.js @@ -10,34 +10,87 @@ You may obtain a copy of the BSD 3-Clause License at https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ -/* global chrome, ally */ +/* global chrome */ -(function (fluid, $) { +(function (fluid) { "use strict"; var gamepad = fluid.registerNamespace("gamepad"); fluid.registerNamespace("gamepad.inputMapper"); - // TODO: Focus trap in modal, look at Weavly and others. - // TODO: Close on click outside inner container. - // TODO: Close on escape. fluid.defaults("gamepad.inputMapper", { gradeNames: ["gamepad.inputMapper.base", "fluid.viewComponent"], - listeners: { - "onCreate.restoreFocus": "{that}.restoreFocus", - "onCreate.updateControls": "{that}.updateControls" + model: { + activeModal: false, + shadowElement: false, + textInputValue: "", + textInputType: "" }, - invokers: { - // Actions, these are called with: value, speedFactor, invert, background, oldValue, homepageURL - restoreFocus: { - funcName: "gamepad.inputMapper.restoreFocus", - args: ["{that}.options.windowObject", "{that}.tabindexSortFilter"] + modelListeners: { + pageInView: { + func: "gamepad.inputMapper.handlePageInViewChange", + args: ["{that}"] }, - updateControls: { + activeModal: { + func: "{that}.updateTabbables" + }, + textInputValue: { + funcName: "gamepad.inputMapper.updateFormFieldText", + args: ["{that}"] + } + }, + events: { + onWindowFocus: null, + onWindowBlur: null, + onPageShow: null, + onPageHide: null + }, + + listeners: { + "onCreate.updateControls": { funcName: "gamepad.inputMapper.updateControls", args: ["{that}"] }, + + // Wire up event listeners to window. + "onCreate.handleWindowFocus": { + funcName: "window.addEventListener", + args: ["focus", "{that}.events.onWindowFocus.fire"] + }, + "onCreate.handleWindowBlur": { + funcName: "window.addEventListener", + args: ["blur", "{that}.events.onWindowBlur.fire"] + }, + "onCreate.handlePageShow": { + funcName: "window.addEventListener", + args: ["pageshow", "{that}.events.onPageShow.fire"] + }, + "onCreate.handlePageHide": { + funcName: "window.addEventListener", + args: ["pagehide", "{that}.events.onPageHide.fire"] + }, + + // Handle window-related events + "onWindowFocus": { + funcName: "gamepad.inputMapper.handleFocused", + args: ["{that}"] + }, + "onPageShow": { + funcName: "gamepad.inputMapper.handleFocused", + args: ["{that}"] + }, + "onWindowBlur": { + funcName: "gamepad.inputMapper.handleBlurred", + args: ["{that}"] + }, + "onPageHide": { + funcName: "gamepad.inputMapper.handleBlurred", + args: ["{that}"] + } + }, + invokers: { + // Actions, these are called with: value, speedFactor, invert, background, oldValue, homepageURL goToPreviousTab: { funcName: "gamepad.inputMapperUtils.background.sendMessage", args: ["{that}", "goToPreviousTab", "{arguments}.0", "{arguments}.4"] @@ -103,177 +156,75 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE args: ["{that}", "{arguments}.0", "{arguments}.4"] // value, oldValue } }, - model: { - hideActionPanelLauncher: true - }, components: { - actionLauncher: { + modalManager: { container: "{that}.container", - type: "gamepad.actionLauncher", + type: "gamepad.modalManager", options: { model: { - hidden: "{gamepad.inputMapper}.model.hideActionPanelLauncher", - lastExternalFocused: "{gamepad.inputMapper}.model.lastExternalFocused" + activeModal: "{gamepad.inputMapper}.model.activeModal", + lastExternalFocused: "{gamepad.inputMapper}.model.lastExternalFocused", + shadowElement: "{gamepad.inputMapper}.model.shadowElement", + textInputValue: "{gamepad.inputMapper}.model.textInputValue", + textInputType: "{gamepad.inputMapper}.model.textInputType" } } } } }); - gamepad.inputMapper.openActionLauncher = function (that, value, oldValue) { - if (value && !oldValue) { - that.applier.change("lastExternalFocused", document.activeElement); - - that.applier.change("hideActionPanelLauncher", false); - } + gamepad.inputMapper.handleFocused = function (that) { + that.applier.change("pageInView", true); }; - /** - * - * Restore the previously focused element on the web page after history navigation or - * switching tabs/windows. - * - * @param {Object} windowObject - The inputMapper component's windowObject option. - * @param {Function} tabindexSortFilter - The filter to be used for sorting elements - * based on their tabindex value. - * - */ - gamepad.inputMapper.restoreFocus = function (windowObject, tabindexSortFilter) { - $(document).ready(function () { - /** - * Get the index of the previously focused element stored in the local - * storage. - */ - var pageAddress = windowObject.location.href; - chrome.storage.local.get([pageAddress], function (resultObject) { - // Focus only if some element was focused before the history navigation. - var activeElementIndex = resultObject[pageAddress]; - if (activeElementIndex && activeElementIndex !== -1) { - var tabbableElements = ally.query.tabbable({ strategy: "strict" }).sort(tabindexSortFilter), - activeElement = tabbableElements[activeElementIndex]; - if (activeElement) { - activeElement.focus(); - } - - // Clear the stored index of the active element after usage. - chrome.storage.local.remove([pageAddress]); - } - }); - }); + gamepad.inputMapper.handleBlurred = function (that) { + that.applier.change("pageInView", false); }; - /** - * - * Tracks the page/window visibility state and calls the inputMapper instance manager - * accordingly. - * - * @param {Function} inputMapperManager - The function that handles the instance of - * the inputMapper component. - * @param {Object} configurationOptions - The configuration options for the - * inputMapper component. - * - */ - gamepad.visibilityChangeTracker = (function (windowObject) { - // Assume that the page isn't focused initially. - var inView = false; - return function (inputMapperManager, configurationOptions) { - configurationOptions = configurationOptions || {}; - - // Track changes to the focus/visibility of the window object. - windowObject.onfocus = windowObject.onblur = windowObject.onpageshow = windowObject.onpagehide = function (event) { - /** - * Call the inputMapper instance manager according to the visibility - * status of the page/window and update the inView value. - */ - if (event.type === "focus" || event.type === "pageshow") { - /** - * Call the inputMapper instance manager with the "visible" status if - * the page/window is focused back after switching or when page loads - * (using inView to verify). - */ - if (!inView) { - inputMapperManager("visible", configurationOptions); - inView = true; - } - } - else if (inView) { - /** - * Otherwise, call the inputMapper instance manager with the "hidden" - * status when the focus/visibility of current window/tab is moved to - * some other window/tab. - */ - inputMapperManager("hidden"); - inView = false; - } - }; - }; - })(window); - - // TODO: Make this something the input mapper does itself to decide whether - // it should be active. When not visible, deactivate all modals and stop - // listening to gamepad input. When visible, start listening. See if there - // is some way to keep it alive if the window is visible but the dev tools - // are focused. - /** - * - * Manages the inputMapper instance according to the visibility status of the - * tab/window. - * - * @param {String} visibilityStatus - The visibility status of the tab/window. - * @param {Object} configurationOptions - The configuration options for the - * inputMapper component. - * - */ - gamepad.inputMapperManager = (function () { - var inputMapperInstance = null; + gamepad.inputMapper.updateFormFieldText = function (that) { + if (that.model.lastExternalFocused && gamepad.inputMapperUtils.content.isTextInput(that.model.lastExternalFocused)) { + that.model.lastExternalFocused.value = that.model.textInputValue; + } + }; - return function (visibilityStatus, configurationOptions) { - configurationOptions = configurationOptions || {}; + gamepad.inputMapper.openActionLauncher = function (that, value, oldValue) { + if (that.model.pageInView && value && !oldValue) { + // In this case we don't want to fail over to a modal's activeElement. + that.applier.change("lastExternalFocused", document.activeElement); - /** - * Create an instance of the inputMapper when the tab/window is focused - * again and start reading gamepad inputs (if any gamepad is connected). - */ - if (visibilityStatus === "visible") { - /** - * TODO: Refactor the approach to use a dynamic component instead of - * creating components dynamically. - */ + that.applier.change("activeModal", "actionLauncher"); + } + }; - // Pass the configuration options to the inputMapper component. - // TODO: Reactivate a "dormant" inputMapper instance here. - inputMapperInstance = gamepad.inputMapper("body", configurationOptions); - inputMapperInstance.events.onGamepadConnected.fire(); - } - else if (visibilityStatus === "hidden" && inputMapperInstance !== null) { - /** - * Destroy the instance of the inputMapper in the current tab when another - * window/tab is focused or opened. - */ - // TODO: Manage this better, it makes inspecting HTML really hard. - // TODO: Make the input mapper "dormant". - inputMapperInstance.destroy(); - } - }; - })(); + gamepad.inputMapper.handlePageInViewChange = function (that) { + if (that.model.pageInView) { + gamepad.inputMapper.updateControls(that); + // TODO: Restore focus. + } + else { + that.applier.change("activeModal", false); + } + }; /** * - * Update the gamepad configuration if a custom configuration is available. + * (Re)load the gamepad configuration from local storage. * * @param {Object} that - The inputMapper component. * */ gamepad.inputMapper.updateControls = function (that) { - chrome.storage.local.get(["gamepadConfiguration"], function (configWrapper) { - var gamepadConfig = configWrapper.gamepadConfiguration; + if (that.model.pageInView) { + chrome.storage.local.get(["gamepadConfiguration"], function (configWrapper) { + var gamepadConfig = configWrapper.gamepadConfiguration; - // Update the gamepad configuration only if it's available. - if (gamepadConfig) { - that.applier.change("map", gamepadConfig); - } - }); + // Update the gamepad configuration only if it's available. + if (gamepadConfig) { + that.applier.change("map", gamepadConfig); + } + }); + } }; - gamepad.visibilityChangeTracker(gamepad.inputMapperManager); + gamepad.inputMapperInstance = gamepad.inputMapper("body"); })(fluid, jQuery); diff --git a/src/js/content_scripts/modal.js b/src/js/content_scripts/modal.js index 9a0ec45..33e799e 100644 --- a/src/js/content_scripts/modal.js +++ b/src/js/content_scripts/modal.js @@ -23,8 +23,8 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE fluid.defaults("gamepad.modal", { gradeNames: ["gamepad.templateRenderer"], model: { - hidden: true, - lastExternalFocused: false + classNames: "", + hidden: true }, modelListeners: { hidden: [ @@ -49,12 +49,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }, markup: { // TODO: Add the ability to retrieve our icon URL and display the icon onscreen. - container: "" + container: "" }, invokers: { closeModal: { funcName: "gamepad.modal.closeModal", - args: ["{that}", "{arguments}.0"] // event + args: ["{gamepad.modalManager}", "{arguments}.0"] // event }, handleKeydown: { funcName: "gamepad.modal.handleKeydown", @@ -74,14 +74,6 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE args: ["{that}", true] } }, - // TODO: Relay hidden classname to control visibility. - // TODO: clicking outside the inner container should close the modal. - // We can't use this form because we also need to restore focus. - // modelRelay: { - // source: "{that}.model.dom.outerContainer.click", - // target: "{that}.model.hidden", - // singleTransform: "fluid.transforms.toggle" - // }, listeners: { "onCreate.bindOuterContainerClick": { this: "{that}.container", @@ -134,11 +126,16 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }; - gamepad.modal.closeModal = function (that, event) { + /** + * + * @param {Object} modalManager - The modal manager component. + * @param {Event} event - The event to which we are responding. + */ + gamepad.modal.closeModal = function (modalManager, event) { event.preventDefault(); - that.applier.change("hidden", true); - if (that.model.lastExternalFocused && that.model.lastExternalFocused.focus) { - that.model.lastExternalFocused.focus(); + modalManager.applier.change("activeModal", false); + if (modalManager.model.lastExternalFocused && modalManager.model.lastExternalFocused.focus) { + modalManager.model.lastExternalFocused.focus(); } }; @@ -171,4 +168,85 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE that.focusFirst(); } }; + + fluid.defaults("gamepad.modalManager", { + gradeNames: ["gamepad.templateRenderer"], + markup: { + container: "
", + styles: "" + }, + model: { + activeModal: false, + shadowElement: false, + lastExternalFocused: false, + textInputValue: "", + textInputType: "", + + // Inline all styles from JS-wrapped global namespaced variable. + styles: gamepad.css + }, + events: { + onShadowReady: null + }, + components: { + actionLauncher: { + container: "{that}.model.shadowElement", + type: "gamepad.actionLauncher", + createOnEvent: "onShadowReady", + options: { + model: { + hidden: "{gamepad.modalManager}.model.hideActionLauncher" + } + } + }, + onscreenKeyboard: { + container: "{that}.model.shadowElement", + type: "gamepad.osk.modal", + createOnEvent: "onShadowReady", + options: { + model: { + hidden: "{gamepad.modalManager}.model.hideOnscreenKeyboard", + textInputValue: "{gamepad.modalManager}.model.textInputValue", + textInputType: "{gamepad.modalManager}.model.textInputType" + } + } + } + }, + listeners: { + "onCreate.createShadow": { + funcName: "gamepad.modalManager.createShadow", + args: ["{that}"] + } + }, + modelListeners: { + activeModal: { + excludeSource: "init", + funcName: "gamepad.modalManager.toggleModals", + args: ["{that}"] + } + } + }); + + gamepad.modalManager.createShadow = function (that) { + var host = that.container[0]; + var shadowElement = host.attachShadow({mode: "open"}); + + // We inline all styles here so that all modals get the common styles, + // and to avoid managing multiple shadow elements. + shadowElement.innerHTML = fluid.stringTemplate(that.options.markup.styles, that.model); + + that.applier.change("shadowElement", shadowElement); + that.events.onShadowReady.fire(); + }; + + gamepad.modalManager.toggleModals = function (that) { + var transaction = that.applier.initiate(); + var hideActionLauncher = that.model.activeModal !== "actionLauncher"; + transaction.fireChangeRequest({ path: "hideActionLauncher", value: hideActionLauncher }); + + var hideOnscreenKeyboard = that.model.activeModal !== "onscreenKeyboard"; + transaction.fireChangeRequest({ path: "hideOnscreenKeyboard", value: hideOnscreenKeyboard }); + + transaction.commit(); + }; })(fluid); diff --git a/src/js/content_scripts/onscreen-keyboard.js b/src/js/content_scripts/onscreen-keyboard.js new file mode 100644 index 0000000..4743ac8 --- /dev/null +++ b/src/js/content_scripts/onscreen-keyboard.js @@ -0,0 +1,107 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +(function (fluid) { + "use strict"; + var gamepad = fluid.registerNamespace("gamepad"); + + // Adapted from: https://github.com/duhrer/fluid-osk/blob/GH-1/examples/js/customTextInput.js + + fluid.defaults("gamepad.osk.modal", { + gradeNames: ["gamepad.modal"], + model: { + classNames: " onscreen-keyboard-modal", + label: "Gamepad Navigator: Onscreen Keyboard", + lastExternalFocused: false, + textInputValue: "" + }, + components: { + input: { + container: "{that}.dom.modalBody", + type: "osk.inputs.text", + options: { + model: { + composition: "{gamepad.osk.modal}.model.textInputValue" + }, + modelListeners: { + composition: { + excludeSource: "local", + funcName: "gamepad.osk.modal.updateTextInputWithExternalValue", + args: ["{that}"] + } + } + } + }, + osk: { + container: "{that}.dom.modalBody", + type: "gamepad.osk.keyboard", + options: { + listeners: { + "onAction.updateInput": { + priority: "before:handleLatches", + funcName: "gamepad.osk.modal.processAction", + args: ["{that}", "{osk.inputs.text}", "{arguments}.0"] // inputComponent, actionDef + } + } + } + } + } + }); + + // Our assumptions are slightly different than fluid-osk, so we hack in an override to pass in external values. + // It may be possible to move this to fluid-osk, but in testing this causes problems with other external updates, + // i.e. those made indirectly via addChar and other invokers. + gamepad.osk.modal.updateTextInputWithExternalValue = function (inputComponent) { + var transaction = inputComponent.applier.initiate(); + transaction.fireChangeRequest({ path: "cursorIndex", value: inputComponent.model.composition.length}); + transaction.fireChangeRequest({ path: "beforeCursor", value: inputComponent.model.composition }); + transaction.fireChangeRequest({ path: "afterCursor", value: "" }); + transaction.commit(); + }; + + gamepad.osk.modal.processAction = function (keyboardComponent, inputComponent, actionDef) { + if (actionDef.action === "text") { + var toAdd = actionDef.payload; + if (keyboardComponent.model.latchedKeys.ShiftLeft || keyboardComponent.model.latchedKeys.ShiftRight ) { + toAdd = actionDef.shiftPayload; + } + else if (keyboardComponent.model.latchedKeys.CapsLock) { + toAdd = actionDef.capsPayload; + } + var isInsert = keyboardComponent.model.latchedKeys.Insert; + inputComponent.addChar(toAdd, isInsert); + } + else if (actionDef.action === "backspace") { + inputComponent.removePreviousChar(); + } + else if (actionDef.action === "delete") { + inputComponent.removeNextChar(); + } + else if (actionDef.action === "up" || actionDef.action === "left") { + inputComponent.moveCursor(-1); + } + else if (actionDef.action === "down" || actionDef.action === "right") { + inputComponent.moveCursor(1); + } + else if (actionDef.action === "home") { + inputComponent.moveCursorToStart(); + } + else if (actionDef.action === "end") { + inputComponent.moveCursorToEnd(); + } + }; + + // Wrapped in case we want to extend later, for example to add bespoke + // buttons for domains. + fluid.defaults("gamepad.osk.keyboard", { + gradeNames: ["osk.keyboard.qwerty"] + }); +})(fluid); diff --git a/src/js/shared/templateRenderer.js b/src/js/content_scripts/templateRenderer.js similarity index 100% rename from src/js/shared/templateRenderer.js rename to src/js/content_scripts/templateRenderer.js diff --git a/src/manifest.json b/src/manifest.json index 23ee38a..212f3be 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -14,19 +14,24 @@ "matches": [ "" ], - "css": [ - "css/common.css", - "css/modal.css", - "css/action-launcher.css" - ], + "css": [], "js": [ "js/lib/infusion/infusion-all.js", "js/lib/ally/ally.min.js", + "js/lib/fluid-osk/templateRenderer.js", + "js/lib/fluid-osk/keydefs.js", + "js/lib/fluid-osk/key.js", + "js/lib/fluid-osk/row.js", + "js/lib/fluid-osk/keyboard.js", + "js/lib/fluid-osk/keyboards.js", + "js/lib/fluid-osk/inputs.js", "js/content_scripts/gamepad-navigator.js", "js/shared/configuration-maps.js", - "js/shared/templateRenderer.js", + "js/content_scripts/styles.js", + "js/content_scripts/templateRenderer.js", "js/content_scripts/modal.js", "js/content_scripts/action-launcher.js", + "js/content_scripts/onscreen-keyboard.js", "js/content_scripts/input-mapper-content-utils.js", "js/content_scripts/input-mapper-background-utils.js", "js/content_scripts/input-mapper-base.js", diff --git a/tests/html/text-input.html b/tests/html/text-input.html new file mode 100644 index 0000000..75f97e9 --- /dev/null +++ b/tests/html/text-input.html @@ -0,0 +1,38 @@ + + + + Text Input + + +

A sample page to try the new onscreen keyboard with various text inputs.

+ +

Input with No Type

+ + + +

Text Input

+ + + +

Search Input

+ + + +

Email Input

+ + + +

Password Input

+ + + +

Telephone Number Input

+ + + +

URL input

+ + + + + diff --git a/utils/bundleCss.js b/utils/bundleCss.js new file mode 100644 index 0000000..a24566f --- /dev/null +++ b/utils/bundleCss.js @@ -0,0 +1,56 @@ +/* eslint-env node */ +"use strict"; +var fluid = require("infusion"); +var fs = require("fs"); +var path = require("path"); + +var gamepad = fluid.registerNamespace("gamepad"); + +fluid.defaults("gamepad.bundleCss", { + gradeNames: ["fluid.component"], + outputPath: "dist/js/content_scripts/styles.js", + outputTemplate: "\"use strict\";\nvar gamepad = fluid.registerNamespace(\"gamepad\");\ngamepad.css = `%payload`;\n", + pathsToBundle: { + src: "src/css", + osk: "node_modules/fluid-osk/src/css" + }, + excludes: [ + "src/css/common.css", + "src/css/configuration-panel.css" + ], + listeners: { + "onCreate.processDirs": { + funcName: "gamepad.bundleCss.processDirs", + args: ["{that}"] + } + } +}); + +gamepad.bundleCss.processDirs = function (that) { + var payload = ""; + + var resolvedExcludes = that.options.excludes.map(function (relativePath) { + return path.resolve(relativePath); + }); + + fluid.each(that.options.pathsToBundle, function (pathToBundle) { + var filenames = fs.readdirSync(pathToBundle); + fluid.each(filenames, function (filename) { + if (filename.toLowerCase().endsWith(".css")) { + var filePath = path.resolve(pathToBundle, filename); + if (!resolvedExcludes.includes(filePath)) { + var fileContents = fs.readFileSync(filePath, { encoding: "utf8"}); + payload += fileContents; + } + } + }); + }); + + var bundle = fluid.stringTemplate(that.options.outputTemplate, { payload: payload}); + + fs.writeFileSync(that.options.outputPath, bundle, { encoding: "utf8"}); + + fluid.log(fluid.logLevel.WARN, "bundled all CSS to '" + that.options.outputPath + "'"); +}; + +gamepad.bundleCss(); From 82dd0ce3441c1d5c898f4ce8b91027d7ac7be5bf Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 12:11:37 +0100 Subject: [PATCH 03/23] NOGH: Refactored to use ally's "tabsequence" query rather than sorting everything ourselves. --- src/js/content_scripts/input-mapper-base.js | 2 +- src/js/content_scripts/input-mapper-content-utils.js | 4 ++-- src/js/content_scripts/modal.js | 3 +-- tests/js/tab/tab-order-tests.js | 11 ++++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/js/content_scripts/input-mapper-base.js b/src/js/content_scripts/input-mapper-base.js index 18586da..e71a075 100644 --- a/src/js/content_scripts/input-mapper-base.js +++ b/src/js/content_scripts/input-mapper-base.js @@ -253,7 +253,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }; gamepad.inputMapper.base.updateTabbables = function (that) { - that.tabbableElements = ally.query.tabbable({ strategy: "strict" }); + that.tabbableElements = ally.query.tabsequence({ strategy: "strict" }); }; diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 2951427..3b283d3 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -414,7 +414,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Get the index of the currently active element, if available. if (fluid.get(document, "activeElement")) { - var tabbableElements = ally.query.tabbable({ strategy: "strict" }); + var tabbableElements = ally.query.tabsequence({ strategy: "strict" }); activeElementIndex = tabbableElements.indexOf(document.activeElement); } @@ -447,7 +447,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Get the index of the currently active element, if available. if (fluid.get(document, "activeElement")) { - var tabbableElements = ally.query.tabbable({ strategy: "strict" }); + var tabbableElements = ally.query.tabsequence({ strategy: "strict" }); activeElementIndex = tabbableElements.indexOf(document.activeElement); } diff --git a/src/js/content_scripts/modal.js b/src/js/content_scripts/modal.js index 33e799e..c41ea71 100644 --- a/src/js/content_scripts/modal.js +++ b/src/js/content_scripts/modal.js @@ -153,8 +153,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE var innerContainer = that.locate("innerContainer"); // Search for tabbables, focus on first or last element depending. - // TODO: Repurpose sorting from other areas where we use ally? - var tabbableElements = ally.query.tabbable({ context: innerContainer, strategy: "strict" }); + var tabbableElements = ally.query.tabsequence({ context: innerContainer, strategy: "strict" }); if (tabbableElements.length) { var elementIndex = reverse ? tabbableElements.length - 1 : 0; var elementToFocus = tabbableElements[elementIndex]; diff --git a/tests/js/tab/tab-order-tests.js b/tests/js/tab/tab-order-tests.js index 59b6167..0b337e2 100644 --- a/tests/js/tab/tab-order-tests.js +++ b/tests/js/tab/tab-order-tests.js @@ -21,11 +21,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE jqUnit.module("Gamepad Navigator Tab Navigation DOM Tests", { setup: function () { + var testElementContainer = $(".test-elements"); /** * Get the list of tabbable elements in the order of their tabindex for * verification. */ - gamepad.tests.sortedTabbableElements = ally.query.tabbable({ strategy: "strict" }).sort(gamepad.inputMapper.base.tabindexSortFilter); + gamepad.tests.tabbableElements = ally.query.tabsequence({ context: testElementContainer, strategy: "strict" }); } }); @@ -33,7 +34,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE jqUnit.expect(1); // Get the first element from the tabbable elements list. - var firstTabbableElement = gamepad.tests.sortedTabbableElements[0], + var firstTabbableElement = gamepad.tests.tabbableElements[0], hasTabindexOne = firstTabbableElement.getAttribute("tabindex") === "1"; jqUnit.assertTrue("The first element should have tabindex=\"1\"", hasTabindexOne); @@ -43,7 +44,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE jqUnit.expect(1); // Get the last element from the tabbable elements list. - var lastTabbableElement = gamepad.tests.sortedTabbableElements[gamepad.tests.sortedTabbableElements.length - 1], + var lastTabbableElement = gamepad.tests.tabbableElements[gamepad.tests.tabbableElements.length - 1], isLastElement = lastTabbableElement.getAttribute("id") === "last"; jqUnit.assertTrue("The first element should be the last div with id=\"last\"", isLastElement); @@ -54,12 +55,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Check if the tabbable elements list contains elements with tabindex="-1". var elementWithNegativeTabindex = $("button[tabindex=\"-1\"]"), - hasElementWithNegativeTabindex = gamepad.tests.sortedTabbableElements.includes(elementWithNegativeTabindex[0]); + hasElementWithNegativeTabindex = gamepad.tests.tabbableElements.includes(elementWithNegativeTabindex[0]); jqUnit.assertFalse("The sorted elements array should not contain elements with tabindex=\"-1\"", hasElementWithNegativeTabindex); // Check if the tabbable elements list contains elements with tabindex="0". var elementWithTabindexZero = $("div[tabindex=\"0\"]"), - hasElementWithTabindexZero = gamepad.tests.sortedTabbableElements.includes(elementWithTabindexZero[0]); + hasElementWithTabindexZero = gamepad.tests.tabbableElements.includes(elementWithTabindexZero[0]); jqUnit.assertTrue("The sorted elements array should contain elements with tabindex=\"0\"", hasElementWithTabindexZero); }); }); From 084f4d0d63d022c4139cad7760b6941061311586 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 12:12:40 +0100 Subject: [PATCH 04/23] GH-84: Updated to latest fluid-osk dev release and tweaked scaling rules. --- package.json | 2 +- src/css/modal.css | 15 +++++++++++++++ src/css/onscreen-keyboard.css | 15 ++++++++------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 9f24ef1..eeea53b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ }, "dependencies": { "ally.js": "1.4.1", - "fluid-osk": "0.1.0-dev.20231110T145341Z.c8aee65.GH-1", + "fluid-osk": "0.1.0-dev.20231120T090252Z.d23b7cd.GH-1", "infusion": "4.6.0" }, "devDependencies": { diff --git a/src/css/modal.css b/src/css/modal.css index f4e5905..e24df9c 100644 --- a/src/css/modal.css +++ b/src/css/modal.css @@ -28,6 +28,20 @@ z-index: 997; } +@media (max-height: 400px) { + :host(.gamepad-navigator-modal-manager.modal-inner-container) { + height: 95%; + top: 2.5%; + } +} + +@media (max-width: 600px) { + :host(.gamepad-navigator-modal-manager) .modal-inner-container { + left: 2.5%; + width: 95%; + } +} + .modal-header { align-self: center; } @@ -39,6 +53,7 @@ .modal-body { border: 1px solid #ccc; + height: 100%; overflow-y: scroll; } diff --git a/src/css/onscreen-keyboard.css b/src/css/onscreen-keyboard.css index 89a11c1..874aa78 100644 --- a/src/css/onscreen-keyboard.css +++ b/src/css/onscreen-keyboard.css @@ -1,14 +1,14 @@ .osk-keyboard { margin: auto; - padding-bottom: 2rem; - padding-top: 2rem; - width: 80%; + padding-bottom: clamp(0.125rem, 5vh, 2rem); + padding-top: clamp(0.125rem, 5vh, 2rem); + width: 90%; } .osk-text-input { margin: auto; - margin-top: 2rem; - width: 80%; + margin-top: clamp(0.5rem, 2vh, 2rem); + width: 90%; } button.osk-key { @@ -17,6 +17,7 @@ button.osk-key { .osk-key { background-color: #ccc; + margin: clamp(0.1rem, 0.3vw, 0.75rem); } .osk-key:focus { @@ -24,6 +25,6 @@ button.osk-key { } .onscreen-keyboard-modal .modal-inner-container { - height: 40rem; - top: 25%; + height: clamp(20rem, 80vh, 50rem); + top: clamp(5rem, 10vh, 10rem); } From c1b0e71a6a73753624f0aad82f8622a152bc2f4b Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 12:13:37 +0100 Subject: [PATCH 05/23] NO-GH: Prefixed case switch rules so that they keep working post-estlint-stylepocalypse. --- .eslintrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index cec9abc..4ff2dbb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,7 @@ }, "extends": "eslint-config-fluid", "rules": { - "indent": [ + "@stylistic/js/indent": [ "error", 4, { "SwitchCase": 1} From a8a2623c6f8ee01f2f57b16c7236b50ad9fa6dc1 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 12:14:53 +0100 Subject: [PATCH 06/23] NOGH: Cleaned up input mapper to check whether an active element is available before manipulating it. --- .../input-mapper-content-utils.js | 108 +++++++++--------- src/js/content_scripts/input-mapper.js | 1 - 2 files changed, 57 insertions(+), 52 deletions(-) diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 3b283d3..1cba6c3 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -298,60 +298,63 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.inputMapperUtils.content.click = function (that, value) { if (that.model.pageInView && (value > 0)) { var activeElement = that.model.activeModal ? fluid.get(that, "model.shadowElement.activeElement") : document.activeElement; - var isTextInput = gamepad.inputMapperUtils.content.isTextInput(activeElement); - - // Open the new onscreen keyboard to input text. - if (isTextInput) { - var lastExternalFocused = activeElement; - that.applier.change("lastExternalFocused", lastExternalFocused); - that.applier.change("textInputValue", lastExternalFocused.value); - that.applier.change("activeModal", "onscreenKeyboard"); - } - /** - * If SELECT element is currently focused, toggle its state. Otherwise perform - * the regular click operation. - */ - else if (activeElement.nodeName === "SELECT") { - var optionsLength = 0; - // Compute the number of options and store it. - activeElement.childNodes.forEach(function (childNode) { - if (childNode.nodeName === "OPTION") { - optionsLength++; - } - }); + if (activeElement) { + var isTextInput = gamepad.inputMapperUtils.content.isTextInput(activeElement); - // Toggle the SELECT dropdown. - if (!activeElement.getAttribute("size") || activeElement.getAttribute("size") === "1") { - /** - * Store the initial size of the dropdown in a separate attribute - * (if specified already). - */ - var initialSizeString = activeElement.getAttribute("size"); - if (initialSizeString) { - activeElement.setAttribute("initialSize", parseInt(initialSizeString)); + // Open the new onscreen keyboard to input text. + if (isTextInput) { + var lastExternalFocused = activeElement; + that.applier.change("lastExternalFocused", lastExternalFocused); + that.applier.change("textInputValue", lastExternalFocused.value); + that.applier.change("activeModal", "onscreenKeyboard"); + } + /** + * If SELECT element is currently focused, toggle its state. Otherwise perform + * the regular click operation. + */ + else if (activeElement.nodeName === "SELECT") { + var optionsLength = 0; + + // Compute the number of options and store it. + activeElement.childNodes.forEach(function (childNode) { + if (childNode.nodeName === "OPTION") { + optionsLength++; + } + }); + + // Toggle the SELECT dropdown. + if (!activeElement.getAttribute("size") || activeElement.getAttribute("size") === "1") { + /** + * Store the initial size of the dropdown in a separate attribute + * (if specified already). + */ + var initialSizeString = activeElement.getAttribute("size"); + if (initialSizeString) { + activeElement.setAttribute("initialSize", parseInt(initialSizeString)); + } + + /** + * Allow limited expansion to avoid an overflowing list, considering the + * list could go as large as 100 or more (for example, a list of + * countries). + */ + var length = Math.min(15, optionsLength); + activeElement.setAttribute("size", length); } + else { + // Obtain the initial size of the dropdown. + var sizeString = activeElement.getAttribute("initialSize") || "1"; - /** - * Allow limited expansion to avoid an overflowing list, considering the - * list could go as large as 100 or more (for example, a list of - * countries). - */ - var length = Math.min(15, optionsLength); - activeElement.setAttribute("size", length); + // Restore the size of the dropdown. + activeElement.setAttribute("size", parseInt(sizeString)); + } } else { - // Obtain the initial size of the dropdown. - var sizeString = activeElement.getAttribute("initialSize") || "1"; - - // Restore the size of the dropdown. - activeElement.setAttribute("size", parseInt(sizeString)); + // Click on the focused element. + activeElement.click(); } } - else { - // Click on the focused element. - activeElement.click(); - } } }; @@ -478,13 +481,16 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE if (that.model.pageInView && (value > 0)) { var keyDownEvent = new KeyboardEvent("keydown", { key: key, code: key, bubbles: true }); var activeElement = that.model.activeModal ? fluid.get(that, "model.shadowElement.activeElement") : document.activeElement; - activeElement.dispatchEvent(keyDownEvent); - // TODO: Test with text inputs and textarea fields to see if - // beforeinput and input are needed. + if (activeElement) { + activeElement.dispatchEvent(keyDownEvent); + + // TODO: Test with text inputs and textarea fields to see if + // beforeinput and input are needed. - var keyUpEvent = new KeyboardEvent("keyup", { key: key, code: key, bubbles: true }); - activeElement.dispatchEvent(keyUpEvent); + var keyUpEvent = new KeyboardEvent("keyup", { key: key, code: key, bubbles: true }); + activeElement.dispatchEvent(keyUpEvent); + } } }; diff --git a/src/js/content_scripts/input-mapper.js b/src/js/content_scripts/input-mapper.js index cfc1518..dc911f4 100644 --- a/src/js/content_scripts/input-mapper.js +++ b/src/js/content_scripts/input-mapper.js @@ -199,7 +199,6 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.inputMapper.handlePageInViewChange = function (that) { if (that.model.pageInView) { gamepad.inputMapper.updateControls(that); - // TODO: Restore focus. } else { that.applier.change("activeModal", false); From d2190b55df6ff314269d1c9be118ddf850007518 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 12:15:23 +0100 Subject: [PATCH 07/23] GH-84: Updated action launcher to add support for arrow keys. --- src/js/content_scripts/action-launcher.js | 200 ++++++++++++++++------ 1 file changed, 146 insertions(+), 54 deletions(-) diff --git a/src/js/content_scripts/action-launcher.js b/src/js/content_scripts/action-launcher.js index adb8512..e05004c 100644 --- a/src/js/content_scripts/action-launcher.js +++ b/src/js/content_scripts/action-launcher.js @@ -37,6 +37,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }, model: { + row: -1, value: 1, oldValue: 0, speedFactor: 1, @@ -58,6 +59,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE handleKeydown: { funcName: "gamepad.actionLauncher.action.handleKeydown", args: ["{that}", "{arguments}.0"] // event + }, + handleFocus: { + funcName: "gamepad.actionLauncher.action.handleFocus", + args: ["{that}", "{arguments}.0"] // event } }, @@ -71,10 +76,32 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE this: "{that}.container", method: "keydown", args: ["{that}.handleKeydown"] + }, + "onCreate.bindFocus": { + this: "{that}.container", + method: "focus", + args: ["{that}.handleFocus"] + } + }, + + modelListeners: { + "focusedRow": { + funcName: "gamepad.actionLauncher.action.handleFocusedRowChange", + args: ["{that}", "{that}.model.focusedRow", "{that}.model.row"] } } }); + gamepad.actionLauncher.action.handleFocusedRowChange = function (that, focusedRow, row) { + if (row === focusedRow) { + that.container[0].focus(); + } + }; + + gamepad.actionLauncher.action.handleFocus = function (that) { + that.applier.change("focusedRow", that.model.row); + }; + gamepad.actionLauncher.action.handleClick = function (actionComponent, inputMapperComponent, modalComponent, event) { event.preventDefault(); @@ -127,93 +154,122 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }, model: { + focusedRow: 0, // TODO: Add support for controlling `backgroundOption` in the `openNewTab` and `openNewWindow` actions. - actionDefs: { - // All button-driven actions, except for the action launcher itself. - click: { - description: "Click" + actionDefs: [ + // All button-driven actions, except for the action launcher itself, ordered by subjective "usefulness". + // TODO: Add action to open configuration menu, when available. + { + key: "openNewWindow", + description: "Open a new browser window" + }, + { + key: "openNewTab", + description: "Open a new tab" + }, + { + key: "goToPreviousWindow", + description: "Switch to the previous browser window" + }, + { + key: "goToNextWindow", + description: "Switch to the next browser window" + }, + { + key: "goToPreviousTab", + description: "Switch to the previous browser tab" + }, + { + key: "goToNextTab", + description: "Switch to the next browser tab" + }, + { + key: "closeCurrentTab", + description: "Close current browser tab" + }, + { + key: "closeCurrentWindow", + description: "Close current browser window" + }, + { + key: "reopenTabOrWindow", + description: "Re-open the last closed tab or window" }, - previousPageInHistory: { + { + key: "previousPageInHistory", description: "History back button" }, - nextPageInHistory: { + { + key: "nextPageInHistory", description: "History next button" }, - reverseTab: { + { + key: "maximizeWindow", + description: "Maximize the current browser window" + }, + { + key: "restoreWindowSize", + description: "Restore the size of current browser window" + }, + // These should nearly always already be bound. + { + key: "click", + description: "Click" + }, + { + key: "reverseTab", description: "Focus on the previous element" }, - forwardTab: { + { + key: "forwardTab", description: "Focus on the next element" }, - scrollLeft: { + // Here for completeness, but IMO less likely to be used. + { + key: "scrollLeft", description: "Scroll left", frequency: 250 }, - scrollRight: { + { + key: "scrollRight", description: "Scroll right", frequency: 250 }, - scrollUp: { + { + key: "scrollUp", description: "Scroll up", frequency: 250 }, - scrollDown: { + { + key: "scrollDown", description: "Scroll down", frequency: 250 }, - goToPreviousTab: { - description: "Switch to the previous browser tab" - }, - goToNextTab: { - description: "Switch to the next browser tab" - }, - closeCurrentTab: { - description: "Close current browser tab" - }, - openNewTab: { - description: "Open a new tab" - }, - closeCurrentWindow: { - description: "Close current browser window" - }, - openNewWindow: { - description: "Open a new browser window" - }, - goToPreviousWindow: { - description: "Switch to the previous browser window" - }, - goToNextWindow: { - description: "Switch to the next browser window" - }, - zoomIn: { + { + key: "zoomIn", description: "Zoom-in on the active web page" }, - zoomOut: { + { + key: "zoomOut", description: "Zoom-out on the active web page" }, - maximizeWindow: { - description: "Maximize the current browser window" - }, - restoreWindowSize: { - description: "Restore the size of current browser window" - }, - reopenTabOrWindow: { - description: "Re-open the last closed tab or window" - }, - sendArrowLeft: { + { + key: "sendArrowLeft", description: "Send left arrow to the focused element." }, - sendArrowRight: { + { + key: "sendArrowRight", description: "Send right arrow to the focused element." }, - sendArrowUp: { + { + key: "sendArrowUp", description: "Send up arrow to the focused element." }, - sendArrowDown: { + { + key: "sendArrowDown", description: "Send down arrow to the focused element." } - // TODO: Add action to open configuration menu, when available. - } + ] }, dynamicComponents: { @@ -223,7 +279,9 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE sources: "{that}.model.actionDefs", options: { model: { - actionKey: "{sourcePath}", + focusedRow: "{gamepad.actionLauncher.actionsPanel}.model.focusedRow", + row: "{sourcePath}", + actionKey: "{source}.key", description: "{source}.description", speedFactor: "{source}.speedFactor", invert: "{source}.invert", @@ -232,6 +290,40 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } } } + }, + + invokers: { + handleKeydown: { + funcName: "gamepad.actionLauncher.actionsPanel.handleKeydown", + args: ["{that}", "{arguments}.0"] // event + } + }, + + listeners: { + "onCreate.bindKeydown": { + this: "{that}.container", + method: "keydown", + args: ["{that}.handleKeydown"] + } } }); + + gamepad.actionLauncher.actionsPanel.handleKeydown = function (that, event) { + if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.code)) { + event.preventDefault(); + var lastRow = that.model.actionDefs.length - 1; + switch (event.code) { + case "ArrowLeft": + case "ArrowUp": + var previousRow = that.model.focusedRow > 0 ? that.model.focusedRow - 1 : lastRow; + that.applier.change("focusedRow", previousRow); + break; + case "ArrowRight": + case "ArrowDown": + var nextRow = that.model.focusedRow < lastRow ? that.model.focusedRow + 1 : 0; + that.applier.change("focusedRow", nextRow); + break; + } + } + }; })(fluid); From 8b4e9b73f98e8ff92513e894ee2c4db65c370cba Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 12:15:44 +0100 Subject: [PATCH 08/23] NOGH: Updated manifest to better match project authorship. --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index 212f3be..6b7a8f7 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "name": "Gamepad Navigator", "version": "0.3.0", "description": "A Chrome extension that allows you to navigate web pages and Chromium-based browsers using a game controller.", - "author": "Divyanshu Mahajan", + "author": "The Gamepad Navigator Authors", "manifest_version": 3, "permissions": [ "storage", From 0b8cbe06868da72a565c666610250705fa68f8c4 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 20 Nov 2023 14:46:40 +0100 Subject: [PATCH 09/23] GH-85: Added 'search' action. --- src/js/background.js | 88 ++++++++++---- .../configuration-panel.js | 3 +- src/js/content_scripts/action-launcher.js | 4 + src/js/content_scripts/input-mapper.js | 22 +++- src/js/content_scripts/modal.js | 82 +------------ src/js/content_scripts/modalManager.js | 112 ++++++++++++++++++ src/js/content_scripts/search-keyboard.js | 70 +++++++++++ src/manifest.json | 5 +- 8 files changed, 273 insertions(+), 113 deletions(-) create mode 100644 src/js/content_scripts/modalManager.js create mode 100644 src/js/content_scripts/search-keyboard.js diff --git a/src/js/background.js b/src/js/background.js index 91cf67a..496fca8 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -250,6 +250,15 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }); }; + gamepad.messageListenerUtils.search = function (actionData) { + chrome.search.query({ + disposition: actionData.disposition, + text: actionData.text + }); + }; + + + var messageListener = { // previous "members" windowProperties: {}, @@ -258,7 +267,9 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE actionExecutor: function (actionData) { gamepad.messageListener.actionExecutor(messageListener, actionData); }, - // All actions are called with: tabId, invert, active, homepageURL, left + + // All legacy actions are called with: tabId, invert, active, homepageURL, left + // TODO: Rewrite these to use actionData directly. openNewTab: function (tabId, invert, active, homepageURL) { // TODO: Currently opens a new tab and a new window. gamepad.messageListenerUtils.openNewTab(active, homepageURL); @@ -266,16 +277,23 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE closeCurrentTab: function (tabId) { chrome.tabs.remove(tabId); }, - goToPreviousTab: function () { - gamepad.messageListenerUtils.switchTab("previousTab"); - }, - goToNextTab: function () { - gamepad.messageListenerUtils.switchTab("nextTab"); - }, openNewWindow: function (tabId, invert, active, homepageURL) { gamepad.messageListenerUtils.openNewWindow(active, homepageURL); }, - closeCurrentWindow: gamepad.messageListenerUtils.closeCurrentWindow, + + maximizeWindow: function (tabId, invert, active, homepageURL, left) { + gamepad.messageListenerUtils.changeWindowSize(messageListener, "maximized", left); + }, + restoreWindowSize: function (tabId, invert, active, homepageURL, left) { + gamepad.messageListenerUtils.changeWindowSize(messageListener, "normal", left); + }, + // TODO: Rewrite these to use actionData directly. + + // From now on, actions will be called directly with the actionData from the message. These are either new + // or have no data and can use the new method. + reopenTabOrWindow: function () { + chrome.sessions.restore(); + }, goToPreviousWindow: function () { gamepad.messageListenerUtils.switchWindow("previousWindow"); }, @@ -288,20 +306,34 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE zoomOut: function () { gamepad.messageListenerUtils.setZoom("zoomOut"); }, - maximizeWindow: function (tabId, invert, active, homepageURL, left) { - gamepad.messageListenerUtils.changeWindowSize(messageListener, "maximized", left); - }, - restoreWindowSize: function (tabId, invert, active, homepageURL, left) { - gamepad.messageListenerUtils.changeWindowSize(messageListener, "normal", left); + goToPreviousTab: function () { + gamepad.messageListenerUtils.switchTab("previousTab"); }, - reopenTabOrWindow: function () { - chrome.sessions.restore(); + goToNextTab: function () { + gamepad.messageListenerUtils.switchTab("nextTab"); }, + closeCurrentWindow: gamepad.messageListenerUtils.closeCurrentWindow, openActionLauncher: function () { gamepad.messageListenerUtils.openActionLauncher(); - } + }, + search: gamepad.messageListenerUtils.search }; + // Temporary list of actions that can take a full actionData payload directly. + gamepad.messageListener.newSchoolActions = [ + "reopenTabOrWindow", + "goToPreviousWindow", + "goToNextWindow", + "zoomIn", + "zoomOut", + "goToPreviousTab", + "goToNextTab", + "closeCurrentWindow", + "openActionLauncher", + "openSearchKeyboard", + "search" + ]; + /** * * Calls the invoker methods according to the message is recieved from the content @@ -318,14 +350,22 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Trigger the action only if a valid action is found. if (action) { - var invert = actionData.invert, - active = actionData.active, - left = actionData.left, - homepageURL = actionData.homepageURL; - chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { - var tabId = tabs[0] ? tabs[0].id : undefined; - action(tabId, invert, active, homepageURL, left); - }); + if (gamepad.messageListener.newSchoolActions.includes(actionData.actionName)) { + chrome.tabs.query({ active: true, currentWindow: true }, function () { + action(actionData); + }); + } + else { + var invert = actionData.invert, + active = actionData.active, + left = actionData.left, + homepageURL = actionData.homepageURL; + chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { + var tabId = tabs[0] ? tabs[0].id : undefined; + action(tabId, invert, active, homepageURL, left); + }); + + } } } }; diff --git a/src/js/configuration_panel/configuration-panel.js b/src/js/configuration_panel/configuration-panel.js index 8008e0d..7112bb2 100644 --- a/src/js/configuration_panel/configuration-panel.js +++ b/src/js/configuration_panel/configuration-panel.js @@ -99,7 +99,8 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE sendArrowRight: "Send right arrow to the focused element.", sendArrowUp: "Send up arrow to the focused element.", sendArrowDown: "Send down arrow to the focused element.", - openActionLauncher: "Open Action Launcher" + openActionLauncher: "Open Action Launcher", + openSearchKeyboard: "Open Search" }, axes: { null: "None", diff --git a/src/js/content_scripts/action-launcher.js b/src/js/content_scripts/action-launcher.js index e05004c..64722e2 100644 --- a/src/js/content_scripts/action-launcher.js +++ b/src/js/content_scripts/action-launcher.js @@ -159,6 +159,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE actionDefs: [ // All button-driven actions, except for the action launcher itself, ordered by subjective "usefulness". // TODO: Add action to open configuration menu, when available. + { + key: "openSearchKeyboard", + description: "Search" + }, { key: "openNewWindow", description: "Open a new browser window" diff --git a/src/js/content_scripts/input-mapper.js b/src/js/content_scripts/input-mapper.js index dc911f4..c0b1ab8 100644 --- a/src/js/content_scripts/input-mapper.js +++ b/src/js/content_scripts/input-mapper.js @@ -154,6 +154,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE openActionLauncher: { funcName: "gamepad.inputMapper.openActionLauncher", args: ["{that}", "{arguments}.0", "{arguments}.4"] // value, oldValue + }, + openSearchKeyboard: { + funcName: "gamepad.inputMapper.openSearchKeyboard", + args: ["{that}", "{arguments}.0", "{arguments}.4"] // value, oldValue } }, components: { @@ -187,15 +191,21 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }; - gamepad.inputMapper.openActionLauncher = function (that, value, oldValue) { - if (that.model.pageInView && value && !oldValue) { - // In this case we don't want to fail over to a modal's activeElement. - that.applier.change("lastExternalFocused", document.activeElement); + gamepad.inputMapper.generateModalOpenFunction = function (modalKey) { + return function (that, value, oldValue) { + if (that.model.pageInView && value && !oldValue) { + // In this case we don't want to fail over to a modal's activeElement. + that.applier.change("lastExternalFocused", document.activeElement); - that.applier.change("activeModal", "actionLauncher"); - } + that.applier.change("activeModal", modalKey); + } + }; }; + // We do this in a funky way because of our fixed method signature across actions. + gamepad.inputMapper.openActionLauncher = gamepad.inputMapper.generateModalOpenFunction("actionLauncher"); + gamepad.inputMapper.openSearchKeyboard = gamepad.inputMapper.generateModalOpenFunction("searchKeyboard"); + gamepad.inputMapper.handlePageInViewChange = function (that) { if (that.model.pageInView) { gamepad.inputMapper.updateControls(that); diff --git a/src/js/content_scripts/modal.js b/src/js/content_scripts/modal.js index c41ea71..432c1ee 100644 --- a/src/js/content_scripts/modal.js +++ b/src/js/content_scripts/modal.js @@ -43,6 +43,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE icon: ".modal-icon", innerContainer: ".modal-inner-container", modalBody: ".modal-body", + modalFooter: ".modal-footer", modalCloseButton: ".modal-close-button", leadingFocusTrap: ".modal-focus-trap-leading", trailingFocusTrap: ".modal-focus-trap-trailing" @@ -167,85 +168,4 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE that.focusFirst(); } }; - - fluid.defaults("gamepad.modalManager", { - gradeNames: ["gamepad.templateRenderer"], - markup: { - container: "
", - styles: "" - }, - model: { - activeModal: false, - shadowElement: false, - lastExternalFocused: false, - textInputValue: "", - textInputType: "", - - // Inline all styles from JS-wrapped global namespaced variable. - styles: gamepad.css - }, - events: { - onShadowReady: null - }, - components: { - actionLauncher: { - container: "{that}.model.shadowElement", - type: "gamepad.actionLauncher", - createOnEvent: "onShadowReady", - options: { - model: { - hidden: "{gamepad.modalManager}.model.hideActionLauncher" - } - } - }, - onscreenKeyboard: { - container: "{that}.model.shadowElement", - type: "gamepad.osk.modal", - createOnEvent: "onShadowReady", - options: { - model: { - hidden: "{gamepad.modalManager}.model.hideOnscreenKeyboard", - textInputValue: "{gamepad.modalManager}.model.textInputValue", - textInputType: "{gamepad.modalManager}.model.textInputType" - } - } - } - }, - listeners: { - "onCreate.createShadow": { - funcName: "gamepad.modalManager.createShadow", - args: ["{that}"] - } - }, - modelListeners: { - activeModal: { - excludeSource: "init", - funcName: "gamepad.modalManager.toggleModals", - args: ["{that}"] - } - } - }); - - gamepad.modalManager.createShadow = function (that) { - var host = that.container[0]; - var shadowElement = host.attachShadow({mode: "open"}); - - // We inline all styles here so that all modals get the common styles, - // and to avoid managing multiple shadow elements. - shadowElement.innerHTML = fluid.stringTemplate(that.options.markup.styles, that.model); - - that.applier.change("shadowElement", shadowElement); - that.events.onShadowReady.fire(); - }; - - gamepad.modalManager.toggleModals = function (that) { - var transaction = that.applier.initiate(); - var hideActionLauncher = that.model.activeModal !== "actionLauncher"; - transaction.fireChangeRequest({ path: "hideActionLauncher", value: hideActionLauncher }); - - var hideOnscreenKeyboard = that.model.activeModal !== "onscreenKeyboard"; - transaction.fireChangeRequest({ path: "hideOnscreenKeyboard", value: hideOnscreenKeyboard }); - - transaction.commit(); - }; })(fluid); diff --git a/src/js/content_scripts/modalManager.js b/src/js/content_scripts/modalManager.js new file mode 100644 index 0000000..d4d24e7 --- /dev/null +++ b/src/js/content_scripts/modalManager.js @@ -0,0 +1,112 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +(function (fluid) { + "use strict"; + + var gamepad = fluid.registerNamespace("gamepad"); + + + fluid.defaults("gamepad.modalManager", { + gradeNames: ["gamepad.templateRenderer"], + markup: { + container: "
", + styles: "" + }, + model: { + activeModal: false, + shadowElement: false, + lastExternalFocused: false, + textInputValue: "", + textInputType: "", + + // Inline all styles from JS-wrapped global namespaced variable. + styles: gamepad.css + }, + events: { + onShadowReady: null + }, + components: { + actionLauncher: { + container: "{that}.model.shadowElement", + type: "gamepad.actionLauncher", + createOnEvent: "onShadowReady", + options: { + model: { + hidden: "{gamepad.modalManager}.model.hideActionLauncher" + } + } + }, + onscreenKeyboard: { + container: "{that}.model.shadowElement", + type: "gamepad.osk.modal", + createOnEvent: "onShadowReady", + options: { + model: { + hidden: "{gamepad.modalManager}.model.hideOnscreenKeyboard", + textInputValue: "{gamepad.modalManager}.model.textInputValue" + } + } + }, + searchKeyboard: { + container: "{that}.model.shadowElement", + type: "gamepad.searchKeyboard.modal", + createOnEvent: "onShadowReady", + options: { + model: { + hidden: "{gamepad.modalManager}.model.hideSearchKeyboard" + } + } + } + }, + listeners: { + "onCreate.createShadow": { + funcName: "gamepad.modalManager.createShadow", + args: ["{that}"] + } + }, + modelListeners: { + activeModal: { + excludeSource: "init", + funcName: "gamepad.modalManager.toggleModals", + args: ["{that}"] + } + } + }); + + gamepad.modalManager.createShadow = function (that) { + var host = that.container[0]; + var shadowElement = host.attachShadow({mode: "open"}); + + // We inline all styles here so that all modals get the common styles, + // and to avoid managing multiple shadow elements. + shadowElement.innerHTML = fluid.stringTemplate(that.options.markup.styles, that.model); + + that.applier.change("shadowElement", shadowElement); + that.events.onShadowReady.fire(); + }; + + gamepad.modalManager.toggleModals = function (that) { + var transaction = that.applier.initiate(); + var hideActionLauncher = that.model.activeModal !== "actionLauncher"; + transaction.fireChangeRequest({ path: "hideActionLauncher", value: hideActionLauncher }); + + var hideOnscreenKeyboard = that.model.activeModal !== "onscreenKeyboard"; + transaction.fireChangeRequest({ path: "hideOnscreenKeyboard", value: hideOnscreenKeyboard }); + + + var hideSearchKeyboard = that.model.activeModal !== "searchKeyboard"; + transaction.fireChangeRequest({ path: "hideSearchKeyboard", value: hideSearchKeyboard }); + + transaction.commit(); + }; +})(fluid); diff --git a/src/js/content_scripts/search-keyboard.js b/src/js/content_scripts/search-keyboard.js new file mode 100644 index 0000000..62fb389 --- /dev/null +++ b/src/js/content_scripts/search-keyboard.js @@ -0,0 +1,70 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +/* globals chrome */ +(function (fluid) { + "use strict"; + var gamepad = fluid.registerNamespace("gamepad"); + + fluid.defaults("gamepad.searchKeyboard.searchButton", { + gradeNames: ["gamepad.templateRenderer"], + markup: { + container: "" + } + }); + + fluid.defaults("gamepad.searchKeyboard.modal", { + gradeNames: ["gamepad.osk.modal"], + model: { + label: "Gamepad Navigator: Search", + classNames: " gamepad-navigator-searchKeyboard" + }, + + invokers: { + handleSearchButtonClick: { + funcName: "gamepad.searchKeyboard.modal.handleSearchButtonClick", + args: ["{that}", "{arguments}.0"] + } + }, + + components: { + searchButton: { + container: "{that}.dom.modalFooter", + type: "gamepad.searchKeyboard.searchButton", + options: { + listeners: { + "onCreate.bindClick": { + this: "{searchButton}.container", + method: "click", + args: ["{gamepad.searchKeyboard.modal}.handleSearchButtonClick"] + } + } + } + } + } + }); + + gamepad.searchKeyboard.modal.handleSearchButtonClick = function (that, event) { + that.applier.change("activeModal", false); + event.preventDefault(); + + if (that.model.textInputValue && that.model.textInputValue.trim().length) { + var searchMessage = { + actionName: "search", + // See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/search/query + disposition: "NEW_TAB", + text: that.model.textInputValue.trim() + }; + + chrome.runtime.sendMessage(searchMessage); + } + }; +})(fluid); diff --git a/src/manifest.json b/src/manifest.json index 6b7a8f7..4e6a63d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -7,7 +7,8 @@ "permissions": [ "storage", "tabs", - "sessions" + "sessions", + "search" ], "content_scripts": [ { @@ -32,6 +33,8 @@ "js/content_scripts/modal.js", "js/content_scripts/action-launcher.js", "js/content_scripts/onscreen-keyboard.js", + "js/content_scripts/search-keyboard.js", + "js/content_scripts/modalManager.js", "js/content_scripts/input-mapper-content-utils.js", "js/content_scripts/input-mapper-background-utils.js", "js/content_scripts/input-mapper-base.js", From 605d57f53dac9bfe8c6b385e142fe656ae7b1227 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Wed, 22 Nov 2023 12:56:10 +0100 Subject: [PATCH 10/23] GH-84: Reenabled editing of textarea content, otherwise Google search doesn't work. --- src/js/content_scripts/input-mapper-content-utils.js | 7 +++---- tests/html/text-input.html | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 1cba6c3..547b70e 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -366,10 +366,9 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE return true; } } - // TODO: Add support for this in the future. - // else if (element.nodeName === "TEXTAREA") { - // return true; - // } + else if (element.nodeName === "TEXTAREA") { + return true; + } return false; }; diff --git a/tests/html/text-input.html b/tests/html/text-input.html index 75f97e9..2706223 100644 --- a/tests/html/text-input.html +++ b/tests/html/text-input.html @@ -34,5 +34,8 @@

URL input

+

Text Area

+ + From 91de3b88a31a52fbb23f7621be915aef1386daeb Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Wed, 22 Nov 2023 13:21:49 +0100 Subject: [PATCH 11/23] GH-111: Limit window and tab navigation to 'controllable' windows. --- src/js/background.js | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/js/background.js b/src/js/background.js index 496fca8..be81566 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -38,11 +38,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ gamepad.messageListenerUtils.switchTab = function (tabDirection) { chrome.tabs.query({ currentWindow: true }, function (tabsArray) { + // Filter to "controllable" tabs. + var filteredTabs = tabsArray.filter(gamepad.messageListenerUtils.filterControllableTabs); + // Switch only if more than one tab is present. - if (tabsArray.length > 1) { + if (filteredTabs.length > 1) { // Find index of the currently active tab. var activeTabIndex = null; - tabsArray.forEach(function (tab, index) { + filteredTabs.forEach(function (tab, index) { if (tab.active) { activeTabIndex = index; } @@ -55,13 +58,13 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Otherwise, switch to the previous tab. */ if (activeTabIndex === 0) { - activeTabIndex = tabsArray.length; + activeTabIndex = filteredTabs.length; } - chrome.tabs.update(tabsArray[activeTabIndex - 1].id, { active: true }); + chrome.tabs.update(filteredTabs[activeTabIndex - 1].id, { active: true }); } else if (tabDirection === "nextTab") { // Switch to the next tab. - chrome.tabs.update(tabsArray[(activeTabIndex + 1) % tabsArray.length].id, { active: true }); + chrome.tabs.update(filteredTabs[(activeTabIndex + 1) % filteredTabs.length].id, { active: true }); } } }); @@ -93,11 +96,23 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.messageListenerUtils.closeCurrentWindow = function () { + // TODO: Add a check to ensure that there is at least one controllable window open and focus on that. chrome.windows.getCurrent(function (currentWindow) { chrome.windows.remove(currentWindow.id); }); }; + gamepad.messageListenerUtils.filterControllableTabs = function (tabElement) { + return tabElement.url && !tabElement.url.startsWith("chrome://"); + }; + + gamepad.messageListenerUtils.filterControllableWindows = function (windowElement) { + if (!windowElement.tabs) { return false; } + + var filteredTabs = windowElement.tabs.filter(gamepad.messageListenerUtils.filterControllableTabs); + return filteredTabs.length > 0; + }; + /** * * Switch to the next or the previous window in the current window. @@ -108,14 +123,17 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.messageListenerUtils.switchWindow = function (windowDirection) { chrome.windows.getLastFocused(function (focusedWindow) { if (focusedWindow) { - chrome.windows.getAll(function (windowsArray) { + chrome.windows.getAll({ populate: true}, function (windowsArray) { + // Filter to controllable windows. + var filteredWindows = windowsArray.filter(gamepad.messageListenerUtils.filterControllableWindows); + // Switch only if more than one window is present. - if (windowsArray.length > 1) { + if (filteredWindows.length > 1) { // Find the index of the currently active window. var focusedWindowIndex = null; - for (var index = 0; index < windowsArray.length; index++) { + for (var index = 0; index < filteredWindows.length; index++) { if (focusedWindowIndex === null) { - var window = windowsArray[index]; + var window = filteredWindows[index]; if (window.id === focusedWindow.id) { focusedWindowIndex = index; } @@ -130,14 +148,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Switch browser window. if (windowDirection === "previousWindow") { if (focusedWindowIndex === 0) { - windowIndexToFocus = windowsArray.length - 1; + windowIndexToFocus = filteredWindows.length - 1; } else { windowIndexToFocus = focusedWindowIndex - 1; } } else if (windowDirection === "nextWindow") { - if (focusedWindowIndex >= windowsArray.length - 1) { + if (focusedWindowIndex >= filteredWindows.length - 1) { windowIndexToFocus = 0; } else { @@ -145,7 +163,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } } - chrome.windows.update(windowsArray[windowIndexToFocus].id, { + chrome.windows.update(filteredWindows[windowIndexToFocus].id, { focused: true }); } @@ -275,6 +293,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.messageListenerUtils.openNewTab(active, homepageURL); }, closeCurrentTab: function (tabId) { + // TODO: Only close the tab if it's "controllable" chrome.tabs.remove(tabId); }, openNewWindow: function (tabId, invert, active, homepageURL) { @@ -292,6 +311,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // From now on, actions will be called directly with the actionData from the message. These are either new // or have no data and can use the new method. reopenTabOrWindow: function () { + // TODO: Confirm whether or not the restored window has focus. chrome.sessions.restore(); }, goToPreviousWindow: function () { From e068e69170e01363e780d65cfe7c9e1b6a0e8b35 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Wed, 22 Nov 2023 14:08:54 +0100 Subject: [PATCH 12/23] GH-116: Limit browser/tab background navigation to 'controllable' tabs and windows. --- src/js/background.js | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/js/background.js b/src/js/background.js index be81566..763cc4f 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -96,16 +96,40 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * */ gamepad.messageListenerUtils.closeCurrentWindow = function () { - // TODO: Add a check to ensure that there is at least one controllable window open and focus on that. - chrome.windows.getCurrent(function (currentWindow) { - chrome.windows.remove(currentWindow.id); + chrome.windows.getAll({ populate: true}, function (windowArray) { + var controllableWindows = windowArray.filter(gamepad.messageListenerUtils.filterControllableWindows); + if (controllableWindows.length > 1) { + var focusedWindow = false; + var focusedWindowIndex = -1; + + controllableWindows.forEach(function (window, index) { + if (window.focused) { + focusedWindow = window; + focusedWindowIndex = index; + } + }); + + if (focusedWindow) { + var newFocusIndex = (focusedWindowIndex + 1) % controllableWindows.length; + var windowToFocus = controllableWindows[newFocusIndex]; + + chrome.windows.remove(focusedWindow.id); + + chrome.windows.update(windowToFocus.id, { + focused: true + }); + } + } }); }; + // Exclude tabs whose URL begins with `chrome://`, which do not contain + // our scripts and cannot be controlled using a gamepad. gamepad.messageListenerUtils.filterControllableTabs = function (tabElement) { return tabElement.url && !tabElement.url.startsWith("chrome://"); }; + // Exclude windows that do not contain controllable tabs (see above). gamepad.messageListenerUtils.filterControllableWindows = function (windowElement) { if (!windowElement.tabs) { return false; } @@ -293,8 +317,18 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.messageListenerUtils.openNewTab(active, homepageURL); }, closeCurrentTab: function (tabId) { - // TODO: Only close the tab if it's "controllable" - chrome.tabs.remove(tabId); + // Only close the tab if there is another "controllable" tab available. + chrome.tabs.query({currentWindow: true }, function (tabs) { + var controllableTabs = tabs.filter(gamepad.messageListenerUtils.filterControllableTabs); + // More than one tab, just close the tab. + if (controllableTabs.length > 1) { + chrome.tabs.remove(tabId); + } + // Fail over to the window close logic. + else { + gamepad.messageListenerUtils.closeCurrentWindow(); + } + }); }, openNewWindow: function (tabId, invert, active, homepageURL) { gamepad.messageListenerUtils.openNewWindow(active, homepageURL); From d6444e6a76ec7803265375d24f92f9f0a88da459 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Fri, 24 Nov 2023 14:14:40 +0100 Subject: [PATCH 13/23] GH-61: Refactored to use await and async functions. --- .eslintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.json b/.eslintrc.json index 4ff2dbb..4f0c7af 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,5 +1,6 @@ { "env": { + "es2017": true, "browser": true }, "extends": "eslint-config-fluid", From aaf5022d055ed8cf373b9eb742bdf8e0a5b31cbf Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Fri, 24 Nov 2023 14:15:42 +0100 Subject: [PATCH 14/23] GH-117: Added support for SVG icons and added to modal. --- package.json | 3 +- src/css/modal.css | 8 +++++ src/images/gamepad-icon-128px.png | Bin 3005 -> 3611 bytes src/images/gamepad-icon-16px.png | Bin 619 -> 517 bytes src/images/gamepad-icon-32px.png | Bin 965 -> 1001 bytes src/images/gamepad-icon-48px.png | Bin 1297 -> 1396 bytes src/images/gamepad-icon.png | Bin 23080 -> 36230 bytes src/images/gamepad-icon.svg | 28 +++++++++++++++ src/images/thumbstick.svg | 37 ++++++++++++++++++++ src/js/content_scripts/modal.js | 12 ++++++- src/manifest.json | 1 + utils/bundleSvgs.js | 55 ++++++++++++++++++++++++++++++ 12 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/images/gamepad-icon.svg create mode 100644 src/images/thumbstick.svg create mode 100644 utils/bundleSvgs.js diff --git a/package.json b/package.json index eeea53b..8aaa12e 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,10 @@ "homepage": "https://github.com/fluid-lab/gamepad-navigator#readme", "scripts": { "lint": "fluid-lint-all", - "build": "npm run build:grunt && npm run build:css", + "build": "npm run build:grunt && npm run build:css && npm run build:svgs", "build:grunt": "grunt build", "build:css": "node ./utils/bundleCss.js", + "build:svgs": "node ./utils/bundleSvgs.js", "postinstall": "npm run build", "test": "testem ci --file tests/testem.json" }, diff --git a/src/css/modal.css b/src/css/modal.css index e24df9c..6b677a0 100644 --- a/src/css/modal.css +++ b/src/css/modal.css @@ -43,7 +43,10 @@ } .modal-header { + align-items: center; align-self: center; + display: flex; + flex-direction: row; } .modal-header h3 { @@ -51,6 +54,11 @@ font-weight: bold; } +.modal-header .modal-icon svg { + height: 5rem; + width: 5rem; +} + .modal-body { border: 1px solid #ccc; height: 100%; diff --git a/src/images/gamepad-icon-128px.png b/src/images/gamepad-icon-128px.png index 708d83c8688ee40362540ab42489e19ccdb36567..b9bd877d6f360e08da9d4366452005e6b1954c7b 100644 GIT binary patch literal 3611 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 literal 3005 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 XO57TlIxj5)YGCkm^>bP0l+XkKE1U2t diff --git a/src/images/gamepad-icon-32px.png b/src/images/gamepad-icon-32px.png index 1f64b5bc7192d49a1b9306f4dbe73fe75c251045..9e138920b3e46997442ec5aa6c545528d33e1e40 100644 GIT binary patch delta 990 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 uw4}v#i?N;p{trCzf3rRou+ezr1^frJs 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 literal 23080 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 HVd?(^a18qF diff --git a/src/images/gamepad-icon.svg b/src/images/gamepad-icon.svg new file mode 100644 index 0000000..5ce8c92 --- /dev/null +++ b/src/images/gamepad-icon.svg @@ -0,0 +1,28 @@ + + + + diff --git a/src/images/thumbstick.svg b/src/images/thumbstick.svg new file mode 100644 index 0000000..719f9d6 --- /dev/null +++ b/src/images/thumbstick.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/src/js/content_scripts/modal.js b/src/js/content_scripts/modal.js index 432c1ee..b75b2c4 100644 --- a/src/js/content_scripts/modal.js +++ b/src/js/content_scripts/modal.js @@ -26,6 +26,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE classNames: "", hidden: true }, + icon: gamepad.svg["gamepad-icon"], modelListeners: { hidden: [ { @@ -50,7 +51,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }, markup: { // TODO: Add the ability to retrieve our icon URL and display the icon onscreen. - container: "" + container: "" }, invokers: { closeModal: { @@ -76,6 +77,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }, listeners: { + "onCreate.drawIcon": { + funcName: "gamepad.modal.drawIcon", + args: ["{that}"] + }, "onCreate.bindOuterContainerClick": { this: "{that}.container", method: "click", @@ -127,6 +132,11 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }; + gamepad.modal.drawIcon = function (that) { + var iconElement = that.locate("icon"); + iconElement.html(that.options.icon); + }; + /** * * @param {Object} modalManager - The modal manager component. diff --git a/src/manifest.json b/src/manifest.json index 4e6a63d..98aa2d6 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -29,6 +29,7 @@ "js/content_scripts/gamepad-navigator.js", "js/shared/configuration-maps.js", "js/content_scripts/styles.js", + "js/content_scripts/svgs.js", "js/content_scripts/templateRenderer.js", "js/content_scripts/modal.js", "js/content_scripts/action-launcher.js", diff --git a/utils/bundleSvgs.js b/utils/bundleSvgs.js new file mode 100644 index 0000000..abbb547 --- /dev/null +++ b/utils/bundleSvgs.js @@ -0,0 +1,55 @@ +/* eslint-env node */ +"use strict"; +var fluid = require("infusion"); +var fs = require("fs"); +var path = require("path"); + +var gamepad = fluid.registerNamespace("gamepad"); + +fluid.defaults("gamepad.bundleSvgs", { + gradeNames: ["fluid.component"], + outputPath: "dist/js/content_scripts/svgs.js", + outputTemplate: "\"use strict\";\nvar gamepad = fluid.registerNamespace(\"gamepad\");\nfluid.registerNamespace(\"gamepad.svg\");\n%payload\n", + singleFileTemplate: "\ngamepad.svg[\"%filename\"] = `%svgText`;\n", + pathsToBundle: { + src: "src/images" + }, + excludes: [], + listeners: { + "onCreate.processDirs": { + funcName: "gamepad.bundleSvgs.processDirs", + args: ["{that}"] + } + } +}); + +gamepad.bundleSvgs.processDirs = function (that) { + var payload = ""; + + var resolvedExcludes = that.options.excludes.map(function (relativePath) { + return path.resolve(relativePath); + }); + + fluid.each(that.options.pathsToBundle, function (pathToBundle) { + var filenames = fs.readdirSync(pathToBundle); + fluid.each(filenames, function (filename) { + if (filename.toLowerCase().endsWith(".svg")) { + var filenameMinusExtension = path.basename(filename, ".svg"); + var filePath = path.resolve(pathToBundle, filename); + if (!resolvedExcludes.includes(filePath)) { + var fileContents = fs.readFileSync(filePath, { encoding: "utf8"}); + var filePayload = fluid.stringTemplate(that.options.singleFileTemplate, { filename: filenameMinusExtension, svgText: fileContents}); + payload += filePayload; + } + } + }); + }); + + var bundle = fluid.stringTemplate(that.options.outputTemplate, { payload: payload}); + + fs.writeFileSync(that.options.outputPath, bundle, { encoding: "utf8"}); + + fluid.log(fluid.logLevel.WARN, "bundled all SVG files to '" + that.options.outputPath + "'"); +}; + +gamepad.bundleSvgs(); From d20e984d96bc9d76fe8edaf9166e3277b146e8b7 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Fri, 24 Nov 2023 14:16:40 +0100 Subject: [PATCH 15/23] NOGH: Moved commend about adding tests to the actual tests. --- tests/js/click/click-basic-tests.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/js/click/click-basic-tests.js b/tests/js/click/click-basic-tests.js index ddfbabc..48acd55 100644 --- a/tests/js/click/click-basic-tests.js +++ b/tests/js/click/click-basic-tests.js @@ -15,6 +15,11 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE (function (fluid, $) { "use strict"; + /** + * TODO: Add tests for links and other elements that involve navigation + * between pages. + */ + $(document).ready(function () { fluid.registerNamespace("gamepad"); From c7d0526843532173a5b66ccc0529ae9cebd8155c Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Fri, 24 Nov 2023 14:17:58 +0100 Subject: [PATCH 16/23] GH-61: Added vibration when an action can't be completed. Refactored message passing to support vibration for background actions. --- src/js/background.js | 501 +++++++++--------- .../input-mapper-background-utils.js | 93 ++-- src/js/content_scripts/input-mapper-base.js | 70 ++- .../input-mapper-content-utils.js | 212 +++++--- src/js/content_scripts/input-mapper.js | 60 +-- src/js/content_scripts/search-keyboard.js | 5 +- 6 files changed, 491 insertions(+), 450 deletions(-) diff --git a/src/js/background.js b/src/js/background.js index 763cc4f..14cfbd7 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -33,41 +33,44 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * * Switch to the next or the previous tab in the current window. * - * @param {String} tabDirection - The direction in which the tab focus should change, + * @param {String} tabDirection - The direction in which the tab focus should change. + * @return {Promise} - A promise that will resolve with a response payload. * */ - gamepad.messageListenerUtils.switchTab = function (tabDirection) { - chrome.tabs.query({ currentWindow: true }, function (tabsArray) { - // Filter to "controllable" tabs. - var filteredTabs = tabsArray.filter(gamepad.messageListenerUtils.filterControllableTabs); - - // Switch only if more than one tab is present. - if (filteredTabs.length > 1) { - // Find index of the currently active tab. - var activeTabIndex = null; - filteredTabs.forEach(function (tab, index) { - if (tab.active) { - activeTabIndex = index; - } - }); - - // Switch browser tab. - if (tabDirection === "previousTab") { - /** - * If the first tab is focused then switch to the last tab. - * Otherwise, switch to the previous tab. - */ - if (activeTabIndex === 0) { - activeTabIndex = filteredTabs.length; - } - chrome.tabs.update(filteredTabs[activeTabIndex - 1].id, { active: true }); + gamepad.messageListenerUtils.switchTab = async function (tabDirection) { + var tabsArray = await chrome.tabs.query({ currentWindow: true }); + // Filter to "controllable" tabs. + var filteredTabs = tabsArray.filter(gamepad.messageListenerUtils.filterControllableTabs); + + // Switch only if more than one tab is present. + if (filteredTabs.length > 1) { + // Find index of the currently active tab. + var activeTabIndex = null; + filteredTabs.forEach(function (tab, index) { + if (tab.active) { + activeTabIndex = index; } - else if (tabDirection === "nextTab") { - // Switch to the next tab. - chrome.tabs.update(filteredTabs[(activeTabIndex + 1) % filteredTabs.length].id, { active: true }); + }); + + // Switch browser tab. + if (tabDirection === "previousTab") { + /** + * If the first tab is focused then switch to the last tab. + * Otherwise, switch to the previous tab. + */ + if (activeTabIndex === 0) { + activeTabIndex = filteredTabs.length; } + await chrome.tabs.update(filteredTabs[activeTabIndex - 1].id, { active: true }); } - }); + else if (tabDirection === "nextTab") { + // Switch to the next tab. + await chrome.tabs.update(filteredTabs[(activeTabIndex + 1) % filteredTabs.length].id, { active: true }); + } + } + else { + return { vibrate: true }; + } }; /** @@ -93,34 +96,37 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE /** * * Close the currently opened window. + * @return {Promise} - A `Promise` that will complete with a response payload. * */ - gamepad.messageListenerUtils.closeCurrentWindow = function () { - chrome.windows.getAll({ populate: true}, function (windowArray) { - var controllableWindows = windowArray.filter(gamepad.messageListenerUtils.filterControllableWindows); - if (controllableWindows.length > 1) { - var focusedWindow = false; - var focusedWindowIndex = -1; - - controllableWindows.forEach(function (window, index) { - if (window.focused) { - focusedWindow = window; - focusedWindowIndex = index; - } - }); + gamepad.messageListenerUtils.closeCurrentWindow = async function () { + var windowArray = await chrome.windows.getAll({ populate: true}); + var controllableWindows = windowArray.filter(gamepad.messageListenerUtils.filterControllableWindows); + if (controllableWindows.length > 1) { + var focusedWindow = false; + var focusedWindowIndex = -1; + + controllableWindows.forEach(function (window, index) { + if (window.focused) { + focusedWindow = window; + focusedWindowIndex = index; + } + }); - if (focusedWindow) { - var newFocusIndex = (focusedWindowIndex + 1) % controllableWindows.length; - var windowToFocus = controllableWindows[newFocusIndex]; + if (focusedWindow) { + var newFocusIndex = (focusedWindowIndex + 1) % controllableWindows.length; + var windowToFocus = controllableWindows[newFocusIndex]; - chrome.windows.remove(focusedWindow.id); + chrome.windows.remove(focusedWindow.id); - chrome.windows.update(windowToFocus.id, { - focused: true - }); - } + chrome.windows.update(windowToFocus.id, { + focused: true + }); } - }); + } + else { + return { vibrate: true }; + } }; // Exclude tabs whose URL begins with `chrome://`, which do not contain @@ -144,57 +150,58 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * @param {String} windowDirection - The direction in which the window focus should change, * */ - gamepad.messageListenerUtils.switchWindow = function (windowDirection) { - chrome.windows.getLastFocused(function (focusedWindow) { - if (focusedWindow) { - chrome.windows.getAll({ populate: true}, function (windowsArray) { - // Filter to controllable windows. - var filteredWindows = windowsArray.filter(gamepad.messageListenerUtils.filterControllableWindows); - - // Switch only if more than one window is present. - if (filteredWindows.length > 1) { - // Find the index of the currently active window. - var focusedWindowIndex = null; - for (var index = 0; index < filteredWindows.length; index++) { - if (focusedWindowIndex === null) { - var window = filteredWindows[index]; - if (window.id === focusedWindow.id) { - focusedWindowIndex = index; - } - } + gamepad.messageListenerUtils.switchWindow = async function (windowDirection) { + var focusedWindow = await chrome.windows.getLastFocused(); + if (focusedWindow) { + var windowsArray = await chrome.windows.getAll({ populate: true}); + // Filter to controllable windows. + var filteredWindows = windowsArray.filter(gamepad.messageListenerUtils.filterControllableWindows); + + // Switch only if more than one window is present. + if (filteredWindows.length > 1) { + // Find the index of the currently active window. + var focusedWindowIndex = null; + for (var index = 0; index < filteredWindows.length; index++) { + if (focusedWindowIndex === null) { + var window = filteredWindows[index]; + if (window.id === focusedWindow.id) { + focusedWindowIndex = index; } + } + } - if (focusedWindowIndex === null) { - throw new Error("Can't detect focused browser window."); + if (focusedWindowIndex === null) { + throw new Error("Can't detect focused browser window."); + } + else { + var windowIndexToFocus = focusedWindowIndex; + // Switch browser window. + if (windowDirection === "previousWindow") { + if (focusedWindowIndex === 0) { + windowIndexToFocus = filteredWindows.length - 1; } else { - var windowIndexToFocus = focusedWindowIndex; - // Switch browser window. - if (windowDirection === "previousWindow") { - if (focusedWindowIndex === 0) { - windowIndexToFocus = filteredWindows.length - 1; - } - else { - windowIndexToFocus = focusedWindowIndex - 1; - } - } - else if (windowDirection === "nextWindow") { - if (focusedWindowIndex >= filteredWindows.length - 1) { - windowIndexToFocus = 0; - } - else { - windowIndexToFocus = focusedWindowIndex + 1; - } - } - - chrome.windows.update(filteredWindows[windowIndexToFocus].id, { - focused: true - }); + windowIndexToFocus = focusedWindowIndex - 1; } } - }); + else if (windowDirection === "nextWindow") { + if (focusedWindowIndex >= filteredWindows.length - 1) { + windowIndexToFocus = 0; + } + else { + windowIndexToFocus = focusedWindowIndex + 1; + } + } + + await chrome.windows.update(filteredWindows[windowIndexToFocus].id, { + focused: true + }); + } } - }); + else { + return { vibrate: true }; + } + } }; /** @@ -202,25 +209,30 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Change the zoom value of the current browser tab. * * @param {String} zoomType - Determines if the page should be zoomed in or out. - * + * @return {Object} - A response payload if there are custom instructions for the client to follow up on. */ - gamepad.messageListenerUtils.setZoom = function (zoomType) { - chrome.tabs.query({ currentWindow: true, active: true }, function (currentTab) { - // Obtain the zoom value of the current tab. - chrome.tabs.getZoom(currentTab.id, function (currentZoomFactor) { - // Compute the new zoom value according to the zoom type. - var newZoomFactor = null; - if (zoomType === "zoomIn") { - newZoomFactor = currentZoomFactor + 0.1; - } - else if (zoomType === "zoomOut") { - newZoomFactor = currentZoomFactor - 0.1; - } + gamepad.messageListenerUtils.setZoom = async function (zoomType) { + var currentTab = await chrome.tabs.query({ currentWindow: true, active: true }); - // Set the new zoom value. - chrome.tabs.setZoom(currentTab.id, newZoomFactor); - }); - }); + // Obtain the zoom value of the current tab. + var currentZoomFactor = await chrome.tabs.getZoom(currentTab.id); + + // Compute the new zoom value according to the zoom type. + var newZoomFactor = null; + if (zoomType === "zoomIn") { + newZoomFactor = currentZoomFactor + 0.1; + } + else if (zoomType === "zoomOut") { + newZoomFactor = currentZoomFactor - 0.1; + } + + if (currentZoomFactor === newZoomFactor) { + return { vibrate: true }; + } + else { + // Set the new zoom value. + chrome.tabs.setZoom(currentTab.id, newZoomFactor); + } }; /** @@ -231,199 +243,158 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * @param {String} windowState - Value of the new state of the browser window. * For example, "maximized", "minimized", etc. * @param {Number} left - The position of the left edge of the screen (in pixels). + * @return {Promise} - A promise that will resolve with a response payload. * */ - gamepad.messageListenerUtils.changeWindowSize = function (that, windowState, left) { - chrome.windows.getCurrent(function (currentWindow) { - chrome.windows.update(currentWindow.id, { state: windowState }, function () { - /** - * Set the dimensions of the window if the "maximized" state doesn't work - * (Fallback Method). - */ - chrome.windows.getCurrent(function (windowPostUpdate) { - that.windowProperties[windowPostUpdate.id] = that.windowProperties[windowPostUpdate.id] || { isMaximized: null }; + gamepad.messageListenerUtils.changeWindowSize = async function (that, windowState, left) { + var currentWindow = await chrome.windows.getCurrent(); - // Value of "state" on OS X is "fullscreen" if window is maximized. - if (that.windowProperties[windowPostUpdate.id].isMaximized === null) { - that.windowProperties[windowPostUpdate.id].isMaximized = windowPostUpdate.state === "fullscreen"; - } - var isMaximized = that.windowProperties[windowPostUpdate.id].isMaximized; - - /** - * Second check is for cases when the window is not maximized during - * the first update. - */ - if (windowState === "maximized" && windowPostUpdate.state !== "maximized" && !isMaximized) { - // Preserve configuration before maximizing. - that.windowProperties[windowPostUpdate.id] = { - width: windowPostUpdate.width, - height: windowPostUpdate.height, - left: windowPostUpdate.left, - top: windowPostUpdate.top - }; - - // Update window with the new properties. - chrome.windows.update(windowPostUpdate.id, { - width: screen.width, - height: screen.height, - left: left, - top: 0 - }); - - // Update the isMaximized member variable. - that.windowProperties[windowPostUpdate.id].isMaximized = true; - } - else if (windowPostUpdate.state === "normal" && windowState === "normal" && isMaximized) { - var previousProperties = that.windowProperties[windowPostUpdate.id]; - - // Update window with the new properties. - chrome.windows.update(windowPostUpdate.id, { - width: previousProperties.width || Math.round(3 * screen.width / 5), - height: previousProperties.height || Math.round(4 * screen.height / 5), - left: previousProperties.left || (left + Math.round(screen.width / 15)), - top: previousProperties.top || Math.round(screen.height / 15) - }); - - // Update the isMaximized member variable. - that.windowProperties[windowPostUpdate.id].isMaximized = false; - } - }); + if (currentWindow.state === windowState) { + return { vibrate: true }; + } + + await chrome.windows.update(currentWindow.id, { state: windowState }); + + var windowPostUpdate = await chrome.windows.getCurrent(); + that.windowProperties[windowPostUpdate.id] = that.windowProperties[windowPostUpdate.id] || { isMaximized: null }; + + // Value of "state" on OS X is "fullscreen" if window is maximized. + if (that.windowProperties[windowPostUpdate.id].isMaximized === null) { + that.windowProperties[windowPostUpdate.id].isMaximized = windowPostUpdate.state === "fullscreen"; + } + var isMaximized = that.windowProperties[windowPostUpdate.id].isMaximized; + + /** + * Second check is for cases when the window is not maximized during + * the first update. + */ + if (windowState === "maximized" && windowPostUpdate.state !== "maximized" && !isMaximized) { + // Preserve configuration before maximizing. + that.windowProperties[windowPostUpdate.id] = { + width: windowPostUpdate.width, + height: windowPostUpdate.height, + left: windowPostUpdate.left, + top: windowPostUpdate.top + }; + + // Update window with the new properties. + await chrome.windows.update(windowPostUpdate.id, { + width: screen.width, + height: screen.height, + left: left, + top: 0 }); - }); + + // Update the isMaximized member variable. + that.windowProperties[windowPostUpdate.id].isMaximized = true; + } + else if (windowPostUpdate.state === "normal" && windowState === "normal" && isMaximized) { + var previousProperties = that.windowProperties[windowPostUpdate.id]; + + // Update window with the new properties. + await chrome.windows.update(windowPostUpdate.id, { + width: previousProperties.width || Math.round(3 * screen.width / 5), + height: previousProperties.height || Math.round(4 * screen.height / 5), + left: previousProperties.left || (left + Math.round(screen.width / 15)), + top: previousProperties.top || Math.round(screen.height / 15) + }); + + // Update the isMaximized member variable. + that.windowProperties[windowPostUpdate.id].isMaximized = false; + } }; - gamepad.messageListenerUtils.search = function (actionData) { + gamepad.messageListenerUtils.search = function (actionOptions) { chrome.search.query({ - disposition: actionData.disposition, - text: actionData.text + disposition: actionOptions.disposition, + text: actionOptions.text }); }; - - var messageListener = { // previous "members" windowProperties: {}, - // previous "invokers" - actionExecutor: function (actionData) { - gamepad.messageListener.actionExecutor(messageListener, actionData); - }, - - // All legacy actions are called with: tabId, invert, active, homepageURL, left - // TODO: Rewrite these to use actionData directly. - openNewTab: function (tabId, invert, active, homepageURL) { + // previous "invokers", or "actions". + // On the client side, we check the control state before triggering + // these, so the method signature is simply `action(actionOptions)`. + openNewTab: async function (actionOptions) { // TODO: Currently opens a new tab and a new window. - gamepad.messageListenerUtils.openNewTab(active, homepageURL); + return await gamepad.messageListenerUtils.openNewTab(actionOptions.active, actionOptions.homepageURL); }, - closeCurrentTab: function (tabId) { - // Only close the tab if there is another "controllable" tab available. - chrome.tabs.query({currentWindow: true }, function (tabs) { + closeCurrentTab: async function (actionOptions) { + if (actionOptions.tabId) { + var tabs = await chrome.tabs.query({currentWindow: true }); var controllableTabs = tabs.filter(gamepad.messageListenerUtils.filterControllableTabs); - // More than one tab, just close the tab. + // More than one controllable tab, just close the tab. if (controllableTabs.length > 1) { - chrome.tabs.remove(tabId); + await chrome.tabs.remove(actionOptions.tabId); } - // Fail over to the window close logic. + // Fail over to the window close logic, which will check for controllable tabs in other windows. else { - gamepad.messageListenerUtils.closeCurrentWindow(); + return await gamepad.messageListenerUtils.closeCurrentWindow(); } - }); + } }, - openNewWindow: function (tabId, invert, active, homepageURL) { - gamepad.messageListenerUtils.openNewWindow(active, homepageURL); + openNewWindow: async function (actionOptions) { + return await gamepad.messageListenerUtils.openNewWindow(actionOptions.active, actionOptions.homepageURL); }, - maximizeWindow: function (tabId, invert, active, homepageURL, left) { - gamepad.messageListenerUtils.changeWindowSize(messageListener, "maximized", left); + maximizeWindow: async function (actionOptions) { + return await gamepad.messageListenerUtils.changeWindowSize(messageListener, "maximized", actionOptions.left); }, - restoreWindowSize: function (tabId, invert, active, homepageURL, left) { - gamepad.messageListenerUtils.changeWindowSize(messageListener, "normal", left); + restoreWindowSize: async function (actionOptions) { + return await gamepad.messageListenerUtils.changeWindowSize(messageListener, "normal", actionOptions.left); }, - // TODO: Rewrite these to use actionData directly. - // From now on, actions will be called directly with the actionData from the message. These are either new - // or have no data and can use the new method. - reopenTabOrWindow: function () { - // TODO: Confirm whether or not the restored window has focus. - chrome.sessions.restore(); + // Restored windows have focus, so there is still a danger of uncontrollable windows popping up. + reopenTabOrWindow: async function () { + await chrome.sessions.restore(); }, - goToPreviousWindow: function () { - gamepad.messageListenerUtils.switchWindow("previousWindow"); + goToPreviousWindow: async function () { + return await gamepad.messageListenerUtils.switchWindow("previousWindow"); }, - goToNextWindow: function () { - gamepad.messageListenerUtils.switchWindow("nextWindow"); + goToNextWindow: async function () { + return await gamepad.messageListenerUtils.switchWindow("nextWindow"); }, - zoomIn: function () { - gamepad.messageListenerUtils.setZoom("zoomIn"); + zoomIn: async function () { + return await gamepad.messageListenerUtils.setZoom("zoomIn"); }, - zoomOut: function () { - gamepad.messageListenerUtils.setZoom("zoomOut"); + zoomOut: async function () { + return await gamepad.messageListenerUtils.setZoom("zoomOut"); }, - goToPreviousTab: function () { - gamepad.messageListenerUtils.switchTab("previousTab"); + goToPreviousTab: async function () { + return await gamepad.messageListenerUtils.switchTab("previousTab"); }, - goToNextTab: function () { - gamepad.messageListenerUtils.switchTab("nextTab"); + goToNextTab: async function () { + return await gamepad.messageListenerUtils.switchTab("nextTab"); }, closeCurrentWindow: gamepad.messageListenerUtils.closeCurrentWindow, - openActionLauncher: function () { - gamepad.messageListenerUtils.openActionLauncher(); + openActionLauncher: async function () { + return await gamepad.messageListenerUtils.openActionLauncher(); }, search: gamepad.messageListenerUtils.search }; - // Temporary list of actions that can take a full actionData payload directly. - gamepad.messageListener.newSchoolActions = [ - "reopenTabOrWindow", - "goToPreviousWindow", - "goToNextWindow", - "zoomIn", - "zoomOut", - "goToPreviousTab", - "goToNextTab", - "closeCurrentWindow", - "openActionLauncher", - "openSearchKeyboard", - "search" - ]; + chrome.runtime.onConnect.addListener(function (port) { + port.onMessage.addListener(async function (actionOptions) { + // Execute the actions only if the action data is available. + if (actionOptions.actionName) { + var action = messageListener[actionOptions.actionName]; - /** - * - * Calls the invoker methods according to the message is recieved from the content - * script. - * - * @param {Object} messageListener - The messageListener. - * @param {Object} actionData - The message object recieved from the content scripts. - * - */ - gamepad.messageListener.actionExecutor = function (messageListener, actionData) { - // Execute the actions only if the action data is available. - if (actionData.actionName) { - var action = messageListener[actionData.actionName]; - - // Trigger the action only if a valid action is found. - if (action) { - if (gamepad.messageListener.newSchoolActions.includes(actionData.actionName)) { - chrome.tabs.query({ active: true, currentWindow: true }, function () { - action(actionData); - }); - } - else { - var invert = actionData.invert, - active = actionData.active, - left = actionData.left, - homepageURL = actionData.homepageURL; - chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { - var tabId = tabs[0] ? tabs[0].id : undefined; - action(tabId, invert, active, homepageURL, left); - }); + // Trigger the action only if a valid action is found. + if (action) { + var tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + var tabId = tabs[0] ? tabs[0].id : undefined; + var wrappedActionOptions = JSON.parse(JSON.stringify(actionOptions)); + wrappedActionOptions.tabId = tabId; + + var actionResult = await action(wrappedActionOptions); + port.postMessage(actionResult); + port.disconnect(); } } - } - }; - - // Instantiate and add action handler as a listener. - chrome.runtime.onMessage.addListener(messageListener.actionExecutor); + }); + }); })(); diff --git a/src/js/content_scripts/input-mapper-background-utils.js b/src/js/content_scripts/input-mapper-background-utils.js index 13c5a7d..e8c1d7b 100644 --- a/src/js/content_scripts/input-mapper-background-utils.js +++ b/src/js/content_scripts/input-mapper-background-utils.js @@ -25,39 +25,40 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * when the user navigates back to the same tab. */ + gamepad.inputMapperUtils.background.postMessageOnControlRelease = function (that, value, oldValue, actionOptions) { + if (value < oldValue && oldValue > that.options.cutoffValue) { + gamepad.inputMapperUtils.background.postMessage(that, actionOptions); + } + }; + /** * - * Sends message to the background script to perform the given action. + * Connect to the background script, send a message, and handle the response. * * @param {Object} that - The inputMapper component. - * @param {String} actionName - The action to be performed. - * @param {Integer} value - The value of the gamepad input. - * @param {Integer} oldValue - The previous value of the gamepad input. - * @param {Boolean} background - Whether the new tab should open in background. - * @param {String} homepageURL - The URL for the new tab. + * @param {String} actionOptions - The action payload to be transmitted. * */ - gamepad.inputMapperUtils.background.sendMessage = function (that, actionName, value, oldValue, background, homepageURL) { - if (value < oldValue && oldValue > that.options.cutoffValue) { - // Set the data object for the action. - var actionData = { actionName: actionName }; - - // Set active key if background parameter is passed as an argument. - if (background !== undefined) { - actionData.active = !background; - } - if (homepageURL) { - actionData.homepageURL = homepageURL; + gamepad.inputMapperUtils.background.postMessage = async function (that, actionOptions) { + // We use this because chrome.runtime.sendMessage did not leave enough time to receive a response. + var port = chrome.runtime.connect(); + port.onMessage.addListener(function (response) { + var vibrate = fluid.get(response, "vibrate"); + if (vibrate) { + that.vibrate(); } + }); - // Set the left pixel if the action is about changing "window size". - if (actionName === "maximizeWindow" || actionName === "restoreWindowSize") { - actionData.left = screen.availLeft; - } + var wrappedActionOptions = fluid.copy(actionOptions); - // Send the message to the background script with the action details. - chrome.runtime.sendMessage(actionData); + wrappedActionOptions.homepageURL = that.model.commonConfiguration.homepageURL; + + // Set the left pixel if the action is about changing "window size". + if (actionOptions.actionName === "maximizeWindow" || actionOptions.actionName === "restoreWindowSize") { + wrappedActionOptions.left = screen.availLeft; } + + port.postMessage(wrappedActionOptions); }; /** @@ -70,19 +71,21 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * @param {Boolean} invert - Whether the zooming should be in opposite order. * */ - gamepad.inputMapperUtils.background.thumbstickZoom = function (that, value, invert) { + gamepad.inputMapperUtils.background.thumbstickZoom = async function (that, value, invert) { + clearInterval(that.intervalRecords.zoomIn); + clearInterval(that.intervalRecords.zoomOut); + // Get the updated input value according to the configuration. var inversionFactor = invert ? -1 : 1; - value = value * inversionFactor; - var zoomType = value > 0 ? "zoomOut" : "zoomIn", - actionData = { actionName: zoomType }; - value = value * (value > 0 ? 1 : -1); + var polarisedValue = value * inversionFactor; + var zoomType = polarisedValue > 0 ? "zoomOut" : "zoomIn"; + var actionOptions = { actionName: zoomType }; // Call the zoom changing invokers according to the input values. - clearInterval(that.intervalRecords.zoomIn); - clearInterval(that.intervalRecords.zoomOut); - if (value > that.options.cutoffValue) { - that.intervalRecords[zoomType] = setInterval(chrome.runtime.sendMessage, that.options.frequency, actionData); + if (Math.abs(value) > that.options.cutoffValue) { + that.intervalRecords[zoomType] = setInterval(function (actionOptions) { + gamepad.inputMapperUtils.background.postMessage(that, actionOptions); + }, that.options.frequency, actionOptions); } }; @@ -98,23 +101,19 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ gamepad.inputMapperUtils.background.thumbstickWindowSize = function (that, value, invert) { // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1, - windowSizeActionLabel = null; - value = value * inversionFactor; - if (value > 0) { - windowSizeActionLabel = "maximizeWindow"; - } - else { - windowSizeActionLabel = "restoreWindowSize"; - value = value * -1; - } + var inversionFactor = invert ? -1 : 1; + var polarisedValue = value * inversionFactor; + + var actionName = polarisedValue > 0 ? "maximizeWindow" : "restoreWindowSize"; + + var actionOptions = { + actionName: actionName, + left: screen.availLeft + }; // Call the window size changing invokers according to the input value. - if (value > that.options.cutoffValue) { - chrome.runtime.sendMessage({ - actionName: windowSizeActionLabel, - left: screen.availLeft - }); + if (Math.abs(value) > that.options.cutoffValue) { + gamepad.inputMapperUtils.background.postMessage(that, actionOptions); } }; })(fluid); diff --git a/src/js/content_scripts/input-mapper-base.js b/src/js/content_scripts/input-mapper-base.js index e71a075..b45b918 100644 --- a/src/js/content_scripts/input-mapper-base.js +++ b/src/js/content_scripts/input-mapper-base.js @@ -93,11 +93,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE "this": "{that}.mutationObserverInstance", method: "disconnect" }, - /** - * TODO: Add tests for links and other elements that involve navigation - * between pages. - */ - // Actions, these are called with: value, speedFactor, invert, background, oldValue, homepageURL + vibrate: { + funcName: "gamepad.inputMapper.base.vibrate", + args: ["{that}"] + }, + + // Actions are called with value, oldValue, actionOptions click: { funcName: "gamepad.inputMapperUtils.content.click", args: ["{that}", "{arguments}.0"] @@ -110,6 +111,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE funcName: "gamepad.inputMapperUtils.content.nextPageInHistory", args: ["{that}", "{arguments}.0"] }, + reverseTab: { funcName: "gamepad.inputMapperUtils.content.buttonTabNavigation", args: ["{that}", "{arguments}.0", "reverseTab"] @@ -118,21 +120,22 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE funcName: "gamepad.inputMapperUtils.content.buttonTabNavigation", args: ["{that}", "{arguments}.0", "forwardTab"] }, + scrollLeft: { funcName: "gamepad.inputMapperUtils.content.scrollLeft", - args: ["{that}", "{arguments}.0", "{arguments}.1"] + args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] }, scrollRight: { funcName: "gamepad.inputMapperUtils.content.scrollRight", - args: ["{that}", "{arguments}.0", "{arguments}.1"] + args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] }, scrollUp: { funcName: "gamepad.inputMapperUtils.content.scrollUp", - args: ["{that}", "{arguments}.0", "{arguments}.1"] + args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] }, scrollDown: { funcName: "gamepad.inputMapperUtils.content.scrollDown", - args: ["{that}", "{arguments}.0", "{arguments}.1"] + args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] }, scrollHorizontally: { funcName: "gamepad.inputMapperUtils.content.scrollHorizontally", @@ -149,7 +152,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // TODO: Add tests for when the number of tabbable elements changes. thumbstickTabbing: { funcName: "gamepad.inputMapperUtils.content.thumbstickTabbing", - args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2"] + args: ["{that}", "{arguments}.0", "{arguments}.2"] }, // Arrow actions for buttons sendArrowLeft: { @@ -171,11 +174,11 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Arrow actions for axes thumbstickHorizontalArrows: { funcName: "gamepad.inputMapperUtils.content.thumbstickArrows", - args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2", "ArrowRight", "ArrowLeft"] // value, speedFactor, invert, forwardKey, backwardKey + args: ["{that}", "{arguments}.0", "{arguments}.2", "ArrowRight", "ArrowLeft"] // value, actionOptions, forwardKey, backwardKey }, thumbstickVerticalArrows: { funcName: "gamepad.inputMapperUtils.content.thumbstickArrows", - args: ["{that}", "{arguments}.0", "{arguments}.1", "{arguments}.2", "ArrowDown", "ArrowUp"] // value, speedFactor, invert, forwardKey, backwardKey + args: ["{that}", "{arguments}.0", "{arguments}.2", "ArrowDown", "ArrowUp"] // value, actionOptions, forwardKey, backwardKey } } }); @@ -191,7 +194,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * configured action map to produce a navigation effect. * * @param {Object} that - The inputMapper component. - * @param {Object} change - The recipt for the change in input values. + * @param {Object} change - The receipt for the change in input values. * */ gamepad.inputMapper.base.produceNavigation = function (that, change) { @@ -201,14 +204,15 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Check if input is generated by axis or button and which button/axes was * disturbed. */ - var inputType = change.path[0], - index = change.path[1], + var inputType = change.path[0], // i. e. "button", or "axis" + index = change.path[1], // i.e. 0, 1, 2 inputValue = change.value, oldInputValue = change.oldValue || 0; - var inputProperties = that.model.map[inputType][index], - actionLabel = fluid.get(inputProperties, "currentAction") || fluid.get(inputProperties, "defaultAction"), - homepageURL = that.model.commonConfiguration.homepageURL; + // Look for a binding at map.axis.0, map.button.1, et cetera. + var binding = that.model.map[inputType][index]; + // TODO: See how/whether we ever fail over using this structure. + var actionLabel = fluid.get(binding, "currentAction") || fluid.get(binding, "defaultAction"); /** * TODO: Modify the action call in such a manner that the action gets triggered @@ -225,13 +229,13 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Trigger the action only if a valid function is found. if (action) { + var actionOptions = fluid.copy(binding); + actionOptions.homepageURL = that.model.commonConfiguration.homepageURL; + action( inputValue, - inputProperties.speedFactor, - inputProperties.invert, - inputProperties.background, oldInputValue, - homepageURL + actionOptions ); } } @@ -287,4 +291,26 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // Start observing the DOM mutations. that.mutationObserverInstance.observe(body, observerConfiguration); }; + + + gamepad.inputMapper.base.vibrate = function (that) { + // TODO: Make this configurable, and/or make more than one vibration. + + var gamepads = that.options.windowObject.navigator.getGamepads(); + var nonNullGamepads = gamepads.filter(function (gamepad) { return gamepad !== null; }); + + fluid.each(nonNullGamepads, function (gamepad) { + if (gamepad.vibrationActuator) { + gamepad.vibrationActuator.playEffect( + "dual-rumble", + { + duration: 250, + startDelay: 0, + strongMagnitude: 0.25, + weakMagnitude: .75 + } + ); + } + }); + }; })(fluid); diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 547b70e..24fa46d 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -12,13 +12,11 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE /* global gamepad, ally, chrome */ -(function (fluid, $) { +(function (fluid) { "use strict"; fluid.registerNamespace("gamepad.inputMapperUtils.content"); - // TODO: Fix argument type in JSDoc comments, especially "value" and "speedFactor". - /** * TODO: Fix the "speedFactor" usage in invokers to reduce the given interval loop * frequency. @@ -29,23 +27,23 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll horizontally across the webpage. * * @param {Object} that - The inputMapper component. - * @param {Integer} value - The value of the gamepad input (in pixels). - * @param {Integer} speedFactor - Times by which the scroll speed should be increased. - * @param {Boolean} invert - Whether the scroll should be in opposite order. + * @param {Integer} value - The current value of the gamepad input. + * @param {Integer} oldValue - The previous value of the gamepad input. + * @param {Object} actionOptions - The action options (ex: speedFactor, invert). * */ - gamepad.inputMapperUtils.content.scrollHorizontally = function (that, value, speedFactor, invert) { + gamepad.inputMapperUtils.content.scrollHorizontally = function (that, value, oldValue, actionOptions) { if (that.model.pageInView) { // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1; + var inversionFactor = actionOptions.invert ? -1 : 1; value = value * inversionFactor; if (value > 0) { clearInterval(that.intervalRecords.leftScroll); - that.scrollRight(value, speedFactor); + that.scrollRight(value, oldValue, actionOptions); } else if (value < 0) { clearInterval(that.intervalRecords.rightScroll); - that.scrollLeft(-1 * value, speedFactor); + that.scrollLeft(-1 * value, oldValue, actionOptions); } else { clearInterval(that.intervalRecords.leftScroll); @@ -59,11 +57,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage in left direction. * * @param {Object} that - The inputMapper component. - * @param {Integer} value - The value of the gamepad input (in pixels). - * @param {Integer} speedFactor - Times by which the scroll speed should be increased. + * @param {Integer} value - The current value of the gamepad input. + * @param {Integer} oldValue - The previous value of the gamepad input. + * @param {Object} actionOptions - The action options (ex: speedFactor). * */ - gamepad.inputMapperUtils.content.scrollLeft = function (that, value, speedFactor) { + gamepad.inputMapperUtils.content.scrollLeft = function (that, value, oldValue, actionOptions) { + var speedFactor = actionOptions.speedFactor || 1; + /** * Stop scrolling for the previous input value. Also stop scrolling if the input * source (analog/button) is at rest. @@ -77,8 +78,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll to the left according to the new input value. that.intervalRecords.leftScroll = setInterval(function () { - var xOffset = $(that.options.windowObject).scrollLeft(); - $(that.options.windowObject).scrollLeft(xOffset - value * that.options.scrollInputMultiplier * speedFactor); + if (window.scrollX > 0) { + window.scroll(window.scrollX - value * that.options.scrollInputMultiplier * speedFactor, window.scrollY); + } + else { + clearInterval(that.intervalRecords.leftScroll); + that.vibrate(); + } + }, that.options.frequency); } }; @@ -88,11 +95,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage towards the right direction. * * @param {Object} that - The inputMapper component. - * @param {Integer} value - The value of the gamepad input (in pixels). - * @param {Integer} speedFactor - Times by which the scroll speed should be increased. + * @param {Integer} value - The current value of the gamepad input. + * @param {Integer} oldValue - The previous value of the gamepad input. + * @param {Object} actionOptions - The action options (ex: speedFactor). * */ - gamepad.inputMapperUtils.content.scrollRight = function (that, value, speedFactor) { + gamepad.inputMapperUtils.content.scrollRight = function (that, value, oldValue, actionOptions) { + var speedFactor = actionOptions.speedFactor || 1; + /** * Stop scrolling for the previous input value. Also stop scrolling if the input * source (analog/button) is at rest. @@ -106,8 +116,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll to the right according to the new input value. that.intervalRecords.rightScroll = setInterval(function () { - var xOffset = $(that.options.windowObject).scrollLeft(); - $(that.options.windowObject).scrollLeft(xOffset + value * that.options.scrollInputMultiplier * speedFactor); + window.scroll(window.scrollX + value * that.options.scrollInputMultiplier * speedFactor, window.scrollY); + + var documentWidth = document.body.scrollWidth; + var currentScrollX = window.scrollX + window.innerWidth; + if (currentScrollX >= documentWidth) { + clearInterval(that.intervalRecords.rightScroll); + that.vibrate(); + } }, that.options.frequency); } }; @@ -117,23 +133,25 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll vertically across the webpage. * * @param {Object} that - The inputMapper component. - * @param {Integer} value - The value of the gamepad input (in pixels). - * @param {Integer} speedFactor - Times by which the scroll speed should be increased. - * @param {Boolean} invert - Whether the scroll should be in opposite order. + * @param {Integer} value - The currrent value of the gamepad input. + * @param {Integer} oldValue - The previous value of the gamepad input. + * @param {Object} actionOptions - The action options (ex: speedFactor). * */ - gamepad.inputMapperUtils.content.scrollVertically = function (that, value, speedFactor, invert) { + gamepad.inputMapperUtils.content.scrollVertically = function (that, value, oldValue, actionOptions) { + var speedFactor = actionOptions.speedFactor || 1; + if (that.model.pageInView) { // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1; + var inversionFactor = actionOptions.invert ? -1 : 1; value = value * inversionFactor; if (value > 0) { clearInterval(that.intervalRecords.upwardScroll); - that.scrollDown(value, speedFactor); + that.scrollDown(value, oldValue, speedFactor); } else if (value < 0) { clearInterval(that.intervalRecords.downwardScroll); - that.scrollUp(-1 * value, speedFactor); + that.scrollUp(-1 * value, oldValue, speedFactor); } else { clearInterval(that.intervalRecords.upwardScroll); @@ -147,11 +165,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage in upward direction. * * @param {Object} that - The inputMapper component. - * @param {Integer} value - The value of the gamepad input (in pixels). - * @param {Integer} speedFactor - Times by which the scroll speed should be increased. + * @param {Integer} value - The current value of the gamepad input. + * @param {Integer} oldValue - The previous value of the gamepad input. + * @param {Object} actionOptions - The action options (ex: speedFactor). * */ - gamepad.inputMapperUtils.content.scrollUp = function (that, value, speedFactor) { + gamepad.inputMapperUtils.content.scrollUp = function (that, value, oldValue, actionOptions) { + var speedFactor = actionOptions.speedFactor || 1; + /** * Stop scrolling for the previous input value. Also stop scrolling if the input * source (analog/button) is at rest. @@ -165,8 +186,13 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll upward according to the new input value. that.intervalRecords.upwardScroll = setInterval(function () { - var yOffset = $(that.options.windowObject).scrollTop(); - $(that.options.windowObject).scrollTop(yOffset - value * that.options.scrollInputMultiplier * speedFactor); + if (window.scrollY > 0) { + window.scroll(window.scrollX, window.scrollY - value * that.options.scrollInputMultiplier * speedFactor); + } + else { + clearInterval(that.intervalRecords.upwardScroll); + that.vibrate(); + } }, that.options.frequency); } }; @@ -176,11 +202,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Scroll the webpage in downward direction. * * @param {Object} that - The inputMapper component. - * @param {Integer} value - The value of the gamepad input (in pixels). - * @param {Integer} speedFactor - Times by which the scroll speed should be increased. + * @param {Integer} value - The current value of the gamepad input. + * @param {Integer} oldValue - The previous value of the gamepad input. + * @param {Object} actionOptions - The action options (ex: speedFactor). * */ - gamepad.inputMapperUtils.content.scrollDown = function (that, value, speedFactor) { + gamepad.inputMapperUtils.content.scrollDown = function (that, value, oldValue, actionOptions) { + var speedFactor = actionOptions.speedFactor || 1; + /** * Stop scrolling for the previous input value. Also stop scrolling if the input * source (analog/button) is at rest. @@ -194,8 +223,16 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE if (that.model.pageInView && (value > that.options.cutoffValue)) { // Scroll upward according to the new input value. that.intervalRecords.downwardScroll = setInterval(function () { - var yOffset = $(that.options.windowObject).scrollTop(); - $(that.options.windowObject).scrollTop(yOffset + value * that.options.scrollInputMultiplier * speedFactor); + window.scroll(window.scrollX, window.scrollY + value * that.options.scrollInputMultiplier * speedFactor); + + // Adapted from: + // https://fjolt.com/article/javascript-check-if-user-scrolled-to-bottom + var documentHeight = document.body.scrollHeight; + var currentScroll = window.scrollY + window.innerHeight; + if (currentScroll >= documentHeight) { + clearInterval(that.intervalRecords.downwardScroll); + that.vibrate(); + }; }, that.options.frequency); } }; @@ -206,13 +243,13 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * * @param {Object} that - The inputMapper component. * @param {Integer} value - The value of the gamepad input. - * @param {Integer} speedFactor - Times by which the tabbing speed should be increased. - * @param {Boolean} invert - Whether the tabbing should be in opposite order. + * @param {Object} actionOptions - The action options (ex: speedFactor). * */ - gamepad.inputMapperUtils.content.thumbstickTabbing = function (that, value, speedFactor, invert) { + gamepad.inputMapperUtils.content.thumbstickTabbing = function (that, value, actionOptions) { if (that.model.pageInView) { - var inversionFactor = invert ? -1 : 1; + var speedFactor = actionOptions.speedFactor || 1; + var inversionFactor = actionOptions.invert ? -1 : 1; value = value * inversionFactor; clearInterval(that.intervalRecords.forwardTab); clearInterval(that.intervalRecords.reverseTab); @@ -379,14 +416,13 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * * @param {Object} that - The inputMapper component. * @param {Integer} value - The value of the gamepad input. - * @param {Boolean} invert - Whether the history navigation should be in opposite - * order. + * @param {Object} actionOptions - The action options (ex: invert). * */ - gamepad.inputMapperUtils.content.thumbstickHistoryNavigation = function (that, value, invert) { + gamepad.inputMapperUtils.content.thumbstickHistoryNavigation = function (that, value, actionOptions) { if (that.model.pageInView) { // Get the updated input value according to the configuration. - var inversionFactor = invert ? -1 : 1; + var inversionFactor = actionOptions.invert ? -1 : 1; value = value * inversionFactor; if (value > 0) { that.nextPageInHistory(value); @@ -412,26 +448,31 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ gamepad.inputMapperUtils.content.previousPageInHistory = function (that, value) { if (that.model.pageInView && (value > that.options.cutoffValue)) { - var activeElementIndex = null; + if (window.history.length > 1) { + var activeElementIndex = null; - // Get the index of the currently active element, if available. - if (fluid.get(document, "activeElement")) { - var tabbableElements = ally.query.tabsequence({ strategy: "strict" }); - activeElementIndex = tabbableElements.indexOf(document.activeElement); - } + // Get the index of the currently active element, if available. + if (fluid.get(document, "activeElement")) { + var tabbableElements = ally.query.tabsequence({ strategy: "strict" }); + activeElementIndex = tabbableElements.indexOf(document.activeElement); + } - /** - * Store the index of the active element in local storage object with its key - * set to the URL of the webpage and navigate back in history. - */ - var storageData = {}, - pageAddress = that.options.windowObject.location.href; - if (activeElementIndex !== -1) { - storageData[pageAddress] = activeElementIndex; + /** + * Store the index of the active element in local storage object with its key + * set to the URL of the webpage and navigate back in history. + */ + var storageData = {}, + pageAddress = that.options.windowObject.location.href; + if (activeElementIndex !== -1) { + storageData[pageAddress] = activeElementIndex; + } + chrome.storage.local.set(storageData, function () { + that.options.windowObject.history.back(); + }); + } + else { + that.vibrate(); } - chrome.storage.local.set(storageData, function () { - that.options.windowObject.history.back(); - }); } }; @@ -445,26 +486,31 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ gamepad.inputMapperUtils.content.nextPageInHistory = function (that, value) { if (that.model.pageInView && (value > that.options.cutoffValue)) { - var activeElementIndex = null; + if (window.history.length > 1) { + var activeElementIndex = null; - // Get the index of the currently active element, if available. - if (fluid.get(document, "activeElement")) { - var tabbableElements = ally.query.tabsequence({ strategy: "strict" }); - activeElementIndex = tabbableElements.indexOf(document.activeElement); - } + // Get the index of the currently active element, if available. + if (fluid.get(document, "activeElement")) { + var tabbableElements = ally.query.tabsequence({ strategy: "strict" }); + activeElementIndex = tabbableElements.indexOf(document.activeElement); + } - /** - * Store the index of the active element in local storage object with its key - * set to the URL of the webpage and navigate forward in history. - */ - var storageData = {}, - pageAddress = that.options.windowObject.location.href; - if (activeElementIndex !== -1) { - storageData[pageAddress] = activeElementIndex; + /** + * Store the index of the active element in local storage object with its key + * set to the URL of the webpage and navigate forward in history. + */ + var storageData = {}, + pageAddress = that.options.windowObject.location.href; + if (activeElementIndex !== -1) { + storageData[pageAddress] = activeElementIndex; + } + chrome.storage.local.set(storageData, function () { + that.options.windowObject.history.forward(); + }); + } + else { + that.vibrate(); } - chrome.storage.local.set(storageData, function () { - that.options.windowObject.history.forward(); - }); } }; @@ -499,14 +545,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * * @param {Object} that - The inputMapper component. * @param {Integer} value - The value of the gamepad input. - * @param {Integer} speedFactor - Times by which the tabbing speed should be increased. - * @param {Boolean} invert - Whether the tabbing should be in opposite order. + * @param {Object} actionOptions - The action options (ex: speedFactor). * @param {String} forwardKey - The key/code for the forward arrow (right or down). * @param {String} backwardKey - The key/code for the backward arrow (left or up). * */ - gamepad.inputMapperUtils.content.thumbstickArrows = function (that, value, speedFactor, invert, forwardKey, backwardKey) { - var inversionFactor = invert ? -1 : 1; + gamepad.inputMapperUtils.content.thumbstickArrows = function (that, value, actionOptions, forwardKey, backwardKey) { + var speedFactor = actionOptions.speedFactor || 1; + var inversionFactor = actionOptions.invert ? -1 : 1; value = value * inversionFactor; clearInterval(that.intervalRecords[forwardKey]); clearInterval(that.intervalRecords[backwardKey]); diff --git a/src/js/content_scripts/input-mapper.js b/src/js/content_scripts/input-mapper.js index c0b1ab8..5bc6620 100644 --- a/src/js/content_scripts/input-mapper.js +++ b/src/js/content_scripts/input-mapper.js @@ -90,74 +90,74 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }, invokers: { - // Actions, these are called with: value, speedFactor, invert, background, oldValue, homepageURL + // Actions, these are called with: value, oldValue, actionOptions goToPreviousTab: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "goToPreviousTab", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToPreviousTab" }] }, goToNextTab: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "goToNextTab", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToNextTab"}] }, closeCurrentTab: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "closeCurrentTab", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "closeCurrentTab"}] }, openNewTab: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "openNewTab", "{arguments}.0", "{arguments}.4", "{arguments}.3", "{arguments}.5"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "openNewTab" }] }, openNewWindow: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "openNewWindow", "{arguments}.0", "{arguments}.4", "{arguments}.3", "{arguments}.5"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "openNewWindow" }] }, closeCurrentWindow: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "closeCurrentWindow", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "closeCurrentWindow" }] }, goToPreviousWindow: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "goToPreviousWindow", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToPreviousWindow" }] }, goToNextWindow: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "goToNextWindow", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToNextWindow" }] }, zoomIn: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "zoomIn", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "zoomIn" }] }, zoomOut: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "zoomOut", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "zoomOut" }] }, thumbstickZoom: { funcName: "gamepad.inputMapperUtils.background.thumbstickZoom", args: ["{that}", "{arguments}.0", "{arguments}.2"] }, maximizeWindow: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "maximizeWindow", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "maximizeWindow" }] }, restoreWindowSize: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "restoreWindowSize", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "restoreWindowSize" }] }, thumbstickWindowSize: { funcName: "gamepad.inputMapperUtils.background.thumbstickWindowSize", - args: ["{that}", "{arguments}.0", "{arguments}.2"] + args: ["{that}", "{arguments}.0", "{arguments}.2"] // value, actionOptions }, reopenTabOrWindow: { - funcName: "gamepad.inputMapperUtils.background.sendMessage", - args: ["{that}", "reopenTabOrWindow", "{arguments}.0", "{arguments}.4"] + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "reopenTabOrWindow" }] }, openActionLauncher: { funcName: "gamepad.inputMapper.openActionLauncher", - args: ["{that}", "{arguments}.0", "{arguments}.4"] // value, oldValue + args: ["{that}", "{arguments}.0", "{arguments}.1"] // value, oldValue }, openSearchKeyboard: { funcName: "gamepad.inputMapper.openSearchKeyboard", - args: ["{that}", "{arguments}.0", "{arguments}.4"] // value, oldValue + args: ["{that}", "{arguments}.0", "{arguments}.1"] // value, oldValue } }, components: { diff --git a/src/js/content_scripts/search-keyboard.js b/src/js/content_scripts/search-keyboard.js index 62fb389..4d82cc2 100644 --- a/src/js/content_scripts/search-keyboard.js +++ b/src/js/content_scripts/search-keyboard.js @@ -9,7 +9,6 @@ compliance with this License. You may obtain a copy of the BSD 3-Clause License at https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ -/* globals chrome */ (function (fluid) { "use strict"; var gamepad = fluid.registerNamespace("gamepad"); @@ -57,14 +56,14 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE event.preventDefault(); if (that.model.textInputValue && that.model.textInputValue.trim().length) { - var searchMessage = { + var actionOptions = { actionName: "search", // See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/search/query disposition: "NEW_TAB", text: that.model.textInputValue.trim() }; - chrome.runtime.sendMessage(searchMessage); + gamepad.inputMapperUtils.background.postMessage(that, actionOptions); } }; })(fluid); From e42a9656e04308c83f476bc6404f93bc2e122553 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Fri, 24 Nov 2023 15:29:54 +0100 Subject: [PATCH 17/23] GH-118: Added code to break out of (some) focus traps. --- .../input-mapper-content-utils.js | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 24fa46d..52a314f 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -305,20 +305,26 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE activeElementIndex = that.currentTabIndex; } + var increment = 0; if (direction === "forwardTab") { - that.currentTabIndex = (activeElementIndex + 1) % length; + increment = 1; } else if (direction === "reverseTab") { - /** - * Move to the first element if the last element on the webpage - * is focused. - */ - if (activeElementIndex === 0) { - activeElementIndex = length; - } - that.currentTabIndex = activeElementIndex - 1; + increment = -1; + } + + activeElement.blur(); + + that.currentTabIndex = (that.tabbableElements.length + (activeElementIndex + increment)) % that.tabbableElements.length; + var elementToFocus = that.tabbableElements[that.currentTabIndex]; + elementToFocus.focus(); + + // If focus didn't succeed, make one more attempt, to attempt to avoid focus traps (See #118). + if (!that.model.activeModal && elementToFocus !== document.activeElement) { + that.currentTabIndex = (that.tabbableElements.length + (that.currentTabIndex + increment)) % that.tabbableElements.length; + var failoverElementToFocus = that.tabbableElements[that.currentTabIndex]; + failoverElementToFocus.focus(); } - that.tabbableElements[that.currentTabIndex].focus(); } } } @@ -344,6 +350,8 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE var lastExternalFocused = activeElement; that.applier.change("lastExternalFocused", lastExternalFocused); that.applier.change("textInputValue", lastExternalFocused.value); + lastExternalFocused.blur(); + that.applier.change("activeModal", "onscreenKeyboard"); } /** From b2bcefca88ebaa698c4a9aceb3b8738ed1893c33 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Sat, 25 Nov 2023 15:27:11 +0100 Subject: [PATCH 18/23] GH-120: Open 'launchpad' on startup if there are no controllable windows. --- src/css/about.css | 20 +++++++++ src/html/about.html | 54 +++++++++++++++++++++++ src/js/about_page/startup.js | 11 +++++ src/js/background.js | 6 +++ src/js/content_scripts/action-launcher.js | 36 +++++++-------- src/js/content_scripts/search-keyboard.js | 6 +-- src/manifest.json | 4 ++ 7 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 src/css/about.css create mode 100644 src/html/about.html create mode 100644 src/js/about_page/startup.js diff --git a/src/css/about.css b/src/css/about.css new file mode 100644 index 0000000..7f26030 --- /dev/null +++ b/src/css/about.css @@ -0,0 +1,20 @@ +body { + font-size: x-large; + padding: 2rem; +} + +.gamepad-launchpad-header { + display: flex; + flex-direction: row; +} + +.gamepad-launchpad-icon { + align-self: center; + float: left; + height: 3rem; + width: 3rem; +} + +.gamepad-launchpad-body { + margin-left: 3rem; +} diff --git a/src/html/about.html b/src/html/about.html new file mode 100644 index 0000000..0b68579 --- /dev/null +++ b/src/html/about.html @@ -0,0 +1,54 @@ + + + + Gamepad Navigator Launchpad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +

Gamepad Navigator Launchpad

+
+ +
+

+ Some pages, like those opened by "New Tab" or "New Window" cannot be controlled using the Gamepad Navigator. +

+ +

+ If there are no controllable pages open on startup, this page is opened automatically to ensure that there + is always at least one controllable window. +

+
+ + diff --git a/src/js/about_page/startup.js b/src/js/about_page/startup.js new file mode 100644 index 0000000..2ecec6c --- /dev/null +++ b/src/js/about_page/startup.js @@ -0,0 +1,11 @@ +/* globals chrome */ +(function () { + "use strict"; + window.addEventListener("load", function () { + // Attempt to close this window, the background script will only close it if there other controllable windows available. + var port = chrome.runtime.connect(); + port.postMessage({ + actionName: "closeCurrentTab" + }); + }); +})(); diff --git a/src/js/background.js b/src/js/background.js index 14cfbd7..812293e 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -397,4 +397,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }); }); + + // Open our "launchpad", which only stays open if there are no other + // "controllable" windows/tabs. + chrome.runtime.onStartup.addListener(function () { + chrome.runtime.openOptionsPage(); + }); })(); diff --git a/src/js/content_scripts/action-launcher.js b/src/js/content_scripts/action-launcher.js index 64722e2..cb3d2d3 100644 --- a/src/js/content_scripts/action-launcher.js +++ b/src/js/content_scripts/action-launcher.js @@ -40,11 +40,15 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE row: -1, value: 1, oldValue: 0, - speedFactor: 1, - invert: false, - background: false, - homepageURL: "https://www.google.com/", - frequency: 100 + commonConfiguration: { + homepageURL: "https://www.google.com/" + }, + actionOptions: { + speedFactor: 1, + invert: false, + background: false, + frequency: 100 + } }, markup: { @@ -114,27 +118,21 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // triggered appropriately. // All actions are called with: - // value, speedFactor, invert, background, oldValue, homepageURL + // value, oldValue, actionOptions // Simulate button down actionFn( 1, - actionComponent.model.speedFactor, - actionComponent.model.invert, - actionComponent.model.background, 0, - actionComponent.model.homepageURL + actionComponent.model.actionOptions ); // Simulate button up after a delay (100ms by default) setTimeout(function () { actionFn( 0, - actionComponent.model.speedFactor, - actionComponent.model.invert, - actionComponent.model.background, 1, - actionComponent.model.homepageURL + actionComponent.model.actionOptions ); }, actionComponent.model.frequency); } @@ -287,10 +285,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE row: "{sourcePath}", actionKey: "{source}.key", description: "{source}.description", - speedFactor: "{source}.speedFactor", - invert: "{source}.invert", - background: "{source}.background", - frequency: "{source}.frequency" + actionOptions: { + speedFactor: "{source}.speedFactor", + invert: "{source}.invert", + background: "{source}.background", + frequency: "{source}.frequency" + } } } } diff --git a/src/js/content_scripts/search-keyboard.js b/src/js/content_scripts/search-keyboard.js index 4d82cc2..dc24f2f 100644 --- a/src/js/content_scripts/search-keyboard.js +++ b/src/js/content_scripts/search-keyboard.js @@ -30,7 +30,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE invokers: { handleSearchButtonClick: { funcName: "gamepad.searchKeyboard.modal.handleSearchButtonClick", - args: ["{that}", "{arguments}.0"] + args: ["{that}", "{inputMapper}", "{arguments}.0"] } }, @@ -51,7 +51,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }); - gamepad.searchKeyboard.modal.handleSearchButtonClick = function (that, event) { + gamepad.searchKeyboard.modal.handleSearchButtonClick = function (that, inputMapper, event) { that.applier.change("activeModal", false); event.preventDefault(); @@ -63,7 +63,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE text: that.model.textInputValue.trim() }; - gamepad.inputMapperUtils.background.postMessage(that, actionOptions); + gamepad.inputMapperUtils.background.postMessage(inputMapper, actionOptions); } }; })(fluid); diff --git a/src/manifest.json b/src/manifest.json index 98aa2d6..78cfc3c 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -63,5 +63,9 @@ "default": "Alt+Shift+G" } } + }, + "options_ui": { + "open_in_tab": true, + "page": "html/about.html" } } From d8f0c889379a39ae63326229ac3fdff3af5cdbee Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Sun, 26 Nov 2023 14:15:18 +0100 Subject: [PATCH 19/23] NO-GH: Added copyright banner to new files and updated in existing files. --- AUTHORS.md | 2 +- CONTRIBUTING.md | 2 +- Gruntfile.js | 2 +- PRIVACY_POLICY.md | 2 +- README.md | 2 +- docs/CANDIDATE_TECHNOLOGIES.md | 2 +- docs/PUBLISHING.md | 2 +- docs/components/configMaps.md | 2 +- docs/components/configurationPanel.md | 2 +- docs/components/inputMapper.base.md | 2 +- docs/components/inputMapper.md | 2 +- docs/components/navigator.md | 2 +- src/html/about.html | 11 +++++++++++ src/js/about_page/startup.js | 12 ++++++++++++ src/js/background.js | 2 +- src/js/configuration_panel/button-listeners.js | 2 +- src/js/configuration_panel/configuration-panel.js | 2 +- src/js/configuration_panel/create-panel-utils.js | 2 +- src/js/configuration_panel/panel-events.js | 2 +- src/js/content_scripts/gamepad-navigator.js | 2 +- .../content_scripts/input-mapper-background-utils.js | 2 +- src/js/content_scripts/input-mapper-base.js | 2 +- src/js/content_scripts/input-mapper-content-utils.js | 2 +- src/js/content_scripts/input-mapper.js | 2 +- src/js/content_scripts/onscreen-keyboard.js | 12 ++++++++++++ src/js/content_scripts/search-keyboard.js | 12 ++++++++++++ src/js/content_scripts/templateRenderer.js | 12 ++++++++++++ src/js/shared/configuration-maps.js | 2 +- templates/LICENSE-banner.txt | 2 +- tests/js/arrows/arrows-mock-utils.js | 2 +- tests/js/arrows/arrows-tests.js | 2 +- tests/js/click/click-basic-tests.js | 2 +- tests/js/click/click-dropdown-tests.js | 2 +- tests/js/click/click-mock-utils.js | 2 +- tests/js/gamepad-mock.js | 2 +- .../js/scroll/scroll-bidirectional-diagonal-tests.js | 2 +- .../js/scroll/scroll-bidirectional-oneaxes-tests.js | 2 +- tests/js/scroll/scroll-mock-utils.js | 2 +- tests/js/scroll/scroll-unidirectional-axes-tests.js | 2 +- .../js/scroll/scroll-unidirectional-button-tests.js | 2 +- tests/js/tab/tab-axes-tests.js | 2 +- tests/js/tab/tab-button-continuous-tests.js | 2 +- tests/js/tab/tab-button-discrete-tests.js | 2 +- tests/js/tab/tab-mock-utils.js | 2 +- tests/js/tab/tab-order-tests.js | 2 +- tests/js/test-utils.js | 2 +- 46 files changed, 100 insertions(+), 41 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 88f9018..79c014d 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,5 +1,5 @@ diff --git a/src/js/about_page/startup.js b/src/js/about_page/startup.js index 2ecec6c..bfb9278 100644 --- a/src/js/about_page/startup.js +++ b/src/js/about_page/startup.js @@ -1,3 +1,15 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + /* globals chrome */ (function () { "use strict"; diff --git a/src/js/background.js b/src/js/background.js index 812293e..d399ed7 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/configuration_panel/button-listeners.js b/src/js/configuration_panel/button-listeners.js index a44ed4c..1df3c7d 100644 --- a/src/js/configuration_panel/button-listeners.js +++ b/src/js/configuration_panel/button-listeners.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/configuration_panel/configuration-panel.js b/src/js/configuration_panel/configuration-panel.js index 7112bb2..675b230 100644 --- a/src/js/configuration_panel/configuration-panel.js +++ b/src/js/configuration_panel/configuration-panel.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/configuration_panel/create-panel-utils.js b/src/js/configuration_panel/create-panel-utils.js index 2ca34af..181c264 100644 --- a/src/js/configuration_panel/create-panel-utils.js +++ b/src/js/configuration_panel/create-panel-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/configuration_panel/panel-events.js b/src/js/configuration_panel/panel-events.js index e475ccf..121b97a 100644 --- a/src/js/configuration_panel/panel-events.js +++ b/src/js/configuration_panel/panel-events.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/content_scripts/gamepad-navigator.js b/src/js/content_scripts/gamepad-navigator.js index f421372..8c02565 100644 --- a/src/js/content_scripts/gamepad-navigator.js +++ b/src/js/content_scripts/gamepad-navigator.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/content_scripts/input-mapper-background-utils.js b/src/js/content_scripts/input-mapper-background-utils.js index e8c1d7b..4490a6f 100644 --- a/src/js/content_scripts/input-mapper-background-utils.js +++ b/src/js/content_scripts/input-mapper-background-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/content_scripts/input-mapper-base.js b/src/js/content_scripts/input-mapper-base.js index b45b918..b9e7e18 100644 --- a/src/js/content_scripts/input-mapper-base.js +++ b/src/js/content_scripts/input-mapper-base.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 52a314f..14602cc 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/content_scripts/input-mapper.js b/src/js/content_scripts/input-mapper.js index 5bc6620..78549c5 100644 --- a/src/js/content_scripts/input-mapper.js +++ b/src/js/content_scripts/input-mapper.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/src/js/content_scripts/onscreen-keyboard.js b/src/js/content_scripts/onscreen-keyboard.js index 4743ac8..e26bf9d 100644 --- a/src/js/content_scripts/onscreen-keyboard.js +++ b/src/js/content_scripts/onscreen-keyboard.js @@ -6,6 +6,18 @@ https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. Licensed under the BSD 3-Clause License. You may not use this file except in compliance with this License. +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + You may obtain a copy of the BSD 3-Clause License at https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ diff --git a/src/js/content_scripts/search-keyboard.js b/src/js/content_scripts/search-keyboard.js index dc24f2f..cd6c0c9 100644 --- a/src/js/content_scripts/search-keyboard.js +++ b/src/js/content_scripts/search-keyboard.js @@ -6,6 +6,18 @@ https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. Licensed under the BSD 3-Clause License. You may not use this file except in compliance with this License. +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + You may obtain a copy of the BSD 3-Clause License at https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ diff --git a/src/js/content_scripts/templateRenderer.js b/src/js/content_scripts/templateRenderer.js index 227eaab..31e2d94 100644 --- a/src/js/content_scripts/templateRenderer.js +++ b/src/js/content_scripts/templateRenderer.js @@ -6,6 +6,18 @@ https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. Licensed under the BSD 3-Clause License. You may not use this file except in compliance with this License. +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + You may obtain a copy of the BSD 3-Clause License at https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE */ diff --git a/src/js/shared/configuration-maps.js b/src/js/shared/configuration-maps.js index 4b8ba8e..2da88f6 100644 --- a/src/js/shared/configuration-maps.js +++ b/src/js/shared/configuration-maps.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/templates/LICENSE-banner.txt b/templates/LICENSE-banner.txt index 292cb62..474bb93 100644 --- a/templates/LICENSE-banner.txt +++ b/templates/LICENSE-banner.txt @@ -1,4 +1,4 @@ -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/arrows/arrows-mock-utils.js b/tests/js/arrows/arrows-mock-utils.js index ab5d925..aae7e4d 100644 --- a/tests/js/arrows/arrows-mock-utils.js +++ b/tests/js/arrows/arrows-mock-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/arrows/arrows-tests.js b/tests/js/arrows/arrows-tests.js index 78bb395..ac8359a 100644 --- a/tests/js/arrows/arrows-tests.js +++ b/tests/js/arrows/arrows-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/click/click-basic-tests.js b/tests/js/click/click-basic-tests.js index 48acd55..66c3149 100644 --- a/tests/js/click/click-basic-tests.js +++ b/tests/js/click/click-basic-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/click/click-dropdown-tests.js b/tests/js/click/click-dropdown-tests.js index 6fbb0ef..2680a76 100644 --- a/tests/js/click/click-dropdown-tests.js +++ b/tests/js/click/click-dropdown-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/click/click-mock-utils.js b/tests/js/click/click-mock-utils.js index 0b3629a..cfa6267 100644 --- a/tests/js/click/click-mock-utils.js +++ b/tests/js/click/click-mock-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/gamepad-mock.js b/tests/js/gamepad-mock.js index bb08027..4977003 100644 --- a/tests/js/gamepad-mock.js +++ b/tests/js/gamepad-mock.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/scroll/scroll-bidirectional-diagonal-tests.js b/tests/js/scroll/scroll-bidirectional-diagonal-tests.js index a3ab184..f5331cb 100644 --- a/tests/js/scroll/scroll-bidirectional-diagonal-tests.js +++ b/tests/js/scroll/scroll-bidirectional-diagonal-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/scroll/scroll-bidirectional-oneaxes-tests.js b/tests/js/scroll/scroll-bidirectional-oneaxes-tests.js index bc10605..c1f931f 100644 --- a/tests/js/scroll/scroll-bidirectional-oneaxes-tests.js +++ b/tests/js/scroll/scroll-bidirectional-oneaxes-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/scroll/scroll-mock-utils.js b/tests/js/scroll/scroll-mock-utils.js index 51ed2be..05503ba 100644 --- a/tests/js/scroll/scroll-mock-utils.js +++ b/tests/js/scroll/scroll-mock-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/scroll/scroll-unidirectional-axes-tests.js b/tests/js/scroll/scroll-unidirectional-axes-tests.js index d139d88..a816eb0 100644 --- a/tests/js/scroll/scroll-unidirectional-axes-tests.js +++ b/tests/js/scroll/scroll-unidirectional-axes-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/scroll/scroll-unidirectional-button-tests.js b/tests/js/scroll/scroll-unidirectional-button-tests.js index eaf9582..1152495 100644 --- a/tests/js/scroll/scroll-unidirectional-button-tests.js +++ b/tests/js/scroll/scroll-unidirectional-button-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/tab/tab-axes-tests.js b/tests/js/tab/tab-axes-tests.js index f71abf6..b7a9603 100644 --- a/tests/js/tab/tab-axes-tests.js +++ b/tests/js/tab/tab-axes-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/tab/tab-button-continuous-tests.js b/tests/js/tab/tab-button-continuous-tests.js index dba516a..2686c4d 100644 --- a/tests/js/tab/tab-button-continuous-tests.js +++ b/tests/js/tab/tab-button-continuous-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/tab/tab-button-discrete-tests.js b/tests/js/tab/tab-button-discrete-tests.js index bbad86d..511a6d3 100644 --- a/tests/js/tab/tab-button-discrete-tests.js +++ b/tests/js/tab/tab-button-discrete-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/tab/tab-mock-utils.js b/tests/js/tab/tab-mock-utils.js index 28849a7..371732d 100644 --- a/tests/js/tab/tab-mock-utils.js +++ b/tests/js/tab/tab-mock-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/tab/tab-order-tests.js b/tests/js/tab/tab-order-tests.js index 0b337e2..6f15449 100644 --- a/tests/js/tab/tab-order-tests.js +++ b/tests/js/tab/tab-order-tests.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. diff --git a/tests/js/test-utils.js b/tests/js/test-utils.js index c60940c..96d633a 100644 --- a/tests/js/test-utils.js +++ b/tests/js/test-utils.js @@ -1,5 +1,5 @@ /* -Copyright (c) 2020 The Gamepad Navigator Authors +Copyright (c) 2023 The Gamepad Navigator Authors See the AUTHORS.md file at the top-level directory of this distribution and at https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. From 726d48c94bfe27350e5317bc9ec3c732327c01f7 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 27 Nov 2023 19:34:05 +0100 Subject: [PATCH 20/23] NOGH: Updated forward-facing version for upcoming 1.0 release. --- package.json | 2 +- src/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8aaa12e..e97ef3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gamepad-navigator", - "version": "0.2.0", + "version": "1.0.0", "description": "A Chrome extension that allows you to navigate web pages and Chromium-based browsers using a game controller.", "contributors": [ { "name": "Divyanshu Mahajan" }, diff --git a/src/manifest.json b/src/manifest.json index 78cfc3c..9b5d930 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,6 @@ { "name": "Gamepad Navigator", - "version": "0.3.0", + "version": "1.0.0", "description": "A Chrome extension that allows you to navigate web pages and Chromium-based browsers using a game controller.", "author": "The Gamepad Navigator Authors", "manifest_version": 3, From d1c1bf0a59d59a8cac19b531bffbb919bad27de2 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 27 Nov 2023 19:34:46 +0100 Subject: [PATCH 21/23] NOGH: Updated outdated copyright date. --- AUTHORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AUTHORS.md b/AUTHORS.md index 79c014d..1f526ec 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -18,7 +18,7 @@ This is the list of Gamepad Navigator copyright holders. It does not list all in assigned copyright to an institution or made only minor changes. Please see the version control system's revision history for details on contributions. -Copyright (c) 2020 +Copyright (c) 2023 - Divyanshu Mahajan - Tony Atkins From a7bf2335ae667bac9b4650e75e7d424dac9f0f50 Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Mon, 27 Nov 2023 19:36:01 +0100 Subject: [PATCH 22/23] GH-103: Initial work in progress on new settings interface. --- src/css/about.css | 20 - src/css/settings.css | 92 ++++ src/html/{about.html => settings.html} | 27 +- src/js/about_page/startup.js | 23 - src/js/background.js | 43 +- src/js/content_scripts/action-launcher.js | 21 +- src/js/content_scripts/bindings.js | 132 ++++++ .../input-mapper-background-utils.js | 4 +- src/js/content_scripts/input-mapper-base.js | 67 +-- .../input-mapper-content-utils.js | 16 +- src/js/content_scripts/input-mapper.js | 96 +++- src/js/content_scripts/modal.js | 1 - src/js/content_scripts/utils.js | 83 ++++ src/js/settings/settings.js | 441 ++++++++++++++++++ src/js/settings/toggle.js | 45 ++ src/js/shared/prefs.js | 34 ++ src/manifest.json | 17 +- 17 files changed, 1018 insertions(+), 144 deletions(-) delete mode 100644 src/css/about.css create mode 100644 src/css/settings.css rename src/html/{about.html => settings.html} (71%) delete mode 100644 src/js/about_page/startup.js create mode 100644 src/js/content_scripts/bindings.js create mode 100644 src/js/content_scripts/utils.js create mode 100644 src/js/settings/settings.js create mode 100644 src/js/settings/toggle.js create mode 100644 src/js/shared/prefs.js diff --git a/src/css/about.css b/src/css/about.css deleted file mode 100644 index 7f26030..0000000 --- a/src/css/about.css +++ /dev/null @@ -1,20 +0,0 @@ -body { - font-size: x-large; - padding: 2rem; -} - -.gamepad-launchpad-header { - display: flex; - flex-direction: row; -} - -.gamepad-launchpad-icon { - align-self: center; - float: left; - height: 3rem; - width: 3rem; -} - -.gamepad-launchpad-body { - margin-left: 3rem; -} diff --git a/src/css/settings.css b/src/css/settings.css new file mode 100644 index 0000000..14aee0f --- /dev/null +++ b/src/css/settings.css @@ -0,0 +1,92 @@ +.gamepad-settings-header { + display: flex; + flex-direction: row; +} + +.gamepad-settings-header h3 { + font-size: 2rem; +} + +.gamepad-settings-icon { + align-self: center; + float: left; + height: 3rem; + width: 3rem; +} + +.gamepad-settings-body { + margin-left: 3rem; +} + +.gamepad-config-editable-section h3 { + font-size: 1.6rem; +} + +.gamepad-config-editable-section-body { + display: flex; + flex-direction: column; + row-gap: 0.5rem; +} + +.gamepad-toggle-outer-container { + align-items: center; + display: flex; + flex-direction: row; + margin-left: 2rem; +} + +.gamepad-toggle-header { + font-size: 1.2rem; + font-weight: bold; + width: 75%; +} + +.gamepad-toggle { + background-color: #999; + border: 2px solid black; + border-radius: 1.25rem; + display: flex; + height: 2.5rem; + justify-items: left; + margin-left: 0.5rem; + width: 5rem; +} + +.gamepad-toggle:focus { + border: 5px solid black; + outline: none; +} + +.gamepad-toggle.checked { + background-color: #fff; + justify-content: right; +} + +.gamepad-toggle-slider { + align-self: center; + background-color: #333; + border: 0; + border-radius: 50%; + height: 2rem; + margin-left: 0.25rem; + width: 2rem; +} + +.gamepad-toggle.checked .gamepad-toggle-slider { + margin-right: 0.25rem; +} + +.gamepad-config-editable-section-footer { + column-gap: 1rem; + display: flex; + flex-direction: row; + justify-content: right; + margin-top: 2rem; +} + +.gamepad-settings-draft-button { + align-content: center; + font-size: 1.5rem; + font-weight: bold; + height: 3rem; +} diff --git a/src/html/about.html b/src/html/settings.html similarity index 71% rename from src/html/about.html rename to src/html/settings.html index 9004935..39f5af2 100644 --- a/src/html/about.html +++ b/src/html/settings.html @@ -12,9 +12,9 @@ - Gamepad Navigator Launchpad + Gamepad Navigator Settings - + @@ -28,6 +28,9 @@ + + + @@ -41,25 +44,17 @@ - - + + -
- +
+ -

Gamepad Navigator Launchpad

+

Gamepad Navigator Settings

-
-

- Some pages, like those opened by "New Tab" or "New Window" cannot be controlled using the Gamepad Navigator. -

- -

- If there are no controllable pages open on startup, this page is opened automatically to ensure that there - is always at least one controllable window. -

+
diff --git a/src/js/about_page/startup.js b/src/js/about_page/startup.js deleted file mode 100644 index bfb9278..0000000 --- a/src/js/about_page/startup.js +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright (c) 2023 The Gamepad Navigator Authors -See the AUTHORS.md file at the top-level directory of this distribution and at -https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. - -Licensed under the BSD 3-Clause License. You may not use this file except in -compliance with this License. - -You may obtain a copy of the BSD 3-Clause License at -https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE -*/ - -/* globals chrome */ -(function () { - "use strict"; - window.addEventListener("load", function () { - // Attempt to close this window, the background script will only close it if there other controllable windows available. - var port = chrome.runtime.connect(); - port.postMessage({ - actionName: "closeCurrentTab" - }); - }); -})(); diff --git a/src/js/background.js b/src/js/background.js index d399ed7..55a87d8 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -311,6 +311,33 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }); }; + gamepad.messageListenerUtils.openOptionsPage = async function () { + var windowsArray = await chrome.windows.getAll({ populate: true}); + var settingsWindowId = null; + var settingsTabId = null; + windowsArray.forEach(function (window) { + if (!settingsTabId) { + var settingsTab = window.tabs.find(function (tab) { + return tab.url.startsWith("chrome-extension://") && tab.url.endsWith("settings.html"); + }); + if (settingsTab) { + settingsWindowId = window.id; + settingsTabId = settingsTab.id; + } + } + }); + + // If there's already a settings tab, focus on it. + if (settingsTabId) { + await chrome.windows.update(settingsWindowId, { focused: true}); + await chrome.tabs.update(settingsTabId, { active: true }); + } + // Otherwise, open a new one. + else { + return await chrome.runtime.openOptionsPage(); + } + }; + var messageListener = { // previous "members" windowProperties: {}, @@ -319,7 +346,6 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // On the client side, we check the control state before triggering // these, so the method signature is simply `action(actionOptions)`. openNewTab: async function (actionOptions) { - // TODO: Currently opens a new tab and a new window. return await gamepad.messageListenerUtils.openNewTab(actionOptions.active, actionOptions.homepageURL); }, closeCurrentTab: async function (actionOptions) { @@ -373,7 +399,8 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE openActionLauncher: async function () { return await gamepad.messageListenerUtils.openActionLauncher(); }, - search: gamepad.messageListenerUtils.search + search: gamepad.messageListenerUtils.search, + openOptionsPage: gamepad.messageListenerUtils.openOptionsPage }; chrome.runtime.onConnect.addListener(function (port) { @@ -398,9 +425,15 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }); }); - // Open our "launchpad", which only stays open if there are no other - // "controllable" windows/tabs. chrome.runtime.onStartup.addListener(function () { - chrome.runtime.openOptionsPage(); + chrome.storage.local.get(["gamepad-prefs"], function (storedObject) { + var storedPrefs = storedObject && storedObject["gamepad-prefs"]; + // If our prefs exactly match the defaults, nothing is stored. + // In this case, the default behaviour is to open a window, so if + // there are no stored settings, we do that. + if (!storedPrefs || storedPrefs["openWindowOnStartup"]) { + chrome.runtime.openOptionsPage(); + } + }); }); })(); diff --git a/src/js/content_scripts/action-launcher.js b/src/js/content_scripts/action-launcher.js index cb3d2d3..ae02879 100644 --- a/src/js/content_scripts/action-launcher.js +++ b/src/js/content_scripts/action-launcher.js @@ -156,7 +156,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE // TODO: Add support for controlling `backgroundOption` in the `openNewTab` and `openNewWindow` actions. actionDefs: [ // All button-driven actions, except for the action launcher itself, ordered by subjective "usefulness". - // TODO: Add action to open configuration menu, when available. + { + key: "openConfigPanel", + description: "Configure Gamepad Navigator" + }, { key: "openSearchKeyboard", description: "Search" @@ -254,22 +257,6 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE { key: "zoomOut", description: "Zoom-out on the active web page" - }, - { - key: "sendArrowLeft", - description: "Send left arrow to the focused element." - }, - { - key: "sendArrowRight", - description: "Send right arrow to the focused element." - }, - { - key: "sendArrowUp", - description: "Send up arrow to the focused element." - }, - { - key: "sendArrowDown", - description: "Send down arrow to the focused element." } ] }, diff --git a/src/js/content_scripts/bindings.js b/src/js/content_scripts/bindings.js new file mode 100644 index 0000000..25827f8 --- /dev/null +++ b/src/js/content_scripts/bindings.js @@ -0,0 +1,132 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +(function (fluid) { + "use strict"; + var gamepad = fluid.registerNamespace("gamepad"); + fluid.registerNamespace("gamepad.bindings"); + + /* + + "Action options", which represent the action to be performed in bindings and in internal calls between functions. + + @typedef {Object} ActionOptions + @property {String} action - The name of the action. + @property {Boolean} [invert] - Whether to invert the direction of motion (for actions that have a direction, like scrolling). + @property {Number} [repeat] - For actions that support continuous operation, how many seconds to wait before repeating the action if the same control is still depressed. + @property {Number} [speed] - How far to navigate in a single action. + @property {Boolean} [background] - For new windows/tabs, whether to open in the background. + @property {String} [key] - For `sendKey`, the key to send. + + */ + + gamepad.bindings.defaults = { + buttons: { + // Cross on PS controller, A on Xbox + 0: { + action: "click" + }, + + // Circle on PS controller, B on Xbox. + // TODO: Make this work only in modals. + 1: { + action: "sendKey", + key: "Escape" + }, + + // Left Bumper. + "4": { + action: "reverseTab", + repeat: 1, + speed: 2.5 + }, + // Right Bumper. + "5": { + action: "forwardTab", + repeat: 1, + speed: 2.5 + }, + + // Select button. + 8: { + action: "openSearchKeyboard" + }, + + // Start button. + 9: { + action: "openActionLauncher" + }, + + // D-pad. + // Up. + 12: { + action: "sendKey", + key: "ArrowUp", + repeat: 1, + speed: 1 + }, + // Down + 13: { + action: "sendKey", + key: "ArrowDown", + repeat: 1, + speed: 1 + }, + // Left + 14: { + action: "sendKey", + key: "ArrowLeft", + repeat: 1, + speedFactor: 1 + }, + // Right. + 15: { + action: "sendKey", + key: "ArrowRight", + repeat: 1, + speed: 1 + }, + + // "Badge" button. + 16: { + action: "openConfigPanel" + } + }, + axes: { + // Left thumbstick horizontal axis. + "0": { + action: "scrollHorizontally", + repeat: 1, + speed: 1, + invert: false + }, + // Left thumbstick vertical axis. + "1": { + action: "scrollVertically", + repeat: 1, + speed: 1, + invert: false + } + } + }; +})(fluid); diff --git a/src/js/content_scripts/input-mapper-background-utils.js b/src/js/content_scripts/input-mapper-background-utils.js index 4490a6f..21d8d88 100644 --- a/src/js/content_scripts/input-mapper-background-utils.js +++ b/src/js/content_scripts/input-mapper-background-utils.js @@ -25,8 +25,8 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * when the user navigates back to the same tab. */ - gamepad.inputMapperUtils.background.postMessageOnControlRelease = function (that, value, oldValue, actionOptions) { - if (value < oldValue && oldValue > that.options.cutoffValue) { + gamepad.inputMapperUtils.background.postMessageOnControlDown = function (that, value, oldValue, actionOptions) { + if (oldValue === 0 && value > that.model.prefs.analogCutoff) { gamepad.inputMapperUtils.background.postMessage(that, actionOptions); } }; diff --git a/src/js/content_scripts/input-mapper-base.js b/src/js/content_scripts/input-mapper-base.js index b9e7e18..26f09ce 100644 --- a/src/js/content_scripts/input-mapper-base.js +++ b/src/js/content_scripts/input-mapper-base.js @@ -16,9 +16,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE "use strict"; var gamepad = fluid.registerNamespace("gamepad"); - fluid.registerNamespace("gamepad.configMaps"); - fluid.registerNamespace("gamepad.inputMapper.base"); - fluid.registerNamespace("gamepad.inputMapperUtils.content"); + // TODO: Fairly sure none of these are required. + // fluid.registerNamespace("gamepad.configMaps"); + // fluid.registerNamespace("gamepad.inputMapper.base"); + // fluid.registerNamespace("gamepad.inputMapperUtils.content"); fluid.defaults("gamepad.inputMapper.base", { gradeNames: ["gamepad.configMaps", "gamepad.navigator"], @@ -50,6 +51,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * TODO: Move the member variables used for the inter-navigation web page * features to the "inputMapper" component. */ + // TODO: These should be expressed per control rather than per action, as we might bind an action to more than one control. intervalRecords: { upwardScroll: null, downwardScroll: null, @@ -66,12 +68,16 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }, currentTabIndex: 0, tabbableElements: null, - mutationObserverInstance: null + mutationObserverInstance: null, + // TODO: Ensure that there are sensible defaults somewhere. + prefs: {}, + bindings: {} }, // TODO: Make this configurable. // "Jitter" cutoff Value for analog thumb sticks. - cutoffValue: 0.40, - scrollInputMultiplier: 50, + cutoffValue: 0.40, // TODO: Make this a preference + scrollInputMultiplier: 50, // TODO: Make this a preference + invokers: { updateTabbables: { funcName: "gamepad.inputMapper.base.updateTabbables", @@ -154,22 +160,29 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE funcName: "gamepad.inputMapperUtils.content.thumbstickTabbing", args: ["{that}", "{arguments}.0", "{arguments}.2"] }, + + sendKey: { + funcName: "gamepad.inputMapperUtils.content.sendKey", + args: ["{that}", "{arguments}.0", "{arguments}.2"] // value, actionOptions + }, + + // TODO: Remove these once we are using the new bindings instead of the old "map". // Arrow actions for buttons sendArrowLeft: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{that}", "{arguments}.0", "ArrowLeft"] // value, key + args: ["{that}", "{arguments}.0", { key: "ArrowLeft" }] // value, actionOptions }, sendArrowRight: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{that}", "{arguments}.0", "ArrowRight"] // value, key + args: ["{that}", "{arguments}.0", { key: "ArrowRight" }] // value, actionOptions }, sendArrowUp: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{that}", "{arguments}.0", "ArrowUp"] // value, key + args: ["{that}", "{arguments}.0", { key: "ArrowUp" }] // value, actionOptions }, sendArrowDown: { funcName: "gamepad.inputMapperUtils.content.sendKey", - args: ["{that}", "{arguments}.0", "ArrowDown"] // value, key + args: ["{that}", "{arguments}.0", { key: "ArrowDown" }] // value, actionOptions }, // Arrow actions for axes thumbstickHorizontalArrows: { @@ -294,23 +307,23 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.inputMapper.base.vibrate = function (that) { - // TODO: Make this configurable, and/or make more than one vibration. - - var gamepads = that.options.windowObject.navigator.getGamepads(); - var nonNullGamepads = gamepads.filter(function (gamepad) { return gamepad !== null; }); + if (that.model.prefs.vibrate) { + var gamepads = that.options.windowObject.navigator.getGamepads(); + var nonNullGamepads = gamepads.filter(function (gamepad) { return gamepad !== null; }); - fluid.each(nonNullGamepads, function (gamepad) { - if (gamepad.vibrationActuator) { - gamepad.vibrationActuator.playEffect( - "dual-rumble", - { - duration: 250, - startDelay: 0, - strongMagnitude: 0.25, - weakMagnitude: .75 - } - ); - } - }); + fluid.each(nonNullGamepads, function (gamepad) { + if (gamepad.vibrationActuator) { + gamepad.vibrationActuator.playEffect( + "dual-rumble", + { + duration: 250, + startDelay: 0, + strongMagnitude: 0.25, + weakMagnitude: .75 + } + ); + } + }); + } }; })(fluid); diff --git a/src/js/content_scripts/input-mapper-content-utils.js b/src/js/content_scripts/input-mapper-content-utils.js index 14602cc..6c8a05d 100644 --- a/src/js/content_scripts/input-mapper-content-utils.js +++ b/src/js/content_scripts/input-mapper-content-utils.js @@ -527,15 +527,19 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE * Simulate a key press (down and up) on the current focused element. * @param {Object} that - The inputMapper component. * @param {Number} value - The current value of the input (from 0 to 1). - * @param {String} key - The key (ex: `ArrowLeft`) to simulate. + * @param {Object} actionOptions - The options for this action. + * @property {String} key - The key (ex: `ArrowLeft`) to simulate. * */ - gamepad.inputMapperUtils.content.sendKey = function (that, value, key) { - if (that.model.pageInView && (value > 0)) { - var keyDownEvent = new KeyboardEvent("keydown", { key: key, code: key, bubbles: true }); + gamepad.inputMapperUtils.content.sendKey = function (that, value, actionOptions) { + var key = fluid.get(actionOptions, "key"); + + // TODO: Make this use the "analogCutoff" preference. + if (that.model.pageInView && (value > that.options.cutoffValue) && (key !== undefined)) { var activeElement = that.model.activeModal ? fluid.get(that, "model.shadowElement.activeElement") : document.activeElement; if (activeElement) { + var keyDownEvent = new KeyboardEvent("keydown", { key: key, code: key, bubbles: true }); activeElement.dispatchEvent(keyDownEvent); // TODO: Test with text inputs and textarea fields to see if @@ -570,7 +574,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE that.options.frequency * speedFactor, // delay that, // arg 0 value, //arg 1 - forwardKey // arg 2 + { key: forwardKey } // arg 2 ); } else if (value < (-1 * that.options.cutoffValue)) { @@ -579,7 +583,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE that.options.frequency * speedFactor, that, -1 * value, - backwardKey + { key: backwardKey } ); } }; diff --git a/src/js/content_scripts/input-mapper.js b/src/js/content_scripts/input-mapper.js index 78549c5..cd3cc32 100644 --- a/src/js/content_scripts/input-mapper.js +++ b/src/js/content_scripts/input-mapper.js @@ -25,7 +25,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE activeModal: false, shadowElement: false, textInputValue: "", - textInputType: "" + textInputType: "", + + bindings: gamepad.bindings.defaults, + prefs: gamepad.prefs.defaults }, modelListeners: { pageInView: { @@ -48,11 +51,18 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }, listeners: { + // Old persistence. + // TODO: Remove "onCreate.updateControls": { funcName: "gamepad.inputMapper.updateControls", args: ["{that}"] }, + "onCreate.loadSettings": { + funcName: "gamepad.inputMapper.loadSettings", + args: ["{that}"] + }, + // Wire up event listeners to window. "onCreate.handleWindowFocus": { funcName: "window.addEventListener", @@ -92,43 +102,43 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE invokers: { // Actions, these are called with: value, oldValue, actionOptions goToPreviousTab: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToPreviousTab" }] }, goToNextTab: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToNextTab"}] }, closeCurrentTab: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "closeCurrentTab"}] }, openNewTab: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "openNewTab" }] }, openNewWindow: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "openNewWindow" }] }, closeCurrentWindow: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "closeCurrentWindow" }] }, goToPreviousWindow: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToPreviousWindow" }] }, goToNextWindow: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "goToNextWindow" }] }, zoomIn: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "zoomIn" }] }, zoomOut: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "zoomOut" }] }, thumbstickZoom: { @@ -136,11 +146,11 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE args: ["{that}", "{arguments}.0", "{arguments}.2"] }, maximizeWindow: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "maximizeWindow" }] }, restoreWindowSize: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "restoreWindowSize" }] }, thumbstickWindowSize: { @@ -148,7 +158,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE args: ["{that}", "{arguments}.0", "{arguments}.2"] // value, actionOptions }, reopenTabOrWindow: { - funcName: "gamepad.inputMapperUtils.background.postMessageOnControlRelease", + funcName: "gamepad.inputMapperUtils.background.postMessageOnControlDown", args: ["{that}", "{arguments}.0", "{arguments}.1", { actionName: "reopenTabOrWindow" }] }, openActionLauncher: { @@ -158,6 +168,10 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE openSearchKeyboard: { funcName: "gamepad.inputMapper.openSearchKeyboard", args: ["{that}", "{arguments}.0", "{arguments}.1"] // value, oldValue + }, + openConfigPanel: { + funcName: "gamepad.inputMapper.openConfigPanel", + args: ["{that}", "{arguments}.0", "{arguments}.1"] // value, oldValue } }, components: { @@ -206,6 +220,12 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE gamepad.inputMapper.openActionLauncher = gamepad.inputMapper.generateModalOpenFunction("actionLauncher"); gamepad.inputMapper.openSearchKeyboard = gamepad.inputMapper.generateModalOpenFunction("searchKeyboard"); + gamepad.inputMapper.openConfigPanel = function (that, value, oldValue) { + if (oldValue === 0 && value > that.model.prefs.analogCutoff) { + gamepad.inputMapperUtils.background.postMessage(that, { actionName: "openOptionsPage"}); + } + }; + gamepad.inputMapper.handlePageInViewChange = function (that) { if (that.model.pageInView) { gamepad.inputMapper.updateControls(that); @@ -215,6 +235,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }; + // TODO: Remove this once the new bindings are fully wired up. /** * * (Re)load the gamepad configuration from local storage. @@ -235,5 +256,52 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }; + gamepad.inputMapper.loadSettings = async function (that) { + gamepad.inputMapper.loadPrefs(that); + + gamepad.inputMapper.loadBindings(that); + + /* + The two params for the onChanged listener callbak are "changes" and "areaName". In our case, "areaName" is + always "local", so we ignore it. "changes" is an object with an entry for each changed key, as in: + + { "gamepad-prefs": newValue: {}} + */ + + chrome.storage.onChanged.addListener(function (changes) { + if (changes["gamepad-prefs"]) { + gamepad.inputMapper.loadPrefs(that); + } + + if (changes["gamepad-bindings"]) { + gamepad.inputMapper.loadBindings(that); + } + }); + }; + + gamepad.inputMapper.loadPrefs = async function (that) { + var storedPrefs = await gamepad.utils.getStoredKey("gamepad-prefs"); + var prefsToSave = storedPrefs || gamepad.prefs.defaults; + + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "prefs", type: "DELETE"}); + transaction.fireChangeRequest({ path: "prefs", value: prefsToSave }); + + transaction.commit(); + }; + + gamepad.inputMapper.loadBindings = async function (that) { + var storedBindings = await gamepad.utils.getStoredKey("gamepad-bindings"); + var bindingsToSave = storedBindings || gamepad.bindings.defaults; + + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "bindings", type: "DELETE"}); + transaction.fireChangeRequest({ path: "bindings", value: bindingsToSave }); + + transaction.commit(); + }; + gamepad.inputMapperInstance = gamepad.inputMapper("body"); })(fluid, jQuery); diff --git a/src/js/content_scripts/modal.js b/src/js/content_scripts/modal.js index b75b2c4..d490afb 100644 --- a/src/js/content_scripts/modal.js +++ b/src/js/content_scripts/modal.js @@ -50,7 +50,6 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE trailingFocusTrap: ".modal-focus-trap-trailing" }, markup: { - // TODO: Add the ability to retrieve our icon URL and display the icon onscreen. container: "" }, invokers: { diff --git a/src/js/content_scripts/utils.js b/src/js/content_scripts/utils.js new file mode 100644 index 0000000..9e6178b --- /dev/null +++ b/src/js/content_scripts/utils.js @@ -0,0 +1,83 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +/* globals chrome */ +(function (fluid) { + "use strict"; + + var gamepad = fluid.registerNamespace("gamepad"); + fluid.registerNamespace("gamepad.utils"); + + gamepad.utils.isDeeplyEqual = function (firstThing, secondThing) { + if (typeof firstThing !== typeof secondThing) { + return false; + } + else if (Array.isArray(firstThing)) { + if (firstThing.length === secondThing.length) { + for (var arrayIndex = 0; arrayIndex < firstThing.length; arrayIndex++) { + var arrayItemsEqual = gamepad.utils.isDeeplyEqual(firstThing[arrayIndex], secondThing[arrayIndex]); + if (!arrayItemsEqual) { return false; } + } + return true; + } + else { + return false; + } + } + else if (typeof firstThing === "object") { + var firstThingKeys = Object.keys(firstThing); + var secondThingKeys = Object.keys(secondThing); + + if (firstThingKeys.length !== secondThingKeys.length) { + return false; + } + + for (var keyIndex = 0; keyIndex < firstThingKeys.length; keyIndex++) { + var key = firstThingKeys[keyIndex]; + var objectPropertiesEqual = gamepad.utils.isDeeplyEqual(firstThing[key], secondThing[key]); + if (!objectPropertiesEqual) { return false; } + } + + return true; + } + else { + return firstThing === secondThing; + } + }; + + gamepad.utils.getStoredKey = function (key) { + var storagePromise = new Promise(function (resolve) { + // Apparently, we have to use a callback and can't use `await` unless we make our own wrapper. + chrome.storage.local.get([key], function (storedObject) { + if (storedObject[key]) { + resolve(storedObject[key]); + } + else { + resolve(false); + } + }); + }); + return storagePromise; + }; + +})(fluid); diff --git a/src/js/settings/settings.js b/src/js/settings/settings.js new file mode 100644 index 0000000..a0e37e2 --- /dev/null +++ b/src/js/settings/settings.js @@ -0,0 +1,441 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +/* globals chrome */ +(function (fluid) { + "use strict"; + var gamepad = fluid.registerNamespace("gamepad"); + fluid.defaults("gamepad.settings", { + gradeNames: ["gamepad.templateRenderer"], + injectionType: "replaceWith", + markup: { + container: "
" + }, + model: { + prefs: gamepad.prefs.defaults, + bindings: gamepad.bindings.defaults + }, + modelListeners: { + prefs: { + excludeSource: "init", + funcName: "gamepad.settings.savePrefs", + args: ["{that}.model.prefs"] + }, + bindings: { + excludeSource: "init", + funcName: "gamepad.settings.saveBindings", + args: ["{that}.model.bindings"] + } + }, + listeners: { + "onCreate.loadSettings": { + funcName: "gamepad.settings.loadSettings", + args: ["{that}"] + } + }, + components: { + prefs: { + container: "{that}.container", + type: "gamepad.config.prefs", + options: { + model: { + prefs: "{gamepad.settings}.model.prefs" + } + } + } + // buttonBindings: { + // container: "{that}.dom.body", + // type: "gamepad.config.bindings", + // options: { + // model: { + // label: "Buttons / Triggers", + // bindings: "{gamepad.settings}.model.bindings.buttons" + // } + // } + // }, + // axisBindings: { + // container: "{that}.dom.body", + // type: "gamepad.config.bindings", + // options: { + // model: { + // label: "Axes (Thumb sticks)", + // bindings: "{gamepad.settings}.model.bindings.axes" + // } + // } + // } + } + }); + + gamepad.settings.loadSettings = async function (that) { + gamepad.settings.loadPrefs(that); + + gamepad.settings.loadBindings(that); + + // In the similar function in input mapper, we add a listener for changes to values in local storage. As we + // have code to ensure that there is only open settings panel, and since only the settings panel can update + // stored values, we should safely be able to avoid listening for local storage changes here. + }; + + gamepad.settings.loadPrefs = async function (that) { + var storedPrefs = await gamepad.utils.getStoredKey("gamepad-prefs"); + var prefsToSave = storedPrefs || gamepad.prefs.defaults; + + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "prefs", type: "DELETE"}); + transaction.fireChangeRequest({ path: "prefs", value: prefsToSave }); + + transaction.commit(); + }; + + gamepad.settings.loadBindings = async function (that) { + var storedBindings = await gamepad.utils.getStoredKey("gamepad-bindings"); + var bindingsToSave = storedBindings || gamepad.bindings.defaults; + + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "bindings", type: "DELETE"}); + transaction.fireChangeRequest({ path: "bindings", value: bindingsToSave }); + + transaction.commit(); + }; + + gamepad.settings.savePrefs = function (prefs) { + var prefsEqualDefaults = gamepad.utils.isDeeplyEqual(gamepad.prefs.defaults, prefs); + if (prefsEqualDefaults) { + chrome.storage.local.remove("gamepad-prefs"); + } + else { + chrome.storage.local.set({ "gamepad-prefs": prefs }); + } + }; + + gamepad.settings.saveBindings = async function (bindings) { + var bindingsEqualDefaults = gamepad.utils.isDeeplyEqual(gamepad.bindings.defaults, bindings); + if (bindingsEqualDefaults) { + chrome.storage.local.remove("gamepad-bindings"); + } + else { + chrome.storage.local.set({ "gamepad-bindings": bindings }); + } + }; + + fluid.defaults("gamepad.config.draftHandlingButton", { + gradeNames: ["gamepad.templateRenderer"], + markup: { + container: "" + }, + model: { + label: "Draft Button", + disabled: true + }, + modelRelay: { + source: "{that}.model.disabled", + target: "{that}.model.dom.container.attr.disabled" + } + }); + + fluid.defaults("gamepad.config.draftHandlingButton.discard", { + gradeNames: ["gamepad.config.draftHandlingButton"], + model: { + label: "Discard Changes" + } + }); + + fluid.defaults("gamepad.config.draftHandlingButton.save", { + gradeNames: ["gamepad.config.draftHandlingButton"], + model: { + label: "Save Changes" + } + }); + + + fluid.defaults("gamepad.config.editableSection", { + gradeNames: ["gamepad.templateRenderer"], + markup: { + container: "

%label

" + }, + selectors: { + body: ".gamepad-config-editable-section-body", + footer: ".gamepad-config-editable-section-footer" + }, + model: { + label: "Editable Section", + draftClean: true + }, + components: { + discardButton: { + container: "{that}.dom.footer", + type: "gamepad.config.draftHandlingButton.discard", + options: { + model: { + disabled: "{gamepad.config.editableSection}.model.draftClean" + }, + listeners: { + "onCreate.bindClick": { + this: "{gamepad.config.draftHandlingButton}.container", + method: "click", + args: ["{gamepad.config.editableSection}.resetDraft"] + } + } + } + }, + saveButton: { + container: "{that}.dom.footer", + type: "gamepad.config.draftHandlingButton.save", + options: { + model: { + disabled: "{gamepad.config.editableSection}.model.draftClean" + }, + listeners: { + "onCreate.bindClick": { + this: "{gamepad.config.draftHandlingButton}.container", + method: "click", + args: ["{gamepad.config.editableSection}.saveDraft"] + } + } + } + } + }, + invokers: { + saveDraft: { + funcName: "fluid.notImplemented" + }, + resetDraft: { + funcName: "fluid.notImplemented" + } + } + }); + + /* + analogCutoff: 0.25, // was 0.4 + + newTabOrWindowURL: "https://www.google.com/", + + openWindowOnStartup: true, + vibrate: true + */ + + fluid.defaults("gamepad.config.prefs", { + gradeNames: ["gamepad.config.editableSection"], + model: { + label: "Preferences", + prefs: {}, + draftPrefs: {} + }, + modelRelay: { + source: "prefs", + target: "draftPrefs", + backward: { + excludeSource: "*" + } + }, + modelListeners: { + prefs: { + excludeSource: "init", + funcName: "gamepad.config.prefs.resetDraft", + args: ["{that}"] + + }, + draftPrefs: { + excludeSource: "local", + funcName: "gamepad.config.prefs.flagDraftChanged", + args: ["{that}"] + } + }, + invokers: { + saveDraft: { + funcName: "gamepad.config.prefs.saveDraft", + args: ["{that}"] + }, + resetDraft: { + funcName: "gamepad.config.prefs.resetDraft", + args: ["{that}"] + } + }, + components: { + vibrate: { + container: "{that}.dom.body", + type: "gamepad.ui.toggle", + options: { + model: { + label: "Vibrate", + checked: "{gamepad.config.prefs}.model.draftPrefs.vibrate" + } + } + }, + openWindowOnStartup: { + container: "{that}.dom.body", + type: "gamepad.ui.toggle", + options: { + model: { + label: "Open Settings on Startup", + checked: "{gamepad.config.prefs}.model.draftPrefs.openWindowOnStartup" + } + } + } + } + }); + + gamepad.config.prefs.resetDraft = function (that) { + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "draftPrefs", type: "DELETE"}); + transaction.fireChangeRequest({ path: "draftPrefs", value: that.model.prefs }); + transaction.fireChangeRequest({ path: "draftClean", value: true}); + + transaction.commit(); + }; + + gamepad.config.prefs.flagDraftChanged = function (that) { + var draftClean = gamepad.utils.isDeeplyEqual(that.model.draftPrefs, that.model.prefs); + that.applier.change("draftClean", draftClean); + }; + + gamepad.config.prefs.saveDraft = function (that) { + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "prefs", type: "DELETE"}); + transaction.fireChangeRequest({ path: "prefs", value: that.model.draftPrefs }); + transaction.fireChangeRequest({ path: "draftClean", value: true}); + + transaction.commit(); + }; + + /* + Existing options we need to display/edit (and actions that use them`) + speedFactorOption: [ + "reverseTab", + "forwardTab", + "scrollLeft", + "scrollRight", + "scrollUp", + "scrollDown", + "scrollHorizontally", + "scrollVertically", + "thumbstickTabbing", + "thumbstickHorizontalArrows", + "thumbstickVerticalArrows" + ], + backgroundOption: ["openNewTab", "openNewWindow"], + invertOption: [ + "scrollHorizontally", + "scrollVertically", + "thumbstickHistoryNavigation", + "thumbstickTabbing", + "thumbstickZoom", + "thumbstickWindowSize", + "thumbstickHorizontalArrows", + "thumbstickVerticalArrows" + ] + + In addition, we need to make the existing hard-coded "frequency" option configurable for everything except: + + key: "click", + key: "openConfigPanel", + key: "openSearchKeyboard", + key: "openNewWindow", + key: "openNewTab", + key: "maximizeWindow", + key: "restoreWindowSize", + + // On the fence, but on balance I'd rather they not be repeatable. + key: "closeCurrentTab", + key: "closeCurrentWindow", + key: "reopenTabOrWindow", + key: "previousPageInHistory", + key: "nextPageInHistory", + + These should be repeatable: + + key: "goToPreviousWindow", + key: "goToNextWindow", + key: "goToPreviousTab", + key: "goToNextTab", + + key: "reverseTab", + key: "forwardTab", + key: "scrollLeft", + key: "scrollRight", + key: "scrollUp", + key: "scrollDown", + key: "zoomIn", + key: "zoomOut", + key: "sendArrowLeft", + key: "sendArrowRight", + key: "sendArrowUp", + key: "sendArrowDown", + + */ + + // Component to edit a section of the bindings, i.e. only axes or buttons. + fluid.defaults("gamepad.config.bindings", { + gradeNames: ["gamepad.config.editableSection"], + model: { + label: "Bindings", + bindings: {}, + draftBindings: "{that}.model.bindings" + }, + modelListeners: { + bindings: { + excludeSource: "init", + funcName: "gamepad.config.prefs.resetDraft", + args: ["{that}"] + + }, + draftBindings: { + excludeSource: "local", + funcName: "gamepad.config.prefs.flagDraftChanged", + args: ["{that}"] + } + }, + invokers: { + saveDraft: { + funcName: "gamepad.config.prefs.saveDraft", + args: ["{that}"] + }, + resetDraft: { + funcName: "gamepad.config.prefs.resetDraft", + args: ["{that}"] + } + } + }); + + gamepad.config.bindings.resetDraft = function (that) { + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "draftBindings", type: "DELETE"}); + transaction.fireChangeRequest({ path: "draftBindings", value: that.model.bindings }); + transaction.fireChangeRequest({ path: "draftClean", value: true}); + + transaction.commit(); + }; + + gamepad.config.bindings.flagDraftChanged = function (that) { + var draftClean = gamepad.utils.isDeeplyEqual(that.model.draftBindings, that.model.bindings); + that.applier.change("draftClean", draftClean); + }; + + gamepad.config.bindings.saveDraft = function (that) { + var transaction = that.applier.initiate(); + + transaction.fireChangeRequest({ path: "bindings", type: "DELETE"}); + transaction.fireChangeRequest({ path: "bindings", value: that.model.draftBindings }); + transaction.fireChangeRequest({ path: "draftClean", value: true}); + + transaction.commit(); + }; + + // TODO: As long as there are unbound buttons/axes, present an "add binding" + // form at the bottom of each list. + + gamepad.settings(".gamepad-settings-body"); +})(fluid); diff --git a/src/js/settings/toggle.js b/src/js/settings/toggle.js new file mode 100644 index 0000000..b76caca --- /dev/null +++ b/src/js/settings/toggle.js @@ -0,0 +1,45 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +(function (fluid) { + "use strict"; + // var gamepad = fluid.registerNamespace("gamepad"); + + fluid.defaults("gamepad.ui.toggle", { + gradeNames: ["gamepad.templateRenderer"], + styles: { + checked: "checked" + }, + model: { + label: "Toggle", + checked: true + }, + markup: { + container: "
%label
" + }, + selectors: { + toggle: ".gamepad-toggle" + }, + modelRelay: [ + { + source: "{that}.model.dom.toggle.click", + target: "{that}.model.checked", + singleTransform: "fluid.transforms.toggle" + }, + { + source: "{that}.model.checked", + target: { + segs: ["dom", "toggle", "class", "{that}.options.styles.checked"] + } + } + ] + }); +})(fluid); diff --git a/src/js/shared/prefs.js b/src/js/shared/prefs.js new file mode 100644 index 0000000..58bbd1f --- /dev/null +++ b/src/js/shared/prefs.js @@ -0,0 +1,34 @@ +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ + +/* +Copyright (c) 2023 The Gamepad Navigator Authors +See the AUTHORS.md file at the top-level directory of this distribution and at +https://github.com/fluid-lab/gamepad-navigator/raw/master/AUTHORS.md. + +Licensed under the BSD 3-Clause License. You may not use this file except in +compliance with this License. + +You may obtain a copy of the BSD 3-Clause License at +https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE +*/ +(function (fluid) { + "use strict"; + var gamepad = fluid.registerNamespace("gamepad"); + fluid.registerNamespace("gamepad.prefs"); + gamepad.prefs.defaults = { + analogCutoff: 0.25, + newTabOrWindowURL: "https://www.google.com/", + openWindowOnStartup: true, + vibrate: true + }; +})(fluid); diff --git a/src/manifest.json b/src/manifest.json index 9b5d930..b277849 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -26,8 +26,11 @@ "js/lib/fluid-osk/keyboard.js", "js/lib/fluid-osk/keyboards.js", "js/lib/fluid-osk/inputs.js", + "js/content_scripts/utils.js", "js/content_scripts/gamepad-navigator.js", "js/shared/configuration-maps.js", + "js/shared/prefs.js", + "js/content_scripts/bindings.js", "js/content_scripts/styles.js", "js/content_scripts/svgs.js", "js/content_scripts/templateRenderer.js", @@ -52,20 +55,8 @@ "48": "images/gamepad-icon-48px.png", "128": "images/gamepad-icon-128px.png" }, - "action": { - "default_icon": "images/gamepad-icon.png", - "default_popup": "html/configuration-panel.html", - "default_title": "Open the gamepad configuration panel." - }, - "commands": { - "_execute_action": { - "suggested_key": { - "default": "Alt+Shift+G" - } - } - }, "options_ui": { "open_in_tab": true, - "page": "html/about.html" + "page": "html/settings.html" } } From 4517ec19e083e3ee738838dbbe87e736f6c445be Mon Sep 17 00:00:00 2001 From: Tony Atkins Date: Tue, 28 Nov 2023 11:11:43 +0100 Subject: [PATCH 23/23] GH-103: Open settings panel when extension icon is clicked. --- src/js/background.js | 6 ++++++ src/manifest.json | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/js/background.js b/src/js/background.js index 55a87d8..98a81db 100644 --- a/src/js/background.js +++ b/src/js/background.js @@ -425,6 +425,7 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE }); }); + // If the user's preferences allow us to, open the settings panel on startup. chrome.runtime.onStartup.addListener(function () { chrome.storage.local.get(["gamepad-prefs"], function (storedObject) { var storedPrefs = storedObject && storedObject["gamepad-prefs"]; @@ -436,4 +437,9 @@ https://github.com/fluid-lab/gamepad-navigator/blob/master/LICENSE } }); }); + + // Open the settings panel when the icon is clicked. + chrome.action.onClicked.addListener(() => { + chrome.runtime.openOptionsPage(); + }); })(); diff --git a/src/manifest.json b/src/manifest.json index b277849..649d790 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -55,6 +55,15 @@ "48": "images/gamepad-icon-48px.png", "128": "images/gamepad-icon-128px.png" }, + "action": { + "default_icon": { + "16": "images/gamepad-icon-16px.png", + "32": "images/gamepad-icon-32px.png", + "48": "images/gamepad-icon-48px.png", + "128": "images/gamepad-icon-128px.png" + }, + "default_title": "Open Gamepad Navigator settings." + }, "options_ui": { "open_in_tab": true, "page": "html/settings.html"