├── .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 | 
8 | 
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 |
--------------------------------------------------------------------------------
/Vimarily/Assets.xcassets/Logo.imageset/logo-1.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/Vimarily/Assets.xcassets/Logo.imageset/logo-2.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/Vimarily/Assets.xcassets/Logo.imageset/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
--------------------------------------------------------------------------------