├── .swift-version ├── .swiftlint.yml ├── assets ├── screenshot.png ├── screenshot.psd ├── logo.svg └── vimlogo.svg ├── .husky └── pre-commit ├── Vimarily ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 512.png │ │ ├── 64.png │ │ ├── 1024.png │ │ └── Contents.json │ ├── AppIconDebug.appiconset │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 512.png │ │ ├── 64.png │ │ ├── 1024.png │ │ └── Contents.json │ └── Logo.imageset │ │ ├── Contents.json │ │ ├── logo-1.svg │ │ ├── logo-2.svg │ │ └── logo.svg ├── Constants.swift ├── Views │ ├── ErrorText.swift │ ├── ReloadReminder.swift │ ├── KeyBindingsView │ │ ├── KeyBindingsViewModel.swift │ │ └── KeyBindingsView.swift │ ├── GeneralView │ │ ├── GeneralViewModel.swift │ │ └── GeneralView.swift │ └── SettingsView │ │ ├── SettingsViewModel.swift │ │ └── SettingsView.swift ├── VimarilyRelease.entitlements ├── VimarilyDebug.entitlements ├── AppDelegate.swift ├── Resources │ └── Credits.rtf ├── Vimarily.swift ├── Info.plist ├── Models │ └── UserDefaults.swift └── Base.lproj │ └── Main.storyboard ├── Vimarily Extension ├── ToolbarItemIcon.pdf ├── SafariExtensionViewController.swift ├── VimarilyExtensionDebug.entitlements ├── VimarilyExtensionRelease.entitlements ├── ConfigurationModel.swift ├── js │ ├── SafariExtensionCommunicator.js │ ├── lib │ │ ├── svim-scripts.js │ │ ├── vimium-scripts.js │ │ └── mousetrap.js │ ├── keyboard-utils.js │ ├── injected.js │ └── link-hints.js ├── Base.lproj │ └── SafariExtensionViewController.xib ├── css │ └── injected.css ├── Info.plist └── SafariExtensionHandler.swift ├── .prettierrc.js ├── lint-staged.config.js ├── jest.config.js ├── Makefile ├── Vimarily.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── WorkspaceSettings.xcsettings │ └── IDEWorkspaceChecks.plist ├── tests ├── mocks.js └── vimarily.spec.js ├── .gitignore ├── .github ├── workflows │ └── nodejs.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── package.json ├── .eslintrc.js ├── LICENSE ├── VIMARI_LICENSE ├── DEVELOPERS.md ├── docs └── safari_12.md └── README.md /.swift-version: -------------------------------------------------------------------------------- 1 | 5 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - todo 3 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /assets/screenshot.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/assets/screenshot.psd -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint:staged && npm test 5 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "author": "xcode", 4 | "version": 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Vimarily Extension/ToolbarItemIcon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily Extension/ToolbarItemIcon.pdf -------------------------------------------------------------------------------- /Vimarily/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Constants { 4 | static let extensionIdentifierSuffix = ".SafariExtension" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | tabWidth: 2, 4 | useTabs: true, 5 | semi: true, 6 | trailingComma: 'es5', 7 | }; 8 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/128.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/16.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/256.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/32.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/512.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/64.png -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MarcDonald/Vimarily/HEAD/Vimarily/Assets.xcassets/AppIconDebug.appiconset/1024.png -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js}': [ 3 | 'eslint --fix -c .eslintrc.js --ext', 4 | 'prettier -c .prettierrc.js --write', 5 | ], 6 | '*.{yml,md,json,scss}': ['prettier -c .prettierrc.js --write'], 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jest-environment-jsdom', 3 | setupFiles: [ 4 | './tests/mocks.js', 5 | './Vimarily Extension/js/lib/mousetrap.js', 6 | './Vimarily Extension/js/injected.js', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all test test-watch 2 | 3 | NPM=$(shell which npm) 4 | NPM_BIN=$(shell npm bin) 5 | 6 | all: deps 7 | 8 | deps: 9 | @$(NPM) install 10 | 11 | test: 12 | @$(NPM_BIN)/jest tests 13 | 14 | test-watch: 15 | @$(NPM_BIN)/jest --watch tests 16 | -------------------------------------------------------------------------------- /Vimarily.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Vimarily/Views/ErrorText.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct ErrorText: View { 5 | var text: String 6 | var condition: Bool 7 | 8 | var body: some View { 9 | if condition { 10 | Text(text).foregroundColor(.red) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Vimarily.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Vimarily.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Vimarily Extension/SafariExtensionViewController.swift: -------------------------------------------------------------------------------- 1 | import SafariServices 2 | 3 | class SafariExtensionViewController: SFSafariExtensionViewController { 4 | static let shared: SafariExtensionViewController = { 5 | let shared = SafariExtensionViewController() 6 | shared.preferredContentSize = NSSize(width: 320, height: 240) 7 | return shared 8 | }() 9 | } 10 | -------------------------------------------------------------------------------- /tests/mocks.js: -------------------------------------------------------------------------------- 1 | const safari = { 2 | self: { 3 | tab: { 4 | dispatchMessage: function () {}, 5 | }, 6 | addEventListener: function () {}, 7 | }, 8 | extension: { 9 | dispatchMessage: function () {}, 10 | }, 11 | }; 12 | 13 | window.safari = safari; 14 | 15 | global.SafariExtensionCommunicator = function () { 16 | return { 17 | requestSettingsUpdate: function () {}, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | # Xcode 4 | ## Build generated 5 | build/ 6 | DerivedData/ 7 | 8 | ## Various settings 9 | xcuserdata/ 10 | 11 | ## Obj-C/Swift specific 12 | *.hmap 13 | *.ipa 14 | *.dSYM.zip 15 | *.dSYM 16 | 17 | ## Playgrounds 18 | timeline.xctimeline 19 | playground.xcworkspace 20 | 21 | .vscode/ 22 | .idea/ 23 | .DS_Store 24 | 25 | ## Xcode 8 and earlier 26 | *.xcscmblueprint 27 | *.xccheckout -------------------------------------------------------------------------------- /Vimarily/Views/ReloadReminder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct ReloadReminder: View { 5 | var padding: CGFloat 6 | 7 | init(padding: CGFloat = 4) { 8 | self.padding = padding 9 | } 10 | 11 | var body: some View { 12 | Text("Note: Reload the page after making changes") 13 | .font(.callout) 14 | .frame(maxWidth: .infinity, alignment: .center) 15 | .padding(padding) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "filename": "logo-2.svg", 5 | "idiom": "universal", 6 | "scale": "1x" 7 | }, 8 | { 9 | "filename": "logo-1.svg", 10 | "idiom": "universal", 11 | "scale": "2x" 12 | }, 13 | { 14 | "filename": "logo.svg", 15 | "idiom": "universal", 16 | "scale": "3x" 17 | } 18 | ], 19 | "info": { 20 | "author": "xcode", 21 | "version": 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Vimarily/VimarilyRelease.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.com.marcdonald.Vimarily 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Vimarily/VimarilyDebug.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.$(PARENT_APP_BUNDLE_IDENTIFIER) 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Vimarily Extension/VimarilyExtensionDebug.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.$(PARENT_APP_BUNDLE_IDENTIFIER) 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Vimarily Extension/VimarilyExtensionRelease.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | group.$(PARENT_APP_BUNDLE_IDENTIFIER) 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [16.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: make all test 20 | run: | 21 | make all test 22 | env: 23 | CI: true 24 | -------------------------------------------------------------------------------- /Vimarily/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | class AppDelegate: NSObject, NSApplicationDelegate { 4 | func applicationDidFinishLaunching(_: Notification) { 5 | // Insert code here to initialize your application 6 | } 7 | 8 | func applicationWillTerminate(_: Notification) { 9 | // Insert code here to tear down your application 10 | } 11 | 12 | func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { 13 | return true 14 | } 15 | 16 | @IBAction func openHelpUrl(_ sender: Any) { 17 | NSWorkspace.shared.open(URL(string: "https://github.com/marcdonald/vimarily#usage")!) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Vimarily/Resources/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf2639 2 | \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | \margl1440\margr1440\vieww9000\viewh8400\viewkind0 6 | \pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\partightenfactor0 7 | 8 | \f0\fs24 \cf0 Full source is available at {\field{\*\fldinst{HYPERLINK "http://github.com/marcdonald/Vimarily"}}{\fldrslt github.com/marcdonald/vimarily}}. This is fork of {\field{\*\fldinst{HYPERLINK "http://github.com/televator-apps/vimari"}}{\fldrslt github.com/televator-apps/vimari}} which is heavily derived from Vimium.} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /Vimarily/Views/KeyBindingsView/KeyBindingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class KeyBindingsViewModel: ObservableObject { 4 | @Published var actionBindings: [UserDefaults.BindingKeys : [String]]! 5 | 6 | init() { 7 | populate() 8 | } 9 | 10 | func populate() { 11 | actionBindings = [:] 12 | UserDefaults.BindingKeys.allCases.forEach { actionKey in 13 | actionBindings[actionKey] = UserDefaults.INSTANCE.stringArray(forKey: actionKey.rawValue) 14 | } 15 | } 16 | 17 | func save() { 18 | actionBindings.forEach { actionKey, keyBindings in 19 | UserDefaults.INSTANCE.set(keyBindings, forKey: actionKey.rawValue) 20 | } 21 | } 22 | 23 | func reset() { 24 | UserDefaults.INSTANCE.resetKeyBindings() 25 | populate() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Vimarily/Vimarily.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct Vimarily: App { 5 | init() { 6 | if UserDefaults.INSTANCE.bool(forKey: .generalKey(.firstRunGone)) == false { 7 | // This will be executed on first run 8 | UserDefaults.INSTANCE.set(true, forKey: .generalKey(.firstRunGone)) 9 | 10 | // Set preferences to their defaults 11 | UserDefaults.INSTANCE.resetGeneralSettings() 12 | UserDefaults.INSTANCE.resetKeyBindings() 13 | } 14 | } 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | GeneralView(viewModel: GeneralViewModel()) 19 | .frame( 20 | minWidth: 512, 21 | idealWidth: 512, 22 | minHeight: 512, 23 | idealHeight: 512, 24 | alignment: .center 25 | ) 26 | .padding() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Vimarily Extension/ConfigurationModel.swift: -------------------------------------------------------------------------------- 1 | protocol ConfigurationModelProtocol { 2 | func getUserSettings() throws -> [String: Any] 3 | } 4 | 5 | import Foundation 6 | import SafariServices 7 | 8 | class ConfigurationModel: ConfigurationModelProtocol { 9 | func getUserSettings() throws -> [String: Any] { 10 | var result: [String: Any] = [:] 11 | var actionBindings: [String: [String]] = [:] 12 | 13 | UserDefaults.BindingKeys.allCases.forEach { actionKey in 14 | actionBindings[actionKey.rawValue] = SafariExtensionHandler.DEFAULTS_INSTANCE.stringArray(forKey: actionKey.rawValue) 15 | } 16 | result["bindings"] = actionBindings 17 | 18 | UserDefaults.GeneralKeys.allCases.forEach { generalKey in 19 | result[generalKey.rawValue] = SafariExtensionHandler.DEFAULTS_INSTANCE.value(forKey: generalKey.rawValue) 20 | } 21 | 22 | return result 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Vimarily Extension/js/SafariExtensionCommunicator.js: -------------------------------------------------------------------------------- 1 | // noinspection ES6ConvertLetToConst 2 | let SafariExtensionCommunicator = function (msgHandler) { 3 | 'use strict'; 4 | const publicAPI = {}; 5 | 6 | // Connect the provided message handler to the received messages. 7 | safari.self.addEventListener('message', msgHandler); 8 | 9 | const sendMessage = function (msgName) { 10 | safari.extension.dispatchMessage(msgName); 11 | }; 12 | 13 | publicAPI.requestSettingsUpdate = function () { 14 | sendMessage('updateSettings'); 15 | }; 16 | publicAPI.requestTabForward = function () { 17 | sendMessage('tabForward'); 18 | }; 19 | publicAPI.requestTabBackward = function () { 20 | sendMessage('tabBackward'); 21 | }; 22 | publicAPI.requestCloseTab = function () { 23 | sendMessage('closeTab'); 24 | }; 25 | 26 | // Return only the public methods. 27 | return publicAPI; 28 | }; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vimarily", 3 | "author": "Marc Donald", 4 | "license": "MIT", 5 | "homepage": "https://github.com/marcdonald/vimarily", 6 | "scripts": { 7 | "lint:staged": "lint-staged --allow-empty", 8 | "husky:prepare": "husky install", 9 | "lint": "eslint .", 10 | "lint:fix": "eslint --fix .", 11 | "format": "prettier --check .", 12 | "format:fix": "prettier --write .", 13 | "test": "jest Vimarily Extension/js/tests" 14 | }, 15 | "devDependencies": { 16 | "eslint": "8.24.0", 17 | "eslint-config-prettier": "8.5.0", 18 | "eslint-config-standard": "17.0.0", 19 | "eslint-plugin-jest": "27.0.4", 20 | "eslint-plugin-prettier": "4.2.1", 21 | "expect.js": "0.3.1", 22 | "husky": "8.0.1", 23 | "jest": "29.1.2", 24 | "jsdom": "20.0.1", 25 | "lint-staged": "13.0.3", 26 | "prettier": "2.7.1" 27 | }, 28 | "dependencies": { 29 | "jest-environment-jsdom": "^29.1.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true, 7 | }, 8 | parserOptions: { 9 | ecmaVersion: 8, 10 | sourceType: 'module', 11 | }, 12 | ignorePatterns: ['node_modules/*'], 13 | extends: ['eslint:recommended', 'plugin:prettier/recommended'], 14 | rules: { 15 | 'no-restricted-imports': ['error'], 16 | 'linebreak-style': ['error', 'unix'], 17 | 'no-undef': ['warn'], 18 | 'no-unused-vars': ['warn'], 19 | 'prettier/prettier': [ 20 | 'error', 21 | {}, 22 | { 23 | usePrettierrc: true, 24 | }, 25 | ], 26 | }, 27 | overrides: [ 28 | { 29 | files: ['**/*.spec.js'], 30 | plugins: ['jest'], 31 | env: { 32 | browser: true, 33 | node: true, 34 | es6: true, 35 | 'jest/globals': true, 36 | }, 37 | }, 38 | { 39 | files: ['**/*.js'], 40 | env: { 41 | browser: true, 42 | node: true, 43 | es6: true, 44 | }, 45 | }, 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Marc Donald. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /VIMARI_LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Phil Crosby, Ilya Sukhar. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | 37 | 38 | - macOS version: 39 | - Safari version: 40 | - Vimarily version: 41 | 42 | **Additional context** 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /Vimarily Extension/js/lib/svim-scripts.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Code in this file is taken from sVim, it has been adjusted to 3 | * work with Vimarily. 4 | * Assumes global variable: settings. 5 | */ 6 | 7 | let animationFrame = null; 8 | 9 | function customScrollBy(x, y) { 10 | // If smooth scroll is off then use regular scroll 11 | if ( 12 | settings == undefined || 13 | settings.smoothScroll === undefined || 14 | !settings.smoothScroll 15 | ) { 16 | window.scrollBy(x, y); 17 | return; 18 | } 19 | window.cancelAnimationFrame(animationFrame); 20 | 21 | // Smooth scroll 22 | let i = 0; 23 | let delta = 0; 24 | 25 | // Ease function 26 | function easeOutExpo(t, b, c, d) { 27 | return c * (-Math.pow(2, (-10 * t) / d) + 1) + b; 28 | } 29 | 30 | // Animate the scroll 31 | function animLoop() { 32 | const toScroll = Math.round( 33 | easeOutExpo(i, 0, y, settings.scrollDuration) - delta 34 | ); 35 | if (toScroll !== 0) { 36 | if (y) { 37 | window.scrollBy(0, toScroll); 38 | } else { 39 | window.scrollBy(toScroll, 0); 40 | } 41 | } 42 | 43 | if (i < settings.scrollDuration) { 44 | animationFrame = window.requestAnimationFrame(animLoop); 45 | } 46 | 47 | delta = easeOutExpo(i, 0, x || y, settings.scrollDuration); 48 | i += 1; 49 | } 50 | 51 | // Start scroll 52 | animLoop(); 53 | } 54 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIconDebug.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "filename": "16.png", 5 | "idiom": "mac", 6 | "scale": "1x", 7 | "size": "16x16" 8 | }, 9 | { 10 | "filename": "32.png", 11 | "idiom": "mac", 12 | "scale": "2x", 13 | "size": "16x16" 14 | }, 15 | { 16 | "filename": "32.png", 17 | "idiom": "mac", 18 | "scale": "1x", 19 | "size": "32x32" 20 | }, 21 | { 22 | "filename": "64.png", 23 | "idiom": "mac", 24 | "scale": "2x", 25 | "size": "32x32" 26 | }, 27 | { 28 | "filename": "128.png", 29 | "idiom": "mac", 30 | "scale": "1x", 31 | "size": "128x128" 32 | }, 33 | { 34 | "filename": "256.png", 35 | "idiom": "mac", 36 | "scale": "2x", 37 | "size": "128x128" 38 | }, 39 | { 40 | "filename": "256.png", 41 | "idiom": "mac", 42 | "scale": "1x", 43 | "size": "256x256" 44 | }, 45 | { 46 | "filename": "512.png", 47 | "idiom": "mac", 48 | "scale": "2x", 49 | "size": "256x256" 50 | }, 51 | { 52 | "filename": "512.png", 53 | "idiom": "mac", 54 | "scale": "1x", 55 | "size": "512x512" 56 | }, 57 | { 58 | "filename": "1024.png", 59 | "idiom": "mac", 60 | "scale": "2x", 61 | "size": "512x512" 62 | } 63 | ], 64 | "info": { 65 | "author": "xcode", 66 | "version": 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Vimarily/Views/KeyBindingsView/KeyBindingsView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | struct KeyBindingsView: View { 5 | @ObservedObject var viewModel: KeyBindingsViewModel 6 | 7 | init(viewModel: KeyBindingsViewModel) { 8 | self.viewModel = viewModel 9 | } 10 | 11 | var body: some View { 12 | Form { 13 | ScrollView { 14 | VStack(alignment: .center) { 15 | ForEach(UserDefaults.BindingKeys.allCases, id: \.self) { key in 16 | TextField(text: Binding( 17 | // TODO temp get and set the first index 18 | get: { viewModel.actionBindings[key]?[0] ?? "" }, 19 | set: { 20 | viewModel.actionBindings[key]?[0] = $0 21 | } 22 | ), label: { Text(key.rawValue) }) 23 | } 24 | .listRowInsets(EdgeInsets()) 25 | } 26 | } 27 | HStack { 28 | Button( 29 | role: .destructive, 30 | action: viewModel.reset, 31 | label: { Text("Reset to Default") } 32 | ) 33 | Button( 34 | action: viewModel.save, 35 | label: { Text("Save") } 36 | ).buttonStyle(.borderedProminent) 37 | } 38 | .frame(maxWidth: .infinity) 39 | ReloadReminder() 40 | } 41 | .padding(EdgeInsets(top: 0, leading: 8, bottom: 6, trailing: 8)) 42 | } 43 | } 44 | 45 | struct KeyBindingsView_Previews: PreviewProvider { 46 | static var previews: some View { 47 | KeyBindingsView(viewModel: KeyBindingsViewModel()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Vimarily/Views/GeneralView/GeneralViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SafariServices.SFSafariApplication 3 | 4 | class GeneralViewModel: ObservableObject { 5 | @Published var extensionStatus: String = "" 6 | 7 | func fetchExtensionStatus() { 8 | if SFSafariServicesAvailable() { 9 | SFSafariExtensionManager.getStateOfSafariExtension( 10 | withIdentifier: (Bundle.main.bundleIdentifier ?? "") + Constants.extensionIdentifierSuffix) { state, error in 11 | print("State", state as Any, "Error", error as Any, state?.isEnabled as Any) 12 | 13 | DispatchQueue.main.async { 14 | // TODO: handle this getting updated in the Safari preferences too. 15 | if let state = state { 16 | if state.isEnabled { 17 | self.extensionStatus = "Enabled" 18 | } else { 19 | self.extensionStatus = "Disabled" 20 | } 21 | } 22 | if let error = error { 23 | NSLog("Error fetching extension status: ", error.localizedDescription) 24 | self.extensionStatus = error.localizedDescription 25 | } 26 | } 27 | } 28 | } else { 29 | NSLog("SFSafariServices not available") 30 | extensionStatus = "Unavailable, Vimarily requires Safari 10 or greater." 31 | } 32 | } 33 | 34 | func openSafariExtensionPreferencesClick() { 35 | SFSafariApplication.showPreferencesForExtension( 36 | withIdentifier: (Bundle.main.bundleIdentifier ?? "") + Constants.extensionIdentifierSuffix) { error in 37 | if error != nil { 38 | NSLog("Error" + (error?.localizedDescription ?? "Unknown")) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Vimarily/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ParentAppBundleIdentifier 6 | $(PARENT_APP_BUNDLE_IDENTIFIER) 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIconFile 12 | 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | ITSAppUsesNonExemptEncryption 26 | 27 | LSApplicationCategoryType 28 | public.app-category.productivity 29 | LSMinimumSystemVersion 30 | $(MACOSX_DEPLOYMENT_TARGET) 31 | NSHumanReadableCopyright 32 | Copyright © 2022 Marc Donald. MIT Licensed. Vimari is Copyright © 2019 Televator, Guy Halford-Thompson, 33 | Simon Egersand, Phil Crosby, Ilya Sukhar, and other contributors. MIT Licensed 34 | 35 | NSMainStoryboardFile 36 | 37 | NSPrincipalClass 38 | NSApplication 39 | NSSupportsAutomaticTermination 40 | 41 | NSSupportsSuddenTermination 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /DEVELOPERS.md: -------------------------------------------------------------------------------- 1 | # Developers 2 | 3 | ## Setup 4 | 5 | 1. Clone the repository 6 | 7 | ### Swift 8 | 9 | 2. Open `Vimarily.xcodeproj` with Xcode. 10 | 3. [Set your signing team](https://help.apple.com/xcode/mac/current/#/dev23aab79b4) for both targets (Vimarily and 11 | Vimarily Extension). 12 | 4. Run the project (+R). 13 | 14 | ### JavaScript 15 | 16 | 5. Run `npm install` in the root directory of the project 17 | 6. Run `npm run husky:prepare` 18 | 19 | You might have to reload the website you had open for the changes to take effect. Also check your configuration file as 20 | it currently does not get upgraded automatically. 21 | 22 | ## Contributing 23 | 24 | If you'd like to contribute to the development of Vimarily you can help us out through several means: 25 | 26 | 1. Create bug reports for issues you encounter, or look trough existing bug reports and try to reproduce their problems. 27 | 2. Try out the latest beta version (if there is one) and report issues back to us. 28 | 3. Contribute ideas, if you'd like something to be added to Vimarily you can create an issue describing exactly what you 29 | have in mind. Together we can help form the idea and get it into Vimarily. 30 | 4. Contribute code, if you find a bug or issue that you think you can help us solve you are more than welcome to do so. 31 | 32 | ### Contributing Code 33 | 34 | If you want to contribute to Vimarily through coding you have to start by selecting an issue to work on. If you'd like 35 | to contribute something new, make an issue first to discuss the idea. 36 | 37 | You can fork the Vimarily source code and make the changes to implement your feature or solve a bug. Once finished you 38 | can create a pull request back into the Vimarily repository where it can be reviewed. 39 | 40 | After a successful review your code will be merged with the main branch and released to Vimarily users in the next 41 | release. Pretty cool! 42 | -------------------------------------------------------------------------------- /Vimarily/Views/GeneralView/GeneralView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Cocoa 3 | import OSLog 4 | 5 | struct GeneralView: View { 6 | @ObservedObject var viewModel: GeneralViewModel 7 | 8 | init(viewModel: GeneralViewModel) { 9 | self.viewModel = viewModel 10 | viewModel.fetchExtensionStatus() 11 | } 12 | 13 | var body: some View { 14 | TabView { 15 | SafariConfigView(viewModel: viewModel) 16 | .tabItem { 17 | Label("General", systemImage: "safari") 18 | } 19 | 20 | SettingsView(viewModel: SettingsViewModel()) 21 | .tabItem { 22 | Label("Settings", systemImage: "puzzlepiece.extension") 23 | } 24 | 25 | KeyBindingsView(viewModel: KeyBindingsViewModel()) 26 | .tabItem { 27 | Label("Key Bindings", systemImage: "keyboard") 28 | } 29 | } 30 | } 31 | 32 | private struct SafariConfigView: View { 33 | @ObservedObject private var viewModel: GeneralViewModel 34 | let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 35 | 36 | init(viewModel: GeneralViewModel) { 37 | self.viewModel = viewModel 38 | } 39 | 40 | var body: some View { 41 | VStack(alignment: .center) { 42 | Image("Logo").resizable().scaledToFit().frame(maxHeight: 256).aspectRatio(contentMode: .fit).padding() 43 | VStack(alignment: .center, spacing: 16) { 44 | Text("Vimarily").font(.title) 45 | Text("Status: " + viewModel.extensionStatus).font(.title2) 46 | Button(action: viewModel.fetchExtensionStatus) { 47 | Text("Refresh Extension Status") 48 | } 49 | Button(action: viewModel.openSafariExtensionPreferencesClick) { 50 | Text("Open Safari Extension Preferences") 51 | } 52 | Text("Version: " + (appVersion ?? "Unknown")).font(.footnote) 53 | } 54 | .padding() 55 | } 56 | .padding() 57 | } 58 | } 59 | } 60 | 61 | struct GeneralView_Previews: PreviewProvider { 62 | static var previews: some View { 63 | GeneralView(viewModel: GeneralViewModel()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "size": "128x128", 5 | "expected-size": "128", 6 | "filename": "128.png", 7 | "folder": "Assets.xcassets/AppIcon.appiconset/", 8 | "idiom": "mac", 9 | "scale": "1x" 10 | }, 11 | { 12 | "size": "256x256", 13 | "expected-size": "256", 14 | "filename": "256.png", 15 | "folder": "Assets.xcassets/AppIcon.appiconset/", 16 | "idiom": "mac", 17 | "scale": "1x" 18 | }, 19 | { 20 | "size": "128x128", 21 | "expected-size": "256", 22 | "filename": "256.png", 23 | "folder": "Assets.xcassets/AppIcon.appiconset/", 24 | "idiom": "mac", 25 | "scale": "2x" 26 | }, 27 | { 28 | "size": "256x256", 29 | "expected-size": "512", 30 | "filename": "512.png", 31 | "folder": "Assets.xcassets/AppIcon.appiconset/", 32 | "idiom": "mac", 33 | "scale": "2x" 34 | }, 35 | { 36 | "size": "32x32", 37 | "expected-size": "32", 38 | "filename": "32.png", 39 | "folder": "Assets.xcassets/AppIcon.appiconset/", 40 | "idiom": "mac", 41 | "scale": "1x" 42 | }, 43 | { 44 | "size": "512x512", 45 | "expected-size": "512", 46 | "filename": "512.png", 47 | "folder": "Assets.xcassets/AppIcon.appiconset/", 48 | "idiom": "mac", 49 | "scale": "1x" 50 | }, 51 | { 52 | "size": "16x16", 53 | "expected-size": "16", 54 | "filename": "16.png", 55 | "folder": "Assets.xcassets/AppIcon.appiconset/", 56 | "idiom": "mac", 57 | "scale": "1x" 58 | }, 59 | { 60 | "size": "16x16", 61 | "expected-size": "32", 62 | "filename": "32.png", 63 | "folder": "Assets.xcassets/AppIcon.appiconset/", 64 | "idiom": "mac", 65 | "scale": "2x" 66 | }, 67 | { 68 | "size": "32x32", 69 | "expected-size": "64", 70 | "filename": "64.png", 71 | "folder": "Assets.xcassets/AppIcon.appiconset/", 72 | "idiom": "mac", 73 | "scale": "2x" 74 | }, 75 | { 76 | "size": "512x512", 77 | "expected-size": "1024", 78 | "filename": "1024.png", 79 | "folder": "Assets.xcassets/AppIcon.appiconset/", 80 | "idiom": "mac", 81 | "scale": "2x" 82 | } 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /docs/safari_12.md: -------------------------------------------------------------------------------- 1 | # Installation notes for Safari version 12 and 13 2 | 3 | A new version of macOS is being released, macOS Mojave, and it's expected to 4 | have a stable release out September or October of 2018. With that new version 5 | comes Safari 12, and a [completely new way of dealing with browser 6 | extensions](https://developer.apple.com/documentation/safariservices/safari_app_extensions). 7 | [We have had some issues](./crowdfunding.md) related to releasing new version 8 | of this extension, but they are now fixed and it's possible to install a version 9 | of Vimarily for Safari 12. 10 | 11 | ## How to install 12 | 13 | **Note: We are currently working on improving this installation flow, as well 14 | as the extension itself. Because Vimarily now has to be released as a _Safari 15 | App Extension_ instead of a _Safari Extension_ it requires some fundamental 16 | changes to the code. We can't guarantee that all the features work in 17 | this version. It's a learning process for us so bare with us.** 18 | 19 | 1. Clone this repo 20 | `sh $ git clone git@github.com:guyht/Vimarily.git ` 21 | 2. Open the Swift project located at `/Vimarily.xcodeproj` in Xcode 22 | 3. Configure the Signing settings for both the `Vimarily` and `extension` targets 23 | to use your information rather than the Vimarily team's ( 24 | see [this SO answer](https://stackoverflow.com/questions/39754341/none-of-your-accounts-are-a-member-code-signing-errors-after-upgrading-to-xcode) 25 | for more information). 26 | 4. If you want different settings than the default ones, make your changes in 27 | `settings.js`. You can always come back later to change the settings again. 28 | 5. Build and run the project (`⌘ + r`) 29 | 6. An empty GUI box will show up - ignore it (we'll fix it later). Go to 30 | Safari and open up settings (`⌘ + ,`). Go to _Extensions_ and you should 31 | see **Vimarily** in the list of extensions. Enable it. 32 | 7. You may now press stop in Xcode and close Xcode. The extension will be 33 | available even if you restart Safari. 34 | 35 | This was tested on High Sierra with Safari Technology Preview (version 12). Let 36 | us know if something is not working for you. 37 | -------------------------------------------------------------------------------- /Vimarily/Views/SettingsView/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SafariServices.SFSafariApplication 3 | 4 | class SettingsViewModel: ObservableObject { 5 | @Published var excludedUrls: [String]! 6 | @Published var linkHintCharacters: String! 7 | @Published var detectByCursorStyle: Bool! 8 | @Published var scrollSize: Int! 9 | @Published var scrollDuration: Int! 10 | @Published var smoothScroll: Bool! 11 | @Published var openTabUrl: String! 12 | @Published var modifier: String! 13 | @Published var transparentBindings: Bool! 14 | 15 | init() { 16 | populate() 17 | } 18 | 19 | public func populate() { 20 | excludedUrls = UserDefaults.INSTANCE.stringArray(forKey: .generalKey(.excludedUrls)) ?? [] 21 | linkHintCharacters = UserDefaults.INSTANCE.string(forKey: .generalKey(.linkHintCharacters)) ?? "" 22 | detectByCursorStyle = UserDefaults.INSTANCE.bool(forKey: .generalKey(.detectByCursorStyle)) 23 | scrollSize = UserDefaults.INSTANCE.integer(forKey: .generalKey(.scrollSize)) ?? 0 24 | scrollDuration = UserDefaults.INSTANCE.integer(forKey: .generalKey(.scrollDuration)) ?? 0 25 | smoothScroll = UserDefaults.INSTANCE.bool(forKey: .generalKey(.smoothScroll)) 26 | openTabUrl = UserDefaults.INSTANCE.string(forKey: .generalKey(.openTabUrl)) ?? "" 27 | modifier = UserDefaults.INSTANCE.string(forKey: .generalKey(.modifier)) ?? "" 28 | transparentBindings = UserDefaults.INSTANCE.bool(forKey: .generalKey(.transparentBindings)) 29 | } 30 | 31 | public func save() { 32 | UserDefaults.INSTANCE.set(excludedUrls, forKey: .generalKey(.excludedUrls)) 33 | UserDefaults.INSTANCE.set(linkHintCharacters, forKey: .generalKey(.linkHintCharacters)) 34 | UserDefaults.INSTANCE.set(detectByCursorStyle, forKey: .generalKey(.detectByCursorStyle)) 35 | UserDefaults.INSTANCE.set(scrollSize, forKey: .generalKey(.scrollSize)) 36 | UserDefaults.INSTANCE.set(scrollDuration, forKey: .generalKey(.scrollDuration)) 37 | UserDefaults.INSTANCE.set(smoothScroll, forKey: .generalKey(.smoothScroll)) 38 | UserDefaults.INSTANCE.set(openTabUrl, forKey: .generalKey(.openTabUrl)) 39 | UserDefaults.INSTANCE.set(modifier, forKey: .generalKey(.modifier)) 40 | UserDefaults.INSTANCE.set(transparentBindings, forKey: .generalKey(.transparentBindings)) 41 | } 42 | 43 | public func reset() { 44 | UserDefaults.INSTANCE.resetGeneralSettings() 45 | populate() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Vimarily Extension/Base.lproj/SafariExtensionViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Vimarily Extension/js/keyboard-utils.js: -------------------------------------------------------------------------------- 1 | const keyCodes = { 2 | ESC: 27, 3 | backspace: 8, 4 | deleteKey: 46, 5 | enter: 13, 6 | space: 32, 7 | shiftKey: 16, 8 | f1: 112, 9 | f12: 123, 10 | }; 11 | const keyNames = { 37: 'left', 38: 'up', 39: 'right', 40: 'down' }; 12 | 13 | // This is a mapping of the incorrect keyIdentifiers generated by Webkit on Windows during keydown events to 14 | // the correct identifiers, which are correctly generated on Mac. We require this mapping to properly handle 15 | // these keys on Windows. See https://bugs.webkit.org/show_bug.cgi?id=19906 for more details. 16 | const keyIdentifierCorrectionMap = { 17 | 'U+00C0': ['U+0060', 'U+007E'], // `~ 18 | 'U+00BD': ['U+002D', 'U+005F'], // -_ 19 | 'U+00BB': ['U+003D', 'U+002B'], // =+ 20 | 'U+00DB': ['U+005B', 'U+007B'], // [{ 21 | 'U+00DD': ['U+005D', 'U+007D'], // ]} 22 | 'U+00DC': ['U+005C', 'U+007C'], // \| 23 | 'U+00BA': ['U+003B', 'U+003A'], // ;: 24 | 'U+00DE': ['U+0027', 'U+0022'], // '" 25 | 'U+00BC': ['U+002C', 'U+003C'], // ,< 26 | 'U+00BE': ['U+002E', 'U+003E'], // .> 27 | 'U+00BF': ['U+002F', 'U+003F'], // /? 28 | }; 29 | 30 | let platform; 31 | if (navigator.userAgent.indexOf('Mac') !== -1) platform = 'Mac'; 32 | else if (navigator.userAgent.indexOf('Linux') !== -1) platform = 'Linux'; 33 | else platform = 'Windows'; 34 | 35 | function getKeyChar(event) { 36 | // Not a letter 37 | if (event.keyIdentifier.slice(0, 2) !== 'U+') { 38 | // Named key 39 | if (keyNames[event.keyCode]) { 40 | return keyNames[event.keyCode]; 41 | } 42 | // F-key 43 | if (event.keyCode >= keyCodes.f1 && event.keyCode <= keyCodes.f12) { 44 | return 'f' + (1 + event.keyCode - keyCodes.f1); 45 | } 46 | return ''; 47 | } 48 | let keyIdentifier = event.keyIdentifier; 49 | // On Windows, the keyIdentifiers for non-letter keys are incorrect. See 50 | // https://bugs.webkit.org/show_bug.cgi?id=19906 for more details. 51 | if ( 52 | (platform === 'Windows' || platform === 'Linux') && 53 | keyIdentifierCorrectionMap[keyIdentifier] 54 | ) { 55 | let correctedIdentifiers = keyIdentifierCorrectionMap[keyIdentifier]; 56 | keyIdentifier = event.shiftKey 57 | ? correctedIdentifiers[0] 58 | : correctedIdentifiers[1]; 59 | } 60 | const unicodeKeyInHex = '0x' + keyIdentifier.substring(2); 61 | return String.fromCharCode(parseInt(unicodeKeyInHex)).toLowerCase(); 62 | } 63 | 64 | function isPrimaryModifierKey(event) { 65 | if (platform === 'Mac') return event.metaKey; 66 | else return event.ctrlKey; 67 | } 68 | 69 | function isEscape(event) { 70 | return ( 71 | event.keyCode === keyCodes.ESC || 72 | (event.ctrlKey && getKeyChar(event) === '[') 73 | ); // c-[ is mapped to ESC in Vim by default. 74 | } 75 | -------------------------------------------------------------------------------- /Vimarily Extension/css/injected.css: -------------------------------------------------------------------------------- 1 | .vimiumReset { 2 | background: none; 3 | border: none; 4 | bottom: auto; 5 | box-shadow: none; 6 | color: black; 7 | cursor: auto; 8 | display: inline; 9 | float: none; 10 | font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 11 | font-size: inherit; 12 | font-style: normal; 13 | font-variant: normal; 14 | font-weight: normal; 15 | height: auto; 16 | left: auto; 17 | letter-spacing: 0; 18 | line-height: 100%; 19 | margin: 0; 20 | max-height: none; 21 | max-width: none; 22 | min-height: 0; 23 | min-width: 0; 24 | opacity: 1; 25 | padding: 0; 26 | position: static; 27 | right: auto; 28 | text-align: left; 29 | text-decoration: none; 30 | text-indent: 0; 31 | text-shadow: none; 32 | text-transform: none; 33 | top: auto; 34 | vertical-align: baseline; 35 | white-space: normal; 36 | width: auto; 37 | z-index: 2147483647; /* Maximum value in Safari */ 38 | } 39 | 40 | div.internalVimiumHintMarker { 41 | position: absolute !important; 42 | display: block; 43 | top: -1px; 44 | left: -1px; 45 | white-space: nowrap !important; 46 | overflow: hidden !important; 47 | font-size: 11px !important; 48 | padding: 2px 3px !important; 49 | background-color: #feda31 !important; 50 | border: 0 !important; 51 | border-radius: 2px !important; 52 | box-shadow: inset 0 -2px 0 #b39922 !important; 53 | } 54 | 55 | div.internalVimiumHintMarker span { 56 | color: #4a400e; 57 | font-family: Helvetica, Arial, sans-serif; 58 | font-weight: bold; 59 | } 60 | 61 | div.internalVimiumHintMarker > .matchingCharacter { 62 | color: #dcbc2a; 63 | } 64 | 65 | .vimiumHUD, 66 | .vimiumHUD * { 67 | line-height: 100%; 68 | font-size: 11px; 69 | font-weight: normal; 70 | } 71 | 72 | .vimiumHUD { 73 | position: fixed; 74 | bottom: 0px; 75 | left: 40px; 76 | color: black; 77 | max-width: 400px; 78 | min-width: 150px; 79 | text-align: center; 80 | background-color: #ebebeb; 81 | padding: 3px 3px 5px 3px; 82 | border: 1px solid #b3b3b3; 83 | border-bottom: none; 84 | border-radius: 4px 4px 0 0; 85 | font-family: Lucida Grande, Arial, Sans; 86 | /* One less than vimium's hint markers, so link hints can be shown e.g. for the panel's close button. */ 87 | z-index: 99999998; 88 | text-shadow: 0px 1px 2px #fff; 89 | line-height: 1; 90 | opacity: 0; 91 | } 92 | 93 | .vimiumHUD a, 94 | .vimiumHUD a:hover { 95 | background: transparent; 96 | color: blue; 97 | text-decoration: underline; 98 | } 99 | 100 | .vimiumHUD a.close-button { 101 | float: right; 102 | font-family: courier new; 103 | font-weight: bold; 104 | color: #9c9a9a; 105 | text-decoration: none; 106 | padding-left: 10px; 107 | margin-top: -1px; 108 | font-size: 14px; 109 | } 110 | 111 | .vimiumHUD a.close-button:hover { 112 | color: #333333; 113 | cursor: default; 114 | -webkit-user-select: none; 115 | } 116 | -------------------------------------------------------------------------------- /Vimarily/Views/SettingsView/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import Combine 4 | 5 | struct SettingsView: View { 6 | @ObservedObject private var viewModel: SettingsViewModel 7 | @State var isScrollSizeValid = true 8 | @State var isScrollDurationValid = true 9 | 10 | init(viewModel: SettingsViewModel) { 11 | self.viewModel = viewModel 12 | } 13 | 14 | var body: some View { 15 | Form { 16 | ScrollView { 17 | VStack(alignment: .leading, spacing: 16) { 18 | Section(header: Text("Scroll").font(.title)) { 19 | TextField("Scroll Size", text: Binding( 20 | get: { String(viewModel.scrollSize) }, 21 | set: { 22 | guard let parsedValue = Int($0) else { 23 | self.isScrollSizeValid = false 24 | return 25 | } 26 | self.isScrollSizeValid = true 27 | viewModel.scrollSize = parsedValue 28 | } 29 | )) 30 | ErrorText(text: "Invalid Scroll Size - Must be a Number", condition: !isScrollSizeValid) 31 | 32 | TextField("Scroll Duration", text: Binding( 33 | get: { String(viewModel.scrollDuration) }, 34 | set: { 35 | guard let parsedValue = Int($0) else { 36 | self.isScrollDurationValid = false 37 | return 38 | } 39 | self.isScrollDurationValid = true 40 | viewModel.scrollDuration = parsedValue 41 | } 42 | )) 43 | ErrorText(text: "Invalid Scroll Duration - Must be a Number", condition: !isScrollDurationValid) 44 | 45 | Toggle(isOn: $viewModel.smoothScroll, label: { Text("Smooth Scroll") }) 46 | } 47 | 48 | Divider() 49 | 50 | Section(header: Text("Keys").font(.title)) { 51 | // TODO key entry 52 | TextField("Modifier", text: $viewModel.modifier, prompt: Text("No Modifier Key Set")) 53 | TextField("Link Hint Characters", text: $viewModel.linkHintCharacters) 54 | Toggle("Transparent Bindings", isOn: $viewModel.transparentBindings) 55 | } 56 | 57 | Divider() 58 | 59 | Section(header: Text("Miscellaneous").font(.title)) { 60 | // TODO multi-entry 61 | TextField("Excluded URLs (Comma Separated)", text: $viewModel.excludedUrls[0], prompt: Text("No Excluded URLs")) 62 | TextField("Open Tab URL", text: $viewModel.openTabUrl) 63 | Toggle("Detect by Cursor Style", isOn: $viewModel.detectByCursorStyle) 64 | } 65 | } 66 | } 67 | HStack { 68 | Button( 69 | role: .destructive, 70 | action: viewModel.reset, 71 | label: { Text("Reset to Default") } 72 | ) 73 | Button( 74 | action: viewModel.save, 75 | label: { Text("Save") } 76 | ).buttonStyle(.borderedProminent) 77 | } 78 | .frame(maxWidth: .infinity) 79 | ReloadReminder() 80 | } 81 | .padding(EdgeInsets(top: 0, leading: 8, bottom: 6, trailing: 8)) 82 | } 83 | } 84 | 85 | struct SettingsView_Previews: PreviewProvider { 86 | static var previews: some View { 87 | SettingsView(viewModel: SettingsViewModel()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Vimarily Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ParentAppBundleIdentifier 6 | $(PARENT_APP_BUNDLE_IDENTIFIER) 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleDisplayName 10 | Vimarily 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSExtension 28 | 29 | NSExtensionMainNibFile 30 | SafariExtensionViewController 31 | NSExtensionPointIdentifier 32 | com.apple.Safari.extension 33 | NSExtensionPrincipalClass 34 | $(PRODUCT_MODULE_NAME).SafariExtensionHandler 35 | SFSafariContentScript 36 | 37 | 38 | Script 39 | svim-scripts.js 40 | 41 | 42 | Script 43 | SafariExtensionCommunicator.js 44 | 45 | 46 | Script 47 | keyboard-utils.js 48 | 49 | 50 | Script 51 | vimium-scripts.js 52 | 53 | 54 | Script 55 | link-hints.js 56 | 57 | 58 | Script 59 | mousetrap.js 60 | 61 | 62 | Script 63 | injected.js 64 | 65 | 66 | SFSafariStyleSheet 67 | 68 | 69 | Style Sheet 70 | injected.css 71 | 72 | 73 | SFSafariToolbarItem 74 | 75 | Action 76 | Command 77 | Identifier 78 | Button 79 | Image 80 | ToolbarItemIcon.pdf 81 | Label 82 | Vimarily Settings 83 | 84 | SFSafariWebsiteAccess 85 | 86 | Level 87 | All 88 | 89 | 90 | NSHumanReadableCopyright 91 | 92 | NSHumanReadableDescription 93 | Adds Vim keybindings to Safari. Vimarily is a fork of Vimari which is a port of Vimium. 94 | 95 | 96 | -------------------------------------------------------------------------------- /Vimarily Extension/js/lib/vimium-scripts.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Code in this file is taken directly from vimium 3 | */ 4 | 5 | /* 6 | * A heads-up-display (HUD) for showing Vimium page operations. 7 | * Note: you cannot interact with the HUD until document.body is available. 8 | */ 9 | HUD = { 10 | _tweenId: -1, 11 | _displayElement: null, 12 | _upgradeNotificationElement: null, 13 | 14 | showForDuration: function (text, duration) { 15 | HUD.show(text); 16 | HUD._showForDurationTimerId = setTimeout(function () { 17 | HUD.hide(); 18 | }, duration); 19 | }, 20 | 21 | show: function (text) { 22 | clearTimeout(HUD._showForDurationTimerId); 23 | HUD.displayElement().innerHTML = text; 24 | clearInterval(HUD._tweenId); 25 | HUD._tweenId = Tween.fade(HUD.displayElement(), 1.0, 150); 26 | HUD.displayElement().style.display = ''; 27 | }, 28 | 29 | onUpdateLinkClicked: function (event) { 30 | HUD.hideUpgradeNotification(); 31 | chrome.extension.sendRequest({ handler: 'upgradeNotificationClosed' }); 32 | }, 33 | 34 | hideUpgradeNotification: function (clickEvent) { 35 | Tween.fade(HUD.upgradeNotificationElement(), 0, 150, function () { 36 | HUD.upgradeNotificationElement().style.display = 'none'; 37 | }); 38 | }, 39 | 40 | updatePageZoomLevel: function (pageZoomLevel) { 41 | // Since the chrome HUD does not scale with the page's zoom level, neither will this HUD. 42 | const inverseZoomLevel = (100.0 / pageZoomLevel) * 100; 43 | if (HUD._displayElement) 44 | HUD.displayElement().style.zoom = inverseZoomLevel + '%'; 45 | if (HUD._upgradeNotificationElement) 46 | HUD.upgradeNotificationElement().style.zoom = inverseZoomLevel + '%'; 47 | }, 48 | 49 | /* 50 | * Retrieves the HUD HTML element. 51 | */ 52 | displayElement: function () { 53 | if (!HUD._displayElement) { 54 | HUD._displayElement = HUD.createHudElement(); 55 | HUD.updatePageZoomLevel(currentZoomLevel); 56 | } 57 | return HUD._displayElement; 58 | }, 59 | 60 | createHudElement: function () { 61 | const element = document.createElement('div'); 62 | element.className = 'vimiumHUD'; 63 | document.body.appendChild(element); 64 | return element; 65 | }, 66 | 67 | hide: function () { 68 | clearInterval(HUD._tweenId); 69 | HUD._tweenId = Tween.fade(HUD.displayElement(), 0, 150, function () { 70 | HUD.displayElement().style.display = 'none'; 71 | }); 72 | }, 73 | 74 | isReady: function () { 75 | return document.body != null; 76 | }, 77 | }; 78 | 79 | Tween = { 80 | /* 81 | * Fades an element's alpha. Returns a timer ID which can be used to stop the tween via clearInterval. 82 | */ 83 | fade: function (element, toAlpha, duration, onComplete) { 84 | const state = {}; 85 | state.duration = duration; 86 | state.startTime = new Date().getTime(); 87 | state.from = parseInt(element.style.opacity) || 0; 88 | state.to = toAlpha; 89 | state.onUpdate = function (value) { 90 | element.style.opacity = value; 91 | if (value == state.to && onComplete) onComplete(); 92 | }; 93 | state.timerId = setInterval(function () { 94 | Tween.performTweenStep(state); 95 | }, 50); 96 | return state.timerId; 97 | }, 98 | 99 | performTweenStep: function (state) { 100 | const elapsed = new Date().getTime() - state.startTime; 101 | if (elapsed >= state.duration) { 102 | clearInterval(state.timerId); 103 | state.onUpdate(state.to); 104 | } else { 105 | const value = 106 | (elapsed / state.duration) * (state.to - state.from) + state.from; 107 | state.onUpdate(value); 108 | } 109 | }, 110 | }; 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Vimarily 4 | 5 | _Keyboard Shortcuts extension for Safari_ 6 | 7 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/marcdonald/vimarily) 8 | ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/marcdonald/vimarily?include_prereleases&label=pre%20release) 9 | 10 | Vimarily (pronounced like 'primarily') is a Safari extension that provides vim style keyboard based navigation. 11 | This lets you control Safari from your keyboard instead of having to use your mouse to open links, scroll, etc. 12 | 13 | Vimarily is a fork of [Vimari](https://github.com/televator-apps/vimari) which is heavily based 14 | on [vimium](https://github.com/philc/vimium), a 15 | Chrome extension that provides much more extensive features. 16 | 17 | 18 | 19 | ## Installation 20 | 21 | ### Safari 12 and above (macOS Mojave or above) 22 | 23 | #### Prebuilt binaries 24 | 25 | 1. Download the [latest version](https://github.com/marcdonald/Vimarily/releases/latest) of Vimarily 26 | 2. Unzip it 27 | 3. Move it to your `/Applications` folder 28 | 4. Launch Vimarily.app 29 | 5. Click "Open in Safari Extensions Preferences...", Safari's Extension Preferences should open 30 | 6. Make sure that the checkbox for the Vimarily extension is ticked 31 | 7. Go back to Vimarily.app and press the reload button to check the status of the app. If it says "Enabled" then it is 32 | ready. 33 | 8. You may need to relaunch Safari for the extension to work 34 | 35 | ## Usage 36 | 37 | **Modifier** - Modifier key to hold down with your action key. If 38 | you leave it blank you don't need to hold down anything (default 39 | setting). 40 | 41 | **Excluded URLs** - Comma separated list of website URLs you don't want 42 | to use Vimarily with. To exclude GitHub for example, provide the value 43 | `github.com` or `http://github.com`. It's smart and should handle all 44 | possible domain cases. 45 | 46 | **Link Hint Characters** - Allowed characters to be used when generating 47 | link shortcuts. 48 | 49 | **Extra detection by cursor style** - Detect clickable links by looking 50 | for HTML elements having cursor style set to "pointer". 51 | 52 | **Scroll Size** - How much each scroll will move on the page. 53 | 54 | **Smooth Scroll** - Scroll smoothly through the page. 55 | 56 | **Normal vs Insert mode** - Isolate website keybindings from the 57 | Vimarily keybindings. In normal mode you can use the Vimarily keybindings 58 | while in insert mode you can use the websites own keybindings. 59 | 60 | **Transparent Bindings** - Full keybinding isolation might not 61 | be your style, instead the transparent bindings setting (when enabled) 62 | allows you to use all **non-Vimarily-bound** keys to interact with the web 63 | page as if you were in insert mode. 64 | 65 | ### Tips & Tricks 66 | 67 | Vimarily is built as a Safari Extension, this poses some limits on what is possible through the extension. However 68 | default Safari shortcuts can help you keep your hands at the keyboard. Some helpful ones are listed here: 69 | 70 | - **Focus URL Bar** l - This is a feature not available in Vimarily, it is also helpful where 71 | extensions are not loaded (for example on `topsites://`). By focusing the URL Bar you can go to a website where the 72 | extension is loaded. 73 | 74 | - **Reader mode** R - Currently Vimarily does not support entering Reader mode (due 75 | to API limitations), also navigation inside reader mode (for example using j or k) is not 76 | supported. 77 | 78 | - **Re-open last closed tab** T - Allows you to reopen a recently closed tab. 79 | 80 | ## Licenses 81 | 82 | ### Vimarily 83 | 84 | Copyright (C) 2022 Marc Donald. See [LICENSE](LICENSE) for details. 85 | 86 | ### Vimari 87 | 88 | Copyright (c) 2010 Phil Crosby, Ilya Sukhar. See [VIMARI_LICENSE](VIMARI_LICENSE) for details. 89 | -------------------------------------------------------------------------------- /Vimarily/Models/UserDefaults.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension UserDefaults { 5 | static let sharedAppGroup = "group." + (Bundle.main.object(forInfoDictionaryKey: "ParentAppBundleIdentifier") as? String ?? "") 6 | static let INSTANCE = UserDefaults(suiteName: UserDefaults.sharedAppGroup)! 7 | 8 | enum GeneralKeys: String, CaseIterable { 9 | case firstRunGone = "firstRunGone" 10 | case excludedUrls = "excludedUrls" 11 | case linkHintCharacters = "linkHintCharacters" 12 | case detectByCursorStyle = "detectByCursorStyle" 13 | case scrollSize = "scrollSize" 14 | case scrollDuration = "scrollDuration" 15 | case smoothScroll = "smoothScroll" 16 | case openTabUrl = "openTabUrl" 17 | case modifier = "modifier" 18 | case transparentBindings = "transparentBindings" 19 | } 20 | 21 | enum BindingKeys: String, CaseIterable { 22 | // Bindings 23 | case hintToggleBinding = "hintToggle" 24 | case newTabHintToggleBinding = "newTabHintToggle" 25 | case scrollUpBinding = "scrollUp" 26 | case scrollDownBinding = "scrollDown" 27 | case scrollLeftBinding = "scrollLeft" 28 | case scrollRightBinding = "scrollRight" 29 | case scrollUpHalfPageBinding = "scrollUpHalfPage" 30 | case scrollDownHalfPageBinding = "scrollDownHalfPage" 31 | case goToPageTopBinding = "goToPageTop" 32 | case goToPageBottomBinding = "goToPageBottom" 33 | case goToFirstInputBinding = "goToFirstInput" 34 | case goBackBinding = "goBack" 35 | case goForwardBinding = "goForward" 36 | case reloadBinding = "reload" 37 | case tabForwardBinding = "tabForward" 38 | case tabBackBinding = "tabBack" 39 | case closeTabBinding = "closeTab" 40 | case openTabBinding = "openTab" 41 | case duplicateTabBinding = "duplicateTab" 42 | case copyUrlBinding = "copyUrl" 43 | } 44 | 45 | enum Key { 46 | case generalKey(GeneralKeys) 47 | case bindingKey(BindingKeys) 48 | 49 | var rawValue: String { 50 | switch self { 51 | case .generalKey(let generalKey): 52 | return generalKey.rawValue 53 | case .bindingKey(let bindingKey): 54 | return bindingKey.rawValue 55 | } 56 | } 57 | } 58 | 59 | func set(_ value: T, forKey key: Key) { 60 | set(value, forKey: key.rawValue) 61 | } 62 | 63 | func bool(forKey key: Key) -> Bool { 64 | bool(forKey: key.rawValue) 65 | } 66 | 67 | func string(forKey key: Key) -> String? { 68 | string(forKey: key.rawValue) 69 | } 70 | 71 | func integer(forKey key: Key) -> Int? { 72 | integer(forKey: key.rawValue) 73 | } 74 | 75 | func url(forKey key: Key) -> URL? { 76 | url(forKey: key.rawValue) 77 | } 78 | 79 | func stringArray(forKey key: Key) -> [String]? { 80 | stringArray(forKey: key.rawValue) 81 | } 82 | 83 | func tempGetSingleBinding(forKey key: BindingKeys) -> String? { 84 | stringArray(forKey: key.rawValue)?[0] 85 | } 86 | 87 | func resetGeneralSettings() { 88 | set([""], forKey: .generalKey(.excludedUrls)) 89 | set("asdfghjklzxcvbnm", forKey: .generalKey(.linkHintCharacters)) 90 | set(false, forKey: .generalKey(.detectByCursorStyle)) 91 | set(150, forKey: .generalKey(.scrollSize)) 92 | set(25, forKey: .generalKey(.scrollDuration)) 93 | set(true, forKey: .generalKey(.smoothScroll)) 94 | set("https://google.com", forKey: .generalKey(.openTabUrl)) 95 | set("", forKey: .generalKey(.modifier)) 96 | set(true, forKey: .generalKey(.transparentBindings)) 97 | } 98 | 99 | func resetKeyBindings() { 100 | set(["f"], forKey: .bindingKey(.hintToggleBinding)) 101 | set(["shift+f"], forKey: .bindingKey(.newTabHintToggleBinding)) 102 | set(["k"], forKey: .bindingKey(.scrollUpBinding)) 103 | set(["j"], forKey: .bindingKey(.scrollDownBinding)) 104 | set(["h"], forKey: .bindingKey(.scrollLeftBinding)) 105 | set(["l"], forKey: .bindingKey(.scrollRightBinding)) 106 | set(["u"], forKey: .bindingKey(.scrollUpHalfPageBinding)) 107 | set(["d"], forKey: .bindingKey(.scrollDownHalfPageBinding)) 108 | set(["g g"], forKey: .bindingKey(.goToPageTopBinding)) 109 | set(["shift+g"], forKey: .bindingKey(.goToPageBottomBinding)) 110 | set(["g i"], forKey: .bindingKey(.goToFirstInputBinding)) 111 | set(["shift+j"], forKey: .bindingKey(.goBackBinding)) 112 | set(["shift+k"], forKey: .bindingKey(.goForwardBinding)) 113 | set(["r"], forKey: .bindingKey(.reloadBinding)) 114 | set(["w"], forKey: .bindingKey(.tabBackBinding)) 115 | set(["q"], forKey: .bindingKey(.tabForwardBinding)) 116 | set(["x"], forKey: .bindingKey(.closeTabBinding)) 117 | set(["t"], forKey: .bindingKey(.openTabBinding)) 118 | set(["y t"], forKey: .bindingKey(.duplicateTabBinding)) 119 | set(["y y"], forKey: .bindingKey(.copyUrlBinding)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/vimarily.spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('expect.js'); 2 | 3 | describe('isExcludedUrl', () => { 4 | const isExcludedUrl = window.isExcludedUrl; 5 | 6 | it('returns true on same exact domain', () => { 7 | const excludedUrl = ['specific-domain.com']; 8 | expect(isExcludedUrl(excludedUrl, 'specific-domain.com')).to.be.ok(); 9 | }); 10 | 11 | it('returns true on duplicate domains', () => { 12 | // TODO temporarily all in index 0 13 | const excludedUrls = ['specific-domain.com,specific-domain.com']; 14 | const currentUrl = 'specific-domain.com'; 15 | expect(isExcludedUrl(excludedUrls, currentUrl)).to.be.ok(); 16 | }); 17 | 18 | it('returns true if any domain match', () => { 19 | // TODO temporarily all in index 0 20 | const excludedUrls = ['different-domain.com,specific-domain.com']; 21 | const currentUrl = 'specific-domain.com'; 22 | expect(isExcludedUrl(excludedUrls, currentUrl)).to.be.ok(); 23 | }); 24 | 25 | it('returns true on comma separated domains', () => { 26 | // TODO temporarily all in index 0 27 | const excludedUrls = ['specific-domain.com,different-domain.com']; 28 | const currentUrl = 'specific-domain.com'; 29 | expect(isExcludedUrl(excludedUrls, currentUrl)).to.be.ok(); 30 | }); 31 | 32 | it('returns false on different domain', () => { 33 | const excludedUrl = ['www.different-domain.com']; 34 | const currentUrl = 'specific-domain.com'; 35 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.not.be.ok(); 36 | }); 37 | 38 | it('returns false if no domains match', () => { 39 | // TODO temporarily all in index 0 40 | const excludedUrls = [ 41 | 'www.different-domain.com,www.different-domain-2.com', 42 | ]; 43 | const currentUrl = 'specific-domain.com'; 44 | expect(isExcludedUrl(excludedUrls, currentUrl)).to.not.be.ok(); 45 | }); 46 | 47 | it('returns false on space separated domains', () => { 48 | // TODO temporarily all in index 0 49 | const excludedUrls = ['specific-domain.com different-domain.com']; 50 | const currentUrl = 'specific-domain.com'; 51 | expect(isExcludedUrl(excludedUrls, currentUrl)).to.not.be.ok(); 52 | }); 53 | 54 | it('returns true on string added in front of current URL', () => { 55 | const excludedUrl = ['specific-domain.com']; 56 | const currentUrl = 'http://specific-domain.com'; 57 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 58 | }); 59 | 60 | it('returns true on string appended to current URL', () => { 61 | const excludedUrl = ['specific-domain.com']; 62 | const currentUrl = 'specific-domain.com/arbitrary-string'; 63 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 64 | }); 65 | 66 | it('returns true on string added on both sides of current URL', () => { 67 | const excludedUrl = ['specific-domain.com']; 68 | const currentUrl = 'http://specific-domain.com/arbitrary-string'; 69 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 70 | }); 71 | 72 | it('returns true if current URL is less specific than excluded domain', () => { 73 | let excludedUrl = ['http://specific-domain.com']; 74 | let currentUrl = 'specific-domain.com'; 75 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 76 | 77 | excludedUrl = ['http://www.specific-domain.com']; 78 | currentUrl = 'specific-domain.com'; 79 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 80 | }); 81 | 82 | it('returns true if current URL with appended string is less specific than excluded domain', () => { 83 | const excludedUrl = ['http://specific-domain.com']; 84 | const currentUrl = 'specific-domain.com/arbitrary-string'; 85 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 86 | }); 87 | 88 | it("returns true even though cases doesn't match", () => { 89 | let excludedUrl = ['SPECIFIC-DOMAIN.com']; 90 | let currentUrl = 'specific-domain.com'; 91 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 92 | 93 | excludedUrl = ['specific-domain.com']; 94 | currentUrl = 'SPECIFIC-DOMAIN.com'; 95 | expect(isExcludedUrl(excludedUrl, currentUrl)).to.be.ok(); 96 | }); 97 | }); 98 | 99 | describe('stripProtocolAndWww', () => { 100 | const stripProtocolAndWww = window.stripProtocolAndWww; 101 | 102 | it('strips http', () => { 103 | const url = 'http://specific-domain.com'; 104 | expect(stripProtocolAndWww(url)).to.equal('specific-domain.com'); 105 | }); 106 | 107 | it('strips https', () => { 108 | const url = 'https://specific-domain.com'; 109 | expect(stripProtocolAndWww(url)).to.equal('specific-domain.com'); 110 | }); 111 | 112 | it('strips www', () => { 113 | const url = 'www.specific-domain.com'; 114 | expect(stripProtocolAndWww(url)).to.equal('specific-domain.com'); 115 | }); 116 | 117 | it('strips http and www', () => { 118 | const url = 'http://www.specific-domain.com'; 119 | expect(stripProtocolAndWww(url)).to.equal('specific-domain.com'); 120 | }); 121 | 122 | it('strips https and www', () => { 123 | const url = 'https://www.specific-domain.com'; 124 | expect(stripProtocolAndWww(url)).to.equal('specific-domain.com'); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | logo 6 | Created with Sketch. 7 | 8 | 42 | 43 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/Logo.imageset/logo-1.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | logo 6 | Created with Sketch. 7 | 8 | 42 | 43 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/Logo.imageset/logo-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | logo 6 | Created with Sketch. 7 | 8 | 42 | 43 | -------------------------------------------------------------------------------- /Vimarily/Assets.xcassets/Logo.imageset/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | logo 6 | Created with Sketch. 7 | 8 | 42 | 43 | -------------------------------------------------------------------------------- /Vimarily Extension/SafariExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | import SafariServices 2 | 3 | enum ActionType: String { 4 | case openLinkInTab 5 | case tabForward 6 | case tabBackward 7 | case closeTab 8 | case updateSettings 9 | } 10 | 11 | enum InputAction: String { 12 | case exampleInputAction 13 | } 14 | 15 | enum TabDirection: String { 16 | case forward 17 | case backward 18 | } 19 | 20 | class SafariExtensionHandler: SFSafariExtensionHandler { 21 | static let DEFAULTS_INSTANCE: UserDefaults = UserDefaults(suiteName: UserDefaults.sharedAppGroup)! 22 | 23 | let configuration: ConfigurationModelProtocol = ConfigurationModel() 24 | 25 | // MARK: Overrides 26 | 27 | // This method handles messages from the Vimarily App (located /Vimarily in the repository) 28 | override func messageReceivedFromContainingApp(withName messageName: String, userInfo: [String: Any]? = nil) { 29 | do { 30 | switch InputAction(rawValue: messageName) { 31 | case .exampleInputAction: 32 | NSLog("Example Input Action") 33 | case .none: 34 | NSLog("Input not supported " + messageName) 35 | } 36 | } catch { 37 | NSLog("Message Received " + error.localizedDescription) 38 | } 39 | } 40 | 41 | // This method handles messages from the extension (in the browser page) 42 | override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String: Any]?) { 43 | NSLog("Received message: \(messageName)") 44 | switch ActionType(rawValue: messageName) { 45 | case .openLinkInTab: 46 | if let urlString = userInfo?["url"] as? String { 47 | if let url = URL(string: urlString) { 48 | openInNewTab(url: url as URL) 49 | } 50 | } 51 | case .tabForward: 52 | changeTab(withDirection: .forward, from: page) 53 | case .tabBackward: 54 | changeTab(withDirection: .backward, from: page) 55 | case .closeTab: 56 | closeTab(from: page) 57 | case .updateSettings: 58 | updateSettings(page: page) 59 | case .none: 60 | NSLog("Received message with unsupported type: \(messageName)") 61 | } 62 | } 63 | 64 | override func toolbarItemClicked(in _: SFSafariWindow) { 65 | // This method will be called when your toolbar item is clicked. 66 | NSLog("The extension's toolbar item was clicked") 67 | #if DEBUG 68 | NSWorkspace.shared.launchApplication("Vimarily Debug") 69 | #else 70 | NSWorkspace.shared.launchApplication("Vimarily") 71 | #endif 72 | } 73 | 74 | override func validateToolbarItem( 75 | in _: SFSafariWindow, 76 | validationHandler: @escaping (Bool, String) -> Void 77 | ) { 78 | /* This is called when Safari's state changed in some way that would require the extension's toolbar item to be 79 | validated again. 80 | */ 81 | validationHandler(true, "") 82 | } 83 | 84 | override func popoverViewController() -> SFSafariExtensionViewController { 85 | return SafariExtensionViewController.shared 86 | } 87 | 88 | // MARK: Tabs Methods 89 | 90 | private func openInNewTab(url: URL) { 91 | SFSafariApplication.getActiveWindow { activeWindow in 92 | activeWindow?.openTab(with: url, makeActiveIfPossible: false, completionHandler: { _ in 93 | // Perform some action here after the page loads 94 | }) 95 | } 96 | } 97 | 98 | private func changeTab( 99 | withDirection direction: TabDirection, 100 | from page: SFSafariPage, 101 | completionHandler: (() -> Void)? = nil 102 | ) { 103 | page.getContainingTab { currentTab in 104 | // Using .currentWindow instead of .containingWindow, this prevents the window being nil in the case of a pinned tab. 105 | self.currentWindow(from: page) { window in 106 | window?.getAllTabs { tabs in 107 | tabs.forEach { tab in 108 | NSLog(tab.description) 109 | } 110 | if let currentIndex = tabs.firstIndex(of: currentTab) { 111 | let indexStep = direction == TabDirection.forward ? 1 : -1 112 | 113 | // Wrap around the ends with a modulus operator. 114 | // % calculates the remainder, not the modulus, so we need a 115 | // custom function. 116 | let newIndex = mod(currentIndex + indexStep, tabs.count) 117 | 118 | tabs[newIndex].activate(completionHandler: completionHandler ?? { 119 | }) 120 | 121 | } 122 | } 123 | } 124 | } 125 | } 126 | 127 | /** 128 | Returns the containing window of a SFSafariPage, if not available default to the current active window. 129 | */ 130 | private func currentWindow(from page: SFSafariPage, completionHandler: @escaping (SFSafariWindow?) -> Void) { 131 | page.getContainingTab { 132 | $0.getContainingWindow { window in 133 | if window != nil { 134 | return completionHandler(window) 135 | } else { 136 | SFSafariApplication.getActiveWindow { window in 137 | return completionHandler(window) 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | private func closeTab(from page: SFSafariPage) { 145 | page.getContainingTab { tab in 146 | tab.close() 147 | } 148 | } 149 | 150 | // MARK: Settings 151 | 152 | private func updateSettings(page: SFSafariPage) { 153 | do { 154 | let settings: [String: Any] = try configuration.getUserSettings() 155 | page.dispatch(settings: settings) 156 | } catch { 157 | NSLog(error.localizedDescription) 158 | } 159 | } 160 | } 161 | 162 | // MARK: Helpers 163 | 164 | // swiftlint:disable identifier_name 165 | private func mod(_ a: Int, _ n: Int) -> Int { 166 | // https://stackoverflow.com/questions/41180292/negative-number-modulo-in-swift 167 | precondition(n > 0, "modulus must be positive") 168 | let r = a % n 169 | return r >= 0 ? r : r + n 170 | } 171 | 172 | private extension SFSafariPage { 173 | func dispatch(settings: [String: Any]) { 174 | self.dispatchMessageToScript( 175 | withName: "updateSettingsEvent", 176 | userInfo: settings 177 | ) 178 | } 179 | } 180 | 181 | private extension SFSafariApplication { 182 | static func getActivePage(completionHandler: @escaping (SFSafariPage?) -> Void) { 183 | SFSafariApplication.getActiveWindow { 184 | $0?.getActiveTab { 185 | $0?.getActivePage(completionHandler: completionHandler) 186 | } 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /assets/vimlogo.svg: -------------------------------------------------------------------------------- 1 | 2 | 10 | 12 | 13 | 15 | image/svg+xml 16 | 18 | 19 | 20 | 21 | 24 | 27 | 31 | 35 | 39 | 43 | 47 | 51 | 55 | 59 | 64 | 68 | 72 | 76 | 80 | 85 | 89 | 93 | 96 | 100 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /Vimarily Extension/js/injected.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Vimarily injected script. 3 | * 4 | * This script is called before the requested page is loaded. This allows us 5 | * to intercept events before they are passed to the requested pages code and 6 | * therefore we can stop certain pages (google) stealing the focus. 7 | */ 8 | 9 | /* 10 | * Global vars 11 | * 12 | * topWindow - true if top window, false if iframe 13 | * settings - stores user settings 14 | * currentZoomLevel - required for vimium scripts to run correctly 15 | * linkHintCss - required from vimium scripts 16 | * extensionActive - is the extension currently enabled (should only be true when tab is active) 17 | * shiftKeyToggle - is shift key currently toggled 18 | */ 19 | 20 | let topWindow = window.top === window; 21 | let settings = {}; 22 | let currentZoomLevel = 100; 23 | let linkHintCss = {}; 24 | let extensionActive = true; 25 | let insertMode = false; 26 | let shiftKeyToggle = false; 27 | let hudDuration = 5000; 28 | let extensionCommunicator = SafariExtensionCommunicator(messageHandler); 29 | 30 | const actionMap = { 31 | hintToggle: function () { 32 | HUD.showForDuration('Open link in current tab', hudDuration); 33 | activateLinkHintsMode(false, false); 34 | }, 35 | 36 | newTabHintToggle: function () { 37 | HUD.showForDuration('Open link in new tab', hudDuration); 38 | activateLinkHintsMode(true, false); 39 | }, 40 | 41 | tabForward: function () { 42 | extensionCommunicator.requestTabForward(); 43 | }, 44 | 45 | tabBack: function () { 46 | extensionCommunicator.requestTabBackward(); 47 | }, 48 | 49 | scrollDown: function () { 50 | customScrollBy(0, settings.scrollSize); 51 | }, 52 | 53 | scrollUp: function () { 54 | customScrollBy(0, -settings.scrollSize); 55 | }, 56 | 57 | scrollLeft: function () { 58 | customScrollBy(-settings.scrollSize, 0); 59 | }, 60 | 61 | scrollRight: function () { 62 | customScrollBy(settings.scrollSize, 0); 63 | }, 64 | 65 | goBack: function () { 66 | window.history.back(); 67 | }, 68 | 69 | goForward: function () { 70 | window.history.forward(); 71 | }, 72 | 73 | reload: function () { 74 | window.location.reload(); 75 | }, 76 | 77 | openTab: function () { 78 | window.open(settings.openTabUrl); 79 | }, 80 | 81 | closeTab: function () { 82 | extensionCommunicator.requestCloseTab(); 83 | }, 84 | 85 | duplicateTab: function () { 86 | window.open(window.location.href); 87 | }, 88 | 89 | copyUrl: function () { 90 | navigator.clipboard.writeText(window.location.href); 91 | HUD.showForDuration('Copied URL: ' + window.location.href, hudDuration); 92 | }, 93 | 94 | scrollDownHalfPage: function () { 95 | customScrollBy(0, window.innerHeight / 2); 96 | }, 97 | 98 | scrollUpHalfPage: function () { 99 | customScrollBy(0, window.innerHeight / -2); 100 | }, 101 | 102 | goToPageBottom: function () { 103 | customScrollBy(0, document.body.scrollHeight); 104 | }, 105 | 106 | goToPageTop: function () { 107 | customScrollBy(0, -document.body.scrollHeight); 108 | }, 109 | 110 | goToFirstInput: function () { 111 | goToFirstInput(); 112 | }, 113 | }; 114 | 115 | // Inspiration and general algorithm taken from sVim. 116 | function goToFirstInput() { 117 | const inputs = document.querySelectorAll('input,textarea'); 118 | 119 | let bestInput = null; 120 | let bestInViewInput = null; 121 | 122 | inputs.forEach(function (input) { 123 | // Skip if hidden or disabled 124 | if ( 125 | input.offsetParent === null || 126 | input.disabled || 127 | input.getAttribute('type') === 'hidden' || 128 | getComputedStyle(input).visibility === 'hidden' || 129 | input.getAttribute('display') === 'none' 130 | ) { 131 | return; 132 | } 133 | 134 | // Skip things that are not actual inputs 135 | if ( 136 | input.localName !== 'textarea' && 137 | input.localName !== 'input' && 138 | input.getAttribute('contenteditable') !== 'true' 139 | ) { 140 | return; 141 | } 142 | 143 | // Skip non-text inputs 144 | if ( 145 | /button|radio|file|image|checkbox|submit/i.test( 146 | input.getAttribute('type') 147 | ) 148 | ) { 149 | return; 150 | } 151 | 152 | const inputRect = input.getClientRects()[0]; 153 | const isInView = 154 | inputRect.top >= -inputRect.height && 155 | inputRect.top <= window.innerHeight && 156 | inputRect.left >= -inputRect.width && 157 | inputRect.left <= window.innerWidth; 158 | 159 | if (bestInput === null) { 160 | bestInput = input; 161 | } 162 | 163 | if (isInView && bestInViewInput === null) { 164 | bestInViewInput = input; 165 | } 166 | }); 167 | 168 | const inputToFocus = bestInViewInput || bestInput; 169 | if (inputToFocus !== null) { 170 | inputToFocus.focus(); 171 | } 172 | } 173 | 174 | // Meant to be overridden, but still has to be copy/pasted from the original... 175 | Mousetrap.prototype.stopCallback = function (e, element, combo) { 176 | // Escape key is special, no need to stop. Vimarily-specific. 177 | if (combo === 'esc' || combo === 'ctrl+[') { 178 | return false; 179 | } 180 | 181 | // Preserve the behavior of allowing ex. ctrl-j in an input 182 | if (settings.modifier) { 183 | return false; 184 | } 185 | 186 | // if the element has the class "mousetrap" then no need to stop 187 | if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { 188 | return false; 189 | } 190 | 191 | const tagName = element.tagName; 192 | const contentIsEditable = 193 | element.contentEditable && element.contentEditable === 'true'; 194 | 195 | // stop for input, select, and textarea 196 | return ( 197 | tagName === 'INPUT' || 198 | tagName === 'SELECT' || 199 | tagName === 'TEXTAREA' || 200 | contentIsEditable 201 | ); 202 | }; 203 | 204 | // Set up key codes to event handlers 205 | function bindKeyCodesToActions() { 206 | // Only add if topWindow... not iframe 207 | Mousetrap.reset(); 208 | if (topWindow) { 209 | Mousetrap.bind('esc', enterNormalMode); 210 | Mousetrap.bind('ctrl+[', enterNormalMode); 211 | Mousetrap.bind('i', enterInsertMode); 212 | for (let actionName in actionMap) { 213 | // eslint-disable-next-line no-prototype-builtins 214 | if (actionMap.hasOwnProperty(actionName)) { 215 | const keyCode = getKeyCode(actionName); 216 | Mousetrap.bind(keyCode, executeAction(actionName), 'keydown'); 217 | } 218 | } 219 | } 220 | } 221 | 222 | function enterNormalMode() { 223 | // Clear input focus 224 | document.activeElement.blur(); 225 | 226 | // Clear link hints (if any) 227 | deactivateLinkHintsMode(); 228 | 229 | if (insertMode === false) { 230 | return; // We are already in normal mode. 231 | } 232 | 233 | // Re-enable if in insert mode 234 | insertMode = false; 235 | HUD.showForDuration('Normal Mode', hudDuration); 236 | 237 | Mousetrap.bind('i', enterInsertMode); 238 | } 239 | 240 | // Calling it 'insert mode', but it's really just a user-triggered 241 | // off switch for the actions. 242 | function enterInsertMode() { 243 | if (insertMode === true) { 244 | return; // We are already in insert mode. 245 | } 246 | insertMode = true; 247 | HUD.showForDuration('Insert Mode', hudDuration); 248 | Mousetrap.unbind('i'); 249 | } 250 | 251 | function executeAction(actionName) { 252 | return function () { 253 | // don't do anything if we're not supposed to 254 | if (linkHintsModeActivated || !extensionActive || insertMode) return; 255 | 256 | //Call the action function 257 | actionMap[actionName](); 258 | 259 | // Tell mousetrap to stop propagation 260 | return false; 261 | }; 262 | } 263 | 264 | function unbindKeyCodes() { 265 | Mousetrap.reset(); 266 | document.removeEventListener('keydown', stopSitePropagation); 267 | } 268 | 269 | // Returns all keys bound in the settings. 270 | function boundKeys() { 271 | const splitBinding = (s) => s.split(/\+| /i); 272 | const bindings = Object.values(settings.bindings) 273 | // Split multi-key bindings. 274 | .flatMap((s) => { 275 | if (typeof s === 'string' || s instanceof String) { 276 | return splitBinding(s); 277 | } else if (Array.isArray(s)) { 278 | return s.flatMap(splitBinding); 279 | } 280 | }); 281 | 282 | // Manually add the modifier, i, esc, and ctr+[. 283 | bindings.push(settings.modifier); 284 | bindings.push('i'); 285 | bindings.push('Escape'); 286 | bindings.push('Control'); 287 | bindings.push('['); 288 | 289 | // Use a set to remove duplicates. 290 | return new Set(bindings); 291 | } 292 | 293 | // Stops propagation of keyboard events in normal mode. Adding this 294 | // callback to the document using the useCapture flag allows us to 295 | // prevent custom key behaviour implemented by the underlying website. 296 | function stopSitePropagation() { 297 | return function (e) { 298 | if (insertMode) { 299 | // Never stop propagation in insert mode. 300 | return; 301 | } 302 | 303 | if (settings.transparentBindings === true) { 304 | if (boundKeys().has(e.key) && !isActiveElementEditable()) { 305 | // If we are in normal mode with transparentBindings enabled we 306 | // should only stop propagation in an editable element or if the 307 | // key is bound to a Vimarily action. 308 | e.stopPropagation(); 309 | } 310 | } else if (!isActiveElementEditable()) { 311 | e.stopPropagation(); 312 | } 313 | }; 314 | } 315 | 316 | // Check whether the current active element is editable. 317 | function isActiveElementEditable() { 318 | const el = document.activeElement; 319 | return el != null && isEditable(el); 320 | } 321 | 322 | // Adds an optional modifier to the configured key code for the action 323 | function getKeyCode(actionName) { 324 | if (settings === undefined) { 325 | return ''; 326 | } 327 | 328 | const keyCode = settings['bindings'][actionName]; 329 | const addModifier = (s) => { 330 | if (settings.modifier && settings.modifier.length > 0) { 331 | return `${settings.modifier}+${s}`; 332 | } else { 333 | return s; 334 | } 335 | }; 336 | 337 | if (Array.isArray(keyCode)) { 338 | return keyCode.map(addModifier); 339 | } else { 340 | return addModifier(keyCode); 341 | } 342 | } 343 | 344 | /* 345 | * Adds the given CSS to the page. 346 | * This function is required by vimium but deprecated for vimarily as the 347 | * css is preloaded into the page. 348 | */ 349 | function addCssToPage(css) {} 350 | 351 | /* 352 | * Input or text elements are considered focusable and able to receive their own keyboard events, 353 | * and will enter `enter` mode if focused. Also note that the "contentEditable" attribute can be set on 354 | * any element which makes it a rich text editor, like the notes on jjot.com. 355 | * Note: we used to discriminate for text-only inputs, but this is not accurate since all input fields 356 | * can be controlled via the keyboard, particularly SELECT combo boxes. 357 | */ 358 | function isEditable(target) { 359 | if (target.getAttribute('contentEditable') === 'true') return true; 360 | const focusableInputs = ['input', 'textarea', 'select', 'button']; 361 | return focusableInputs.indexOf(target.tagName.toLowerCase()) >= 0; 362 | } 363 | 364 | /* 365 | * Embedded elements like Flash and quicktime players can obtain focus but cannot be programmatically 366 | * unfocused. 367 | */ 368 | function isEmbed(element) { 369 | return ['EMBED', 'OBJECT'].indexOf(element.tagName) > 0; 370 | } 371 | 372 | // ========================== 373 | // Message handling functions 374 | // ========================== 375 | 376 | function messageHandler(event) { 377 | if (event.name === 'updateSettingsEvent') { 378 | setSettings(event.message); 379 | } 380 | } 381 | 382 | /* 383 | * Callback to pass settings to injected script 384 | */ 385 | function setSettings(msg) { 386 | settings = msg; 387 | activateExtension(settings); 388 | } 389 | 390 | function activateExtension(settings) { 391 | if ( 392 | typeof settings != 'undefined' && 393 | isExcludedUrl(settings.excludedUrls, document.URL) 394 | ) { 395 | return; 396 | } 397 | 398 | // Stop keydown propagation 399 | document.addEventListener('keydown', stopSitePropagation(), true); 400 | bindKeyCodesToActions(settings); 401 | } 402 | 403 | function isExcludedUrl(storedExcludedUrls, currentUrl) { 404 | if (!storedExcludedUrls) { 405 | return false; 406 | } 407 | 408 | let excludedUrls, regexp, url, formattedUrl, _i, _len; 409 | // TODO temporarily only the first index until comma separated string is replaced with array 410 | if (storedExcludedUrls[0]) { 411 | excludedUrls = storedExcludedUrls[0].split(','); 412 | } else { 413 | excludedUrls = ''; 414 | } 415 | 416 | for (_i = 0, _len = excludedUrls.length; _i < _len; _i++) { 417 | url = excludedUrls[_i]; 418 | formattedUrl = stripProtocolAndWww(url); 419 | formattedUrl = formattedUrl.toLowerCase().trim(); 420 | regexp = new RegExp('((.*)?(' + formattedUrl + ')+(.*))'); 421 | if (currentUrl.toLowerCase().match(regexp)) { 422 | return true; 423 | } 424 | } 425 | return false; 426 | } 427 | 428 | // These formations removes the protocol and www so that 429 | // the regexp can catch less AND more specific excluded 430 | // domains than the current URL. 431 | function stripProtocolAndWww(url) { 432 | url = url.replace('http://', ''); 433 | url = url.replace('https://', ''); 434 | if (url.startsWith('www.')) { 435 | url = url.slice(4); 436 | } 437 | 438 | return url; 439 | } 440 | 441 | // Add event listener 442 | function inIframe() { 443 | try { 444 | return window.self !== window.top; 445 | } catch (e) { 446 | return true; 447 | } 448 | } 449 | 450 | if (!inIframe()) { 451 | extensionCommunicator.requestSettingsUpdate(); 452 | } 453 | 454 | // Export to make it testable 455 | window.isExcludedUrl = isExcludedUrl; 456 | window.stripProtocolAndWww = stripProtocolAndWww; 457 | -------------------------------------------------------------------------------- /Vimarily Extension/js/link-hints.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This implements link hinting. Typing "F" will enter link-hinting mode, where all clickable items on 3 | * the page have a hint marker displayed containing a sequence of letters. Typing those letters will select 4 | * a link. 5 | * 6 | * The characters we use to show link hints are a user-configurable option. By default, they're the home row. 7 | * The CSS which is used on the link hints is also a configurable option. 8 | */ 9 | 10 | let hintMarkers = []; 11 | let hintMarkerContainingDiv = null; 12 | // The characters that were typed in while in "link hints" mode. 13 | let hintKeystrokeQueue = []; 14 | let linkHintsModeActivated = false; 15 | let shouldOpenLinkHintInNewTab = false; 16 | let shouldOpenLinkHintWithQueue = false; 17 | // Whether link hint's "open in current/new tab" setting is currently toggled 18 | let openLinkModeToggle = false; 19 | // Whether we have added to the page the CSS needed to display link hints. 20 | const linkHintsCssAdded = false; 21 | 22 | // We need this as a top-level function because our command system doesn't yet support arguments. 23 | function activateLinkHintsModeToOpenInNewTab() { 24 | activateLinkHintsMode(true, false); 25 | } 26 | 27 | function activateLinkHintsModeWithQueue() { 28 | activateLinkHintsMode(true, true); 29 | } 30 | 31 | function activateLinkHintsMode(openInNewTab, withQueue) { 32 | if (!linkHintsCssAdded) addCssToPage(linkHintCss); // linkHintCss is declared by vimiumFrontend.js 33 | linkHintCssAdded = true; 34 | linkHintsModeActivated = true; 35 | setOpenLinkMode(openInNewTab, withQueue); 36 | buildLinkHints(); 37 | document.addEventListener('keydown', onKeyDownInLinkHintsMode, true); 38 | document.addEventListener('keyup', onKeyUpInLinkHintsMode, true); 39 | } 40 | 41 | function setOpenLinkMode(openInNewTab, withQueue) { 42 | shouldOpenLinkHintInNewTab = openInNewTab; 43 | shouldOpenLinkHintWithQueue = withQueue; 44 | } 45 | 46 | /* 47 | * Builds and displays link hints for every visible clickable item on the page. 48 | */ 49 | function buildLinkHints() { 50 | let i; 51 | const visibleElements = getVisibleClickableElements(); 52 | 53 | // Initialize the number used to generate the character hints to be as many digits as we need to 54 | // highlight all the links on the page; we don't want some link hints to have more chars than others. 55 | const digitsNeeded = Math.ceil( 56 | logXOfBase(visibleElements.length, settings.linkHintCharacters.length) 57 | ); 58 | let linkHintNumber = 0; 59 | for (i = 0; i < visibleElements.length; i++) { 60 | hintMarkers.push( 61 | createMarkerFor(visibleElements[i], linkHintNumber, digitsNeeded) 62 | ); 63 | linkHintNumber++; 64 | } 65 | // Note(philc): Append these markers as top level children instead of as child nodes to the link itself, 66 | // because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat 67 | // that if you scroll the page and the link has position=fixed, the marker will not stay fixed. 68 | // Also note that adding these nodes to document.body all at once is significantly faster than one-by-one. 69 | hintMarkerContainingDiv = document.createElement('div'); 70 | hintMarkerContainingDiv.id = 'vimiumHintMarkerContainer'; 71 | hintMarkerContainingDiv.className = 'vimiumReset'; 72 | for (i = 0; i < hintMarkers.length; i++) 73 | hintMarkerContainingDiv.appendChild(hintMarkers[i]); 74 | document.body.appendChild(hintMarkerContainingDiv); 75 | } 76 | 77 | function logXOfBase(x, base) { 78 | return Math.log(x) / Math.log(base); 79 | } 80 | 81 | /* 82 | * Returns all clickable elements that are not hidden and are in the current viewport. 83 | * We prune invisible elements partly for performance reasons, but moreso it's to decrease the number 84 | * of digits needed to enumerate all of the links on screen. 85 | */ 86 | function getVisibleClickableElements() { 87 | // Get all clickable elements. 88 | const elements = getClickableElements(); 89 | 90 | // Get those that are visible too. 91 | const visibleElements = []; 92 | 93 | for (let i = 0; i < elements.length; i++) { 94 | const element = elements[i]; 95 | 96 | const selectedRect = getFirstVisibleRect(element); 97 | if (selectedRect) { 98 | visibleElements.push(selectedRect); 99 | } 100 | } 101 | 102 | return visibleElements; 103 | } 104 | 105 | function getClickableElements() { 106 | const elements = document.getElementsByTagName('*'); 107 | const clickableElements = []; 108 | for (let i = 0; i < elements.length; i++) { 109 | const element = elements[i]; 110 | if (isClickable(element)) clickableElements.push(element); 111 | } 112 | return clickableElements; 113 | } 114 | 115 | function isClickable(element) { 116 | const name = element.nodeName.toLowerCase(); 117 | const role = element.getAttribute('role'); 118 | 119 | return ( 120 | // normal html elements that can be clicked 121 | name === 'a' || 122 | name === 'button' || 123 | (name === 'input' && element.getAttribute('type') !== 'hidden') || 124 | name === 'select' || 125 | name === 'textarea' || 126 | // elements having an ARIA role implying clickability 127 | // (see http://www.w3.org/TR/wai-aria/roles#widget_roles) 128 | role === 'button' || 129 | role === 'checkbox' || 130 | role === 'combobox' || 131 | role === 'link' || 132 | role === 'menuitem' || 133 | role === 'menuitemcheckbox' || 134 | role === 'menuitemradio' || 135 | role === 'radio' || 136 | role === 'tab' || 137 | role === 'textbox' || 138 | // other ways by which we can know an element is clickable 139 | element.hasAttribute('onclick') || 140 | (settings.detectByCursorStyle && 141 | window.getComputedStyle(element).cursor === 'pointer' && 142 | (!element.parentNode || 143 | window.getComputedStyle(element.parentNode).cursor !== 'pointer')) 144 | ); 145 | } 146 | 147 | /* 148 | * Get firs visible rect under an element. 149 | * 150 | * Inline elements can have more than one rect. 151 | * Block elemens only have one rect. 152 | * So, in general, add element's first visible rect, if any. 153 | * If element does not have any visible rect, 154 | * it can still be wrapping other visible children. 155 | * So, in that case, recurse to get the first visible rect 156 | * of the first child that has one. 157 | */ 158 | function getFirstVisibleRect(element) { 159 | // find visible clientRect of element itself 160 | const clientRects = element.getClientRects(); 161 | for (let i = 0; i < clientRects.length; i++) { 162 | const clientRect = clientRects[i]; 163 | if (isVisible(element, clientRect)) { 164 | return { element: element, rect: clientRect }; 165 | } 166 | } 167 | // Only iterate over elements with a children property. This is mainly to 168 | // avoid issues with SVG elements, as Safari doesn't expose a children 169 | // property on them. 170 | if (element.children) { 171 | // find visible clientRect of child 172 | for (let j = 0; j < element.children.length; j++) { 173 | const childClientRect = getFirstVisibleRect(element.children[j]); 174 | if (childClientRect) { 175 | return childClientRect; 176 | } 177 | } 178 | } 179 | return null; 180 | } 181 | 182 | /* 183 | * Returns true if element is visible. 184 | */ 185 | function isVisible(element, clientRect) { 186 | // Exclude links which have just a few pixels on screen, because the link hints won't show for them anyway. 187 | const zoomFactor = currentZoomLevel / 100.0; 188 | if ( 189 | !clientRect || 190 | clientRect.top < 0 || 191 | clientRect.top * zoomFactor >= window.innerHeight - 4 || 192 | clientRect.left < 0 || 193 | clientRect.left * zoomFactor >= window.innerWidth - 4 194 | ) 195 | return false; 196 | 197 | if (clientRect.width < 3 || clientRect.height < 3) return false; 198 | 199 | // eliminate invisible elements (see test_harnesses/visibility_test.html) 200 | const computedStyle = window.getComputedStyle(element, null); 201 | if ( 202 | computedStyle.getPropertyValue('visibility') !== 'visible' || 203 | computedStyle.getPropertyValue('display') === 'none' 204 | ) 205 | return false; 206 | 207 | // Eliminate elements hidden by another overlapping element. 208 | // To do that, get topmost element at some offset from upper-left corner of clientRect 209 | // and check whether it is the element itself or one of its descendants. 210 | // The offset is needed to account for coordinates truncation and elements with rounded borders. 211 | // 212 | // Coordinates truncation occcurs when using zoom. In that case, clientRect coords should be float, 213 | // but we get integers instead. That makes so that elementFromPoint(clientRect.left, clientRect.top) 214 | // sometimes returns an element different from the one clientRect was obtained from. 215 | // So we introduce an offset to make sure elementFromPoint hits the right element. 216 | // 217 | // For elements with a rounded topleft border, the upper left corner lies outside the element. 218 | // Then, we need an offset to get to the point nearest to the upper left corner, but within border. 219 | const coordTruncationOffset = 2, // A value of 1 has been observed not to be enough, 220 | // so we heuristically choose 2, which seems to work well. 221 | // We know a value of 2 is still safe (lies within the element) because, 222 | // from the code above, widht & height are >= 3. 223 | radius = parseFloat(computedStyle.borderTopLeftRadius), 224 | roundedBorderOffset = Math.ceil(radius * (1 - Math.sin(Math.PI / 4))), 225 | offset = Math.max(coordTruncationOffset, roundedBorderOffset); 226 | if (offset >= clientRect.width || offset >= clientRect.height) return false; 227 | let el = document.elementFromPoint( 228 | clientRect.left + offset, 229 | clientRect.top + offset 230 | ); 231 | while (el && el !== element) el = el.parentNode; 232 | return el; 233 | } 234 | 235 | function onKeyDownInLinkHintsMode(event) { 236 | console.log('-- key down pressed --'); 237 | if (event.keyCode === keyCodes.shiftKey && !openLinkModeToggle) { 238 | // Toggle whether to open link in a new or current tab. 239 | setOpenLinkMode(!shouldOpenLinkHintInNewTab, shouldOpenLinkHintWithQueue); 240 | openLinkModeToggle = true; 241 | } 242 | 243 | const keyChar = getKeyChar(event); 244 | if (!keyChar) return; 245 | 246 | // TODO(philc): Ignore keys that have modifiers. 247 | if (isEscape(event)) { 248 | deactivateLinkHintsMode(); 249 | } else if ( 250 | event.keyCode === keyCodes.backspace || 251 | event.keyCode === keyCodes.deleteKey 252 | ) { 253 | if (hintKeystrokeQueue.length === 0) { 254 | deactivateLinkHintsMode(); 255 | } else { 256 | hintKeystrokeQueue.pop(); 257 | updateLinkHints(); 258 | } 259 | } else if (settings.linkHintCharacters.indexOf(keyChar) >= 0) { 260 | hintKeystrokeQueue.push(keyChar); 261 | updateLinkHints(); 262 | } else { 263 | return; 264 | } 265 | 266 | event.stopPropagation(); 267 | event.preventDefault(); 268 | } 269 | 270 | function onKeyUpInLinkHintsMode(event) { 271 | if (event.keyCode === keyCodes.shiftKey && openLinkModeToggle) { 272 | // Revert toggle on whether to open link in new or current tab. 273 | setOpenLinkMode(!shouldOpenLinkHintInNewTab, shouldOpenLinkHintWithQueue); 274 | openLinkModeToggle = false; 275 | } 276 | event.stopPropagation(); 277 | event.preventDefault(); 278 | } 279 | 280 | /* 281 | * Updates the visibility of link hints on screen based on the keystrokes typed 282 | * thus far. If the provided keystrokes match exactly with one LinkHint, click 283 | * on that link and exit link hints mode. 284 | */ 285 | function updateLinkHints() { 286 | const hintStringLength = hintMarkers[0].getAttribute('hintString').length; 287 | const matchString = hintKeystrokeQueue.join(''); 288 | const linksMatched = highlightLinkMatches(matchString); 289 | if (linksMatched.length === 0) { 290 | deactivateLinkHintsMode(); 291 | } else if ( 292 | linksMatched.length === 1 && 293 | matchString.length === hintStringLength 294 | ) { 295 | const matchedLink = linksMatched[0]; 296 | if (isSelectable(matchedLink)) { 297 | matchedLink.focus(); 298 | // When focusing a textbox, put the selection caret at the end of the textbox's contents. 299 | matchedLink.setSelectionRange( 300 | matchedLink.value.length, 301 | matchedLink.value.length 302 | ); 303 | deactivateLinkHintsMode(); 304 | } else { 305 | // When we're opening the link in the current tab, don't navigate to the selected link immediately; 306 | // we want to give the user some feedback depicting which link they've selected by focusing it. 307 | if (shouldOpenLinkHintWithQueue) { 308 | simulateClick(matchedLink, false); 309 | resetLinkHintsMode(); 310 | } else if (shouldOpenLinkHintInNewTab) { 311 | simulateClick(matchedLink, true); 312 | matchedLink.focus(); 313 | deactivateLinkHintsMode(); 314 | } else { 315 | setTimeout(function () { 316 | simulateClick(matchedLink, false); 317 | }, 400); 318 | matchedLink.focus(); 319 | deactivateLinkHintsMode(); 320 | } 321 | } 322 | } 323 | } 324 | 325 | /* 326 | * Selectable means the element has a text caret; this is not the same as "focusable". 327 | */ 328 | function isSelectable(element) { 329 | const selectableTypes = ['search', 'text', 'password']; 330 | return ( 331 | (element.tagName === 'INPUT' && 332 | selectableTypes.indexOf(element.type) >= 0) || 333 | element.tagName === 'TEXTAREA' 334 | ); 335 | } 336 | 337 | /* 338 | * Hides link hints which do not match the given search string. To allow the backspace key to work, this 339 | * will also show link hints which do match but were previously hidden. 340 | */ 341 | function highlightLinkMatches(searchString) { 342 | const linksMatched = []; 343 | for (let i = 0; i < hintMarkers.length; i++) { 344 | const linkMarker = hintMarkers[i]; 345 | if (linkMarker.getAttribute('hintString').indexOf(searchString) === 0) { 346 | if (linkMarker.style.display === 'none') linkMarker.style.display = ''; 347 | for (let j = 0; j < linkMarker.childNodes.length; j++) 348 | linkMarker.childNodes[j].className = 349 | j >= searchString.length ? '' : 'matchingCharacter'; 350 | linksMatched.push(linkMarker.clickableItem); 351 | } else { 352 | linkMarker.style.display = 'none'; 353 | } 354 | } 355 | return linksMatched; 356 | } 357 | 358 | /* 359 | * Converts a number like "8" into a hint string like "JK". This is used to sequentially generate all of 360 | * the hint text. The hint string will be "padded with zeroes" to ensure its length is equal to numHintDigits. 361 | */ 362 | function numberToHintString(number, numHintDigits) { 363 | const base = settings.linkHintCharacters.length; 364 | const hintString = []; 365 | let remainder = 0; 366 | do { 367 | remainder = number % base; 368 | hintString.unshift(settings.linkHintCharacters[remainder]); 369 | number -= remainder; 370 | number /= Math.floor(base); 371 | } while (number > 0); 372 | 373 | // Pad the hint string we're returning so that it matches numHintDigits. 374 | const hintStringLength = hintString.length; 375 | for (let i = 0; i < numHintDigits - hintStringLength; i++) 376 | hintString.unshift(settings.linkHintCharacters[0]); 377 | return hintString.reverse().join(''); 378 | } 379 | 380 | function simulateClick(link, openInNewTab) { 381 | if (openInNewTab) { 382 | console.log('-- Open link in new tab --'); 383 | safari.extension.dispatchMessage('openLinkInTab', { url: link.href }); 384 | } else { 385 | link.click(); 386 | } 387 | 388 | // If clicking the link doesn't take you to a new page 389 | // the focus should not stay on the link, hence calling blur() 390 | link.blur(); 391 | } 392 | 393 | function deactivateLinkHintsMode() { 394 | if (hintMarkerContainingDiv) 395 | hintMarkerContainingDiv.parentNode.removeChild(hintMarkerContainingDiv); 396 | hintMarkerContainingDiv = null; 397 | hintMarkers = []; 398 | hintKeystrokeQueue = []; 399 | document.removeEventListener('keydown', onKeyDownInLinkHintsMode, true); 400 | document.removeEventListener('keyup', onKeyUpInLinkHintsMode, true); 401 | linkHintsModeActivated = false; 402 | } 403 | 404 | function resetLinkHintsMode() { 405 | deactivateLinkHintsMode(); 406 | activateLinkHintsModeWithQueue(); 407 | } 408 | 409 | /* 410 | * Creates a link marker for the given link. 411 | */ 412 | function createMarkerFor(link, linkHintNumber, linkHintDigits) { 413 | const hintString = numberToHintString(linkHintNumber, linkHintDigits); 414 | const marker = document.createElement('div'); 415 | marker.className = 'internalVimiumHintMarker vimiumReset'; 416 | const innerHTML = []; 417 | // Make each hint character a span, so that we can highlight the typed characters as you type them. 418 | for (let i = 0; i < hintString.length; i++) 419 | innerHTML.push( 420 | '' + hintString[i].toUpperCase() + '' 421 | ); 422 | marker.innerHTML = innerHTML.join(''); 423 | marker.setAttribute('hintString', hintString); 424 | 425 | // Note: this call will be expensive if we modify the DOM in between calls. 426 | const clientRect = link.rect; 427 | // The coordinates given by the window do not have the zoom factor included since the zoom is set only on 428 | // the document node. 429 | const zoomFactor = currentZoomLevel / 100.0; 430 | marker.style.left = clientRect.left + window.scrollX / zoomFactor + 'px'; 431 | marker.style.top = clientRect.top + window.scrollY / zoomFactor + 'px'; 432 | 433 | marker.clickableItem = link.element; 434 | return marker; 435 | } 436 | -------------------------------------------------------------------------------- /Vimarily/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | -------------------------------------------------------------------------------- /Vimarily Extension/js/lib/mousetrap.js: -------------------------------------------------------------------------------- 1 | // Custom behaviour for Vimarily implemented at line 185. Be aware of this 2 | // when upgrading mousetrap. 3 | 4 | /*global define:false */ 5 | /** 6 | * Copyright 2012-2017 Craig Campbell 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | * 20 | * Mousetrap is a simple keyboard shortcut library for Javascript with 21 | * no external dependencies 22 | * 23 | * @version 1.6.5 24 | * @url craig.is/killing/mice 25 | */ 26 | (function (window, document) { 27 | // Check if mousetrap is used inside browser, if not, return 28 | if (!window) { 29 | return; 30 | } 31 | 32 | /** 33 | * mapping of special keycodes to their corresponding keys 34 | * 35 | * everything in this dictionary cannot use keypress events 36 | * so it has to be here to map to the correct keycodes for 37 | * keyup/keydown events 38 | * 39 | * @type {Object} 40 | */ 41 | const _MAP = { 42 | 8: 'backspace', 43 | 9: 'tab', 44 | 13: 'enter', 45 | 16: 'shift', 46 | 17: 'ctrl', 47 | 18: 'alt', 48 | 20: 'capslock', 49 | 27: 'esc', 50 | 32: 'space', 51 | 33: 'pageup', 52 | 34: 'pagedown', 53 | 35: 'end', 54 | 36: 'home', 55 | 37: 'left', 56 | 38: 'up', 57 | 39: 'right', 58 | 40: 'down', 59 | 45: 'ins', 60 | 46: 'del', 61 | 91: 'meta', 62 | 93: 'meta', 63 | 224: 'meta', 64 | }; 65 | 66 | /** 67 | * mapping for special characters so they can support 68 | * 69 | * this dictionary is only used incase you want to bind a 70 | * keyup or keydown event to one of these keys 71 | * 72 | * @type {Object} 73 | */ 74 | const _KEYCODE_MAP = { 75 | 106: '*', 76 | 107: '+', 77 | 109: '-', 78 | 110: '.', 79 | 111: '/', 80 | 186: ';', 81 | 187: '=', 82 | 188: ',', 83 | 189: '-', 84 | 190: '.', 85 | 191: '/', 86 | 192: '`', 87 | 219: '[', 88 | 220: '\\', 89 | 221: ']', 90 | 222: "'", 91 | }; 92 | 93 | /** 94 | * this is a mapping of keys that require shift on a US keypad 95 | * back to the non shift equivelents 96 | * 97 | * this is so you can use keyup events with these keys 98 | * 99 | * note that this will only work reliably on US keyboards 100 | * 101 | * @type {Object} 102 | */ 103 | const _SHIFT_MAP = { 104 | '~': '`', 105 | '!': '1', 106 | '@': '2', 107 | '#': '3', 108 | $: '4', 109 | '%': '5', 110 | '^': '6', 111 | '&': '7', 112 | '*': '8', 113 | '(': '9', 114 | ')': '0', 115 | _: '-', 116 | '+': '=', 117 | ':': ';', 118 | '"': "'", 119 | '<': ',', 120 | '>': '.', 121 | '?': '/', 122 | '|': '\\', 123 | }; 124 | 125 | /** 126 | * this is a list of special strings you can use to map 127 | * to modifier keys when you specify your keyboard shortcuts 128 | * 129 | * @type {Object} 130 | */ 131 | const _SPECIAL_ALIASES = { 132 | option: 'alt', 133 | command: 'meta', 134 | return: 'enter', 135 | escape: 'esc', 136 | plus: '+', 137 | mod: /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'meta' : 'ctrl', 138 | }; 139 | 140 | /** 141 | * variable to store the flipped version of _MAP from above 142 | * needed to check if we should use keypress or not when no action 143 | * is specified 144 | * 145 | * @type {Object|undefined} 146 | */ 147 | let _REVERSE_MAP; 148 | 149 | /** 150 | * loop through the f keys, f1 to f19 and add them to the map 151 | * programmatically 152 | */ 153 | for (let i = 1; i < 20; ++i) { 154 | _MAP[111 + i] = 'f' + i; 155 | } 156 | 157 | /** 158 | * loop through to map numbers on the numeric keypad 159 | */ 160 | for (let i = 0; i <= 9; ++i) { 161 | // This needs to use a string cause otherwise since 0 is falsey 162 | // mousetrap will never fire for numpad 0 pressed as part of a keydown 163 | // event. 164 | // 165 | // @see https://github.com/ccampbell/mousetrap/pull/258 166 | _MAP[i + 96] = i.toString(); 167 | } 168 | 169 | /** 170 | * cross browser add event method 171 | * 172 | * @param {Element|HTMLDocument} object 173 | * @param {string} type 174 | * @param {Function} callback 175 | * @returns void 176 | */ 177 | function _addEvent(object, type, callback) { 178 | if (object.addEventListener) { 179 | // VIMARILY CUSTOMISATION: 180 | // We set the useCapture to true such that events are handled before 181 | // being dispatched to any EventTarget beneath it in the DOM tree. 182 | object.addEventListener(type, callback, true); 183 | return; 184 | } 185 | 186 | object.attachEvent('on' + type, callback); 187 | } 188 | 189 | /** 190 | * takes the event and returns the key character 191 | * 192 | * @param {Event} e 193 | * @return {string} 194 | */ 195 | function _characterFromEvent(e) { 196 | // for keypress events we should return the character as is 197 | if (e.type == 'keypress') { 198 | let character = String.fromCharCode(e.which); 199 | 200 | // if the shift key is not pressed then it is safe to assume 201 | // that we want the character to be lowercase. this means if 202 | // you accidentally have caps lock on then your key bindings 203 | // will continue to work 204 | // 205 | // the only side effect that might not be desired is if you 206 | // bind something like 'A' cause you want to trigger an 207 | // event when capital A is pressed caps lock will no longer 208 | // trigger the event. shift+a will though. 209 | if (!e.shiftKey) { 210 | character = character.toLowerCase(); 211 | } 212 | 213 | return character; 214 | } 215 | 216 | // for non keypress events the special maps are needed 217 | if (_MAP[e.which]) { 218 | return _MAP[e.which]; 219 | } 220 | 221 | if (_KEYCODE_MAP[e.which]) { 222 | return _KEYCODE_MAP[e.which]; 223 | } 224 | 225 | // if it is not in the special map 226 | 227 | // with keydown and keyup events the character seems to always 228 | // come in as an uppercase character whether you are pressing shift 229 | // or not. we should make sure it is always lowercase for comparisons 230 | return String.fromCharCode(e.which).toLowerCase(); 231 | } 232 | 233 | /** 234 | * checks if two arrays are equal 235 | * 236 | * @param {Array} modifiers1 237 | * @param {Array} modifiers2 238 | * @returns {boolean} 239 | */ 240 | function _modifiersMatch(modifiers1, modifiers2) { 241 | return modifiers1.sort().join(',') === modifiers2.sort().join(','); 242 | } 243 | 244 | /** 245 | * takes a key event and figures out what the modifiers are 246 | * 247 | * @param {Event} e 248 | * @returns {Array} 249 | */ 250 | function _eventModifiers(e) { 251 | const modifiers = []; 252 | 253 | if (e.shiftKey) { 254 | modifiers.push('shift'); 255 | } 256 | 257 | if (e.altKey) { 258 | modifiers.push('alt'); 259 | } 260 | 261 | if (e.ctrlKey) { 262 | modifiers.push('ctrl'); 263 | } 264 | 265 | if (e.metaKey) { 266 | modifiers.push('meta'); 267 | } 268 | 269 | return modifiers; 270 | } 271 | 272 | /** 273 | * prevents default for this event 274 | * 275 | * @param {Event} e 276 | * @returns void 277 | */ 278 | function _preventDefault(e) { 279 | if (e.preventDefault) { 280 | e.preventDefault(); 281 | return; 282 | } 283 | 284 | e.returnValue = false; 285 | } 286 | 287 | /** 288 | * stops propogation for this event 289 | * 290 | * @param {Event} e 291 | * @returns void 292 | */ 293 | function _stopPropagation(e) { 294 | if (e.stopPropagation) { 295 | e.stopPropagation(); 296 | return; 297 | } 298 | 299 | e.cancelBubble = true; 300 | } 301 | 302 | /** 303 | * determines if the keycode specified is a modifier key or not 304 | * 305 | * @param {string} key 306 | * @returns {boolean} 307 | */ 308 | function _isModifier(key) { 309 | return key == 'shift' || key == 'ctrl' || key == 'alt' || key == 'meta'; 310 | } 311 | 312 | /** 313 | * reverses the map lookup so that we can look for specific keys 314 | * to see what can and can't use keypress 315 | * 316 | * @return {Object} 317 | */ 318 | function _getReverseMap() { 319 | if (!_REVERSE_MAP) { 320 | _REVERSE_MAP = {}; 321 | for (let key in _MAP) { 322 | // pull out the numeric keypad from here cause keypress should 323 | // be able to detect the keys from the character 324 | if (key > 95 && key < 112) { 325 | continue; 326 | } 327 | 328 | // eslint-disable-next-line no-prototype-builtins 329 | if (_MAP.hasOwnProperty(key)) { 330 | _REVERSE_MAP[_MAP[key]] = key; 331 | } 332 | } 333 | } 334 | return _REVERSE_MAP; 335 | } 336 | 337 | /** 338 | * picks the best action based on the key combination 339 | * 340 | * @param {string} key - character for key 341 | * @param {Array} modifiers 342 | * @param {string=} action passed in 343 | */ 344 | function _pickBestAction(key, modifiers, action) { 345 | // if no action was picked in we should try to pick the one 346 | // that we think would work best for this key 347 | if (!action) { 348 | action = _getReverseMap()[key] ? 'keydown' : 'keypress'; 349 | } 350 | 351 | // modifier keys don't work as expected with keypress, 352 | // switch to keydown 353 | if (action == 'keypress' && modifiers.length) { 354 | action = 'keydown'; 355 | } 356 | 357 | return action; 358 | } 359 | 360 | /** 361 | * Converts from a string key combination to an array 362 | * 363 | * @param {string} combination like "command+shift+l" 364 | * @return {Array} 365 | */ 366 | function _keysFromString(combination) { 367 | if (combination === '+') { 368 | return ['+']; 369 | } 370 | 371 | combination = combination.replace(/\+{2}/g, '+plus'); 372 | return combination.split('+'); 373 | } 374 | 375 | /** 376 | * Gets info for a specific key combination 377 | * 378 | * @param {string} combination key combination ("command+s" or "a" or "*") 379 | * @param {string=} action 380 | * @returns {Object} 381 | */ 382 | function _getKeyInfo(combination, action) { 383 | let keys; 384 | let key; 385 | let i; 386 | const modifiers = []; 387 | 388 | // take the keys from this pattern and figure out what the actual 389 | // pattern is all about 390 | keys = _keysFromString(combination); 391 | 392 | for (i = 0; i < keys.length; ++i) { 393 | key = keys[i]; 394 | 395 | // normalize key names 396 | if (_SPECIAL_ALIASES[key]) { 397 | key = _SPECIAL_ALIASES[key]; 398 | } 399 | 400 | // if this is not a keypress event then we should 401 | // be smart about using shift keys 402 | // this will only work for US keyboards however 403 | if (action && action != 'keypress' && _SHIFT_MAP[key]) { 404 | key = _SHIFT_MAP[key]; 405 | modifiers.push('shift'); 406 | } 407 | 408 | // if this key is a modifier then add it to the list of modifiers 409 | if (_isModifier(key)) { 410 | modifiers.push(key); 411 | } 412 | } 413 | 414 | // depending on what the key combination is 415 | // we will try to pick the best event for it 416 | action = _pickBestAction(key, modifiers, action); 417 | 418 | return { 419 | key: key, 420 | modifiers: modifiers, 421 | action: action, 422 | }; 423 | } 424 | 425 | function _belongsTo(element, ancestor) { 426 | if (element === null || element === document) { 427 | return false; 428 | } 429 | 430 | if (element === ancestor) { 431 | return true; 432 | } 433 | 434 | return _belongsTo(element.parentNode, ancestor); 435 | } 436 | 437 | function Mousetrap(targetElement) { 438 | const self = this; 439 | 440 | targetElement = targetElement || document; 441 | 442 | if (!(self instanceof Mousetrap)) { 443 | return new Mousetrap(targetElement); 444 | } 445 | 446 | /** 447 | * element to attach key events to 448 | * 449 | * @type {Element} 450 | */ 451 | self.target = targetElement; 452 | 453 | /** 454 | * a list of all the callbacks setup via Mousetrap.bind() 455 | * 456 | * @type {Object} 457 | */ 458 | self._callbacks = {}; 459 | 460 | /** 461 | * direct map of string combinations to callbacks used for trigger() 462 | * 463 | * @type {Object} 464 | */ 465 | self._directMap = {}; 466 | 467 | /** 468 | * keeps track of what level each sequence is at since multiple 469 | * sequences can start out with the same sequence 470 | * 471 | * @type {Object} 472 | */ 473 | const _sequenceLevels = {}; 474 | 475 | /** 476 | * variable to store the setTimeout call 477 | * 478 | * @type {null|number} 479 | */ 480 | let _resetTimer; 481 | 482 | /** 483 | * temporary state where we will ignore the next keyup 484 | * 485 | * @type {boolean|string} 486 | */ 487 | let _ignoreNextKeyup = false; 488 | 489 | /** 490 | * temporary state where we will ignore the next keypress 491 | * 492 | * @type {boolean} 493 | */ 494 | let _ignoreNextKeypress = false; 495 | 496 | /** 497 | * are we currently inside of a sequence? 498 | * type of action ("keyup" or "keydown" or "keypress") or false 499 | * 500 | * @type {boolean|string} 501 | */ 502 | let _nextExpectedAction = false; 503 | 504 | /** 505 | * resets all sequence counters except for the ones passed in 506 | * 507 | * @param {Object} doNotReset 508 | * @returns void 509 | */ 510 | function _resetSequences(doNotReset) { 511 | doNotReset = doNotReset || {}; 512 | 513 | let activeSequences = false, 514 | key; 515 | 516 | for (key in _sequenceLevels) { 517 | if (doNotReset[key]) { 518 | activeSequences = true; 519 | continue; 520 | } 521 | _sequenceLevels[key] = 0; 522 | } 523 | 524 | if (!activeSequences) { 525 | _nextExpectedAction = false; 526 | } 527 | } 528 | 529 | /** 530 | * finds all callbacks that match based on the keycode, modifiers, 531 | * and action 532 | * 533 | * @param {string} character 534 | * @param {Array} modifiers 535 | * @param {Event|Object} e 536 | * @param {string=} sequenceName - name of the sequence we are looking for 537 | * @param {string=} combination 538 | * @param {number=} level 539 | * @returns {Array} 540 | */ 541 | function _getMatches( 542 | character, 543 | modifiers, 544 | e, 545 | sequenceName, 546 | combination, 547 | level 548 | ) { 549 | let i; 550 | let callback; 551 | const matches = []; 552 | const action = e.type; 553 | 554 | // if there are no events related to this keycode 555 | if (!self._callbacks[character]) { 556 | return []; 557 | } 558 | 559 | // if a modifier key is coming up on its own we should allow it 560 | if (action == 'keyup' && _isModifier(character)) { 561 | modifiers = [character]; 562 | } 563 | 564 | // loop through all callbacks for the key that was pressed 565 | // and see if any of them match 566 | for (i = 0; i < self._callbacks[character].length; ++i) { 567 | callback = self._callbacks[character][i]; 568 | 569 | // if a sequence name is not specified, but this is a sequence at 570 | // the wrong level then move onto the next match 571 | if ( 572 | !sequenceName && 573 | callback.seq && 574 | _sequenceLevels[callback.seq] != callback.level 575 | ) { 576 | continue; 577 | } 578 | 579 | // if the action we are looking for doesn't match the action we got 580 | // then we should keep going 581 | if (action != callback.action) { 582 | continue; 583 | } 584 | 585 | // if this is a keypress event and the meta key and control key 586 | // are not pressed that means that we need to only look at the 587 | // character, otherwise check the modifiers as well 588 | // 589 | // chrome will not fire a keypress if meta or control is down 590 | // safari will fire a keypress if meta or meta+shift is down 591 | // firefox will fire a keypress if meta or control is down 592 | if ( 593 | (action == 'keypress' && !e.metaKey && !e.ctrlKey) || 594 | _modifiersMatch(modifiers, callback.modifiers) 595 | ) { 596 | // when you bind a combination or sequence a second time it 597 | // should overwrite the first one. if a sequenceName or 598 | // combination is specified in this call it does just that 599 | // 600 | // @todo make deleting its own method? 601 | const deleteCombo = !sequenceName && callback.combo == combination; 602 | const deleteSequence = 603 | sequenceName && 604 | callback.seq == sequenceName && 605 | callback.level == level; 606 | if (deleteCombo || deleteSequence) { 607 | self._callbacks[character].splice(i, 1); 608 | } 609 | 610 | matches.push(callback); 611 | } 612 | } 613 | 614 | return matches; 615 | } 616 | 617 | /** 618 | * actually calls the callback function 619 | * 620 | * if your callback function returns false this will use the jquery 621 | * convention - prevent default and stop propogation on the event 622 | * 623 | * @param {Function} callback 624 | * @param {Event} e 625 | * @returns void 626 | */ 627 | function _fireCallback(callback, e, combo, sequence) { 628 | // if this event should not happen stop here 629 | if (self.stopCallback(e, e.target || e.srcElement, combo, sequence)) { 630 | return; 631 | } 632 | 633 | if (callback(e, combo) === false) { 634 | _preventDefault(e); 635 | _stopPropagation(e); 636 | } 637 | } 638 | 639 | /** 640 | * handles a character key event 641 | * 642 | * @param {string} character 643 | * @param {Array} modifiers 644 | * @param {Event} e 645 | * @returns void 646 | */ 647 | self._handleKey = function (character, modifiers, e) { 648 | const callbacks = _getMatches(character, modifiers, e); 649 | let i; 650 | const doNotReset = {}; 651 | let maxLevel = 0; 652 | let processedSequenceCallback = false; 653 | 654 | // Calculate the maxLevel for sequences so we can only execute the longest callback sequence 655 | for (i = 0; i < callbacks.length; ++i) { 656 | if (callbacks[i].seq) { 657 | maxLevel = Math.max(maxLevel, callbacks[i].level); 658 | } 659 | } 660 | 661 | // loop through matching callbacks for this key event 662 | for (i = 0; i < callbacks.length; ++i) { 663 | // fire for all sequence callbacks 664 | // this is because if for example you have multiple sequences 665 | // bound such as "g i" and "g t" they both need to fire the 666 | // callback for matching g cause otherwise you can only ever 667 | // match the first one 668 | if (callbacks[i].seq) { 669 | // only fire callbacks for the maxLevel to prevent 670 | // subsequences from also firing 671 | // 672 | // for example 'a option b' should not cause 'option b' to fire 673 | // even though 'option b' is part of the other sequence 674 | // 675 | // any sequences that do not match here will be discarded 676 | // below by the _resetSequences call 677 | if (callbacks[i].level != maxLevel) { 678 | continue; 679 | } 680 | 681 | processedSequenceCallback = true; 682 | 683 | // keep a list of which sequences were matches for later 684 | doNotReset[callbacks[i].seq] = 1; 685 | _fireCallback( 686 | callbacks[i].callback, 687 | e, 688 | callbacks[i].combo, 689 | callbacks[i].seq 690 | ); 691 | continue; 692 | } 693 | 694 | // if there were no sequence matches but we are still here 695 | // that means this is a regular match so we should fire that 696 | if (!processedSequenceCallback) { 697 | _fireCallback(callbacks[i].callback, e, callbacks[i].combo); 698 | } 699 | } 700 | 701 | // if the key you pressed matches the type of sequence without 702 | // being a modifier (ie "keyup" or "keypress") then we should 703 | // reset all sequences that were not matched by this event 704 | // 705 | // this is so, for example, if you have the sequence "h a t" and you 706 | // type "h e a r t" it does not match. in this case the "e" will 707 | // cause the sequence to reset 708 | // 709 | // modifier keys are ignored because you can have a sequence 710 | // that contains modifiers such as "enter ctrl+space" and in most 711 | // cases the modifier key will be pressed before the next key 712 | // 713 | // also if you have a sequence such as "ctrl+b a" then pressing the 714 | // "b" key will trigger a "keypress" and a "keydown" 715 | // 716 | // the "keydown" is expected when there is a modifier, but the 717 | // "keypress" ends up matching the _nextExpectedAction since it occurs 718 | // after and that causes the sequence to reset 719 | // 720 | // we ignore keypresses in a sequence that directly follow a keydown 721 | // for the same character 722 | const ignoreThisKeypress = e.type == 'keypress' && _ignoreNextKeypress; 723 | if ( 724 | e.type == _nextExpectedAction && 725 | !_isModifier(character) && 726 | !ignoreThisKeypress 727 | ) { 728 | _resetSequences(doNotReset); 729 | } 730 | 731 | _ignoreNextKeypress = processedSequenceCallback && e.type == 'keydown'; 732 | }; 733 | 734 | /** 735 | * handles a keydown event 736 | * 737 | * @param {Event} e 738 | * @returns void 739 | */ 740 | function _handleKeyEvent(e) { 741 | // normalize e.which for key events 742 | // @see http://stackoverflow.com/questions/4285627/javascript-keycode-vs-charcode-utter-confusion 743 | if (typeof e.which !== 'number') { 744 | e.which = e.keyCode; 745 | } 746 | 747 | const character = _characterFromEvent(e); 748 | 749 | // no character found then stop 750 | if (!character) { 751 | return; 752 | } 753 | 754 | // need to use === for the character check because the character can be 0 755 | if (e.type == 'keyup' && _ignoreNextKeyup === character) { 756 | _ignoreNextKeyup = false; 757 | return; 758 | } 759 | 760 | self.handleKey(character, _eventModifiers(e), e); 761 | } 762 | 763 | /** 764 | * called to set a 1 second timeout on the specified sequence 765 | * 766 | * this is so after each key press in the sequence you have 1 second 767 | * to press the next key before you have to start over 768 | * 769 | * @returns void 770 | */ 771 | function _resetSequenceTimer() { 772 | clearTimeout(_resetTimer); 773 | _resetTimer = setTimeout(_resetSequences, 1000); 774 | } 775 | 776 | /** 777 | * binds a key sequence to an event 778 | * 779 | * @param {string} combo - combo specified in bind call 780 | * @param {Array} keys 781 | * @param {Function} callback 782 | * @param {string=} action 783 | * @returns void 784 | */ 785 | function _bindSequence(combo, keys, callback, action) { 786 | // start off by adding a sequence level record for this combination 787 | // and setting the level to 0 788 | _sequenceLevels[combo] = 0; 789 | 790 | /** 791 | * callback to increase the sequence level for this sequence and reset 792 | * all other sequences that were active 793 | * 794 | * @param {string} nextAction 795 | * @returns {Function} 796 | */ 797 | function _increaseSequence(nextAction) { 798 | return function () { 799 | _nextExpectedAction = nextAction; 800 | ++_sequenceLevels[combo]; 801 | _resetSequenceTimer(); 802 | }; 803 | } 804 | 805 | /** 806 | * wraps the specified callback inside of another function in order 807 | * to reset all sequence counters as soon as this sequence is done 808 | * 809 | * @param {Event} e 810 | * @returns void 811 | */ 812 | function _callbackAndReset(e) { 813 | _fireCallback(callback, e, combo); 814 | 815 | // we should ignore the next key up if the action is key down 816 | // or keypress. this is so if you finish a sequence and 817 | // release the key the final key will not trigger a keyup 818 | if (action !== 'keyup') { 819 | _ignoreNextKeyup = _characterFromEvent(e); 820 | } 821 | 822 | // weird race condition if a sequence ends with the key 823 | // another sequence begins with 824 | setTimeout(_resetSequences, 10); 825 | } 826 | 827 | // loop through keys one at a time and bind the appropriate callback 828 | // function. for any key leading up to the final one it should 829 | // increase the sequence. after the final, it should reset all sequences 830 | // 831 | // if an action is specified in the original bind call then that will 832 | // be used throughout. otherwise we will pass the action that the 833 | // next key in the sequence should match. this allows a sequence 834 | // to mix and match keypress and keydown events depending on which 835 | // ones are better suited to the key provided 836 | for (let i = 0; i < keys.length; ++i) { 837 | const isFinal = i + 1 === keys.length; 838 | const wrappedCallback = isFinal 839 | ? _callbackAndReset 840 | : _increaseSequence(action || _getKeyInfo(keys[i + 1]).action); 841 | _bindSingle(keys[i], wrappedCallback, action, combo, i); 842 | } 843 | } 844 | 845 | /** 846 | * binds a single keyboard combination 847 | * 848 | * @param {string} combination 849 | * @param {Function} callback 850 | * @param {string=} action 851 | * @param {string=} sequenceName - name of sequence if part of sequence 852 | * @param {number=} level - what part of the sequence the command is 853 | * @returns void 854 | */ 855 | function _bindSingle(combination, callback, action, sequenceName, level) { 856 | // store a direct mapped reference for use with Mousetrap.trigger 857 | self._directMap[combination + ':' + action] = callback; 858 | 859 | // make sure multiple spaces in a row become a single space 860 | combination = combination.replace(/\s+/g, ' '); 861 | 862 | const sequence = combination.split(' '); 863 | let info; 864 | 865 | // if this pattern is a sequence of keys then run through this method 866 | // to reprocess each pattern one key at a time 867 | if (sequence.length > 1) { 868 | _bindSequence(combination, sequence, callback, action); 869 | return; 870 | } 871 | 872 | info = _getKeyInfo(combination, action); 873 | 874 | // make sure to initialize array if this is the first time 875 | // a callback is added for this key 876 | self._callbacks[info.key] = self._callbacks[info.key] || []; 877 | 878 | // remove an existing match if there is one 879 | _getMatches( 880 | info.key, 881 | info.modifiers, 882 | { type: info.action }, 883 | sequenceName, 884 | combination, 885 | level 886 | ); 887 | 888 | // add this call back to the array 889 | // if it is a sequence put it at the beginning 890 | // if not put it at the end 891 | // 892 | // this is important because the way these are processed expects 893 | // the sequence ones to come first 894 | self._callbacks[info.key][sequenceName ? 'unshift' : 'push']({ 895 | callback: callback, 896 | modifiers: info.modifiers, 897 | action: info.action, 898 | seq: sequenceName, 899 | level: level, 900 | combo: combination, 901 | }); 902 | } 903 | 904 | /** 905 | * binds multiple combinations to the same callback 906 | * 907 | * @param {Array} combinations 908 | * @param {Function} callback 909 | * @param {string|undefined} action 910 | * @returns void 911 | */ 912 | self._bindMultiple = function (combinations, callback, action) { 913 | for (let i = 0; i < combinations.length; ++i) { 914 | _bindSingle(combinations[i], callback, action); 915 | } 916 | }; 917 | 918 | // start! 919 | _addEvent(targetElement, 'keypress', _handleKeyEvent); 920 | _addEvent(targetElement, 'keydown', _handleKeyEvent); 921 | _addEvent(targetElement, 'keyup', _handleKeyEvent); 922 | } 923 | 924 | /** 925 | * binds an event to mousetrap 926 | * 927 | * can be a single key, a combination of keys separated with +, 928 | * an array of keys, or a sequence of keys separated by spaces 929 | * 930 | * be sure to list the modifier keys first to make sure that the 931 | * correct key ends up getting bound (the last key in the pattern) 932 | * 933 | * @param {string|Array} keys 934 | * @param {Function} callback 935 | * @param {string=} action - 'keypress', 'keydown', or 'keyup' 936 | * @returns void 937 | */ 938 | Mousetrap.prototype.bind = function (keys, callback, action) { 939 | const self = this; 940 | keys = keys instanceof Array ? keys : [keys]; 941 | self._bindMultiple.call(self, keys, callback, action); 942 | return self; 943 | }; 944 | 945 | /** 946 | * unbinds an event to mousetrap 947 | * 948 | * the unbinding sets the callback function of the specified key combo 949 | * to an empty function and deletes the corresponding key in the 950 | * _directMap dict. 951 | * 952 | * TODO: actually remove this from the _callbacks dictionary instead 953 | * of binding an empty function 954 | * 955 | * the keycombo+action has to be exactly the same as 956 | * it was defined in the bind method 957 | * 958 | * @param {string|Array} keys 959 | * @param {string} action 960 | * @returns void 961 | */ 962 | Mousetrap.prototype.unbind = function (keys, action) { 963 | const self = this; 964 | return self.bind.call(self, keys, function () {}, action); 965 | }; 966 | 967 | /** 968 | * triggers an event that has already been bound 969 | * 970 | * @param {string} keys 971 | * @param {string=} action 972 | * @returns void 973 | */ 974 | Mousetrap.prototype.trigger = function (keys, action) { 975 | const self = this; 976 | if (self._directMap[keys + ':' + action]) { 977 | self._directMap[keys + ':' + action]({}, keys); 978 | } 979 | return self; 980 | }; 981 | 982 | /** 983 | * resets the library back to its initial state. this is useful 984 | * if you want to clear out the current keyboard shortcuts and bind 985 | * new ones - for example if you switch to another page 986 | * 987 | * @returns void 988 | */ 989 | Mousetrap.prototype.reset = function () { 990 | const self = this; 991 | self._callbacks = {}; 992 | self._directMap = {}; 993 | return self; 994 | }; 995 | 996 | /** 997 | * should we stop this event before firing off callbacks 998 | * 999 | * @param {Event} e 1000 | * @param {Element} element 1001 | * @return {boolean} 1002 | */ 1003 | Mousetrap.prototype.stopCallback = function (e, element) { 1004 | const self = this; 1005 | 1006 | // if the element has the class "mousetrap" then no need to stop 1007 | if ((' ' + element.className + ' ').indexOf(' mousetrap ') > -1) { 1008 | return false; 1009 | } 1010 | 1011 | if (_belongsTo(element, self.target)) { 1012 | return false; 1013 | } 1014 | 1015 | // Events originating from a shadow DOM are re-targetted and `e.target` is the shadow host, 1016 | // not the initial event target in the shadow tree. Note that not all events cross the 1017 | // shadow boundary. 1018 | // For shadow trees with `mode: 'open'`, the initial event target is the first element in 1019 | // the event’s composed path. For shadow trees with `mode: 'closed'`, the initial event 1020 | // target cannot be obtained. 1021 | if ('composedPath' in e && typeof e.composedPath === 'function') { 1022 | // For open shadow trees, update `element` so that the following check works. 1023 | const initialEventTarget = e.composedPath()[0]; 1024 | if (initialEventTarget !== e.target) { 1025 | element = initialEventTarget; 1026 | } 1027 | } 1028 | 1029 | // stop for input, select, and textarea 1030 | return ( 1031 | element.tagName == 'INPUT' || 1032 | element.tagName == 'SELECT' || 1033 | element.tagName == 'TEXTAREA' || 1034 | element.isContentEditable 1035 | ); 1036 | }; 1037 | 1038 | /** 1039 | * exposes _handleKey publicly so it can be overwritten by extensions 1040 | */ 1041 | Mousetrap.prototype.handleKey = function () { 1042 | const self = this; 1043 | return self._handleKey.apply(self, arguments); 1044 | }; 1045 | 1046 | /** 1047 | * allow custom key mappings 1048 | */ 1049 | Mousetrap.addKeycodes = function (object) { 1050 | for (let key in object) { 1051 | // eslint-disable-next-line no-prototype-builtins 1052 | if (object.hasOwnProperty(key)) { 1053 | _MAP[key] = object[key]; 1054 | } 1055 | } 1056 | _REVERSE_MAP = null; 1057 | }; 1058 | 1059 | /** 1060 | * Init the global mousetrap functions 1061 | * 1062 | * This method is needed to allow the global mousetrap functions to work 1063 | * now that mousetrap is a constructor function. 1064 | */ 1065 | Mousetrap.init = function () { 1066 | const documentMousetrap = Mousetrap(document); 1067 | for (let method in documentMousetrap) { 1068 | if (method.charAt(0) !== '_') { 1069 | Mousetrap[method] = (function (method) { 1070 | return function () { 1071 | return documentMousetrap[method].apply( 1072 | documentMousetrap, 1073 | arguments 1074 | ); 1075 | }; 1076 | })(method); 1077 | } 1078 | } 1079 | }; 1080 | 1081 | Mousetrap.init(); 1082 | 1083 | // expose mousetrap to the global object 1084 | window.Mousetrap = Mousetrap; 1085 | 1086 | // expose as a common js module 1087 | if (typeof module !== 'undefined' && module.exports) { 1088 | module.exports = Mousetrap; 1089 | } 1090 | 1091 | // expose mousetrap as an AMD module 1092 | if (typeof define === 'function' && define.amd) { 1093 | define(function () { 1094 | return Mousetrap; 1095 | }); 1096 | } 1097 | })( 1098 | typeof window !== 'undefined' ? window : null, 1099 | typeof window !== 'undefined' ? document : null 1100 | ); 1101 | --------------------------------------------------------------------------------