├── .gitignore ├── README.markdown ├── keysearch.safariextension ├── Info.plist ├── Settings.plist ├── global.html ├── index.css ├── index.html ├── toolbar.png └── toolbar@2x.png ├── package.json ├── resources └── toolbar.idraw ├── src ├── actions │ ├── index.js │ └── rules.js ├── components │ ├── Button.jsx │ ├── Rule.jsx │ ├── TokenInput.jsx │ └── index.js ├── constants │ └── actionTypes.js ├── containers │ ├── App.jsx │ ├── Root.jsx │ └── RulesApp.jsx ├── global.js ├── index.jsx ├── middleware │ └── api.js ├── reducers │ ├── index.js │ └── rules.js ├── routes.js ├── store.js ├── store │ └── configureStore.js ├── styles │ ├── components.scss │ ├── components │ │ ├── _button.scss │ │ ├── _rule.scss │ │ ├── _rules-app.scss │ │ └── _token-input.scss │ └── index.scss └── utils.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | keysearch.safariextension/*.js 3 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # DEPRECATED: Safari no longer supports extensions that modify the behaviour of the address bar. Consider using a separate tool, such as Alfred: https://www.alfredapp.com 2 | 3 | --- 4 | 5 | # KeySearch 6 | 7 | **_A keyword search extension for Safari._** 8 | 9 | **[Download](http://www.macosxtips.co.uk/keysearch)** 10 | 11 | ## Introduction 12 | 13 | **_KeySearch_ adds a new way of searching the Internet to Safari.** It uses short keywords to allow you to search any site you want, right from the toolbar. 14 | 15 | While browsing the web, just press ^S (or click the toolbar button) to bring up the KeySearch bar. Type a keyword, then your search query, and hit , and KeySearch will find the site you want to search and perform the search for you. 16 | 17 | No more going to Google first! 18 | 19 | ## Examples 20 | 21 | KeySearch comes with plenty of keywords that are ready for you to use. 22 | 23 | **Want to see videos of cute kittens?** `youtube cute kittens` 24 | 25 | **Forgotten who directed *Star Trek*?** `imdb Star Trek 2009` 26 | 27 | **Need directions for a trip?** `map New York to LA` 28 | 29 | ## Creating Keywords 30 | 31 | To create a new keyword, just do the following: 32 | 33 | - right-click on any search box in a web page 34 | - choose “Create keyword for this search” 35 | - type in a keyword 36 | 37 | …And let KeySearch take care of the rest. 38 | 39 | You can keep track of all your keywords by clicking “Edit Keywords” in the KeySearch bar. Here, you can manually create new keywords, add specific keyboard shortcuts for each keyword, and delete (or temporarily disable) keywords. 40 | 41 | ## Customisations 42 | 43 | There are a few different ways you can search using Keysearch: 44 | 45 | - Click the toolbar button to bring up the popover search 46 | 47 | - Use a keyboard shortcut to bring up the popover search (configurable in settings) 48 | 49 | - Use a keyboard shortcut to search using a semi-transparent “HUD” over the current web page 50 | 51 | - Search using the address bar (or “omnibar” in Safari 6). The keyboard shortcut ⌘L can be used to activate the address bar. 52 | 53 | ## Sites that don’t have search 54 | 55 | Some sites don’t have their own search engines, but you can still use KeySearch to search them. 56 | 57 | Instead of a keyword, just type `>example.com query` to search any site using Google Site Search. If you want to search the site that is currently open, just use `> query`. 58 | 59 | ## Faster Bookmarks 60 | 61 | Keywords aren’t just great for search. You can assign them to any web address you want, and use them as a quick way to access your favourite sites. Use `yt` to take you to YouTube, or `fb` to take you to Facebook. 62 | -------------------------------------------------------------------------------- /keysearch.safariextension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Author 6 | Matt Swain 7 | Builder Version 8 | 12602.3.3 9 | CFBundleDisplayName 10 | KeySearch 11 | CFBundleIdentifier 12 | com.matt-swain.keysearch 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleShortVersionString 16 | 3.0.0 17 | CFBundleVersion 18 | 3.0.0 19 | Chrome 20 | 21 | Context Menu Items 22 | 23 | 24 | Command 25 | keySearchContext 26 | Identifier 27 | keySearchContext 28 | Title 29 | Create a keyword for this search 30 | 31 | 32 | Global Page 33 | global.html 34 | Toolbar Items 35 | 36 | 37 | Command 38 | keySearchButton 39 | Identifier 40 | keySearchButton 41 | Image 42 | toolbar.png 43 | Label 44 | KeySearch 45 | Palette Label 46 | KeySearch 47 | Tool Tip 48 | KeySearch 49 | 50 | 51 | 52 | Description 53 | Use keywords to quickly search any site from the address bar 54 | DeveloperIdentifier 55 | 6M853ET88Q 56 | ExtensionInfoDictionaryVersion 57 | 1.0 58 | Permissions 59 | 60 | Website Access 61 | 62 | Include Secure Pages 63 | 64 | Level 65 | None 66 | 67 | 68 | Update Manifest URL 69 | http://matt-swain.com/keysearch/keysearch.plist 70 | Website 71 | http://matt-swain.com/keysearch 72 | 73 | 74 | -------------------------------------------------------------------------------- /keysearch.safariextension/Settings.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DefaultValue 7 | false 8 | Key 9 | openSettings 10 | Title 11 | Click here to open the KeySearch settings page and edit your rules. You can also access the settings page by clicking on the KeySearch toolbar button. 12 | Type 13 | CheckBox 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /keysearch.safariextension/global.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /keysearch.safariextension/index.css: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: #fff; 3 | font-size: 14px; 4 | border: none; 5 | border-radius: 4px; 6 | cursor: pointer; 7 | color: #444; 8 | margin: 0; 9 | padding: 2px 6px 3px 6px; 10 | transition: background-color 200ms linear, color 200ms linear; } 11 | .button__icon { 12 | height: 1.4em; 13 | width: 1.4em; } 14 | .button__text { 15 | vertical-align: middle; 16 | line-height: 1.4em; } 17 | .button:hover { 18 | color: #fff; 19 | background-color: #444; } 20 | 21 | .rules-app__add { 22 | margin: 10px 5px 0 0; 23 | float: left; 24 | color: #00bc00; } 25 | .rules-app__add:hover { 26 | background-color: #00bc00; } 27 | 28 | .rules-app__import { 29 | margin: 10px 5px 0 0; 30 | float: left; } 31 | 32 | .rules-app__export { 33 | margin: 10px 5px 0 0; 34 | float: left; } 35 | 36 | .rule { 37 | padding: 20px 0; } 38 | .rule.is-disabled { 39 | opacity: 0.5; } 40 | .rule__header { 41 | display: flex; } 42 | .rule__body { 43 | position: relative; 44 | display: flex; } 45 | .rule__name { 46 | margin: 0 5px 5px 0; 47 | flex: 1 80%; } 48 | .rule__enable { 49 | margin: 2px 0 8px 5px; } 50 | .rule__delete { 51 | margin: 2px 0 8px 5px; 52 | color: #d40000; } 53 | .rule__delete:hover { 54 | background-color: #d40000; } 55 | .rule__key { 56 | position: relative; 57 | flex: 1 25%; 58 | padding: 8px; 59 | background: #e9e9e9; 60 | border-top-left-radius: 5px; 61 | border-bottom-left-radius: 5px; } 62 | .rule__key .is-token { 63 | background: #e9e9e9; } 64 | .rule__key:after { 65 | content: ''; 66 | display: block; 67 | right: -46px; 68 | position: absolute; 69 | top: 0px; 70 | width: 0; 71 | height: 0; 72 | border-left: 23px solid #e9e9e9; 73 | border-right: 23px solid transparent; 74 | border-top: 23px solid transparent; 75 | border-bottom: 23px solid transparent; } 76 | .rule__url { 77 | flex: 1 75%; 78 | padding: 8px 8px 8px 31px; 79 | background: #f4f4f4; 80 | border-top-right-radius: 5px; 81 | border-bottom-right-radius: 5px; } 82 | .rule__url .is-token { 83 | background: #f4f4f4; } 84 | .rule__chevron::before { 85 | border-style: solid; 86 | border-width: 0.25em 0.25em 0 0; 87 | content: ''; 88 | display: inline-block; 89 | height: 0.65em; 90 | width: 0.65em; 91 | position: relative; 92 | top: 1em; 93 | transform: rotate(45deg); 94 | vertical-align: top; 95 | left: 0; } 96 | 97 | .token-input { 98 | position: relative; 99 | height: 30px; 100 | color: #444; } 101 | .token-input__input { 102 | background: transparent; 103 | outline: none; 104 | border: 1px solid transparent; 105 | position: absolute; 106 | margin: 0; 107 | top: 0; 108 | bottom: 0; 109 | left: 0; 110 | right: 0; 111 | font-size: inherit; 112 | font-weight: inherit; 113 | color: inherit; 114 | line-height: 1; 115 | padding: 8px 4px; } 116 | .token-input__input:hover { 117 | border: 1px solid #ddd; } 118 | .token-input__input:focus { 119 | border: 1px solid #498feb; } 120 | .token-input__overlay { 121 | pointer-events: none; 122 | color: transparent; 123 | position: absolute; 124 | top: 0; 125 | bottom: 0; 126 | left: 0; 127 | right: 0; 128 | line-height: 1; 129 | padding: 7px 4px; 130 | white-space: pre; 131 | overflow: hidden; 132 | border: 1px solid transparent; } 133 | .token-input__overlay.is-focused { 134 | opacity: 0; } 135 | .token-input__span.is-token { 136 | padding: 0 4px; } 137 | .token-input__span.is-token > span { 138 | border-radius: 3px; 139 | border: 2px solid #498feb; 140 | padding: 1px 5px 2px; 141 | color: #498feb; } 142 | 143 | html { 144 | box-sizing: border-box; } 145 | 146 | body { 147 | margin: 0; 148 | padding: 0 10px; 149 | font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; 150 | font-size: 14px; 151 | line-height: 1.5; 152 | color: #444; } 153 | 154 | h2 { 155 | font-weight: normal; 156 | font-size: 24px; } 157 | 158 | .container { 159 | max-width: 970px; 160 | margin: 0 auto 50px; } 161 | 162 | .row:after { 163 | content: ''; 164 | display: block; 165 | clear: both; } 166 | 167 | .navbar { 168 | height: 58px; 169 | border-bottom: 2px solid #444; } 170 | .navbar__brand { 171 | float: left; 172 | margin: 20px 0 10px 0; 173 | line-height: 28px; } 174 | .navbar__nav { 175 | float: right; 176 | padding: 0; 177 | margin: 20px 0 0 0; 178 | list-style: none; } 179 | .navbar__item { 180 | float: left; 181 | position: relative; 182 | display: block; } 183 | .navbar__link { 184 | line-height: 38px; 185 | padding: 20px 5px 10px 5px; 186 | font-weight: bold; 187 | text-decoration: none; 188 | font-size: 16px; 189 | border-bottom: 2px solid transparent; } 190 | .navbar__link.active { 191 | color: #498feb; 192 | border-bottom: 2px solid #498feb; } 193 | -------------------------------------------------------------------------------- /keysearch.safariextension/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KeySearch 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /keysearch.safariextension/toolbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcs07/KeySearch/eca6152c719a0435630979b852f3933f39fbdaf6/keysearch.safariextension/toolbar.png -------------------------------------------------------------------------------- /keysearch.safariextension/toolbar@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcs07/KeySearch/eca6152c719a0435630979b852f3933f39fbdaf6/keysearch.safariextension/toolbar@2x.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "KeySearch", 3 | "version": "3.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "NODE_ENV=development webpack --progress --colors --watch --devtool eval-source-map", 7 | "build": "NODE_ENV=production webpack -p --config ./webpack.config.js" 8 | }, 9 | "devDependencies": { 10 | "babel-loader": "^6.2.8", 11 | "babel-plugin-transform-runtime": "^6.15.0", 12 | "babel-preset-es2015": "^6.18.0", 13 | "babel-preset-react": "^6.16.0", 14 | "babel-preset-stage-0": "^6.16.0", 15 | "bem-cn": "^2.1.3", 16 | "css-loader": "^0.26.0", 17 | "extract-text-webpack-plugin": "^2.0.0-beta.4", 18 | "file-loader": "^0.9.0", 19 | "file-saver": "^1.3.3", 20 | "json-loader": "^0.5.4", 21 | "lodash": "^4.17.2", 22 | "node-sass": "^3.13.0", 23 | "node-uuid": "^1.4.7", 24 | "react": "^15.4.1", 25 | "react-dom": "^15.4.1", 26 | "react-dropzone": "^3.7.3", 27 | "react-icons": "^2.2.1", 28 | "react-loader": "^2.4.0", 29 | "react-redux": "^4.4.6", 30 | "react-router": "^3.0.0", 31 | "react-router-redux": "^4.0.7", 32 | "redux": "^3.6.0", 33 | "redux-logger": "^2.7.4", 34 | "redux-thunk": "^2.1.0", 35 | "sass-loader": "^4.0.2", 36 | "semver": "^5.3.0", 37 | "strip-loader": "^0.1.2", 38 | "style-loader": "^0.13.1", 39 | "velocity-react": "^1.1.11", 40 | "webpack": "^2.1.0-beta.27" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /resources/toolbar.idraw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcs07/KeySearch/eca6152c719a0435630979b852f3933f39fbdaf6/resources/toolbar.idraw -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | export {loadRules, loadRule, createRule, updateRule, deleteRule} from './rules' 2 | -------------------------------------------------------------------------------- /src/actions/rules.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes' 2 | import {CALL_API} from '../middleware/api' 3 | 4 | 5 | export function loadRules() { 6 | return { 7 | [CALL_API]: { 8 | types: [types.RULES_REQUEST, types.RULES_SUCCESS, types.RULES_FAILURE], 9 | method: 'all' 10 | } 11 | } 12 | } 13 | 14 | 15 | export function loadRule(id) { 16 | return { 17 | [CALL_API]: { 18 | types: [types.RULE_REQUEST, types.RULE_SUCCESS, types.RULE_FAILURE], 19 | method: 'get', 20 | args: [id] 21 | } 22 | } 23 | } 24 | 25 | 26 | export function createRule(rule) { 27 | return { 28 | [CALL_API]: { 29 | types: [types.CREATE_RULE_REQUEST, types.CREATE_RULE_SUCCESS, types.CREATE_RULE_FAILURE], 30 | method: 'create', 31 | args: [rule] 32 | } 33 | } 34 | } 35 | 36 | 37 | export function updateRule(rule) { 38 | return { 39 | [CALL_API]: { 40 | types: [types.UPDATE_RULE_REQUEST, types.UPDATE_RULE_SUCCESS, types.UPDATE_RULE_FAILURE], 41 | method: 'update', 42 | args: [rule] 43 | } 44 | } 45 | } 46 | 47 | 48 | export function deleteRule(id) { 49 | return { 50 | [CALL_API]: { 51 | types: [types.DELETE_RULE_REQUEST, types.DELETE_RULE_SUCCESS, types.DELETE_RULE_FAILURE], 52 | method: 'delete', 53 | args: [id] 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import block from 'bem-cn' 3 | import MdAdd from 'react-icons/lib/md/add' 4 | import MdClear from 'react-icons/lib/md/clear' 5 | import MdPause from 'react-icons/lib/md/pause' 6 | import MdPlayArrow from 'react-icons/lib/md/play-arrow' 7 | import MdDelete from 'react-icons/lib/md/delete' 8 | import MdFileUpload from 'react-icons/lib/md/file-upload' 9 | import MdFileDownload from 'react-icons/lib/md/file-download' 10 | 11 | 12 | export default class Button extends React.Component { 13 | 14 | render() { 15 | let {icon, className, children, ...props} = this.props 16 | let b = block('button') 17 | let iconEl = null 18 | switch (icon) { 19 | case 'add': iconEl = MdAdd 20 | break 21 | case 'clear': iconEl = MdClear 22 | break 23 | case 'pause': iconEl = MdPause 24 | break 25 | case 'play': iconEl = MdPlayArrow 26 | break 27 | case 'delete': iconEl = MdDelete 28 | break 29 | case 'export': iconEl = MdFileDownload 30 | break 31 | case 'import': iconEl = MdFileUpload 32 | break 33 | } 34 | return ( 35 | 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/Rule.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import block from 'bem-cn' 3 | import Button from './Button' 4 | import TokenInput from './TokenInput' 5 | 6 | 7 | export default class Rule extends React.Component { 8 | 9 | handleKeyChange(value) { 10 | if (this.props.data.key !== value) { 11 | console.log(`Rule: Changing key ${this.props.data.key} => ${value}`) 12 | let data = Object.assign(this.props.data, {key: value}) 13 | this.props.onUpdate(data) 14 | } 15 | } 16 | 17 | handleUrlChange(value) { 18 | if (this.props.data.url !== value) { 19 | console.log(`Rule: Changing url ${this.props.data.url} => ${value}`) 20 | let data = Object.assign(this.props.data, {url: value}) 21 | this.props.onUpdate(data) 22 | } 23 | } 24 | 25 | handleNameChange(value) { 26 | if (this.props.data.name !== value) { 27 | console.log(`Rule: Changing name ${this.props.data.name} => ${value}`) 28 | let data = Object.assign(this.props.data, {name: value}) 29 | this.props.onUpdate(data) 30 | } 31 | } 32 | 33 | handleEnable(e) { 34 | console.log(`Rule: Changing enabled ${this.props.data.enabled} => ${!this.props.data.enabled}`) 35 | let data = Object.assign(this.props.data, {enabled: !this.props.data.enabled}) 36 | this.props.onUpdate(data) 37 | } 38 | 39 | handleDelete(e) { 40 | console.log(`Rule: Removing ${this.props.data.name} (${this.props.data.id})`) 41 | this.props.onDelete(this.props.data.id) 42 | } 43 | 44 | render() { 45 | let {data} = this.props 46 | let b = block('rule') 47 | let enableText = data.enabled ? 'Disable' : 'Enable' 48 | let enableIcon = data.enabled ? 'pause' : 'play' 49 | return ( 50 |
51 |
52 |

53 | 54 | 55 |
56 |
57 |
58 |
59 |
60 |
61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/TokenInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import block from 'bem-cn' 3 | 4 | export default class TokenInput extends React.Component { 5 | 6 | constructor(props) { 7 | super(props) 8 | this.state = {currentValue: this.props.value, focused: false} 9 | } 10 | 11 | // handleClick(e) { 12 | // console.log('TokenInput clicked, focusing...') 13 | // if (!this.state.focused) { 14 | // this.refs.input.focus() 15 | // } 16 | // } 17 | 18 | handleChange(e) { 19 | this.setState({currentValue: e.target.value}) 20 | } 21 | 22 | handleFocus(e) { 23 | console.log('TokenInput handleFocus') 24 | this.setState({focused: true}) 25 | } 26 | 27 | handleBlur(e) { 28 | console.log(`TokenInput blur: ${e.target.value}`) 29 | this.props.onChange(e.target.value) 30 | this.setState({focused: false}) 31 | } 32 | 33 | handleKeyDown(e) { 34 | // Tab, Return 35 | if ([9, 13].indexOf(e.keyCode) !== -1) { 36 | e.preventDefault() 37 | this.refs.input.blur() 38 | } 39 | } 40 | 41 | renderSpan(value, token) { 42 | let className = this.props.noTokens ? block('token-input')('span') : block('token-input')('span').state({token}) 43 | return ( 44 | 45 | {value} 46 | 47 | ) 48 | } 49 | 50 | render() { 51 | let {currentValue, focused} = this.state 52 | let {value, placeholder} = this.props 53 | let b = block('token-input') 54 | 55 | // Insert spans in place of tokens 56 | let spans = [] 57 | let re = /\{\{.+?\}\}/g 58 | let lastIndex = 0 59 | let match 60 | while ((match = re.exec(value)) !== null) { 61 | spans.push(this.renderSpan(value.substring(lastIndex, match.index), false)) 62 | spans.push(this.renderSpan(value.substring(match.index + 2, match.index + match[0].length - 2), true)) 63 | lastIndex = match.index + match[0].length 64 | } 65 | spans.push(this.renderSpan(value.substring(lastIndex), false)) 66 | return ( 67 |
68 | 79 |
{spans}
80 |
81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export Button from './Button' 2 | export Rule from './Rule' 3 | export TokenInput from './TokenInput' 4 | -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const RULES_REQUEST = 'RULES_REQUEST' 2 | export const RULES_SUCCESS = 'RULES_SUCCESS' 3 | export const RULES_FAILURE = 'RULES_FAILURE' 4 | 5 | export const RULE_REQUEST = 'RULE_REQUEST' 6 | export const RULE_SUCCESS = 'RULE_SUCCESS' 7 | export const RULE_FAILURE = 'RULE_FAILURE' 8 | 9 | export const CREATE_RULE_REQUEST = 'CREATE_RULE_REQUEST' 10 | export const CREATE_RULE_SUCCESS = 'CREATE_RULE_SUCCESS' 11 | export const CREATE_RULE_FAILURE = 'CREATE_RULE_FAILURE' 12 | 13 | export const UPDATE_RULE_REQUEST = 'UPDATE_RULE_REQUEST' 14 | export const UPDATE_RULE_SUCCESS = 'UPDATE_RULE_SUCCESS' 15 | export const UPDATE_RULE_FAILURE = 'UPDATE_RULE_FAILURE' 16 | 17 | export const DELETE_RULE_REQUEST = 'DELETE_RULE_REQUEST' 18 | export const DELETE_RULE_SUCCESS = 'DELETE_RULE_SUCCESS' 19 | export const DELETE_RULE_FAILURE = 'DELETE_RULE_FAILURE' 20 | -------------------------------------------------------------------------------- /src/containers/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Link} from 'react-router' 3 | import block from 'bem-cn' 4 | 5 | 6 | export default class App extends React.Component { 7 | render() { 8 | let h = block('navbar') 9 | return ( 10 |
11 |
12 |

KeySearch

13 | 18 |
19 | {this.props.children} 20 |
21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/containers/Root.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | import {Provider} from 'react-redux' 3 | import {Router} from 'react-router' 4 | import routes from '../routes' 5 | 6 | 7 | export default class Root extends React.Component { 8 | 9 | static propTypes = { 10 | store: PropTypes.object.isRequired, 11 | history: PropTypes.object.isRequired 12 | } 13 | 14 | render() { 15 | let {store, history} = this.props 16 | return ( 17 | 18 |
19 | 20 |
21 |
22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/containers/RulesApp.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {bindActionCreators} from 'redux' 3 | import {connect} from 'react-redux' 4 | import {VelocityTransitionGroup} from 'velocity-react' 5 | import * as actionCreators from '../actions' 6 | import {Button, Rule, TokenInput} from '../components' 7 | import block from 'bem-cn' 8 | import FileSaver from 'file-saver' 9 | import Dropzone from 'react-dropzone' 10 | 11 | 12 | class RulesApp extends React.Component { 13 | 14 | componentDidMount() { 15 | this.props.actions.loadRules() 16 | } 17 | 18 | handleAdd(e) { 19 | console.log('RulesApp handleAdd') 20 | this.props.actions.createRule({name: '', key: '', url: '', enabled: true}) 21 | } 22 | 23 | handleImport(files, rejected) { 24 | console.log('RulesApp handleImport') 25 | if (files.length > 0) { 26 | console.log('Files: ', files); 27 | let reader = new FileReader() 28 | reader.onload = (function(createRule) { 29 | return function(e) { 30 | let rules = JSON.parse(e.target.result) 31 | console.log(rules) 32 | for (let rule of rules) { 33 | createRule(rule) 34 | } 35 | } 36 | })(this.props.actions.createRule) 37 | reader.readAsText(files[0]); 38 | } 39 | } 40 | 41 | handleExport(e) { 42 | console.log('RulesApp handleExport') 43 | console.log(this.props.rulesList.rules) 44 | // No ideal configuration here for now... 45 | // application/octet-stream => downloads, "Unknown" filename 46 | // application/json => displays with incorrect encoding, "Unknown.json" default name in save dialog 47 | // application/json;charset=utf-8 => fails completely 48 | // text/plain;charset=utf-8 => displays with correct encoding, "Unknown.css" default name in save dialog 49 | let blob = new Blob([JSON.stringify(this.props.rulesList.rules, null, 2)], {type: 'text/plain;charset=utf-8'}) 50 | FileSaver.saveAs(blob, 'keysearch.json') 51 | } 52 | 53 | render() { 54 | let {rulesList, actions} = this.props 55 | let rows = !rulesList.rules ? [] : rulesList.rules.map((rule, i) => ) 61 | let b = block('rules-app') 62 | return ( 63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 |
71 | {rulesList.loading &&
Loading rules...
} 72 | {rulesList.rules != null && rulesList.rules.length === 0 &&
You have no rules!
} 73 | {rulesList.rules != null && 74 | 75 | {rows} 76 | 77 | } 78 |
79 | ) 80 | } 81 | } 82 | 83 | 84 | const mapStateToProps = (state) => ({ 85 | rulesList: state.rules.rulesList, 86 | // updateRule: state.rules.updateRule, 87 | // deleteRule: state.rules.deleteRule 88 | }) 89 | 90 | 91 | const mapDispatchToProps = (dispatch) => ({ 92 | actions: bindActionCreators(actionCreators, dispatch) 93 | }) 94 | 95 | 96 | export default connect(mapStateToProps, mapDispatchToProps)(RulesApp) 97 | -------------------------------------------------------------------------------- /src/global.js: -------------------------------------------------------------------------------- 1 | import semver from 'semver' 2 | import store from './store' 3 | const isInteger = require('lodash/isInteger') 4 | 5 | 6 | // Abbreviations 7 | const app = safari.application 8 | const ext = safari.extension 9 | 10 | 11 | // Safari Application event listeners 12 | app.addEventListener('command', performCommand, false) 13 | app.addEventListener('validate', validateCommand, false) 14 | app.addEventListener('beforeSearch', handleBeforeSearch, false) 15 | app.addEventListener('message', handleMessage, false) 16 | ext.settings.addEventListener('change', handleSettingChanged, false); 17 | 18 | 19 | // Debug logging 20 | console.log('KeySearch global page loaded') 21 | console.log('======') 22 | for (let k of store) { 23 | console.log(k) 24 | } 25 | console.log('======') 26 | 27 | 28 | // Runs once per browser session 29 | function init() { 30 | if (!('version' in ext.settings)) { 31 | // New installation 32 | console.log('New KeySearch installation. Doing initial setup...') 33 | // Initialize the KeySearch store with some example keywords 34 | store.addPresets() 35 | } else if (isInteger(ext.settings.version)) { 36 | // Upgrade older KeySearch where version was stored as an integer 37 | console.log('Upgrading KeySearch from version ' + ext.settings.version) 38 | store.upgrade() 39 | } 40 | ext.settings.version = '3.0.0' 41 | } 42 | 43 | 44 | // Accept search input via the address bar 45 | function handleBeforeSearch(e) { 46 | console.log('beforeSearch') 47 | console.log(e) 48 | let textEntered = e.query 49 | if (textEntered) { 50 | let urls = parseQuery(textEntered) 51 | if (urls.length > 0) { 52 | // Set the first URL as the target of the search event 53 | e.preventDefault() 54 | e.target.url = urls.shift() 55 | for (let url of urls) { 56 | // Open any additional URLs in background tabs 57 | app.activeBrowserWindow.openTab('background').url = url 58 | } 59 | } 60 | } 61 | } 62 | 63 | 64 | // Convert the entered text into URL(s) according to rules 65 | function parseQuery(textEntered) { 66 | console.log('Parsing input: ' + textEntered) 67 | const token_re = /\{\{(.*?)\}\}/g 68 | let urls = [] 69 | for (let rule of store) { 70 | // Get each token in key 71 | let tokens = rule.key.match(token_re) 72 | // Replace tokens with (.+?) and compile to regex 73 | let query_re = new RegExp('^' + rule.key.replace(token_re, '(.+?)') + '$') 74 | let queries = textEntered.match(query_re) 75 | if (queries) { 76 | // Replace tokens in URL with corresponding query 77 | let url = rule.url 78 | queries.shift() // Remove first element (entire text entered) 79 | for (let i = 0; i < queries.length; i++) { 80 | url = url.split(tokens[i]).join(queries[i]) 81 | } 82 | console.log('URL: ' + url) 83 | urls.push(url) 84 | } 85 | } 86 | return urls 87 | } 88 | 89 | 90 | // Open settings page 91 | function openSettings() { 92 | console.log('Opening KeySearch settings page') 93 | app.activeBrowserWindow.openTab('foreground').url = ext.baseURI + 'index.html' 94 | } 95 | 96 | 97 | // Perform commands 98 | function performCommand(e) { 99 | console.log('Command: ' + e.command) 100 | if (e.command === 'keySearchButton') { 101 | console.log('Toolbar button pressed') 102 | openSettings() 103 | } else if (e.command === 'keySearchContext') { 104 | console.log('Context menu item chosen') 105 | store.set(e.userInfo) 106 | openSettings() 107 | } 108 | } 109 | 110 | 111 | // Validate whether the context menu item should be shown 112 | function validateCommand(e) { 113 | if (e.userInfo && e.userInfo.url == 'noUrl') { 114 | e.target.disabled = true 115 | } else { 116 | e.target.disabled = false 117 | } 118 | } 119 | 120 | 121 | // Handle messages from injected script 122 | function handleMessage(e) { 123 | if (e.name === 'storeRequest') { 124 | console.log('global storeRequest') 125 | console.log(e) 126 | // Perform operation on keyword store and respond with result message 127 | let {id, method, args} = e.message 128 | let response = store[method].apply(store, args) 129 | e.target.page.dispatchMessage('storeResponse', {id, response}) 130 | } 131 | } 132 | 133 | 134 | // Handle settings changes 135 | function handleSettingChanged(e) { 136 | console.log(e) 137 | if (e.key === 'openSettings') { 138 | openSettings() 139 | } 140 | } 141 | 142 | 143 | // Run init function when global page is loaded 144 | init() 145 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {hashHistory} from 'react-router' 4 | import {syncHistoryWithStore} from 'react-router-redux' 5 | import Root from './containers/Root' 6 | import configureStore from './store/configureStore' 7 | import './styles/index.scss' 8 | 9 | const store = configureStore() 10 | const history = syncHistoryWithStore(hashHistory, store) 11 | 12 | ReactDOM.render( 13 | , 14 | document.getElementById('root') 15 | ) 16 | -------------------------------------------------------------------------------- /src/middleware/api.js: -------------------------------------------------------------------------------- 1 | class StoreClient { 2 | 3 | constructor() { 4 | this.callbacks = {} 5 | 6 | // Call the corresponding Promise callback 7 | safari.self.addEventListener('message', (e) => { 8 | if (e.name === 'storeResponse') { 9 | console.log('StoreClient storeResponse') 10 | let {id, response} = e.message 11 | let cb = this.callbacks[id] 12 | if (cb) { 13 | delete this.callbacks[id] 14 | cb.fulfill(response) 15 | // TODO: Call cb.reject(data) if there is an error 16 | } 17 | } 18 | }, false) 19 | } 20 | 21 | request(method, args) { 22 | // Return Promise that is fulfilled by the response message from the global page 23 | return new Promise((fulfill, reject) => { 24 | let id = Math.random() 25 | this.callbacks[id] = {fulfill, reject} 26 | safari.self.tab.dispatchMessage('storeRequest', {id, method, args}) 27 | }) 28 | } 29 | 30 | } 31 | 32 | const storeClient = new StoreClient() 33 | 34 | 35 | export const CALL_API = Symbol('Call API') 36 | 37 | 38 | export default store => next => action => { 39 | const callAPI = action[CALL_API] 40 | if (typeof callAPI === 'undefined') { 41 | return next(action) 42 | } 43 | 44 | function actionWith(data) { 45 | const finalAction = Object.assign({}, action, data) 46 | delete finalAction[CALL_API] 47 | return finalAction 48 | } 49 | 50 | let {method, args, types} = callAPI 51 | const [requestType, successType, errorType] = types 52 | next(actionWith({payload: args, type: requestType})) 53 | return storeClient.request(method, args).then( 54 | response => next(actionWith({payload: response, type: successType})), 55 | error => next(actionWith({payload: error, type: errorType})) 56 | ) 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import {routerReducer as routing} from 'react-router-redux' 2 | import {combineReducers} from 'redux' 3 | import rules from './rules' 4 | 5 | 6 | const rootReducer = combineReducers({ 7 | routing, 8 | rules 9 | }) 10 | 11 | 12 | export default rootReducer 13 | -------------------------------------------------------------------------------- /src/reducers/rules.js: -------------------------------------------------------------------------------- 1 | import {createReducer} from '../utils' 2 | import * as types from '../constants/actionTypes' 3 | 4 | const initialState = { 5 | rulesList: {rules: null, error: null, loading: false}, 6 | newRule: {error: null, loading: false}, 7 | updateRule: {error: null, loading: false}, 8 | deleteRule: {error: null, loading: false} 9 | } 10 | 11 | export default createReducer(initialState, { 12 | [types.RULES_REQUEST]: (state, payload) => { 13 | return {...state, rulesList: {rules: state.rulesList.rules, error: null, loading: true}} 14 | }, 15 | [types.RULES_SUCCESS]: (state, payload) => { 16 | return {...state, rulesList: {rules: payload, error: null, loading: false}} 17 | }, 18 | [types.RULES_FAILURE]: (state, payload) => { 19 | return {...state, rulesList: {rules: null, error: payload, loading: false}} 20 | }, 21 | [types.CREATE_RULE_REQUEST]: (state, payload) => { 22 | return {...state, newRule: {error: null, loading: true}} 23 | }, 24 | [types.CREATE_RULE_SUCCESS]: (state, payload) => { 25 | // Add the rule in the local state 26 | let rulesList = Object.assign({}, state.rulesList) 27 | rulesList.rules.unshift(payload) 28 | return {...state, rulesList, newRule: {error: null, loading: false}} 29 | }, 30 | [types.CREATE_RULE_FAILURE]: (state, payload) => { 31 | return {...state, newRule: {error: payload, loading: false}} 32 | }, 33 | [types.UPDATE_RULE_REQUEST]: (state, payload) => { 34 | return {...state, updateRule: {error: null, loading: true}} 35 | }, 36 | [types.UPDATE_RULE_SUCCESS]: (state, payload) => { 37 | // Update the rule in the local state 38 | let rulesList = Object.assign({}, state.rulesList) 39 | for(var i = 0; i < rulesList.rules.length; i++) { 40 | if (rulesList.rules[i].id === payload.id) { 41 | rulesList.rules[i] = payload 42 | break 43 | } 44 | } 45 | return {...state, rulesList, updateRule: {error: null, loading: false}} 46 | }, 47 | [types.UPDATE_RULE_FAILURE]: (state, payload) => { 48 | return {...state, updateRule: {error: payload, loading: false}} 49 | }, 50 | [types.DELETE_RULE_REQUEST]: (state, payload) => { 51 | return {...state, deleteRule: {error: null, loading: true}} 52 | }, 53 | [types.DELETE_RULE_SUCCESS]: (state, payload) => { 54 | // Remove the deleted rule from the local state 55 | let rulesList = Object.assign({}, state.rulesList) 56 | for(var i = 0; i < rulesList.rules.length; i++) { 57 | if (rulesList.rules[i].id === payload) { 58 | rulesList.rules.splice(i, 1) 59 | break 60 | } 61 | } 62 | return {...state, rulesList, deleteRule: {error: null, loading: false}} 63 | }, 64 | [types.DELETE_RULE_FAILURE]: (state, payload) => { 65 | return {...state, deleteRule: {error: payload, loading: false}} 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Route, IndexRedirect} from 'react-router' 3 | import App from './containers/App' 4 | import RulesApp from './containers/RulesApp' 5 | 6 | 7 | export default 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import uuid from 'node-uuid' 2 | 3 | // KeySearch data store 4 | 5 | // This makes use of the Safari extension settings API to store the necessary data for each saved rule. 6 | // Data is stored with ids starting with 'rule_' as a rudimentary namespace to distinguish from any other 7 | // KeySearch settings. 8 | 9 | 10 | const presets = [ 11 | {key: 'wiki {{query}}', name: 'Wikipedia', url: 'http://en.wikipedia.org/w/index.php?search={{query}}', enabled: true}, 12 | {key: '! {{url}}', name: 'Open URL', url: 'http://{{url}}', enabled: true}, 13 | {key: 'amazon {{query}}', name: 'Amazon', url: 'http://www.amazon.com/s/ref=nb_sb_noss?field-keywords={{query}}&url=search-alias%3Daps&tag=moxt-20', enabled: true}, 14 | {key: 'image {{query}}', name: 'Google Images', url: 'http://www.google.com/images?q={{query}}', enabled: true}, 15 | {key: 'gmail {{query}}', name: 'GMail', url: 'https://mail.google.com/mail/?shva=1#search/{{query}}', enabled: true}, 16 | {key: 'map {{where}}', name: 'Google Maps', url: 'http://maps.google.com/maps?q={{where}}', enabled: true}, 17 | {key: 'imdb {{query}}', name: 'IMDb', url: 'http://www.imdb.com/find?s=all&q={{query}}', enabled: true}, 18 | {key: 'youtube {{query}}', name: 'YouTube', url: 'http://www.youtube.com/results?search_query={{query}}', enabled: true}, 19 | {key: 'fb {{query}}', name: 'FaceBook', url: 'https://www.facebook.com/search.php?q={{query}}', enabled: true}, 20 | {key: 'r {{query}}', name: 'reddit', url: 'http://www.reddit.com/search?q={{query}}', enabled: true} 21 | ] 22 | 23 | 24 | class Store { 25 | 26 | // Initialize the store with example rules on first run 27 | addPresets() { 28 | for (let preset of presets) { 29 | console.log(`Adding preset rule ${preset.key}`) 30 | this.create(preset) 31 | } 32 | } 33 | 34 | // Get the stored data for a rule 35 | get(id) { 36 | console.log('Getting rule') 37 | let jsonString = safari.extension.settings.getItem('rule_' + id) 38 | if (jsonString) { 39 | let data = JSON.parse(jsonString) 40 | return data 41 | } 42 | } 43 | 44 | // Set the stored data for a new rule 45 | create(data) { 46 | console.log('Creating rule') 47 | data.created = Date.now() 48 | data.updated = data.created 49 | data.id = data.created + '_' + uuid.v4() 50 | let jsonString = JSON.stringify(data) 51 | safari.extension.settings.setItem('rule_' + data.id, jsonString) 52 | return this.get(data.id) 53 | } 54 | 55 | // Update stored data for an existing rule 56 | update(data) { 57 | console.log('Updating rule') 58 | data.updated = Date.now() 59 | let jsonString = JSON.stringify(data) 60 | safari.extension.settings.setItem('rule_' + data.id, jsonString) 61 | return this.get(data.id) 62 | } 63 | 64 | // Remove stored data for a rule 65 | delete(id) { 66 | console.log('Deleting rule') 67 | safari.extension.settings.removeItem('rule_' + id) 68 | return id 69 | } 70 | 71 | all() { 72 | console.log('Getting all rules') 73 | return Array.from(this) 74 | } 75 | 76 | // Iterator for rule data 77 | [Symbol.iterator]() { 78 | let i = 0 79 | let ids = null 80 | return { 81 | next: () => { 82 | if (ids === null) { 83 | ids = [] 84 | for (let id in safari.extension.settings) { 85 | if (id.substr(0, 5) == 'rule_') { 86 | ids.push(id.substr(5)) 87 | } 88 | } 89 | ids.sort().reverse() 90 | } 91 | if (i >= ids.length) { 92 | return {done: true} 93 | } else { 94 | return {done: false, value: this.get(ids[i++])} 95 | } 96 | } 97 | } 98 | } 99 | 100 | // Upgrade data store from old versions of KeySearch 101 | upgrade() { 102 | for (let id in safari.extension.settings) { 103 | if (id.substr(0, 2) == '__') { 104 | let jsonString = safari.extension.settings.getItem(id) 105 | if (jsonString) { 106 | let data = JSON.parse(jsonString) 107 | data.key = data.keyword + ' {{query}}' 108 | data.url = data.url.replace('@@@', '{{query}}') 109 | delete data.keyword 110 | this.create(data) 111 | safari.extension.settings.removeItem(id) 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | export default new Store() 119 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import {createStore, applyMiddleware, compose} from 'redux' 2 | import thunk from 'redux-thunk' 3 | // import createLogger from 'redux-logger' 4 | import api from '../middleware/api' 5 | import rootReducer from '../reducers' 6 | 7 | export default function configureStore(initialState) { 8 | let middleware = [thunk, api] 9 | if (process.env.NODE_ENV !== 'production') { 10 | let createLogger = require('redux-logger') 11 | middleware = [...middleware, createLogger()] 12 | } 13 | return createStore(rootReducer, initialState, applyMiddleware(...middleware)) 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/components.scss: -------------------------------------------------------------------------------- 1 | @import "components/button.scss"; 2 | @import "components/rules-app.scss"; 3 | @import "components/rule.scss"; 4 | @import "components/token-input.scss"; 5 | -------------------------------------------------------------------------------- /src/styles/components/_button.scss: -------------------------------------------------------------------------------- 1 | .button { 2 | background-color: #fff; //#f4f4f4; 3 | font-size: 14px; 4 | border: none; 5 | border-radius: 4px; 6 | cursor: pointer; 7 | color: #444; 8 | margin: 0; 9 | padding: 2px 6px 3px 6px; 10 | transition: background-color 200ms linear, color 200ms linear; 11 | 12 | &__icon { 13 | height: 1.4em; 14 | width: 1.4em; 15 | } 16 | 17 | &__text { 18 | vertical-align: middle; 19 | line-height: 1.4em; 20 | } 21 | 22 | &:hover { 23 | color: #fff; 24 | background-color: #444; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/components/_rule.scss: -------------------------------------------------------------------------------- 1 | .rule { 2 | // border-bottom: 1px solid #d1d3d4; 3 | padding: 20px 0; 4 | 5 | &.is-disabled { 6 | opacity: 0.5; 7 | } 8 | 9 | &__header { 10 | display: flex; 11 | } 12 | 13 | &__body { 14 | position: relative; 15 | display: flex; 16 | } 17 | 18 | &__name { 19 | margin: 0 5px 5px 0; 20 | flex: 1 80%; 21 | } 22 | 23 | &__enable { 24 | margin: 2px 0 8px 5px; 25 | } 26 | 27 | &__delete { 28 | margin: 2px 0 8px 5px; 29 | color: #d40000; 30 | // background-color: #f9f9f9; 31 | &:hover { 32 | // color: #fff; 33 | background-color: #d40000; 34 | } 35 | } 36 | 37 | &__key { 38 | position: relative; 39 | flex: 1 25%; 40 | padding: 8px; 41 | background: #e9e9e9; 42 | border-top-left-radius: 5px; 43 | border-bottom-left-radius: 5px; 44 | 45 | & .is-token { 46 | background: #e9e9e9; 47 | } 48 | 49 | &:after { 50 | content: ''; 51 | display: block; 52 | right: -46px; 53 | position: absolute; 54 | top: 0px; 55 | width: 0; 56 | height: 0; 57 | border-left: 23px solid #e9e9e9; 58 | border-right: 23px solid transparent; 59 | border-top: 23px solid transparent; 60 | border-bottom: 23px solid transparent; 61 | } 62 | } 63 | 64 | &__url { 65 | flex: 1 75%; 66 | padding: 8px 8px 8px 31px; 67 | background: #f4f4f4; 68 | border-top-right-radius: 5px; 69 | border-bottom-right-radius: 5px; 70 | 71 | & .is-token { 72 | background: #f4f4f4; 73 | } 74 | } 75 | 76 | &__chevron { 77 | &::before { 78 | border-style: solid; 79 | border-width: 0.25em 0.25em 0 0; 80 | content: ''; 81 | display: inline-block; 82 | height: 0.65em; 83 | width: 0.65em; 84 | position: relative; 85 | top: 1em; 86 | transform: rotate(45deg); 87 | vertical-align: top; 88 | left: 0; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/styles/components/_rules-app.scss: -------------------------------------------------------------------------------- 1 | .rules-app { 2 | &__add { 3 | margin: 10px 5px 0 0; 4 | float: left; 5 | color: #00bc00; 6 | 7 | &:hover { 8 | background-color: #00bc00; 9 | } 10 | } 11 | 12 | &__import { 13 | margin: 10px 5px 0 0; 14 | float: left; 15 | } 16 | 17 | &__export { 18 | margin: 10px 5px 0 0; 19 | float: left; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/styles/components/_token-input.scss: -------------------------------------------------------------------------------- 1 | .token-input { 2 | position: relative; 3 | height: 30px; 4 | color: #444; 5 | 6 | &__input { 7 | background: transparent; 8 | outline: none; 9 | border: 1px solid transparent; 10 | position: absolute; 11 | margin: 0; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | font-size: inherit; 17 | font-weight: inherit; 18 | color: inherit; 19 | line-height: 1; 20 | padding: 8px 4px; 21 | 22 | &:hover { 23 | border: 1px solid #ddd; 24 | } 25 | 26 | &:focus { 27 | border: 1px solid #498feb; 28 | } 29 | 30 | } 31 | 32 | &__overlay { 33 | pointer-events: none; 34 | color: transparent; 35 | position: absolute; 36 | top: 0; 37 | bottom: 0; 38 | left: 0; 39 | right: 0; 40 | line-height: 1; 41 | padding: 7px 4px; 42 | white-space: pre; 43 | overflow: hidden; 44 | border: 1px solid transparent; 45 | 46 | &.is-focused { 47 | opacity: 0; 48 | } 49 | } 50 | 51 | &__span { 52 | 53 | &.is-token { 54 | padding: 0 4px; 55 | 56 | &>span { 57 | border-radius: 3px; 58 | border: 2px solid #498feb; 59 | padding: 1px 5px 2px; 60 | color: #498feb; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "./components"; 2 | 3 | html { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | padding: 0 10px; 10 | font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif; 11 | font-size: 14px; 12 | line-height: 1.5; 13 | color: #444; 14 | } 15 | 16 | h2 { 17 | font-weight: normal; 18 | font-size: 24px; 19 | } 20 | 21 | .container { 22 | max-width: 970px; 23 | margin: 0 auto 50px; 24 | } 25 | 26 | .row:after { 27 | content: ''; 28 | display: block; 29 | clear: both; 30 | } 31 | 32 | .navbar { 33 | height: 58px; 34 | border-bottom: 2px solid #444; 35 | 36 | &__brand { 37 | float: left; 38 | // color: #498feb; 39 | margin: 20px 0 10px 0; 40 | line-height: 28px; 41 | } 42 | 43 | &__nav { 44 | float: right; 45 | padding: 0; 46 | margin: 20px 0 0 0; 47 | list-style: none; 48 | } 49 | 50 | &__item { 51 | float: left; 52 | position: relative; 53 | display: block; 54 | } 55 | 56 | &__link { 57 | line-height: 38px; 58 | padding: 20px 5px 10px 5px; 59 | font-weight: bold; 60 | text-decoration: none; 61 | font-size: 16px; 62 | border-bottom: 2px solid transparent; 63 | } 64 | 65 | &__link.active { 66 | color: #498feb; 67 | border-bottom: 2px solid #498feb; 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function createReducer(initialState, reducerMap) { 2 | return (state = initialState, action) => { 3 | const reducer = reducerMap[action.type] 4 | return reducer ? reducer(state, action.payload) : state 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | const webpack = require('webpack'); 3 | const {resolve} = require('path'); 4 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 5 | 6 | const extensionPath = resolve(__dirname, 'keysearch.safariextension'); 7 | const isProduction = process.env.NODE_ENV === 'production' 8 | 9 | var jsLoaders = [{ 10 | loader: 'babel-loader', 11 | options: { 12 | presets: [['es2015', {modules: false}], 'stage-0', 'react'], 13 | plugins: ['transform-runtime'] 14 | } 15 | }] 16 | 17 | if (isProduction) { 18 | jsLoaders.push({ 19 | loader: 'strip-loader', 20 | options: { 21 | strip: ['console.log'] 22 | } 23 | }) 24 | } 25 | 26 | module.exports = { 27 | entry: { 28 | global: './src/global.js', 29 | index: './src/index.jsx' 30 | }, 31 | output: { 32 | path: extensionPath, 33 | filename: '[name].js' 34 | }, 35 | plugins: [ 36 | new webpack.NoErrorsPlugin(), 37 | new ExtractTextPlugin('[name].css') 38 | ], 39 | resolve: { 40 | extensions: ['.js', '.jsx'] 41 | }, 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.scss$/, 46 | loader: ExtractTextPlugin.extract({ 47 | fallbackLoader: ['style-loader'], 48 | loader: ['css-loader', 'sass-loader'] 49 | }) 50 | }, 51 | { 52 | test: /\.jsx?$/, 53 | exclude: /node_modules/, 54 | use: jsLoaders 55 | }, 56 | { 57 | test: /\.(jpg|png|svg|ttf|eot|woff2?)$/, 58 | loader: 'file-loader', 59 | options: { 60 | name: '[path][name].[ext]' 61 | } 62 | }, 63 | {test: /\.json$/, loader: 'json-loader'} 64 | ] 65 | } 66 | } 67 | --------------------------------------------------------------------------------