├── package ├── strings │ └── .gitkeep ├── scripts │ └── .gitkeep ├── assets │ ├── icon.png │ └── start.html ├── config.json └── settings.default.js ├── Makefile ├── bin ├── upload └── build ├── src ├── TabBrowser │ ├── ToolBar │ │ ├── ToolBar.js │ │ ├── ToolBarButtonContainer.js │ │ ├── ToolBarButton.js │ │ └── LocationBar │ │ │ ├── LocationBar.js │ │ │ └── LocationBarCompletion.js │ ├── TabList.js │ └── Tab │ │ └── TabContentWebView.js ├── Component.js ├── Observer.js ├── main.js └── content-script.js ├── .gitignore ├── package.json ├── webpack.config.js └── README.md /package/strings/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package/scripts/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /package/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mooz/ikeysnail/HEAD/package/assets/icon.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCES = $(shell find src/ -type f -name '*.js') package/settings.default.js 2 | PACKAGE = package/.output/ikeysnail.box 3 | 4 | .PHONY: package 5 | package: $(PACKAGE) 6 | 7 | $(PACKAGE): $(SOURCES) 8 | npx webpack-cli --mode=production 9 | cd package; npx jsbox-cli build 10 | 11 | release: $(PACKAGE) 12 | npx release-it 13 | -------------------------------------------------------------------------------- /bin/upload: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PROJ_ROOT=$(dirname $(realpath $0))/../ 4 | cd $PROJ_ROOT 5 | 6 | if [ $# -ne 1 ]; then 7 | echo "Usage: upload HOSTNAME" 8 | exit 1 9 | fi 10 | 11 | HOST=$1 12 | PKG_FILE="package/.output/ikeysnail.box" 13 | 14 | if [ -e $PKG_FILE ]; then 15 | curl -X POST --form "files[]"=@$PKG_FILE http://${HOST}/upload 16 | else 17 | echo "No package found !" 18 | exit 1 19 | fi 20 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PROJ_ROOT=$(dirname $(realpath $0))/../ 4 | cd $PROJ_ROOT 5 | 6 | npx prettier --tab-width 2 --write 'src/**/*.js' 7 | npx webpack --mode=production 8 | 9 | (cd package; npx jsbox build) 10 | 11 | PKG_FILE="package/.output/ikeysnail.box" 12 | 13 | if [[ -e $PKG_FILE ]]; then 14 | echo "Finished creating the package." 15 | echo "Run $ npm run release" 16 | else 17 | echo "Oops. Something went wrong." 18 | exit 1 19 | fi 20 | -------------------------------------------------------------------------------- /src/TabBrowser/ToolBar/ToolBar.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("../../Component"); 2 | 3 | class ToolBar extends Component { 4 | constructor(height) { 5 | super(); 6 | this._height = height; 7 | } 8 | 9 | build() { 10 | return { 11 | type: "view", 12 | props: { 13 | bgcolor: $color("clear") 14 | }, 15 | layout: (make, view) => { 16 | make.width.equalTo(view.super); 17 | make.height.equalTo(this._height); 18 | } 19 | }; 20 | } 21 | } 22 | 23 | exports.ToolBar = ToolBar; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/8edb8a95c4c4b3dce71a378aaaf89275510b9cef/Global/Linux.gitignore 2 | 3 | *~ 4 | 5 | # temporary files which can be created if a process still has a handle open of a deleted file 6 | .fuse_hidden* 7 | 8 | # KDE directory preferences 9 | .directory 10 | 11 | # Linux trash folder which might appear on any partition or disk 12 | .Trash-* 13 | 14 | # .nfs files are created when an open file is removed but is still being accessed 15 | .nfs* 16 | 17 | last-tabs.json 18 | last-url.txt 19 | 20 | node_modules/ 21 | .idea/ 22 | 23 | package/main.js 24 | package/content-script.js 25 | 26 | package-lock.json 27 | package/.output 28 | -------------------------------------------------------------------------------- /package/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "name": "ikeysnail", 4 | "url": "https://github.com/mooz/ikeysnail/releases/latest/download/ikeysnail.box", 5 | "version": "0.2.0", 6 | "author": "Masafumi Oyamada", 7 | "website": "https://github.com/mooz/ikeysnail", 8 | "types": 0 9 | }, 10 | "settings": { 11 | "minSDKVer": "1.0.0", 12 | "minOSVer": "10.0.0", 13 | "idleTimerDisabled": false, 14 | "autoKeyboardEnabled": false, 15 | "keyboardToolbarEnabled": false, 16 | "rotateDisabled": false 17 | }, 18 | "widget": { 19 | "height": 0, 20 | "staticSize": false, 21 | "tintColor": "", 22 | "iconColor": "" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ikeysnail", 3 | "version": "1.5.0", 4 | "description": "Hackable web browser for iOS", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "./bin/build", 9 | "upload": "./bin/upload" 10 | }, 11 | "author": "Masafumi Oyamada", 12 | "license": "MIT", 13 | "dependencies": { 14 | "webpack": "^4.44.1" 15 | }, 16 | "devDependencies": { 17 | "jsbox-cli": "^1.2.1", 18 | "prettier": "^1.18.2", 19 | "release-it": "^12.3.5", 20 | "webpack-cli": "^3.3.12" 21 | }, 22 | "release-it": { 23 | "github": { 24 | "release": true, 25 | "assets": [ 26 | "package/.output/ikeysnail.box" 27 | ] 28 | }, 29 | "npm": { 30 | "publish": false 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/TabBrowser/ToolBar/ToolBarButtonContainer.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("../../Component"); 2 | 3 | class ToolBarButtonContainer extends Component { 4 | constructor(align = "left", WIDTH_RATIO = 0.5, bgcolor = $color("clear")) { 5 | super(); 6 | this._align = align; 7 | this._bgcolor = bgcolor; 8 | this.WIDTH_RATIO = WIDTH_RATIO; 9 | } 10 | 11 | get align() { 12 | return this._align; 13 | } 14 | 15 | build() { 16 | return { 17 | type: "view", 18 | props: { 19 | bgcolor: this._bgcolor 20 | }, 21 | layout: (make, view) => { 22 | make.top.equalTo(view.super.top); 23 | make.height.equalTo(view.super.height); 24 | make.width.equalTo(view.super.width).multipliedBy(this.WIDTH_RATIO); 25 | 26 | if (this._align === "left") { 27 | make.left.inset(0); 28 | } else { 29 | make.right.inset(0); 30 | } 31 | } 32 | }; 33 | } 34 | } 35 | 36 | exports.ToolBarButtonContainer = ToolBarButtonContainer; 37 | -------------------------------------------------------------------------------- /package/assets/start.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Start Page 6 | 8 | 9 | 52 | 53 |

Welcome

54 | 55 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | /* 5 | * SplitChunksPlugin is enabled by default and replaced 6 | * deprecated CommonsChunkPlugin. It automatically identifies modules which 7 | * should be splitted of chunk by heuristics using module duplication count and 8 | * module category (i. e. node_modules). And splits the chunks… 9 | * 10 | * It is safe to remove "splitChunks" from the generated configuration 11 | * and was added as an educational example. 12 | * 13 | * https://webpack.js.org/plugins/split-chunks-plugin/ 14 | * 15 | */ 16 | 17 | module.exports = { 18 | mode: 'development', 19 | target: 'node', 20 | entry: { 21 | "main": './src/main.js', 22 | "content-script": './src/content-script.js' 23 | }, 24 | output: { 25 | filename: '[name].js', 26 | path: path.resolve(__dirname, 'package') 27 | }, 28 | 29 | plugins: [new webpack.ProgressPlugin()], 30 | 31 | resolve: { 32 | modules: ["./src"], 33 | }, 34 | 35 | module: { 36 | rules: [ 37 | { 38 | test: /.(js|jsx)$/, 39 | include: [], 40 | loader: 'babel-loader', 41 | 42 | options: { 43 | plugins: ['syntax-dynamic-import'], 44 | 45 | presets: [ 46 | [ 47 | '@babel/preset-env', 48 | { 49 | modules: false 50 | } 51 | ] 52 | ] 53 | } 54 | } 55 | ] 56 | }, 57 | 58 | optimization: { 59 | splitChunks: { 60 | cacheGroups: { 61 | vendors: { 62 | priority: -10, 63 | test: /[\\/]node_modules[\\/]/ 64 | } 65 | }, 66 | 67 | chunks: 'async', 68 | minChunks: 1, 69 | minSize: 30000, 70 | name: true 71 | } 72 | }, 73 | 74 | devServer: { 75 | open: true 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/TabBrowser/ToolBar/ToolBarButton.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("../../Component"); 2 | 3 | const SIZE_TOPBAR_ICON_BUTTON = 18; 4 | const COLOR_TOPBAR_BUTTON_FG = $color("#007AFF"); 5 | 6 | class ToolBarButton extends Component { 7 | constructor(iconType, onTapped) { 8 | super(); 9 | this._iconOrSymbol = iconType; 10 | this._onTapped = onTapped; 11 | this._padding = 30; 12 | } 13 | 14 | build() { 15 | const viewSource = { 16 | type: "button", 17 | props: { 18 | bgcolor: $color("clear") 19 | }, 20 | events: { 21 | tapped: this._onTapped 22 | }, 23 | layout: (make, view) => { 24 | // TODO: Better way? 25 | const siblings = this._parent.element.views; 26 | const nthChild = siblings.indexOf(view); 27 | if (this._parent.align === "left") { 28 | const basis = 29 | nthChild === 0 ? view.super.left : siblings[nthChild - 1].right; 30 | make.left.equalTo(basis).offset(this._padding); 31 | } else { 32 | const basis = 33 | nthChild === 0 ? view.super.right : siblings[nthChild - 1].left; 34 | make.right.equalTo(basis).offset(-this._padding); 35 | } 36 | make.centerY.equalTo(view.super); 37 | make.height.equalTo(view.super); 38 | } 39 | }; 40 | 41 | if (/^[0-9]+$/.test(this._iconOrSymbol)) { 42 | // https://github.com/cyanzhong/xTeko/tree/master/extension-icons 43 | viewSource.props.icon = $icon( 44 | this._iconOrSymbol, 45 | COLOR_TOPBAR_BUTTON_FG, 46 | $size(SIZE_TOPBAR_ICON_BUTTON, SIZE_TOPBAR_ICON_BUTTON) 47 | ); 48 | } else { 49 | // https://sfsymbols.com/ 50 | // https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ 51 | viewSource.props.symbol = this._iconOrSymbol; 52 | viewSource.props.tintColor = COLOR_TOPBAR_BUTTON_FG; 53 | } 54 | 55 | return viewSource; 56 | } 57 | } 58 | 59 | exports.ToolBarButton = ToolBarButton; 60 | -------------------------------------------------------------------------------- /src/Component.js: -------------------------------------------------------------------------------- 1 | /*** 2 | * コンポーネント 3 | * 4 | * 全ての要素はこのコンポーネントにより実装される 5 | */ 6 | class Component { 7 | constructor() { 8 | this._parent = null; 9 | this.id = $objc("NSUUID") 10 | .$UUID() 11 | .$UUIDString() 12 | .rawValue(); 13 | this.children = []; 14 | this._layout = null; 15 | this.state = {}; 16 | this._stateValue = {}; 17 | } 18 | 19 | defineState(keyValues) { 20 | Object.entries(keyValues).forEach(([key, value]) => { 21 | Object.defineProperty(this.state, key, { 22 | get: () => { 23 | return this._stateValue[key]; 24 | }, 25 | set: value => { 26 | throw Error( 27 | `Trying to assign a value ${value} to "${key}" by assignment op (=). Use setState() instead.` 28 | ); 29 | } 30 | }); 31 | this._stateValue[key] = value; 32 | }); 33 | } 34 | 35 | setState(keyValues) { 36 | Object.entries(keyValues).forEach(([key, value]) => { 37 | if (!this._stateValue.hasOwnProperty(key)) { 38 | $ui.toast(`No such state: ${key} in ${this.constructor.name}`); 39 | throw `No such state: ${key}`; 40 | } 41 | this._stateValue[key] = value; 42 | }); 43 | // TODO: 真に状態が変わったかをチェックすると、より再描画が減って効率的 44 | this._onStateChange(); 45 | } 46 | 47 | set layout(val) { 48 | this._layout = val; 49 | } 50 | 51 | get layout() { 52 | return this._layout; 53 | } 54 | 55 | get rendered() { 56 | return !!this.element; 57 | } 58 | 59 | get element() { 60 | return $(this.id); 61 | } 62 | 63 | set parent(val) { 64 | this._parent = val; 65 | } 66 | 67 | /** 68 | * このコンポーネントの状態が変わったり子供が追加されたら呼ばれる。 69 | * 70 | * デフォルトの挙動は、再レンダリング 71 | */ 72 | _onStateChange() { 73 | if (this.rendered) { 74 | console.error("Should be re-rendered. OK?"); 75 | } 76 | this.render(); 77 | } 78 | 79 | addChild(childComponent) { 80 | this.children.push(childComponent); 81 | childComponent.parent = this; 82 | return this; 83 | } 84 | 85 | removeChild(childComponent) { 86 | let childIndex = this.children.indexOf(childComponent); 87 | if (childIndex >= 0) { 88 | this.children[childIndex].element.remove(); 89 | this.children.splice(childIndex, 1); 90 | } else { 91 | throw "Child not found"; 92 | } 93 | } 94 | 95 | removeMe() { 96 | this._parent.removeChild(this); 97 | } 98 | 99 | get runtime() { 100 | return this.element.runtimeValue(); 101 | } 102 | 103 | get layer() { 104 | return this.runtime.$layer(); 105 | } 106 | 107 | build() { 108 | throw "Implement build() method"; 109 | } 110 | 111 | buildSource() { 112 | // Traverse children (DFS) 113 | // build() // this element 114 | // build() // child 1 115 | // build() // child 2 116 | // build() // child 2-1 117 | const viewSource = this.build(); 118 | if (viewSource) { 119 | viewSource.props.id = this.id; 120 | if (this.layout) { 121 | const originalLayout = viewSource.layout; 122 | const additionalLayout = this.layout; 123 | viewSource.layout = (make, view) => { 124 | originalLayout(make, view); 125 | additionalLayout(make, view); 126 | }; 127 | } 128 | 129 | // Limit to children whose viewSource is not null 130 | if (!viewSource.views) { 131 | viewSource.views = []; 132 | } 133 | viewSource.views = viewSource.views.concat( 134 | this.children.map(child => child.buildSource()).filter(view => view) 135 | ); 136 | } 137 | return viewSource; 138 | } 139 | 140 | /** 141 | * 自身より下の Component をレンダリングする 142 | * 143 | * 既にレンダリングされていた場合は、再レンダリング 144 | */ 145 | render() { 146 | const viewSource = this.buildSource(); 147 | 148 | if (this._parent) { 149 | // **** Non-root element **** 150 | // 既にレンダリングされていたら、一旦、削除する 151 | if (this.element) { 152 | this.element.remove(); 153 | } 154 | $(this._parent.id).add(viewSource); 155 | } else { 156 | // **** Root element **** 157 | // Root element 158 | $ui.render(viewSource); 159 | } 160 | } 161 | } 162 | 163 | exports.Component = Component; 164 | -------------------------------------------------------------------------------- /src/TabBrowser/ToolBar/LocationBar/LocationBar.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("../../../Component"); 2 | const { LocationBarCompletion } = require("./LocationBarCompletion"); 3 | 4 | // URL Bar 5 | const COLOR_URL_FG = "#2B9E46"; 6 | 7 | function debounce(func, interval = 500) { 8 | let timer = null; 9 | return (...args) => { 10 | if (timer) { 11 | clearTimeout(timer); 12 | } 13 | timer = setTimeout(async () => { 14 | func(...args); 15 | }, interval); 16 | }; 17 | } 18 | 19 | class LocationBar extends Component { 20 | constructor(browser, completion, WIDTH_RATIO = 0.5, HEIGHT_RATIO = 0.65) { 21 | super(); 22 | this._browser = browser; 23 | this._completion = completion; 24 | this.WIDTH_RATIO = WIDTH_RATIO; 25 | this.HEIGHT_RATIO = HEIGHT_RATIO; 26 | } 27 | 28 | focus() { 29 | this.element.focus(); 30 | this.runtime.$selectAll(null); 31 | this._completion.reset(); 32 | } 33 | 34 | blur() { 35 | this.element.blur(); 36 | this._browser.focusContent(); 37 | } 38 | 39 | setURLText(url) { 40 | this.element.text = decodeURIComponent(url); 41 | } 42 | 43 | build() { 44 | const isURL = urlLike => /https?:\/\//.test(urlLike); 45 | let browser = this._browser; 46 | let originalURL = null; 47 | 48 | let latestQuery = null; 49 | const obtainSuggestions = async (query, sender) => { 50 | latestQuery = query; 51 | if (query.length < 1) { 52 | this._completion.suggestions = null; 53 | return; 54 | } 55 | if (this._completion.canceled) { 56 | return; 57 | } 58 | const NUM_CANDIDATE_MAX = 5; 59 | const suggestionList = this._browser.config.LOCATIONBAR_SUGGESTIONS; 60 | // config.LOCATIONBAR_SUGGESTIONS = [ 61 | // "SuggestionTab", 62 | // "SuggestionBookmark", 63 | // "SuggestionHistory", 64 | // "SuggestionWebQuery", 65 | // ]; 66 | const suggestionTasks = suggestionList.map(suggestionClass => { 67 | if (typeof suggestionClass === "string") { 68 | const CompletionModule = require("./LocationBarCompletion"); 69 | suggestionClass = CompletionModule[suggestionClass]; 70 | } 71 | return suggestionClass.generateByQuery(query, browser); 72 | }); 73 | 74 | const waitAll = this._browser.config.LOCATIONBAR_SUGGESTIONS_SYNCED; 75 | if (waitAll) { 76 | let suggestions = await Promise.all(suggestionTasks); 77 | suggestions = suggestions 78 | .filter(s => !!s) 79 | .map(eachSuggestions => eachSuggestions.slice(0, NUM_CANDIDATE_MAX)) 80 | .flat(); 81 | if (this._completion.canceled) { 82 | return; 83 | } 84 | this._completion.suggestions = suggestions; 85 | } else { 86 | // (TODO) reorder candidates according to sources -> Not needed for usability. 87 | let allSuggestions = []; 88 | suggestionTasks.forEach(task => { 89 | task.then(completedSuggestions => { 90 | if (latestQuery !== query) { 91 | return; 92 | } 93 | if (completedSuggestions) { 94 | allSuggestions = allSuggestions.concat( 95 | completedSuggestions.slice(0, NUM_CANDIDATE_MAX) 96 | ); 97 | } 98 | if (this._completion.canceled) { 99 | return; 100 | } 101 | this._completion.setSuggestions( 102 | allSuggestions, 103 | this._completion.suggestionSelected 104 | ? this._completion.suggestionIndex 105 | : -1 106 | ); 107 | }); 108 | }); 109 | } 110 | }; 111 | const obtainSuggestionsDebounce = debounce(obtainSuggestions, 150); 112 | 113 | return { 114 | type: "input", 115 | props: { 116 | id: this.id, 117 | textColor: $color(COLOR_URL_FG), 118 | align: $align.center 119 | }, 120 | layout: (make, view) => { 121 | make.centerY.equalTo(view.super.center); 122 | make.height.equalTo(view.super.height).multipliedBy(this.HEIGHT_RATIO); 123 | make.width.equalTo(view.super.width).multipliedBy(this.WIDTH_RATIO); 124 | make.centerX.equalTo(view.super.center).priority(100); 125 | }, 126 | events: { 127 | didBeginEditing: sender => { 128 | sender.align = $align.left; 129 | sender.textColor = $rgba(0, 0, 0, 1); 130 | originalURL = sender.text; 131 | }, 132 | tapped: sender => { 133 | this.focus(); 134 | }, 135 | returned: sender => { 136 | this.decideCandidate(); 137 | }, 138 | didEndEditing: sender => { 139 | this._completion.cancel(); 140 | sender.align = $align.center; 141 | sender.textColor = $color(COLOR_URL_FG); 142 | sender.text = originalURL; 143 | }, 144 | changed: sender => { 145 | if (isURL(sender.text)) { 146 | return; 147 | } 148 | obtainSuggestionsDebounce(sender.text, sender); 149 | } 150 | } 151 | }; 152 | } 153 | 154 | decideCandidate() { 155 | if (this._completion.suggestionSelected) { 156 | this._completion.decide(); 157 | } else { 158 | this._browser.visitURL(this.element.text); 159 | this._browser.focusContent(); 160 | } 161 | } 162 | 163 | selectNextCandidate() { 164 | this._completion.selectNextCandidate(); 165 | } 166 | 167 | selectPreviousCandidate() { 168 | this._completion.selectPreviousCandidate(); 169 | } 170 | } 171 | 172 | exports.LocationBar = LocationBar; 173 | -------------------------------------------------------------------------------- /src/TabBrowser/TabList.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("Component"); 2 | 3 | const SIZE_TAB_CLOSE_ICON_BUTTON = 15; 4 | // blue 5 | const COLOR_TAB_BG_SELECTED = $rgba(250, 250, 250, 0.9); 6 | const COLOR_TAB_FG_SELECTED = $color("#000000"); 7 | const COLOR_TAB_BG_INACTIVE = $color("#cccccc"); 8 | const COLOR_TAB_FG_INACTIVE = $color("#666666"); 9 | const COLOR_TAB_LIST_BG = $color("#bbbbbb"); 10 | 11 | class TabList extends Component { 12 | constructor(browser) { 13 | super(); 14 | 15 | this.config = browser.config; 16 | this._browser = browser; 17 | this._eventHandlers = { 18 | didSelect: (sender, indexPath) => { 19 | this._browser.selectTab(indexPath.row); 20 | }, 21 | didLongPress: (sender, indexPath) => { 22 | const commands = [ 23 | ["Copy", () => browser.copyTabInfo(indexPath.row)], 24 | ["Close other tabs", () => browser.closeTabsBesides(indexPath.row)], 25 | [ 26 | "Open in external browser", 27 | () => browser.openInExternalBrowser(indexPath.row) 28 | ] 29 | ]; 30 | $ui.menu({ 31 | items: commands.map(c => c[0]), 32 | handler: function(title, idx) { 33 | if (idx >= 0) { 34 | commands[idx][1](); 35 | } 36 | }, 37 | finished: function(cancelled) { 38 | // nothing? 39 | } 40 | }); 41 | } 42 | }; 43 | 44 | this._tabTemplate = { 45 | props: {}, 46 | views: [ 47 | { 48 | type: "view", 49 | props: { 50 | id: "tab-rectangle" 51 | }, 52 | layout: $layout.fill, 53 | views: [ 54 | { 55 | type: "label", 56 | props: { 57 | id: "tab-name", 58 | align: $align.center, 59 | font: $font(this.config.SIZE_TAB_FONT) 60 | }, 61 | layout: (make, view) => { 62 | make.height.equalTo(view.super.height); 63 | make.width.equalTo(view.super.width).offset(-30); 64 | make.left.equalTo(view.super.left).offset(25); 65 | } 66 | } 67 | ] 68 | }, 69 | { 70 | type: "button", 71 | props: { 72 | id: "close-button", 73 | icon: $icon( 74 | "225", 75 | $rgba(140, 140, 140, 0.8), 76 | $size(SIZE_TAB_CLOSE_ICON_BUTTON, SIZE_TAB_CLOSE_ICON_BUTTON) 77 | ), 78 | bgcolor: $color("clear") 79 | }, 80 | events: { 81 | tapped: async () => { 82 | this._browser.closeCurrentTab(); 83 | } 84 | }, 85 | layout: (make, view) => { 86 | make.left.equalTo(view.super.left).offset(5); 87 | make.top.inset(5); 88 | } 89 | } 90 | ] 91 | }; 92 | } 93 | 94 | get tabNames() { 95 | let names = this._browser._tabs.map(tab => tab.title); 96 | return names; 97 | } 98 | 99 | get currentTabIndex() { 100 | return this._browser.currentTabIndex; 101 | } 102 | 103 | build() { 104 | const data = this.tabNames.map((name, index) => { 105 | if (index === this.currentTabIndex) { 106 | return { 107 | "tab-name": { 108 | text: name 109 | }, 110 | "tab-rectangle": { 111 | bgcolor: COLOR_TAB_BG_SELECTED, 112 | textColor: COLOR_TAB_FG_SELECTED, 113 | tabIndex: index 114 | } 115 | }; 116 | } else { 117 | return { 118 | "tab-name": { 119 | text: name 120 | }, 121 | "tab-rectangle": { 122 | bgcolor: COLOR_TAB_BG_INACTIVE, 123 | textColor: COLOR_TAB_FG_INACTIVE, 124 | tabIndex: index 125 | }, 126 | "close-button": { 127 | hidden: true 128 | } 129 | }; 130 | } 131 | }); 132 | 133 | if (!data || data.length === 0) { 134 | return null; 135 | } 136 | 137 | return this._buildTabList(data); 138 | } 139 | } 140 | 141 | class TabListVertical extends TabList { 142 | constructor(browser) { 143 | super(browser); 144 | this.config = browser.config; 145 | } 146 | 147 | _buildTabList(data) { 148 | return { 149 | type: "list", 150 | events: this._eventHandlers, 151 | props: { 152 | id: "pages-tab", 153 | rowHeight: this.config.TAB_HEIGHT, 154 | // spacing: 0, 155 | template: this._tabTemplate, 156 | data: data, 157 | bgcolor: COLOR_TAB_LIST_BG, 158 | borderWidth: 0 159 | }, 160 | layout: (make, view) => { 161 | make.width.equalTo(this.config.TAB_VERTICAL_WIDTH); 162 | make.height.equalTo(view.super); 163 | make.top.equalTo(view.super); 164 | make.left.equalTo(view.super); 165 | } 166 | }; 167 | } 168 | } 169 | 170 | class TabListHorizontal extends TabList { 171 | constructor(browser) { 172 | super(browser); 173 | this.config = browser.config; 174 | } 175 | 176 | _buildTabList(data) { 177 | return { 178 | type: "matrix", 179 | events: this._eventHandlers, 180 | props: { 181 | id: "pages-tab", 182 | columns: data.length, 183 | itemHeight: this.config.TAB_HEIGHT, 184 | spacing: 0, 185 | template: this._tabTemplate, 186 | data: data 187 | }, 188 | layout: (make, view) => { 189 | make.width.equalTo(view.super); 190 | make.height.equalTo(this.config.TAB_HEIGHT); 191 | make.top.equalTo(view.super); 192 | make.left.equalTo(view.super); 193 | } 194 | }; 195 | } 196 | } 197 | 198 | exports.TabListHorizontal = TabListHorizontal; 199 | exports.TabListVertical = TabListVertical; 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iKeySnail 2 | 3 | iKeySnail provides fully-configurable hardware keyboard functionalities for web browsing on iOS (iPadOS). 4 | 5 | The aim of this project is to provide { Vimium, Vimperator, Surfingkeys, KeySnail } for iOS. Currently, **iKeySnail** supports 6 | 7 | - **Hardware keyboard supported web browsing** 8 | - **Emacs-like keybindings/functionalities** 9 | - e.g., `Ctrl-Space` to set mark, `Meta-w` to copy the selected region, `Ctrl-y` to yank (paste) the clipboard text 10 | - **Vim-like keybindings/functionalities** 11 | - e.g., `j/k/h/l/g/G` to quickly scroll web pages 12 | - **Link-hints (Hit-a-hint)** 13 | - For clicking links without touching your iOS device screen 14 | - ![hints](https://gyazo.com/e29a8499426094c5502882996549df49.png) 15 | - **Vertical tabs** 16 | - We do support vertical tabs in iOS! 17 | - Setting `config.TAB_VERTICAL = true` makes tab orientations vertical 18 | - ![vtabs](https://i.gyazo.com/709ea2e6826261fc9f190f5c40d83b4d.png) 19 | - **Omnibar support** 20 | - Work-in-progress, but we do support omnibar. By default, pressing `o` opens your bookmarks. 21 | - ![omnibar](https://i.gyazo.com/fd8c924afcf242a85598bc4123070f53.png) 22 | 23 | ## Installation 24 | 25 | You need JSBox (https://docs.xteko.com/#/en/) to run iKeySnail. After installing the JSBox, access either of 26 | - 27 | - jsbox://import/?url=https%3A%2F%2Fgithub.com%2Fmooz%2Fikeysnail%2Freleases%2Flatest%2Fdownload%2Fikeysnail.box 28 | 29 | from iOS Safari. Then JSBox will install iKeySnail. 30 | 31 | ### Manual Build 32 | 33 | You can also manually build the package and install it in JSBox: 34 | 35 | make package 36 | 37 | then copy `.output/ikeysnail.box` to your JSBox app. 38 | 39 | ## Usage 40 | 41 | See `strings/settings.js` for available shortcuts. 42 | 43 | ## Customization 44 | 45 | Edit `strings/settings.js`. 46 | 47 | ### Remote Settings 48 | 49 | Tired of manually syncing your config across all of your devices? 50 | 51 | Remote settings is your help. Prepare `strings/settings.js` that contains a variable `config.REMOTE_CONFIG_URL` and ikeysnail refers to the specified configuration on the remote server. For example 52 | ```javascript 53 | config.REMOTE_CONFIG_URL = "https://gist.githubusercontent.com/mooz/676f15e3814751df2e1b67e0b14f5f97/raw/ikeysnail_config.js"; 54 | ``` 55 | works. 56 | 57 | ### Defining / Customizing Keymap 58 | 59 | We have four types of `mode` for key bindings. 60 | 61 | 1. `all`-mode, whose keymaps are always active. 62 | - 63 | 2. `view`-mode, whose keymaps are active only if the cursor isn't on editable elements (akin to vim's `normal` mode). 64 | - 65 | 3. `rich`-mode, whose keymaps are active only if the cursor is on rich text editors (such as CodeMirror, Ace, Scrapbox, and `contenteditable`). 66 | - 67 | 4. `edit`-mode, whose keymaps are active only if the cursor is on `input` or `textarea`. 68 | - 69 | 70 | In each keymap, you can define a key's functionality in two ways: 71 | 1. Remapping to different key (e.g., `"ctrl-s": "meta-f"`), and 72 | 2. Invoke a JavaScript function (e.g., `"ctrl-y": () => keysnail.paste()`). 73 | - See `keysnail` object for checking available functionalities 74 | 75 | ### Defining a site 76 | 77 | You can also define a site configuration in your `settings.js`. Configuration consists of 78 | - `keymap` -> keymap 79 | - `style` -> user css 80 | - `alias` -> alias 81 | - `url` -> url. 82 | 83 | Examples are follows. 84 | 85 | ```javascript 86 | config.sites.push({ 87 | alias: "Google", 88 | url: "https://www.google.com" 89 | }); 90 | 91 | const GDOCS_KEYMAP = { 92 | rich: { 93 | "meta-f": keysnail.marked("alt-ArrowRight"), 94 | "meta-b": keysnail.marked("alt-ArrowLeft"), 95 | "meta-d": keysnail.marked("alt-Delete"), 96 | "ctrl-_": "ctrl-z", 97 | "ctrl-z": "meta-z", 98 | "ctrl-s": "ctrl-f" 99 | } 100 | }; 101 | 102 | config.sites.push({ 103 | alias: "Google Docs", 104 | url: "https://docs.google.com/", 105 | keymap: GDOCS_KEYMAP 106 | }); 107 | 108 | config.sites.push({ 109 | alias: "Google Docs (Slide)", 110 | url: "https://docs.google.com/presentation/", 111 | keymap: GDOCS_KEYMAP 112 | }); 113 | 114 | config.sites.push({ 115 | alias: "OverLeaf", 116 | url: "https://www.overleaf.com/project/", 117 | style: ` 118 | .toolbar { font-size: small !important; } 119 | .entity { font-size: small !important; } 120 | ` 121 | }); 122 | 123 | config.sites.push({ 124 | alias: "Scrapbox", 125 | url: "https://scrapbox.io/", 126 | keymap: { 127 | rich: { 128 | "meta-f": keysnail.marked("alt-ArrowRight"), 129 | "meta-b": keysnail.marked("alt-ArrowLeft"), 130 | "ctrl-i": "ctrl-i", 131 | "ctrl-t": "ctrl-t" 132 | } 133 | }, 134 | style: ` 135 | #editor { 136 | caret-color: transparent !important; 137 | } 138 | ` 139 | }); 140 | ``` 141 | 142 | ## Gifs 143 | 144 | ### Omnibar 145 | 146 | ![omnibar-mov](https://i.gyazo.com/cd5257f363c6b496b0b576523b771782.gif) 147 | 148 | ### Link hints 149 | 150 | ![linkhints-mov](https://i.gyazo.com/18bc245a55f29876c4937a0884d8bf8d.gif) 151 | 152 | ## Acknowledgements 153 | 154 | Parts of iKeySnail are inspired by previous wonderful works (thanks to). 155 | 156 | - Scrapbox scripts by @four_or_three 157 | - 158 | - Bookmarklet Hit-a-hint @okayu_tar_gz 159 | - 160 | 161 | # Releasing (for developers) 162 | 163 | ## Building a package 164 | 165 | make package 166 | 167 | ## Releasing a package 168 | 169 | make release 170 | -------------------------------------------------------------------------------- /src/Observer.js: -------------------------------------------------------------------------------- 1 | class Observer { 2 | constructor() {} 3 | 4 | _onReady() {} 5 | 6 | _onExit() {} 7 | 8 | onReady() { 9 | try { 10 | this._onReady(); 11 | } catch (x) { 12 | console.error(x); 13 | } 14 | } 15 | 16 | onExit() { 17 | try { 18 | this._onExit(); 19 | } catch (x) { 20 | console.error(x); 21 | } 22 | } 23 | } 24 | 25 | export class SystemKeyHandler extends Observer { 26 | constructor(browser, config) { 27 | super(); 28 | this.browser = browser; 29 | this.config = config; 30 | } 31 | 32 | _onExit() { 33 | $objc("RedBoxCore").$cleanClass("UIApplication"); 34 | } 35 | 36 | _onReady() { 37 | function flip(obj) { 38 | const ret = {}; 39 | Object.keys(obj).forEach(key => { 40 | ret[obj[key]] = key; 41 | }); 42 | return ret; 43 | } 44 | 45 | let ctrlKey = false; 46 | let metaKey = false; 47 | let optionKey = false; 48 | 49 | let locationBarInputElement = this.browser._locationBar.element.runtimeValue(); 50 | let findBarInputElement = this.browser._searchBar.textInput.runtimeValue(); 51 | 52 | const key = { 53 | option: 226, 54 | meta: 227, 55 | Escape: 41, 56 | Enter: 40, 57 | ctrl: 224, 58 | " ": 44 59 | }; 60 | for (let i = 0; i < 27; ++i) { 61 | key[String.fromCharCode(97 + i)] = 4 + i; 62 | } 63 | let config = this.config; 64 | if (config.SWAP_COMMAND_OPTION) { 65 | let originalOption = key.option; 66 | key.option = key.meta; 67 | key.meta = originalOption; 68 | } 69 | Object.freeze(key); 70 | const codeToKey = flip(key); 71 | 72 | let defaultCommands = Object.assign({}, config.systemKeyMap.all); 73 | if (config.CAPTURE_CTRL_SPACE) { 74 | defaultCommands["ctrl- "] = browser => 75 | browser.selectedTab.dispatchCtrlSpace(); 76 | } 77 | let findBarCommands = Object.assign({}, config.systemKeyMap.findBar); 78 | let urlBarCommands = Object.assign({}, config.systemKeyMap.urlBar); 79 | 80 | // Key repeat handler 81 | let keyRepeatTimer = null; 82 | let keyRepeatThread = null; 83 | let keyRepeatString = null; 84 | 85 | let browser = this.browser; 86 | 87 | // Global key configuration 88 | $define({ 89 | type: "UIApplication", 90 | events: { 91 | // Swizzling handleKeyUIEvent doesn't work. We need to swizzle the private one (_handleXXX). 92 | "_handleKeyUIEvent:": evt => { 93 | // https://developer.limneos.net/?ios=11.1.2&framework=UIKit.framework&header=UIPhysicalKeyboardEvent.h 94 | // console.log(evt); 95 | // console.log("commandModifiedInput: " + evt.$__commandModifiedInput()); 96 | // console.log("gsModifierFlags: " + evt.$__gsModifierFlags()); 97 | // console.log("inputFlags: " + evt.$__inputFlags()); 98 | // console.log("isKeyDown: " + evt.$__isKeyDown()); 99 | // console.log("keyCode: " + evt.$__keyCode()); 100 | // console.log("markedInput: " + evt.$__markedInput()); 101 | // console.log("modifiedInput: " + evt.$__modifiedInput()); 102 | // console.log("modifierFlags: " + evt.$__modifierFlags()); 103 | // console.log("privateInput: " + evt.$__privateInput()); 104 | // console.log("shiftModifiedInput: " + evt.$__shiftModifiedInput()); 105 | // console.log("unmodifiedInput: " + evt.$__unmodifiedInput()); 106 | 107 | const keyCode = evt.$__keyCode(); 108 | const pressed = evt.$__isKeyDown(); 109 | const keyString = codeToKey[keyCode]; 110 | 111 | if (!codeToKey.hasOwnProperty(keyCode)) { 112 | return self.$ORIG__handleKeyUIEvent(evt); 113 | } 114 | 115 | // Up -> 82 116 | // Down -> 81 117 | // Left -> 80 118 | // Right -> 79 119 | 120 | // Exec commands 121 | if (keyCode === key.ctrl) { 122 | ctrlKey = pressed; 123 | } else if (keyCode === key.meta) { 124 | metaKey = pressed; 125 | } else if (keyCode === key.option) { 126 | optionKey = pressed; 127 | } else { 128 | let completeKeyString = keyString; 129 | if (metaKey) completeKeyString = "meta-" + completeKeyString; 130 | if (ctrlKey) completeKeyString = "ctrl-" + completeKeyString; 131 | if (optionKey) completeKeyString = "alt-" + completeKeyString; 132 | 133 | function handleKeyDown(completeKeyString, commands) { 134 | if (keyRepeatString !== completeKeyString) { 135 | if (keyRepeatTimer) { 136 | clearTimeout(keyRepeatTimer); 137 | if (keyRepeatThread) { 138 | clearInterval(keyRepeatThread); 139 | keyRepeatThread = null; 140 | } 141 | } 142 | keyRepeatString = completeKeyString; 143 | keyRepeatTimer = setTimeout(() => { 144 | keyRepeatThread = setInterval(() => { 145 | commands[completeKeyString](browser); 146 | }, config.KEY_REPEAT_INTERVAL); 147 | }, config.KEY_REPEAT_INITIAL); 148 | } 149 | 150 | commands[completeKeyString](browser); 151 | } 152 | 153 | function handleKeyUp(completeKeyString) { 154 | if (completeKeyString === keyRepeatString) { 155 | if (keyRepeatTimer) clearTimeout(keyRepeatTimer); 156 | if (keyRepeatThread) clearInterval(keyRepeatThread); 157 | keyRepeatString = null; 158 | keyRepeatTimer = null; 159 | keyRepeatThread = null; 160 | } 161 | } 162 | 163 | // Decide keymap 164 | let commands = defaultCommands; 165 | if (locationBarInputElement.$isFirstResponder()) { 166 | commands = urlBarCommands; 167 | } else if (findBarInputElement.$isFirstResponder()) { 168 | commands = findBarCommands; 169 | } 170 | 171 | if (commands.hasOwnProperty(completeKeyString)) { 172 | if (pressed) { 173 | handleKeyDown(completeKeyString, commands); 174 | } else { 175 | handleKeyUp(completeKeyString); 176 | } 177 | return null; 178 | } else { 179 | // If key is pressed 180 | if (!pressed) { 181 | handleKeyUp(completeKeyString); 182 | } 183 | } 184 | } 185 | return self.$ORIG__handleKeyUIEvent(evt); 186 | } 187 | } 188 | }); 189 | } 190 | } 191 | 192 | export class ShortcutKeyDeactivator extends Observer { 193 | constructor() { 194 | super(); 195 | } 196 | 197 | _onExit() { 198 | $objc("RedBoxCore").$cleanClass("UIResponder"); 199 | } 200 | 201 | _onReady() { 202 | $define({ 203 | type: "UIResponder", 204 | events: { 205 | "_keyCommandForEvent:": evt => { 206 | // Disable all shortcut keys of JSBox (Meta-w) 207 | // TODO: Does overriding `keyCommands` property work? (it's bettter, because it prevents showing shortcut key help) 208 | return null; 209 | } 210 | } 211 | }); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/TabBrowser/Tab/TabContentWebView.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("../../Component"); 2 | 3 | // -------------------------------------------------------------------- // 4 | // Tab class 5 | // -------------------------------------------------------------------- // 6 | 7 | class TabContentWebView extends Component { 8 | constructor(browser, config, url = "http://www.google.com", userScript = "") { 9 | super(); 10 | 11 | this.browser = browser; 12 | this.config = config; 13 | this.userScript = userScript; 14 | this._title = url; 15 | this.url = url; 16 | this._loaded = false; 17 | 18 | let tab = this; 19 | this.eventHandler = { 20 | log: ({ message }) => { 21 | if (this.config.DEBUG_CONSOLE) { 22 | console.log(message); 23 | } 24 | }, 25 | titleDetermined: ({ title }) => { 26 | if (this.element.url === "about:blank") { 27 | this.title = "New Tab"; 28 | } else { 29 | this.title = title; 30 | } 31 | this.browser.onTabTitleDetermined(this); 32 | }, 33 | decideNavigation: (sender, action) => { 34 | if (!action.runtimeValue().$targetFrame()) { 35 | // target == _blank or something like that (targetFrame == nil) 36 | this.browser.createNewTab(action.requestURL, true); 37 | return false; 38 | } 39 | return true; 40 | }, 41 | didFinish: async sender => { 42 | // Nothing? 43 | }, 44 | didStart: sender => { 45 | this.title = "Loading ..."; 46 | this.browser.onTabStartLoading(this); 47 | }, 48 | urlDidChange: async sender => { 49 | this.title = await evalScript(tab, "document.title", true); 50 | this.browser.onTabURLChanged(this); 51 | }, 52 | exitApplication: () => $app.close(), 53 | share: () => this.browser.share(), 54 | scrap: () => this.browser.scrap(), 55 | gotoDailyNote: () => this.browser.gotoDailyNote(), 56 | message: ({ message, duration }) => { 57 | $ui.toast(message, duration || 3); 58 | }, 59 | paste: () => { 60 | evalScript( 61 | tab, 62 | `__keysnail__.insertText('${escape($clipboard.text)}', true)`, 63 | false 64 | ); 65 | }, 66 | closeTab: () => { 67 | this.browser.closeTab(tab); 68 | }, 69 | createNewTab: args => { 70 | let { url, openInBackground } = args || { 71 | url: null, 72 | openInBackground: false 73 | }; 74 | this.browser.createNewTab(url || "about:blank", !openInBackground); 75 | if (!url) { 76 | this.browser.focusLocationBar(); 77 | } 78 | }, 79 | loadScript: ({ src, encoding }) => { 80 | $http.request({ 81 | method: "GET", 82 | url: src, 83 | handler: async resp => { 84 | await evalScript(tab, resp.data); 85 | await evalScript(tab, `__keysnail__.notifyScriptLoaded('${src}')`); 86 | } 87 | }); 88 | }, 89 | selectNextTab: () => { 90 | this.browser.selectNextTab(); 91 | }, 92 | selectPreviousTab: () => { 93 | this.browser.selectPreviousTab(); 94 | }, 95 | undoClosedTab: () => { 96 | this.browser.undoClosedTab(); 97 | }, 98 | focusLocationBar: () => { 99 | this.browser.focusLocationBar(); 100 | }, 101 | searchText: (args = {}) => { 102 | let { backward } = args; 103 | this.browser.focusFindBar(backward); 104 | }, 105 | updateSearchPositionInfo: ({ resultText }) => { 106 | this.browser.updateSearchPositionInfo(resultText); 107 | }, 108 | copyText: ({ text }) => { 109 | $clipboard.set({ type: "public.plain-text", value: text }); 110 | }, 111 | openClipboardURL: async () => { 112 | let url = await evalScript(tab, `__keysnail__.getSelectedText()`); 113 | if (!url) { 114 | url = $clipboard.text; 115 | } 116 | this.browser.createNewTab(url, true); 117 | }, 118 | selectTabsByPanel: () => { 119 | browser.selectTabsByPanel(); 120 | }, 121 | selectTabByIndex: ({ index }) => { 122 | this.browser.selectTab(index); 123 | } 124 | }; 125 | } 126 | 127 | destroy() { 128 | this.visitURL(null); // Expect early GC 129 | this.removeMe(); 130 | } 131 | 132 | get selected() { 133 | const browser = this.browser; 134 | return this === browser.selectedTab; 135 | } 136 | 137 | get loaded() { 138 | return this._loaded; 139 | } 140 | 141 | deselect() { 142 | if (this.element) { 143 | this.element.hidden = true; 144 | } 145 | } 146 | 147 | select() { 148 | this.load(); 149 | if (this.element.hidden) { 150 | this.element.hidden = false; 151 | } 152 | // https://github.com/WebKit/webkit/blob/39a299616172a4d4fe1f7aaf573b41020a1d7358/Source/WebKit/UIProcess/API/Cocoa/WKWebView.mm#L1318 153 | this.runtimeWebView.$becomeFirstResponder(); 154 | } 155 | 156 | set url(val) { 157 | this._url = val; 158 | if (this._loaded) { 159 | this.element.url = val; 160 | } 161 | } 162 | 163 | get iconURL() { 164 | return `https://cdn-ak.favicon.st-hatena.com/?url=${encodeURIComponent(this.url)}`; 165 | } 166 | 167 | get url() { 168 | let url = null; 169 | if (this.element) { 170 | url = this.element.url; 171 | } else { 172 | url = this._url; 173 | } 174 | if (!url) { 175 | return this.config.NEW_PAGE_URL; 176 | } 177 | return url; 178 | } 179 | 180 | get element() { 181 | let element = $(this.id); 182 | return element; 183 | } 184 | 185 | get title() { 186 | return this._title; 187 | } 188 | 189 | set title(value) { 190 | this._title = value; 191 | this.browser._tabList.render(); 192 | } 193 | 194 | showBookmark() { 195 | evalScript(this, "__keysnail__.startSiteSelector(true)"); 196 | } 197 | 198 | showKeyHelp() { 199 | evalScript(this, "__keysnail__.showKeyHelp()"); 200 | } 201 | 202 | async searchText(text, backward = false) { 203 | await evalScript( 204 | this, 205 | `__keysnail__.searchTextEncoded('${encodeURIComponent( 206 | text 207 | )}', ${backward})` 208 | ); 209 | } 210 | 211 | goBack() { 212 | this.element.goBack(); 213 | } 214 | 215 | goForward() { 216 | this.element.goForward(); 217 | } 218 | 219 | visitURL(url) { 220 | this.url = url; 221 | } 222 | 223 | load() { 224 | if (this._loaded) return; 225 | this._loaded = true; 226 | this.render(); 227 | this.runtimeWebView.$setAllowsBackForwardNavigationGestures(true); 228 | } 229 | 230 | get runtimeWebView() { 231 | return this.element.runtimeValue(); 232 | } 233 | 234 | unload() { 235 | if (this._loaded) { 236 | this._loaded = false; 237 | this.element.url = null; 238 | this.element.remove(); 239 | } 240 | } 241 | 242 | dispatchCtrlSpace() { 243 | evalScript(this, `__keysnail__.dispatchKey("ctrl- ", false, true)`); 244 | } 245 | 246 | build() { 247 | if (!this._loaded) return null; 248 | 249 | let url = this.url; 250 | let userScript = this.userScript; 251 | let props = { 252 | id: this.id, 253 | ua: this.config.USER_AGENT, 254 | script: userScript, 255 | hidden: false, 256 | url: url 257 | }; 258 | 259 | return { 260 | type: "web", 261 | props: props, 262 | events: this.eventHandler, 263 | layout: (make, view) => { 264 | make.edges.equalTo(view.super); 265 | } 266 | }; 267 | } 268 | 269 | evalScript(contentScript) { 270 | return new Promise((resolve, reject) => { 271 | this.element.eval({ 272 | script: contentScript, 273 | handler: (result, err) => { 274 | if (err) { 275 | reject(err); 276 | } else { 277 | resolve(result); 278 | } 279 | } 280 | }); 281 | }); 282 | } 283 | } 284 | 285 | function evalScript(tab, contentScript, promisify = true) { 286 | if (promisify) { 287 | return new Promise((resolve, reject) => { 288 | tab.element.eval({ 289 | script: contentScript, 290 | handler: (result, err) => { 291 | if (err || typeof result === "object") { 292 | reject(err); 293 | } else { 294 | resolve(result); 295 | } 296 | } 297 | }); 298 | }); 299 | } else { 300 | tab.element.eval({ script: contentScript }); 301 | return null; 302 | } 303 | } 304 | 305 | exports.TabContentWebView = TabContentWebView; 306 | -------------------------------------------------------------------------------- /package/settings.default.js: -------------------------------------------------------------------------------- 1 | config.DEBUG_SHOW_INPUT_KEY = false; 2 | config.DEBUG_SHOW_DISPATCH_KEY = false; 3 | config.DEBUG_SHOW_MESSAGE = false; 4 | 5 | // Tab UI related settings 6 | config.TAB_HEIGHT = 30; 7 | config.TAB_VERTICAL = false; 8 | config.TAB_VERTICAL_WIDTH = 250; 9 | config.TAB_LAZY_LOADING = true; 10 | config.SIZE_TAB_FONT = 13; 11 | config.TOPBAR_HEIGHT = 50; 12 | 13 | // Other UI related settings 14 | config.HIDE_STATUSBAR = true; 15 | config.HIDE_TOOLBAR = true; 16 | 17 | // Auto key-repeating for alphabets and numbers 18 | config.KEY_REPEAT_ENABLED = true; 19 | config.KEY_REPEAT_INTERVAL = 0.03 * 1000; 20 | config.KEY_REPEAT_INITIAL = 0.2 * 1000; 21 | 22 | // Wether to swap command and option key (useful for non Mac keyboards) 23 | config.SWAP_COMMAND_OPTION = false; 24 | 25 | // Wether to capture ctrl-space key 26 | config.CAPTURE_CTRL_SPACE = true; 27 | 28 | // Specify your scrapbox account 29 | config.SCRAPBOX_USER = null; 30 | 31 | // Misc settings 32 | config.NEW_PAGE_URL = "https://www.google.com/"; 33 | config.USER_AGENT = 34 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0 Safari/605.1.15"; 35 | 36 | if (!isContent) { 37 | // Configure order of location bar suggestion 38 | config.LOCATIONBAR_SUGGESTIONS = [ 39 | "SuggestionTab", 40 | "SuggestionBookmark", 41 | "SuggestionHistory", 42 | "SuggestionScrapbox", 43 | "SuggestionWebQuery", 44 | ]; 45 | config.LOCATIONBAR_SUGGESTIONS_SYNCED = false; 46 | } 47 | 48 | // -------------------------------------------------------------------------------- 49 | // Keymap 50 | // -------------------------------------------------------------------------------- 51 | 52 | // Global Keymap. See for key syntax. 53 | // https://developer.mozilla.org/ja/docs/Web/API/KeyboardEvent/key/Key_Values 54 | 55 | config.globalKeyMap = { 56 | all: { 57 | "meta-x": keysnail.command( 58 | () => keysnail.showKeyHelp(), 59 | "M-x (Show key help and commands)" 60 | ), 61 | "meta-w": keysnail.command( 62 | () => keysnail.copyRegion(), 63 | "Copy selected text" 64 | ), 65 | "ctrl-meta-j": keysnail.command( 66 | () => $notify("selectNextTab"), 67 | "Select next tab" 68 | ), 69 | "ctrl-meta-k": keysnail.command( 70 | () => $notify("selectPreviousTab"), 71 | "Select previous tab" 72 | ), 73 | "ctrl-Tab": keysnail.command( 74 | () => $notify("selectNextTab"), 75 | "Select next tab" 76 | ), 77 | "ctrl-shift-Tab": keysnail.command( 78 | () => $notify("selectPreviousTab"), 79 | "Select previous tab" 80 | ), 81 | "meta-l": keysnail.command( 82 | () => $notify("focusLocationBar"), 83 | "Focus to the location bar" 84 | ), 85 | "ctrl-l": keysnail.command( 86 | () => $notify("focusLocationBar"), 87 | "Focus to the location bar" 88 | ), 89 | "meta-t": keysnail.command(() => $notify("createNewTab"), "Create a new tab"), 90 | "ctrl-t": keysnail.command(() => $notify("createNewTab"), "Create a new tab"), 91 | "ctrl-meta-g": keysnail.command( 92 | () => $notify("openClipboardURL"), 93 | "Open clipboard URL" 94 | ), 95 | "ctrl-x": { 96 | k: keysnail.command(() => $notify("closeTab"), "Close current tab"), 97 | u: keysnail.command("meta-z", "Undo") 98 | , 99 | }, 100 | "meta-f": keysnail.command(() => $notify("searchText"), "Search text (forward)"), 101 | "ctrl-s": keysnail.command(() => $notify("searchText"), "Search text (forward)"), 102 | "ctrl-r": keysnail.command(() => $notify("searchText", { backward: true }), "Search text (backward)"), 103 | }, 104 | rich: { 105 | "meta-x": keysnail.command(() => keysnail.showKeyHelp(["rich", "all"]), "M-x (Show key help and commands)"), 106 | "ctrl-g": keysnail.command(() => keysnail.escape(), "Cancel (Quit key)"), 107 | Escape: keysnail.command(() => keysnail.escape(), "Escape from the editor"), 108 | "¥": keysnail.command(() => keysnail.insertText("\\"), "Insert backslash"), 109 | "ctrl- ": keysnail.marked(() => keysnail.setMark(), "Set mark"), 110 | "ctrl-@": keysnail.marked(() => keysnail.setMark(), "Set mark"), 111 | "ctrl-l": keysnail.marked(() => keysnail.recenter(), "Recenter cursor"), 112 | "meta-f": keysnail.marked("ctrl-ArrowRight", "Forward word"), 113 | "meta-b": keysnail.marked("ctrl-ArrowLeft", "Backward word"), 114 | "meta-d": keysnail.marked("ctrl-Delete", "Delete forward word"), 115 | "ctrl-_": keysnail.command("meta-z", "Undo"), 116 | "ctrl-z": keysnail.command("meta-z", "Undo"), 117 | "ctrl-s": keysnail.command("meta-f", "Search text (forward)"), 118 | "ctrl-h": keysnail.command("Backspace", "Delete backward char"), 119 | "ctrl-r": keysnail.command("ctrl-shift-k", "Search text (backward)"), 120 | "meta-s": keysnail.command("ctrl-h", "???"), 121 | "shift-ArrowRight": keysnail.marked("shift-ArrowRight", ""), 122 | "shift-ArrowLeft": keysnail.marked("shift-ArrowLeft", ""), 123 | "shift-ArrowDown": keysnail.marked("shift-ArrowDown", ""), 124 | "shift-ArrowUp": keysnail.marked("shift-ArrowUp", ""), 125 | ArrowRight: keysnail.marked("ArrowRight", ""), 126 | ArrowLeft: keysnail.marked("ArrowLeft", ""), 127 | ArrowDown: keysnail.marked("ArrowDown", ""), 128 | ArrowUp: keysnail.marked("ArrowUp", ""), 129 | "meta-,": keysnail.marked("ctrl-Home", ""), 130 | "meta-.": keysnail.marked("ctrl-End", ""), 131 | "ctrl-p": keysnail.marked("ArrowUp", "Previous line"), 132 | "ctrl-n": keysnail.marked("ArrowDown", "Next line"), 133 | "ctrl-f": keysnail.marked("ArrowRight", "Forward character"), 134 | "ctrl-b": keysnail.marked("ArrowLeft", "Backward character"), 135 | "ctrl-a": keysnail.marked("Home", "Beginning of the line"), 136 | "ctrl-e": keysnail.marked("End", "End of the line"), 137 | "ctrl-d": keysnail.command("Delete", "Delete forward char"), 138 | "ctrl-i": keysnail.command("Tab", "Indent"), 139 | "ctrl-m": keysnail.command("Enter", "New line"), 140 | "ctrl-v": keysnail.marked("PageDown", "Scroll page down"), 141 | "meta-v": keysnail.marked("PageUp", "Scroll page up"), 142 | "ctrl-y": keysnail.command(() => keysnail.paste(), "Paste (Yank)"), 143 | "ctrl-k": keysnail.command(() => keysnail.killLine(), "Kill line"), 144 | "ctrl-w": keysnail.command(() => keysnail.killRegion(), "Kill region"), 145 | }, 146 | edit: { 147 | // "meta-x": keysnail.command(() => keysnail.showKeyHelp(["edit", "all"]), "M-x"), 148 | "ctrl-g": keysnail.command(() => keysnail.escape(), "Cancel (Quit key)"), 149 | Escape: keysnail.command(() => keysnail.escape(), "Escape"), 150 | "¥": keysnail.command(() => keysnail.insertText("\\"), "Insert backslash"), 151 | "ctrl-k": keysnail.command(() => keysnail.killLine(), "Kill line"), 152 | "ctrl-w": keysnail.command(() => keysnail.killRegion(), "Kill region"), 153 | "ctrl-p": "ArrowUp", 154 | "ctrl-n": "ArrowDown", 155 | "meta-f": null, 156 | }, 157 | view: { 158 | "?": keysnail.command(() => keysnail.showKeyHelp(), "Show all shortcut keys"), 159 | d: { 160 | d: keysnail.command(() => $notify("closeTab"), "Close current tab"), 161 | }, 162 | ":": keysnail.command(() => keysnail.runEvalConsole(), "JavaScript Console (Eval)"), 163 | o: keysnail.command(() => $notify("focusLocationBar"), "Focus to the location bar"), 164 | "ctrl-a": keysnail.command(() => $notify("selectTabsByPanel"), "Select tabs by panel"), 165 | E: keysnail.command(() => keysnail.toggleHitHint(true), "Open a link by hints (background tab)"), 166 | e: keysnail.command(() => keysnail.toggleHitHint(), "Open a link by hints"), 167 | Escape: keysnail.command(() => keysnail.escape(), "Escape"), 168 | "ctrl-g": keysnail.command(() => keysnail.escape(), "Cancel (Quite)"), 169 | y: { 170 | y: keysnail.command(() => { 171 | $notify("copyText", { text: location.href }); 172 | message("Copied: " + location.href); 173 | }, "Copy URL of the current page"), 174 | }, 175 | u: keysnail.command(() => $notify("undoClosedTab"), "Undo closed tab"), 176 | p: keysnail.command(() => $notify("openClipboardURL"), "Open clipboard URL"), 177 | r: keysnail.command(() => location.reload(), "Reload page"), 178 | i: keysnail.command(() => keysnail.focusEditor(), "Focus to the (rich) text editor"), 179 | j: keysnail.command(() => keysnail.scrollDown(), "Scroll down"), 180 | k: keysnail.command(() => keysnail.scrollUp(), "Scroll up"), 181 | s: keysnail.command(() => $notify("scrap"), "Scrap this page (Scrapbox)"), 182 | S: keysnail.command(() => $notify("share"), "Share this page"), 183 | l: keysnail.command(() => $notify("selectNextTab"), "Select next tab"), 184 | h: keysnail.command(() => $notify("selectPreviousTab"), "Select previous tab"), 185 | " ": keysnail.command(() => keysnail.scrollPageDown(), "Scroll page down"), 186 | b: keysnail.command(() => keysnail.scrollPageUp(), "Scroll page up"), 187 | B: keysnail.command(() => keysnail.back(), "History backward"), 188 | H: keysnail.command(() => keysnail.back(), "History backward"), 189 | F: keysnail.command(() => keysnail.forward(), "History forward"), 190 | L: keysnail.command(() => keysnail.forward(), "History backward"), 191 | f: keysnail.command(() => keysnail.focusFirstInput(), "Focus to the first input"), 192 | a: { 193 | a: keysnail.command(() => $notify("scrap"), "Scrap this page (Scrapbox)"), 194 | n: keysnail.command(() => $notify("gotoDailyNote"), "Goto daily note (Scrapbox)"), 195 | }, 196 | g: { 197 | g: keysnail.command(() => keysnail.cursorTop(), "Goto the beginning of the page"), 198 | i: keysnail.command(() => keysnail.focusFirstInput(), "Focus to the first input"), 199 | e: keysnail.command(() => keysnail.focusEditor(), "Focus to the (rich text) editor"), 200 | t: keysnail.command(() => $notify("selectTabsByPanel"), "Select tabs by panel"), 201 | }, 202 | G: keysnail.command(() => keysnail.cursorBottom(), "Goto the end of the page"), 203 | "ctrl-p": keysnail.command("ArrowUp", "Scroll up"), 204 | "ctrl-n": keysnail.command("ArrowDown", "Scroll down"), 205 | "ctrl-f": keysnail.command("ArrowRight", "Scroll right"), 206 | "ctrl-b": keysnail.command("ArrowLeft", "Scroll left"), 207 | "/": keysnail.command(() => $notify("searchText"), "Search text (forward)"), 208 | "meta-shift-t": keysnail.command(() => { 209 | $notify("createNewTab", { 210 | url: `https://translate.google.com/translate?hl=auto&sl=auto&&sandbox=1&u=${encodeURIComponent( 211 | location.href 212 | )}`, 213 | }); 214 | }, "Translate the page"), 215 | q: keysnail.command(() => $notify("exitApplication"), "Exit ikeysnail"), 216 | "meta-i": keysnail.command(() => keysnail.startOutlineSelector(), "Show table of contents / outline of the page"), 217 | }, 218 | }; 219 | 220 | // -------------------------------------------------------------------------------- 221 | // Keymap (System-level) 222 | // -------------------------------------------------------------------------------- 223 | 224 | config.systemKeyMap = { 225 | all: { 226 | "ctrl-meta-j": (browser) => browser.selectNextTab(), 227 | "ctrl-meta-k": (browser) => browser.selectPreviousTab(), 228 | "meta-l": (browser) => browser.focusLocationBar(), 229 | }, 230 | findBar: { 231 | "ctrl-m": (browser) => browser.findNext(), 232 | "ctrl-g": (browser) => browser.blurFindBar(), 233 | "ctrl-s": (browser) => browser.findNext(), 234 | "ctrl-r": (browser) => browser.findPrevious(), 235 | "ctrl-meta-j": (browser) => browser.selectNextTab(), 236 | "ctrl-meta-k": (browser) => browser.selectPreviousTab(), 237 | Escape: (browser) => browser.blurFindBar(), 238 | }, 239 | urlBar: { 240 | "ctrl-p": (browser) => browser.selectLocationBarPreviousCandidate(), 241 | "ctrl-n": (browser) => browser.selectLocationBarNextCandidate(), 242 | "ctrl-m": (browser) => browser.decideLocationBarCandidate(), 243 | "ctrl-g": (browser) => browser.blurLocationBar(), 244 | "ctrl-meta-j": (browser) => browser.selectNextTab(), 245 | "ctrl-meta-k": (browser) => browser.selectPreviousTab(), 246 | Escape: (browser) => browser.blurLocationBar(), 247 | }, 248 | }; 249 | 250 | // -------------------------------------------------------------------------------- 251 | // Websites 252 | // -------------------------------------------------------------------------------- 253 | 254 | config.sites.push({ 255 | alias: "Google", 256 | url: "https://www.google.com", 257 | }); 258 | 259 | const GDOCS_KEYMAP = { 260 | rich: { 261 | "meta-f": keysnail.marked("alt-ArrowRight"), 262 | "meta-b": keysnail.marked("alt-ArrowLeft"), 263 | "meta-d": keysnail.marked("alt-Delete"), 264 | "ctrl-_": "ctrl-z", 265 | "ctrl-z": "meta-z", 266 | "ctrl-s": "ctrl-f", 267 | }, 268 | }; 269 | 270 | config.sites.push({ 271 | alias: "Google Docs", 272 | url: "https://docs.google.com/", 273 | keymap: GDOCS_KEYMAP, 274 | }); 275 | 276 | config.sites.push({ 277 | alias: "Google Docs (Slide)", 278 | url: "https://docs.google.com/presentation/", 279 | keymap: GDOCS_KEYMAP, 280 | }); 281 | 282 | config.sites.push({ 283 | alias: "OverLeaf", 284 | url: "https://www.overleaf.com/project/", 285 | style: ` 286 | .toolbar { font-size: small !important; } 287 | .entity { font-size: small !important; } 288 | `, 289 | }); 290 | 291 | config.sites.push({ 292 | alias: "Scrapbox", 293 | url: "https://scrapbox.io/", 294 | keymap: { 295 | rich: { 296 | "meta-f": keysnail.marked("alt-ArrowRight"), 297 | "meta-b": keysnail.marked("alt-ArrowLeft"), 298 | "meta-d": () => { 299 | keysnail.dispatchKey("alt-shift-ArrowRight"); 300 | keysnail.dispatchKey("Backspace"); 301 | }, 302 | "ctrl-i": "ctrl-i", 303 | }, 304 | }, 305 | style: ` 306 | #editor { 307 | caret-color: transparent !important; 308 | } 309 | `, 310 | }); 311 | 312 | config.sites.push({ 313 | alias: "HackMD", 314 | url: "https://hackmd.io/", 315 | style: ` 316 | .CodeMirror { 317 | caret-color: transparent !important; 318 | } 319 | `, 320 | }); 321 | -------------------------------------------------------------------------------- /src/TabBrowser/ToolBar/LocationBar/LocationBarCompletion.js: -------------------------------------------------------------------------------- 1 | const { Component } = require("../../../Component"); 2 | 3 | const COLOR_CANDIDATE_BORDER = $color("#c6c6c6"); 4 | const COLOR_CANDIDATE_BG = $color("#bbbbbb"); 5 | const COLOR_CANDIDATE_BG_SELECTED = $color("#DDDDDD"); 6 | 7 | // TODO: Add bookmark search (sites). 8 | 9 | class LocationBarCompletion extends Component { 10 | constructor(browser, TOPBAR_HEIGHT, CANDIDATE_HEIGHT = 40) { 11 | super(); 12 | this._browser = browser; 13 | this._locationBar = null; 14 | this._canceled = false; 15 | // TODO: 最終的にはピクセル計算やめたいので、この変数は消したい 16 | this.TOPBAR_HEIGHT = TOPBAR_HEIGHT; 17 | this.CANDIDATE_HEIGHT = CANDIDATE_HEIGHT; 18 | 19 | this.defineState({ 20 | suggestionList: null, 21 | suggestionIndex: -1 22 | }); 23 | } 24 | 25 | set locationBar(val) { 26 | this._locationBar = val; 27 | } 28 | 29 | get suggestionIndex() { 30 | return this.state.suggestionIndex; 31 | } 32 | 33 | get suggestionSelected() { 34 | return this.state.suggestionIndex >= 0; 35 | } 36 | 37 | set suggestions(val) { 38 | this.setSuggestions(val); 39 | } 40 | 41 | setSuggestions(suggestionList, index = -1) { 42 | if (suggestionList && index >= 0) { 43 | index = Math.min(index, suggestionList.length - 1); 44 | } 45 | this.setState({ 46 | suggestionList: suggestionList, 47 | suggestionIndex: index 48 | }); 49 | } 50 | 51 | reset() { 52 | this._canceled = false; 53 | } 54 | 55 | cancel() { 56 | this._canceled = true; 57 | this.suggestions = null; 58 | } 59 | 60 | get canceled() { 61 | return this._canceled; 62 | } 63 | 64 | decide(index) { 65 | if (typeof index !== "number") { 66 | index = this.state.suggestionIndex; 67 | } 68 | if (index >= 0) { 69 | let cand = this.state.suggestionList[index]; 70 | cand.constructor.execAction(cand, this._browser); 71 | } 72 | } 73 | 74 | selectNextCandidate() { 75 | if (this.state.suggestionIndex < 0) { 76 | this.setState({ suggestionIndex: 0 }); 77 | } else { 78 | this.setState({ 79 | suggestionIndex: 80 | (this.state.suggestionIndex + 1) % this.state.suggestionList.length 81 | }); 82 | } 83 | } 84 | 85 | selectPreviousCandidate() { 86 | let index = -1; 87 | if (this.state.suggestionIndex < 0) { 88 | index = this.state.suggestionList.length - 1; 89 | } else { 90 | if (this.state.suggestionIndex - 1 < 0) { 91 | index = this.state.suggestionList.length - 1; 92 | } else { 93 | index = this.state.suggestionIndex - 1; 94 | } 95 | } 96 | this.setState({ suggestionIndex: index }); 97 | } 98 | 99 | build() { 100 | const candidates = this.state.suggestionList; 101 | const selectedIndex = this.state.suggestionIndex; 102 | 103 | // if (!candidates || !candidates.length) { 104 | // return null; 105 | // } 106 | 107 | const ICON_AREA_WIDTH = 32; 108 | 109 | const template = { 110 | props: {}, 111 | views: [ 112 | { 113 | type: "view", 114 | props: { 115 | id: "completion-rectangle", 116 | bgcolor: $color("#FFFFFF") 117 | }, 118 | layout: $layout.fill, 119 | views: [ 120 | { 121 | type: "view", 122 | layout: (make, view) => { 123 | // Left 124 | // $layout.fill 125 | make.width.equalTo(ICON_AREA_WIDTH); 126 | make.height.equalTo(view.super); 127 | }, 128 | views: [ 129 | { 130 | type: "button", 131 | props: { 132 | id: "completion-icon", 133 | bgcolor: $color("clear"), 134 | align: $align.center 135 | }, 136 | layout: (make, view) => { 137 | make.centerX.equalTo(view.super); 138 | make.centerY.equalTo(view.super); 139 | } 140 | } 141 | ] 142 | }, 143 | { 144 | type: "view", 145 | layout: (make, view) => { 146 | make.width.equalTo(view.super).offset(ICON_AREA_WIDTH); 147 | make.height.equalTo(view.super); 148 | make.left.equalTo(ICON_AREA_WIDTH); 149 | }, 150 | views: [ 151 | { 152 | type: "label", 153 | props: { 154 | id: "completion-label", 155 | align: $align.left, 156 | font: $font(15), 157 | borderWidth: 0, 158 | textColor: $color("#000000") 159 | }, 160 | layout: (make, view) => { 161 | make.top.inset(3); 162 | make.left.inset(3); 163 | make.width.equalTo(view.super.width); 164 | } 165 | }, 166 | { 167 | type: "label", 168 | props: { 169 | id: "completion-url", 170 | align: $align.left, 171 | font: $font(12), 172 | textColor: $rgba(0, 0, 0, 0.6) 173 | }, 174 | layout: (make, view) => { 175 | make.bottom.inset(3); 176 | make.left.inset(3); 177 | make.width.equalTo(view.super.width); 178 | } 179 | } 180 | ] 181 | } 182 | ] 183 | } 184 | ] 185 | }; 186 | 187 | const data = (candidates || []).map((candidate, index) => { 188 | let label = { 189 | "completion-label": { 190 | text: candidate.text, 191 | tabIndex: index 192 | }, 193 | "completion-url": { 194 | text: candidate.urlReadable 195 | }, 196 | "completion-icon": {} 197 | }; 198 | if (/^[0-9]+$/.test(candidate.iconType)) { 199 | label["completion-icon"].icon = candidate.icon; 200 | } else { 201 | label["completion-icon"].symbol = candidate.iconType; 202 | } 203 | 204 | if (index === selectedIndex) { 205 | label["completion-rectangle"] = { 206 | bgcolor: COLOR_CANDIDATE_BG_SELECTED 207 | }; 208 | } 209 | return label; 210 | }); 211 | 212 | let hidden = (candidates || []).length === 0; 213 | 214 | return { 215 | type: "list", 216 | events: { 217 | didSelect: (sender, indexPath) => { 218 | this.decide(indexPath.row); 219 | } 220 | }, 221 | props: { 222 | id: "completion-list", 223 | rowHeight: this.CANDIDATE_HEIGHT, 224 | template: template, 225 | data: data, 226 | bgcolor: COLOR_CANDIDATE_BG, 227 | borderWidth: 1, 228 | radius: 5, 229 | borderColor: COLOR_CANDIDATE_BORDER, 230 | hidden: hidden 231 | }, 232 | layout: (make, view) => { 233 | let locationBar = this._locationBar.element; 234 | make.centerX.equalTo(locationBar.centerX); 235 | make.width.equalTo(locationBar).priority(1); 236 | make.height 237 | .equalTo(this.CANDIDATE_HEIGHT * (candidates || []).length) 238 | .priority(1); 239 | // このコードだと、最初に候補が出たときかならず表示がバグる ( スクリーン上部にはりつく) 240 | // make.top.equalTo(locationBar.bottom).priority(1); 241 | // なので、汚いがピクセル固定でごまかす。。 242 | const URLBAR_HEIGHT = 243 | this.TOPBAR_HEIGHT * this._locationBar.HEIGHT_RATIO; 244 | make.top.equalTo( 245 | URLBAR_HEIGHT + (this.TOPBAR_HEIGHT - URLBAR_HEIGHT) / 2 + 1 246 | ); 247 | } 248 | }; 249 | } 250 | } 251 | 252 | // TODO: Make the off-the-ui-thread 253 | // TODO: Or use sqlite! 254 | function findPageEntriesByQuery( 255 | query, 256 | urls, 257 | titles, 258 | reverse = false, 259 | caseInsensitive = false, 260 | RESULTS_COUNT_LIMIT = 5 261 | ) { 262 | if (caseInsensitive) { 263 | query = query.toLowerCase(); 264 | } 265 | 266 | return new Promise((resolve, reject) => { 267 | try { 268 | let matchedIndices = []; 269 | let index; 270 | for (let i = 0; i < urls.length; ++i) { 271 | // Reverse order (e.g., for histories, it's natural to show last ones) 272 | index = reverse ? urls.length - 1 - i : i; 273 | // sometimes url is null 274 | if (!urls[index]) continue; 275 | // Don't match to http part (because it's obvious) 276 | let url = urls[index].replace(/^https?:\/\//, ""); 277 | let title = titles[index] || ""; 278 | if (caseInsensitive) { 279 | title = title.toLowerCase(); 280 | } 281 | if (url.indexOf(query) >= 0 || title.indexOf(query) >= 0) { 282 | matchedIndices.push(index); 283 | if (matchedIndices.length >= RESULTS_COUNT_LIMIT) { 284 | resolve(matchedIndices); 285 | return; 286 | } 287 | } 288 | } 289 | resolve(matchedIndices); 290 | } catch (x) { 291 | reject(x); 292 | } 293 | }); 294 | } 295 | 296 | // ------------------------------------------------------------------------ // 297 | // Suggestion class 298 | // ------------------------------------------------------------------------ // 299 | 300 | class Suggestion { 301 | constructor(text, url) { 302 | this.text = text; 303 | this.url = url; 304 | } 305 | 306 | get urlReadable() { 307 | return decodeURIComponent(this.url.replace(/^https?:\/\//, "")); 308 | } 309 | 310 | static async generateByQuery(query, browser) { 311 | throw "Implement"; 312 | } 313 | 314 | static execAction(suggestion, browser) { 315 | browser.visitURL(suggestion.url); 316 | browser.blurLocationBar(); 317 | } 318 | 319 | get iconType() { 320 | return "star.fill"; 321 | } 322 | 323 | get icon() { 324 | return $icon(this.iconType, $rgba(140, 140, 140, 0.8), $size(13, 13)); 325 | } 326 | } 327 | 328 | class SuggestionTab extends Suggestion { 329 | constructor(title, url, tabIndex) { 330 | super(title, url); 331 | this.tabIndex = tabIndex; 332 | } 333 | 334 | get iconType() { 335 | return "macwindow"; 336 | } 337 | 338 | static execAction(suggestion, browser) { 339 | browser.blurLocationBar(); 340 | browser.selectTab(suggestion.tabIndex); 341 | } 342 | 343 | static async generateByQuery(query, browser) { 344 | const urls = browser._tabs.map(tab => tab.url); 345 | const titles = browser._tabs.map(tab => tab.title); 346 | const result = await findPageEntriesByQuery( 347 | query, 348 | urls, 349 | titles, 350 | false, 351 | true 352 | ); 353 | return result.map(idx => new SuggestionTab(titles[idx], urls[idx], idx)); 354 | } 355 | } 356 | 357 | class SuggestionHistory extends Suggestion { 358 | constructor(title, url) { 359 | super(title, url); 360 | } 361 | 362 | get iconType() { 363 | return "clock"; 364 | } 365 | 366 | static execAction(suggestion, browser) { 367 | browser.visitURL(suggestion.url); 368 | browser.blurLocationBar(); 369 | } 370 | 371 | static async generateByQuery(query, browser) { 372 | const urls = browser._pastURLs; 373 | const titles = browser._pastTitles; 374 | const result = await findPageEntriesByQuery( 375 | query, 376 | urls, 377 | titles, 378 | true, 379 | true 380 | ); 381 | return result.map(idx => new SuggestionHistory(titles[idx], urls[idx])); 382 | } 383 | } 384 | 385 | class SuggestionBookmark extends Suggestion { 386 | constructor(title, url) { 387 | super(title, url); 388 | } 389 | 390 | get iconType() { 391 | return "star.fill"; 392 | } 393 | 394 | static execAction(suggestion, browser) { 395 | browser.visitURL(suggestion.url); 396 | browser.blurLocationBar(); 397 | } 398 | 399 | static async generateByQuery(query, browser) { 400 | let bookmarks = browser.bookmarks; 401 | const result = await findPageEntriesByQuery( 402 | query, 403 | bookmarks.map(b => b.url), 404 | bookmarks.map(b => b.title), 405 | false, 406 | true 407 | ); 408 | return result.map( 409 | idx => new SuggestionBookmark(bookmarks[idx].title, bookmarks[idx].url) 410 | ); 411 | } 412 | } 413 | 414 | const SUGGESTION_GOOGLE = 415 | "https://www.google.com/complete/search?client=chrome-omni&q="; 416 | 417 | class SuggestionWebQuery extends Suggestion { 418 | constructor(query, name) { 419 | super(query, "Suggested by " + name); 420 | } 421 | 422 | get urlReadable() { 423 | return this.url; 424 | } 425 | 426 | get iconType() { 427 | return "magnifyingglass"; 428 | } 429 | 430 | static execAction(suggestion, browser) { 431 | browser.visitURL(suggestion.text); 432 | browser.blurLocationBar(); 433 | } 434 | 435 | static generateByQuery(query, browser, endPoint = SUGGESTION_GOOGLE) { 436 | const completionURL = SUGGESTION_GOOGLE + encodeURIComponent(query); 437 | return new Promise((resolve, reject) => { 438 | $http.request({ 439 | method: "GET", 440 | url: completionURL, 441 | handler: function(resp) { 442 | if (resp.error) { 443 | reject(resp.error); 444 | } else { 445 | if (typeof resp.data === "string") { 446 | return resolve([]); 447 | } 448 | let words = resp.data[1]; 449 | resolve( 450 | words.map(word => { 451 | word = word.replace(/[\\|¥]u([\d\w]{4})/gi, (match, grp) => 452 | String.fromCharCode(parseInt(grp, 16)) 453 | ); 454 | return new SuggestionWebQuery(word, "Google"); 455 | }) 456 | ); 457 | } 458 | } 459 | }); 460 | }); 461 | } 462 | } 463 | 464 | class SuggestionScrapbox extends Suggestion { 465 | constructor(query, url) { 466 | super(query, url); 467 | } 468 | 469 | get iconType() { 470 | return "dollarsign.square"; 471 | } 472 | 473 | static generateByQuery(query, browser) { 474 | const userName = browser.config.SCRAPBOX_USER; 475 | const completionURL = 476 | `https://scrapbox.io/api/pages/${userName}/search/query?skip=0&sort=updated&limit=30&q=` + 477 | encodeURIComponent(query); 478 | return new Promise((resolve, reject) => { 479 | if (!userName) { 480 | return resolve(null); 481 | } 482 | 483 | $http.request({ 484 | method: "GET", 485 | url: completionURL, 486 | handler: function(resp) { 487 | if (resp.error || resp.data.statusCode === 401) { 488 | resolve(null); 489 | } else { 490 | resolve( 491 | resp.data.pages.map( 492 | p => 493 | new SuggestionScrapbox( 494 | p.title, 495 | `https://scrapbox.io/${userName}/${encodeURIComponent( 496 | p.title 497 | )}` 498 | ) 499 | ) 500 | ); 501 | } 502 | } 503 | }); 504 | }); 505 | } 506 | } 507 | 508 | exports.LocationBarCompletion = LocationBarCompletion; 509 | exports.Suggestion = Suggestion; 510 | exports.SuggestionTab = SuggestionTab; 511 | exports.SuggestionBookmark = SuggestionBookmark; 512 | exports.SuggestionWebQuery = SuggestionWebQuery; 513 | exports.SuggestionHistory = SuggestionHistory; 514 | exports.SuggestionScrapbox = SuggestionScrapbox; 515 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { ShortcutKeyDeactivator, SystemKeyHandler } from "./Observer"; 2 | const { TabContentWebView } = require("TabBrowser/Tab/TabContentWebView"); 3 | const { Component } = require("Component"); 4 | const { ToolBar } = require("TabBrowser/ToolBar/ToolBar"); 5 | const { ToolBarButton } = require("TabBrowser/ToolBar/ToolBarButton"); 6 | const { 7 | ToolBarButtonContainer 8 | } = require("TabBrowser/ToolBar/ToolBarButtonContainer"); 9 | const { LocationBar } = require("TabBrowser/ToolBar/LocationBar/LocationBar"); 10 | const { 11 | LocationBarCompletion 12 | } = require("TabBrowser/ToolBar/LocationBar/LocationBarCompletion"); 13 | const { TabListHorizontal } = require("TabBrowser/TabList"); 14 | const { TabListVertical } = require("TabBrowser/TabList"); 15 | 16 | function readFile(file, initialContent = "") { 17 | if (!$file.exists(file)) { 18 | $file.write({ 19 | data: $data({ 20 | string: initialContent 21 | }), 22 | path: file 23 | }); 24 | return initialContent; 25 | } 26 | return $file.read(file).string; 27 | } 28 | 29 | // ----------------------------------------------------------- // 30 | // Tab Info (TODO: class) 31 | // ----------------------------------------------------------- // 32 | 33 | function loadTabInfo() { 34 | try { 35 | let [tabURLs, tabIndex, tabNames] = JSON.parse( 36 | $file.read("last-tabs.json").string.trim() 37 | ); 38 | if (tabURLs.length !== tabNames.length) throw "Invalid"; 39 | return [tabURLs, tabIndex, tabNames]; 40 | } catch (x) { 41 | return [[], 0, []]; 42 | } 43 | } 44 | 45 | function saveTabInfo(browser) { 46 | let tabURLs = browser._tabs.map(tab => tab.url); 47 | let tabTitles = browser._tabs.map(tab => tab.title); 48 | let lastTabIndex = browser.currentTabIndex; 49 | let lastTabInfo = [tabURLs, lastTabIndex, tabTitles]; 50 | $file.write({ 51 | data: $data({ string: JSON.stringify(lastTabInfo) }), 52 | path: "last-tabs.json" 53 | }); 54 | } 55 | 56 | function measure(name, process) { 57 | let begin = Date.now(); 58 | let value = process(); 59 | let end = Date.now(); 60 | console.log(`${name}: ${end - begin} msec`); 61 | return value; 62 | } 63 | 64 | // ----------------------------------------------------------- // 65 | // History (TODO: class) 66 | // ----------------------------------------------------------- // 67 | 68 | function loadHistory() { 69 | try { 70 | return measure("load history", () => { 71 | let history = JSON.parse($file.read("history.json").string.trim()); 72 | if (!Array.isArray(history.page.urls)) 73 | throw "History: page urls are invalid"; 74 | if (!Array.isArray(history.page.titles)) 75 | throw "History: titles are invalid"; 76 | if (!Array.isArray(history.bookmark)) 77 | throw "History: bookmarks are invalid"; 78 | return history; 79 | }); 80 | } catch (x) { 81 | $ui.toast("Valid history file not found. Create from scratch."); 82 | // Backup invalid history.json (sometimes it's useful) 83 | if ($file.exists("history.json")) { 84 | $file.move({ 85 | src: "history.json", 86 | dst: "history.json.invalid_backup" 87 | }); 88 | } 89 | return { 90 | page: { 91 | urls: [], 92 | titles: [] 93 | }, 94 | bookmark: [] 95 | }; 96 | } 97 | } 98 | 99 | function saveHistory(browser) { 100 | measure("save history", () => { 101 | $file.write({ 102 | data: $data({ string: JSON.stringify(browser.history) }), 103 | path: "history.json" 104 | }); 105 | }); 106 | } 107 | 108 | // ----------------------------------------------------------- // 109 | // User script 110 | // ----------------------------------------------------------- // 111 | 112 | const CONFIG = { 113 | SYSTEM: { 114 | path: "settings.default.js", 115 | template: ``, 116 | }, 117 | USER: { 118 | path: "settings.js", 119 | template: `// Put your configurations here. 120 | // NOTE: Don't use settings.default.js for storing your config 121 | // since the file will be overridden by the system. 122 | ` 123 | }, 124 | REMOTE: { 125 | path: "settings.remote.js", 126 | template: ``, 127 | }, 128 | }; 129 | // const CONFIG_SYSTEM = "settings.default.js"; 130 | // const CONFIG_REMOTE_CACHE = "settings.remote.js"; 131 | // const CONFIG_USER = "settings.js"; 132 | // const CONFIG_NAMES = [CONFIG_SYSTEM, CONFIG_USER, CONFIG_REMOTE_CACHE]; 133 | 134 | function asyncRemoteGet(url) { 135 | return new Promise(resolve => { 136 | $http.get({ 137 | url: url, 138 | handler: ({ data }) => resolve(data) 139 | }); 140 | }); 141 | } 142 | 143 | async function loadConfig() { 144 | const config = { sites: [], sources: {} }; 145 | const keysnail = { marked: () => null, command: () => null}; 146 | const isContent = false; 147 | for (let configName of Object.keys(CONFIG)) { 148 | let script = readFile(CONFIG[configName].path, CONFIG[configName].template); 149 | if (script) { 150 | eval(script); 151 | config.sources[configName] = script; 152 | console.log("Loading " + CONFIG[configName].path + " -> done"); 153 | } else { 154 | console.log("Loading " + CONFIG[configName].path + " -> skipped (not found or empty)"); 155 | } 156 | } 157 | return config; 158 | } 159 | 160 | function readUserScript(userSettings) { 161 | let contentScript = readFile("./content-script.js"); 162 | console.log(contentScript); 163 | let userScript = contentScript.replace( 164 | "/*@preserve SETTINGS_HERE*/", 165 | `function setup(config, keysnail, isContent) { 166 | ${userSettings} 167 | }` 168 | ); 169 | console.log(userScript); 170 | return userScript; 171 | } 172 | 173 | // ----------------------------------------------------------- // 174 | // Query processing 175 | // ----------------------------------------------------------- // 176 | 177 | function convertURLLikeInputToURL(url) { 178 | if (!/^https?:/.test(url) && url !== "about:blank") { 179 | return "https://www.google.com/search?q=" + encodeURIComponent(url); 180 | } 181 | return url; 182 | } 183 | 184 | // 185 | // 186 | // 187 | // 188 | class TabAndContentContainer extends Component { 189 | constructor(verticalOffset) { 190 | super(); 191 | this._verticalOffset = verticalOffset; 192 | } 193 | 194 | build() { 195 | return { 196 | type: "view", 197 | props: { 198 | bgcolor: $color("clear") 199 | }, 200 | layout: (make, view) => { 201 | make.width.equalTo(view.super.width); 202 | make.height.equalTo(view.super.height).offset(-this._verticalOffset); 203 | make.top.equalTo(view.super.top).offset(this._verticalOffset); 204 | make.left.equalTo(view.super); 205 | } 206 | }; 207 | } 208 | } 209 | 210 | class TabContentHolder extends Component { 211 | constructor(browser) { 212 | super(); 213 | this.config = browser.config; 214 | } 215 | 216 | build() { 217 | return { 218 | type: "view", 219 | props: { 220 | bgcolor: $color("clear") 221 | }, 222 | layout: (make, view) => { 223 | if (this.config.TAB_VERTICAL) { 224 | make.width 225 | .equalTo(view.super.width) 226 | .offset(-this.config.TAB_VERTICAL_WIDTH); 227 | make.height.equalTo(view.super.height); 228 | make.top.equalTo(view.super.top); 229 | make.left.equalTo(view.super).offset(this.config.TAB_VERTICAL_WIDTH); 230 | } else { 231 | make.width.equalTo(view.super.width); 232 | make.height 233 | .equalTo(view.super.height) 234 | .offset(-this.config.TAB_HEIGHT); 235 | make.top.equalTo(view.super.top).offset(this.config.TAB_HEIGHT); 236 | make.left.equalTo(view.super); 237 | } 238 | } 239 | }; 240 | } 241 | } 242 | 243 | class SearchBar extends Component { 244 | /*** 245 | * @param {TabBrowser} browser 246 | */ 247 | constructor(browser) { 248 | super(); 249 | this._browser = browser; 250 | this.ID_TEXT_INPUT = "search-text-input"; 251 | } 252 | 253 | get textInput() { 254 | return this.element.views[0]; 255 | } 256 | 257 | get positionLabel() { 258 | return this.element.views[1]; 259 | } 260 | 261 | next() { 262 | return this._browser.searchText(this.textInput.text); 263 | } 264 | 265 | previous() { 266 | return this._browser.searchText(this.textInput.text, true); 267 | } 268 | 269 | focus() { 270 | this.element.hidden = false; 271 | this.layer.$setZPosition(1); // Brings UI to top 272 | this.textInput.text = ""; 273 | this.textInput.focus(); 274 | } 275 | 276 | blur() { 277 | this._browser.searchText(""); // reset view 278 | this.element.hidden = true; 279 | this.layer.$setZPosition(-1); // Hide UI 280 | this.textInput.blur(); 281 | this._browser.focusContent(); 282 | } 283 | 284 | updatePositionInfo(info) { 285 | this.positionLabel.text = info; 286 | } 287 | 288 | build() { 289 | const height = 45; 290 | 291 | return { 292 | type: "view", 293 | props: { 294 | hidden: true, 295 | bgcolor: $color("#C6C8CE"), 296 | color: $color("#000000") 297 | }, 298 | layout: (make, view) => { 299 | make.height.equalTo(height); 300 | make.width.equalTo(view.super.width); 301 | make.top.equalTo(0); 302 | }, 303 | views: [ 304 | { 305 | type: "input", 306 | id: this.ID_TEXT_INPUT, 307 | props: { 308 | textColor: $color("#000000"), 309 | bgcolor: $color("#D1D3D9"), 310 | radius: 10, 311 | align: $align.left 312 | }, 313 | layout: (make, view) => { 314 | make.height.equalTo(height * 0.75); 315 | make.width.equalTo(view.super).multipliedBy(0.7); 316 | make.centerY.equalTo(view.super); 317 | make.centerX.equalTo(view.super); 318 | }, 319 | events: { 320 | didBeginEditing: sender => { 321 | // focused 322 | this.positionLabel.text = ""; 323 | }, 324 | tapped: sender => { 325 | this.focus(); 326 | }, 327 | returned: sender => { 328 | this.next(); 329 | }, 330 | didEndEditing: sender => { 331 | this.blur(); 332 | }, 333 | changed: sender => { 334 | // TODO: debounce? 335 | this._browser.searchText(sender.text); 336 | } 337 | } 338 | }, 339 | { 340 | type: "label", 341 | props: { 342 | textColor: $color("gray"), 343 | text: "", 344 | align: $align.right 345 | }, 346 | layout: (make, view) => { 347 | make.right.equalTo(-20); 348 | make.centerY.equalTo(view.super); 349 | }, 350 | events: {} 351 | } 352 | ] 353 | }; 354 | } 355 | } 356 | 357 | // -------------------------------------------------------------------- // 358 | // Browser class 359 | // -------------------------------------------------------------------- // 360 | 361 | class TabBrowser extends Component { 362 | /** 363 | * Tab browser (maintain collection of tabs) 364 | */ 365 | constructor(userScript, onInitialize, config) { 366 | super(); 367 | this._config = config; 368 | 369 | this.userScript = userScript; 370 | this.currentTabIndex = 0; 371 | this._tabs = []; 372 | this._closedTabs = []; 373 | this._pastURLs = []; 374 | this._pastTitles = []; 375 | this._onInitialize = onInitialize; 376 | 377 | const TOPBAR_HEIGHT = config.TOPBAR_HEIGHT; 378 | 379 | // Width ratio computation 380 | const LOCATION_WIDTH_RATIO = 0.5; 381 | const TOOLBAR_CONTAINER_WIDTH_RATIO = (1.0 - LOCATION_WIDTH_RATIO) / 2; 382 | 383 | let leftToolBar = new ToolBarButtonContainer( 384 | "left", 385 | TOOLBAR_CONTAINER_WIDTH_RATIO 386 | ); 387 | let rightToolBar = new ToolBarButtonContainer( 388 | "right", 389 | TOOLBAR_CONTAINER_WIDTH_RATIO 390 | ); 391 | 392 | let completion = new LocationBarCompletion(this, TOPBAR_HEIGHT); 393 | const locationBar = new LocationBar(this, completion, LOCATION_WIDTH_RATIO); 394 | this._locationBar = locationBar; 395 | completion.locationBar = locationBar; 396 | 397 | const toolbar = new ToolBar(TOPBAR_HEIGHT); 398 | this._toolbar = toolbar; 399 | 400 | const tabAndContentContainer = new TabAndContentContainer(TOPBAR_HEIGHT); 401 | this._tabAndContentContainer = tabAndContentContainer; 402 | 403 | const tabContentHolder = new TabContentHolder(this); 404 | this._tabContentHolder = tabContentHolder; 405 | 406 | const searchBar = new SearchBar(this); 407 | this._searchBar = searchBar; 408 | 409 | const tabList = config.TAB_VERTICAL 410 | ? new TabListVertical(this) 411 | : new TabListHorizontal(this); 412 | this._tabList = tabList; 413 | 414 | rightToolBar 415 | .addChild( 416 | new ToolBarButton("questionmark.circle", () => this.showKeyHelp()) 417 | ) 418 | .addChild( 419 | new ToolBarButton("rectangle.on.rectangle", () => 420 | this.selectTabsByPanel() 421 | ) 422 | ) 423 | .addChild(new ToolBarButton("plus", () => this.createNewTab(null, true))) 424 | .addChild(new ToolBarButton("square.and.arrow.up", () => this.share())); 425 | 426 | leftToolBar 427 | .addChild(new ToolBarButton("multiply", () => $app.close())) 428 | .addChild(new ToolBarButton("chevron.left", () => this.goBack())) 429 | .addChild(new ToolBarButton("chevron.right", () => this.goForward())) 430 | .addChild(new ToolBarButton("book", () => this.showBookmark())); 431 | 432 | // Declare view relationship 433 | toolbar 434 | .addChild(leftToolBar) 435 | .addChild(locationBar) 436 | .addChild(rightToolBar); 437 | 438 | tabContentHolder.addChild(searchBar); 439 | 440 | tabAndContentContainer.addChild(tabList).addChild(tabContentHolder); 441 | 442 | this.addChild(completion) 443 | .addChild(toolbar) 444 | .addChild(tabAndContentContainer); 445 | 446 | // Render UI 447 | this.render(); 448 | } 449 | 450 | get config() { 451 | return this._config; 452 | } 453 | 454 | build() { 455 | let browser = this; 456 | 457 | return { 458 | props: { 459 | id: this.id, 460 | title: "iKeySnail", 461 | statusBarHidden: this.config.HIDE_STATUSBAR, 462 | navBarHidden: this.config.HIDE_TOOLBAR 463 | // bgcolor: COLOR_CONTAINER_BG 464 | }, 465 | events: { 466 | appeared: sender => { 467 | this._onInitialize(this); 468 | } 469 | } 470 | }; 471 | } 472 | 473 | focusLocationBar() { 474 | this._locationBar.focus(); 475 | } 476 | 477 | blurLocationBar() { 478 | this._locationBar.blur(); 479 | } 480 | 481 | focusFindBar() { 482 | this._searchBar.focus(); 483 | } 484 | 485 | blurFindBar() { 486 | this._searchBar.blur(); 487 | } 488 | 489 | findNext() { 490 | this._searchBar.next(); 491 | } 492 | 493 | findPrevious() { 494 | this._searchBar.previous(); 495 | } 496 | 497 | updateSearchPositionInfo(info) { 498 | this._searchBar.updatePositionInfo(info); 499 | } 500 | 501 | searchText(text, backward = false) { 502 | return this.selectedTab.searchText(text, backward); 503 | } 504 | 505 | decideLocationBarCandidate() { 506 | this._locationBar.decideCandidate(); 507 | } 508 | 509 | selectLocationBarNextCandidate() { 510 | this._locationBar.selectNextCandidate(); 511 | } 512 | 513 | selectLocationBarPreviousCandidate() { 514 | this._locationBar.selectPreviousCandidate(); 515 | } 516 | 517 | get history() { 518 | return { 519 | page: { 520 | urls: this._pastURLs, 521 | titles: this._pastTitles 522 | }, 523 | bookmark: [] 524 | }; 525 | } 526 | 527 | set history(val) { 528 | this._pastURLs = val.page.urls; 529 | this._pastTitles = val.page.titles; 530 | this._bookmark = val.bookmark; 531 | } 532 | 533 | addHistory(tabURL, tabName) { 534 | this._pastURLs.push(tabURL); 535 | this._pastTitles.push(tabName); 536 | } 537 | 538 | get bookmarks() { 539 | return this.config.sites.map(site => ({ 540 | title: site.alias, 541 | url: site.url 542 | })); 543 | } 544 | 545 | get selectedTab() { 546 | return this._tabs[this.currentTabIndex]; 547 | } 548 | 549 | selectTabsByPanel() { 550 | let candidates = this._tabs.map((tab, index) => ({ 551 | text: tab.title, 552 | url: tab.url, 553 | icon: tab.iconURL 554 | })); 555 | let idx = this._tabs.indexOf(this.selectedTab); 556 | this.selectedTab.evalScript(`__keysnail__.runPanel( 557 | ${JSON.stringify(candidates)}, { toggle: true, initialIndex: ${idx}, action: index => $notify("selectTabByIndex", { index })} 558 | )`); 559 | } 560 | 561 | showKeyHelp() { 562 | this.selectedTab.showKeyHelp(); 563 | } 564 | 565 | onTabStartLoading(tab) { 566 | if (tab === this.selectedTab) { 567 | this.setURLView(tab.url); 568 | } 569 | } 570 | 571 | onTabTitleDetermined(tab) { 572 | this.addHistory(tab.url, tab.title); 573 | } 574 | 575 | onTabURLChanged(tab) { 576 | if (tab === this.selectedTab) { 577 | this.setURLView(tab.url); 578 | } 579 | } 580 | 581 | focusContent() { 582 | this.selectedTab.select(); 583 | } 584 | 585 | setURLView(url) { 586 | this._locationBar.setURLText(url); 587 | } 588 | 589 | visitURL(url) { 590 | url = convertURLLikeInputToURL(url); 591 | this.selectedTab.visitURL(url); 592 | this.setURLView(url); 593 | this.selectedTab.select(); 594 | } 595 | 596 | showBookmark() { 597 | this.selectedTab.showBookmark(); 598 | } 599 | 600 | goBack() { 601 | this.selectedTab.goBack(); 602 | } 603 | 604 | goForward() { 605 | this.selectedTab.goForward(); 606 | } 607 | 608 | share() { 609 | let tab = this.selectedTab; 610 | $share.sheet([tab.url, tab.title]); 611 | } 612 | 613 | _getTodayString() { 614 | const d = new Date(); 615 | const todayDate = ('0' + d.getDate()).slice(-2); 616 | const todayMonth = ('0' + (d.getMonth() + 1)).slice(-2); 617 | const todayYear = d.getFullYear(); 618 | return `${todayYear}-${todayMonth}-${todayDate}`; 619 | } 620 | 621 | gotoDailyNote() { 622 | if (!this.config.SCRAPBOX_USER) { 623 | $ui.toast( 624 | `Specify Scrapbox user in settings.js: config.SCRAPBOX_USER = 'XXX';` 625 | ); 626 | return; 627 | } 628 | this.visitURL(`https://scrapbox.io/${this.config.SCRAPBOX_USER}/${this._getTodayString()}`); 629 | } 630 | 631 | scrap() { 632 | if (!this.config.SCRAPBOX_USER) { 633 | $ui.toast( 634 | `Specify Scrapbox user in settings.js: config.SCRAPBOX_USER = 'XXX';` 635 | ); 636 | return; 637 | } 638 | let tab = this.selectedTab; 639 | let content = `#bookmark #${this._getTodayString()} 640 | 641 | ${tab.url} 642 | `; 643 | 644 | this.createNewTab( 645 | `https://scrapbox.io/${this.config.SCRAPBOX_USER}/${encodeURIComponent( 646 | tab.title 647 | )}?body=${encodeURIComponent(content)}`, 648 | true 649 | ); 650 | } 651 | 652 | copyTabInfo(tabIndex) { 653 | $clipboard.set({ 654 | type: "public.plain-text", 655 | value: this._tabs[tabIndex].url 656 | }); 657 | } 658 | 659 | openInExternalBrowser(tabIndex) { 660 | $app.openURL(this._tabs[tabIndex].url); 661 | } 662 | 663 | closeTabsBesides(tabIndexToRetain) { 664 | let tabToRetain = this._tabs[tabIndexToRetain]; 665 | this._tabs.forEach((tab, index) => { 666 | this._closedTabs.push({ 667 | url: tab.url, 668 | title: tab.title 669 | }); 670 | if (index !== tabIndexToRetain && tab.loaded) { 671 | tab.visitURL(null); 672 | tab.removeMe(); 673 | } 674 | }); 675 | this._tabs = [tabToRetain]; 676 | this.selectTab(0); 677 | } 678 | 679 | closeCurrentTab() { 680 | return this.closeTab(this._tabs[this.currentTabIndex]); 681 | } 682 | 683 | /** 684 | * タブを閉じる 685 | * @param {*} tab 閉じるタブ 686 | */ 687 | closeTab(tab) { 688 | this._closedTabs.push({ 689 | url: tab.url, 690 | title: tab.title 691 | }); 692 | 693 | if (this._tabs.length <= 1) { 694 | this._tabs[0].visitURL(this.config.NEW_PAGE_URL); 695 | } else { 696 | tab.visitURL(null); 697 | let index = this._tabs.indexOf(tab); 698 | tab.removeMe(); 699 | if (index >= 0) { 700 | this._tabs.splice(index, 1); 701 | } 702 | this.selectTab(Math.max(0, this.currentTabIndex - 1)); 703 | } 704 | } 705 | 706 | _createNewTabInternal(url, tabTitle = null) { 707 | let tab = new TabContentWebView(this, this.config, url, this.userScript); 708 | if (tabTitle) { 709 | tab._title = tabTitle; 710 | } 711 | this._tabContentHolder.addChild(tab); 712 | this._tabs.push(tab); 713 | return tab; 714 | } 715 | 716 | createNewTabs(urls, tabIndexToSelect = -1, titles = []) { 717 | urls.forEach((url, idx) => { 718 | let title = titles ? titles[idx] : null; 719 | this._createNewTabInternal(url, title); 720 | }); 721 | if (tabIndexToSelect >= 0) { 722 | this.selectTab(tabIndexToSelect); 723 | } 724 | } 725 | 726 | createNewTab(url, selectNewTab = false) { 727 | if (!url) { 728 | url = this.config.NEW_PAGE_URL; 729 | } 730 | url = convertURLLikeInputToURL(url); 731 | let tab = this._createNewTabInternal(url); 732 | // TODO: Create rendering stop option 733 | if (selectNewTab) { 734 | this.selectTab(this._tabs.indexOf(tab)); 735 | } else { 736 | this._tabList.render(); 737 | } 738 | } 739 | 740 | selectTab(tabIndexToSelect) { 741 | this.currentTabIndex = tabIndexToSelect; 742 | // TODO: ugly? 743 | this._tabs.forEach((tab, index) => { 744 | if (index === tabIndexToSelect) { 745 | tab.select(); 746 | this.setURLView(tab.url); 747 | } else { 748 | tab.deselect(); 749 | } 750 | }); 751 | this._tabList.render(); 752 | } 753 | 754 | /** 755 | * Select next tab 756 | */ 757 | selectNextTab() { 758 | this.selectTab((this.currentTabIndex + 1) % this._tabs.length); 759 | } 760 | 761 | /** 762 | * Select previous tab 763 | */ 764 | selectPreviousTab() { 765 | if (this.currentTabIndex - 1 < 0) { 766 | this.selectTab(this._tabs.length - 1); 767 | } else { 768 | this.selectTab(this.currentTabIndex - 1); 769 | } 770 | } 771 | 772 | undoClosedTab() { 773 | if (!this._closedTabs.length) { 774 | $ui.toast("No closed tabs in the history", 0.7); 775 | return; 776 | } 777 | let tab = this._closedTabs.pop(); 778 | this.createNewTab(tab.url, true); 779 | } 780 | } 781 | 782 | // Session to start 783 | async function startSession(urlToVisit) { 784 | try { 785 | const config = await loadConfig(); 786 | 787 | let lastTabs = []; 788 | let lastTabIndex = 0; 789 | let tabTitles = null; 790 | try { 791 | let [tabUrls, tabIndex, tabTitles] = loadTabInfo(); 792 | lastTabIndex = tabIndex; 793 | tabTitles = tabTitles || []; 794 | for (let i = 0; i < tabUrls.length; ++i) { 795 | lastTabs.push({ url: tabUrls[i], title: tabTitles[i] }); 796 | } 797 | } catch (x) {} 798 | 799 | let browser = new TabBrowser( 800 | readUserScript(Object.values(config.sources).join("\n")), 801 | browser => { 802 | browser.history = loadHistory(); 803 | 804 | if (lastTabs.length) { 805 | browser.createNewTabs( 806 | lastTabs.map(t => t.url), 807 | lastTabIndex, 808 | lastTabs.map(t => t.title) 809 | ); 810 | if (urlToVisit) { 811 | browser.createNewTab(urlToVisit, true); 812 | } else { 813 | browser.selectTab(lastTabIndex); 814 | } 815 | } else { 816 | browser.createNewTab(urlToVisit || config.NEW_TAB_URL, true); 817 | } 818 | }, 819 | config 820 | ); 821 | 822 | const shortcutKeyDeactivator = new ShortcutKeyDeactivator(); 823 | const systemKeyHandler = new SystemKeyHandler(browser, config); 824 | 825 | $app.listen({ 826 | ready: () => { 827 | shortcutKeyDeactivator.onReady(); 828 | systemKeyHandler.onReady(); 829 | }, 830 | exit: () => { 831 | shortcutKeyDeactivator.onExit(); 832 | systemKeyHandler.onExit(); 833 | saveTabInfo(browser); 834 | saveHistory(browser); 835 | } 836 | }); 837 | 838 | // If remote config url is specified, compare it with the cache 839 | if (config.REMOTE_CONFIG_URL) { 840 | console.log("Remote config found"); 841 | const remoteConfig = await asyncRemoteGet(config.REMOTE_CONFIG_URL); 842 | console.log("Remote config found. Let's compare."); 843 | if (remoteConfig !== config.sources.REMOTE) { 844 | // Update cache 845 | $file.write({ 846 | data: $data({ string: remoteConfig }), 847 | path: CONFIG.REMOTE.path 848 | }); 849 | $ui.alert("Detected remote config (updates). Please restart your ikeysnail app."); 850 | $app.close(); 851 | } else { 852 | console.log("Config already cached"); 853 | } 854 | } 855 | 856 | } catch (x) { 857 | console.error("Error in launching procedure"); 858 | console.error(x); 859 | } 860 | } 861 | 862 | /* $app.keyboardToolbarEnabled = false; */ 863 | 864 | /* $app.autoKeyboardEnabled = true; */ 865 | 866 | startSession($context.query.url || null); 867 | -------------------------------------------------------------------------------- /src/content-script.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | if (location.host === "scrapbox.io") { 3 | // Dirty hack for mimicking 'non-touchable device (normal Mac)' to Scrapbox 4 | // (as of 5/24 2020, it uses `ontouchstart === undefined` to check whether the device is iPadOS or not) 5 | Object.defineProperty(document, "ontouchstart", { get: () => void 0 }); 6 | } 7 | 8 | let beginTime = Date.now(); 9 | 10 | function log(message) { 11 | if (config.DEBUG_CONSOLE) { 12 | $notify("log", { message }); 13 | } 14 | } 15 | 16 | function message(msg, duration) { 17 | $notify("message", { message: msg, duration: duration }); 18 | } 19 | 20 | let messageTimer = null; 21 | 22 | function inIframe() { 23 | try { 24 | return window.self !== window.top; 25 | } catch (e) { 26 | return true; 27 | } 28 | } 29 | 30 | const IFRAME_WHITE_LIST = ["https://translate.googleusercontent.com"]; 31 | 32 | // Do not load in ifrmae pages 33 | if ( 34 | window !== window.parent && 35 | IFRAME_WHITE_LIST.every((prefix) => location.href.indexOf(prefix) === -1) 36 | ) { 37 | return; 38 | } 39 | 40 | function showFloatingMessage(msg, duration) { 41 | if (messageTimer) { 42 | clearTimeout(messageTimer); 43 | messageTimer = null; 44 | } 45 | const id = "keysnail-message"; 46 | let messageElement = document.getElementById(id); 47 | if (!messageElement) { 48 | messageElement = document.createElement("div"); 49 | messageElement.setAttribute("id", id); 50 | document.documentElement.appendChild(messageElement); 51 | } else { 52 | messageElement.hidden = true; 53 | } 54 | if (msg) { 55 | if (msg[0] === "<") { 56 | messageElement.innerHTML = msg; 57 | } else { 58 | messageElement.textContent = msg; 59 | } 60 | messageElement.hidden = false; 61 | messageTimer = setTimeout(() => { 62 | messageElement.hidden = true; 63 | }, duration || 3000); 64 | } 65 | } 66 | 67 | function createNewTab(url, openInBackground) { 68 | $notify("createNewTab", { url, openInBackground }); 69 | } 70 | 71 | function debounce(func, interval = 500) { 72 | let timer = null; 73 | return (...args) => { 74 | if (timer) { 75 | clearTimeout(timer); 76 | } 77 | timer = setTimeout(async () => { 78 | func(...args); 79 | }, interval); 80 | }; 81 | } 82 | 83 | $notify("titleDetermined", { title: document.title }); 84 | 85 | function getFaviconURL(siteURL) { 86 | return `https://cdn-ak.favicon.st-hatena.com/?url=${encodeURIComponent(siteURL)}`; 87 | } 88 | 89 | const config = { sites: [] }; 90 | const Z_INDEX_MAX = 2147483000; 91 | let gLocalKeyMap = null; 92 | let gRichTextEditorInputElement = null; 93 | let gAceEditor = null; 94 | let gCodeMirror = null; 95 | let gGoogleDocsEditor = null; 96 | let gStatusMarked = false; 97 | let gHitHintDisposerInternal = null; 98 | 99 | let scriptLoadedHandlers = {}; 100 | 101 | function loadScript(src, charset = "UTF-8") { 102 | if (scriptLoadedHandlers.hasOwnProperty(src)) { 103 | // Already loaded or requested. 104 | return new Promise((resolve, reject) => { 105 | resolve(false); 106 | }); 107 | } 108 | 109 | // Since injecting custom