├── 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 | - 
15 | - **Vertical tabs**
16 | - We do support vertical tabs in iOS!
17 | - Setting `config.TAB_VERTICAL = true` makes tab orientations vertical
18 | - 
19 | - **Omnibar support**
20 | - Work-in-progress, but we do support omnibar. By default, pressing `o` opens your bookmarks.
21 | - 
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 | 
147 |
148 | ### Link hints
149 |
150 | 
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