├── .gitignore
├── Icon.icns
├── error.html
├── readme.md
├── index.js
├── index.html
├── newtab.html
├── windows.js
├── package.json
├── menu.js
├── renderer.js
├── appmenu.js
└── meny.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | Tabby-darwin-x64/
4 |
--------------------------------------------------------------------------------
/Icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/max-mapper/tabby/HEAD/Icon.icns
--------------------------------------------------------------------------------
/error.html:
--------------------------------------------------------------------------------
1 |
13 |
14 | There was an error loading your page.
15 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Tabby
2 |
3 | > Minimal Chromium based browser with almost no UI, relies on keyboard shortcuts.
4 |
5 | Download it [here](https://github.com/maxogden/tabby/releases).
6 |
7 | 
8 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | var electron = require('electron')
2 | var windows = require('./windows.js')
3 | var setMenu = require('./appmenu.js')
4 |
5 | electron.app.on('ready', function () {
6 | setMenu()
7 | windows.create()
8 | })
9 |
10 | electron.app.on('window-all-closed', windows.onAllClosed)
11 | electron.app.on('activate', windows.onActivate)
12 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/newtab.html:
--------------------------------------------------------------------------------
1 |
13 |
14 | Tabby Browser
15 |
16 | CMD + L Show/Hide URL Bar
17 | CMD + T New Tab
18 | CMD + [ Previous Tab
19 | CMD + ] Next Tab
20 | CMD + W Close Tab
21 | CMD + R Reload Tab
22 | CMD + Left Go Back
23 | CMD + Right Go Forward
24 | CMD + N New Window
25 | CMD + Shift + W Close Window
26 | CMD + Q Quit
27 |
28 | Send PRs: github.com/maxogden/tabby
29 |
30 |
31 |
--------------------------------------------------------------------------------
/windows.js:
--------------------------------------------------------------------------------
1 | var electron = require('electron')
2 | var path = require('path')
3 | var app = electron.app
4 | var windows = []
5 |
6 | module.exports = {
7 | create: create,
8 | destroy: destroy,
9 | onActivate: onActivate,
10 | onAllClosed: onAllClosed
11 | }
12 |
13 | function create () {
14 | var electronScreen = electron.screen
15 | var size = electronScreen.getPrimaryDisplay().workAreaSize
16 | var win = new electron.BrowserWindow({
17 | width: size.width,
18 | height: size.height,
19 | title: 'Tabby'
20 | })
21 | win.loadURL(path.join('file://', __dirname, 'index.html'))
22 | win.on('closed', function () { destroy(win) })
23 | windows.push(win)
24 | }
25 |
26 | function destroy (win) {
27 | var i = windows.indexOf(win)
28 | if (i > -1) windows.splice(i, 1)
29 | win = null
30 | }
31 |
32 | function onAllClosed () {
33 | if (process.platform !== 'darwin') app.quit()
34 | }
35 |
36 | function onActivate () {
37 | if (!windows.length) create()
38 | }
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tabby",
3 | "description": "a browser with almost no UI",
4 | "version": "1.0.0",
5 | "author": "max ogden",
6 | "bugs": {
7 | "url": "https://github.com/maxogden/tabby/issues"
8 | },
9 | "dependencies": {
10 | "csjs-inject": "^1.0.0",
11 | "rainbow-load": "0.0.6",
12 | "tld": "0.0.2",
13 | "vkey": "^1.0.0",
14 | "yo-yo": "^1.1.1"
15 | },
16 | "devDependencies": {
17 | "electron": "1.4.10",
18 | "electron-packager": "^8.3.0",
19 | "standard": "^8.6.0"
20 | },
21 | "homepage": "https://github.com/maxogden/tabby",
22 | "keywords": [
23 | "browser",
24 | "chromium",
25 | "electron",
26 | "minimal"
27 | ],
28 | "license": "ISC",
29 | "main": "index.js",
30 | "repository": {
31 | "type": "git",
32 | "url": "git+https://github.com/maxogden/tabby.git"
33 | },
34 | "scripts": {
35 | "build": "electron-packager . Tabby --platform=darwin --arch=x64 --version=1.4.10 --prune --icon=Icon.icns",
36 | "start": "electron index.js",
37 | "test": "standard"
38 | },
39 | "standard": {
40 | "ignore": [
41 | "meny.js"
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/menu.js:
--------------------------------------------------------------------------------
1 | var yo = require('yo-yo')
2 | var csjs = require('csjs-inject')
3 | var vkey = require('vkey')
4 | var Meny = require('./meny.js')
5 |
6 | module.exports = function (onupdate) {
7 | var menuEl = document.querySelector('.menu')
8 | var contentsEl = document.querySelector('.tabs')
9 | var meny = createMenu(menuEl, contentsEl)
10 | var styles = csjs`.input {
11 | height: 25px;
12 | width: 100%;
13 | font-size: 14px;
14 | font-family: "Helvetica Neue";
15 | font-weight: 200;
16 | outline: none;
17 | }`
18 | var input = yo``
19 | menuEl.appendChild(input)
20 |
21 | function toggle () {
22 | if (meny.isOpen()) {
23 | meny.close()
24 | input.blur()
25 | } else {
26 | meny.open()
27 | input.focus()
28 | input.select()
29 | }
30 | }
31 |
32 | function onkeydown (e) {
33 | if (vkey[e.keyCode] === '') {
34 | onupdate(input.value)
35 | return toggle()
36 | }
37 |
38 | if (vkey[e.keyCode] === '') {
39 | return toggle()
40 | }
41 | }
42 |
43 | meny.toggle = toggle
44 | meny.input = input
45 | return meny
46 | }
47 |
48 | function createMenu (menu, contents) {
49 | return Meny.create({
50 | // The element that will be animated in from off screen
51 | menuElement: menu,
52 |
53 | // The contents that gets pushed aside while Meny is active
54 | contentsElement: contents,
55 |
56 | // The alignment of the menu (top/right/bottom/left)
57 | position: 'top',
58 |
59 | // The height of the menu (when using top/bottom position)
60 | height: 31,
61 |
62 | // The width of the menu (when using left/right position)
63 | width: 260,
64 |
65 | // The angle at which the contents will rotate to.
66 | angle: 30,
67 |
68 | // The mouse distance from menu position which can trigger menu to open.
69 | threshold: 40,
70 |
71 | // Width(in px) of the thin line you see on screen when menu is in closed position.
72 | overlap: 0,
73 |
74 | // The total time taken by menu animation.
75 | transitionDuration: '0.25s',
76 |
77 | // Transition style for menu animations
78 | transitionEasing: 'ease',
79 |
80 | // Gradient overlay for the contents
81 | gradient: 'rgba(0,0,0,0) 0%, rgba(0,0,0,0) 0%)',
82 |
83 | // Use mouse movement to automatically open/close
84 | mouse: false,
85 |
86 | // Use touch swipe events to open/close
87 | touch: false
88 | })
89 | }
90 |
--------------------------------------------------------------------------------
/renderer.js:
--------------------------------------------------------------------------------
1 | var url = require('url')
2 | var dns = require('dns')
3 | var path = require('path')
4 | var electron = require('electron')
5 | var yo = require('yo-yo')
6 | var load = require('rainbow-load')
7 | var tld = require('tld')
8 | tld.defaultFile = path.join(__dirname, 'tlds.dat')
9 | var Menu = require('./menu.js')
10 | var pkg = require('./package.json')
11 |
12 | var errPage = 'file://' + path.join(__dirname, 'error.html')
13 | var newPage = 'file://' + path.join(__dirname, 'newtab.html')
14 |
15 | module.exports = function () {
16 | var menu = Menu(function onNewURL (href) {
17 | var original = href
18 | var tab = currentTab()
19 | if (href.indexOf(' ') > -1) return search(original)
20 | if (href === 'about:blank') return tab.setAttribute('src', newPage)
21 | var parsed = url.parse(href)
22 | if (!parsed.protocol || parsed.protocol === 'localhost:') {
23 | href = 'http://' + href
24 | parsed = url.parse(href)
25 | }
26 | var validTld = tld.registered(parsed.hostname)
27 | if (validTld && href.indexOf('.') > -1) return tab.setAttribute('src', href)
28 |
29 | var queryFinished = false
30 | setTimeout(function () {
31 | if (queryFinished) return
32 | queryFinished = true
33 | search(original)
34 | }, 250)
35 |
36 | dns.lookup(parsed.hostname, function (err, address) {
37 | console.log('dns', err, address)
38 | if (queryFinished) return
39 | queryFinished = true
40 | if (err) return search(original)
41 | else tab.setAttribute('src', href)
42 | })
43 |
44 | function search (href) {
45 | href = 'https://duckduckgo.com/?q=' + href.split(' ').join('+')
46 | return tab.setAttribute('src', href)
47 | }
48 | })
49 | var tabs = []
50 | initShortcuts()
51 | newTab()
52 |
53 | window.tabs = tabs
54 | window.showTab = showTab
55 | window.newTab = newTab
56 | window.changeTab = changeTab
57 |
58 | function newTab (src) {
59 | if (!src) src = newPage
60 | var tab = yo``
61 | tabs.push(tab)
62 | showTab(tab)
63 | tab.addEventListener('did-start-loading', function () {
64 | var src = tab.getAttribute('src')
65 | console.log('did-start-loading', src)
66 | if (src === errPage) return true
67 | if (src === newPage) menu.input.value = 'about:blank'
68 | else menu.input.value = src
69 | delete tab.__GOT_RESPONSE
70 | load.show()
71 | return true
72 | })
73 | tab.addEventListener('did-stop-loading', function () {
74 | var src = tab.getAttribute('src')
75 | console.log('did-stop-loading', src)
76 | if (src === errPage) return true
77 | if (src === newPage) menu.input.value = 'about:blank'
78 | else menu.input.value = src
79 | load.hide()
80 | if (tab.__LOADFAIL) {
81 | console.error('Error loading', src)
82 | tab.setAttribute('src', errPage)
83 | }
84 | return true
85 | })
86 | tab.addEventListener('did-navigate-in-page', function (e) {
87 | tab.__LOADFAIL = false
88 | load.hide()
89 | })
90 | tab.addEventListener('did-get-response-details', function () {
91 | tab.__LOADFAIL = false
92 | })
93 | tab.addEventListener('did-fail-load', function (e) {
94 | var src = tab.getAttribute('src')
95 | console.log('did-fail-load', src)
96 | console.error('Error loading', src, e)
97 | tab.setAttribute('src', errPage)
98 | load.hide()
99 | })
100 |
101 | var content = document.querySelector('.tabs')
102 | content.appendChild(tab)
103 | electron.ipcRenderer.send('tab-change', tabs.length)
104 | }
105 |
106 | function currentTab () {
107 | for (var i = 0; i < tabs.length; i++) {
108 | if (tabs[i].getAttribute('style').match('flex')) return tabs[i]
109 | }
110 | }
111 |
112 | function showTab (tab) {
113 | var idx = tabs.indexOf(tab)
114 | if (idx === -1) return
115 | for (var i = 0; i < tabs.length; i++) {
116 | if (i === idx) {
117 | tabs[i].setAttribute('style', 'display: flex')
118 | if (tabs[i].getAttribute('src') === newPage) menu.input.value = 'about:blank'
119 | else menu.input.value = tabs[i].getAttribute('src')
120 | } else {
121 | tabs[i].setAttribute('style', 'display: none')
122 | }
123 | }
124 | }
125 |
126 | function changeTab (num) {
127 | for (var i = 0; i < tabs.length; i++) {
128 | if (tabs[i].getAttribute('style').match('flex')) {
129 | var next = i + num
130 | if (next >= tabs.length) next = 0
131 | if (next === -1) next = tabs.length - 1
132 | var nextTab = tabs[next]
133 | if (!nextTab) return console.error('Tab change error', {num: num, next: next, tabs: tabs.length})
134 | return showTab(nextTab)
135 | }
136 | }
137 | }
138 |
139 | function closeTab (tab) {
140 | var idx = tabs.indexOf(tab)
141 | if (idx === -1) return
142 | if (tabs.length === 1) return electron.remote.getCurrentWindow().close()
143 | document.querySelector('.tabs').removeChild(tab)
144 | changeTab(-1)
145 | tabs.splice(idx, 1)
146 | }
147 |
148 | function initShortcuts () {
149 | electron.ipcRenderer.on('appmenu', function (event, type) {
150 | var tab = currentTab()
151 | if (type === 'file:new-tab') newTab()
152 | if (type === 'file:open-location') menu.toggle()
153 | if (type === 'file:close-tab') closeTab(tab)
154 | if (type === 'view:reload') tab.reload()
155 | if (type === 'view:hard-reload') tab.reloadIgnoringCache()
156 | if (type === 'history:back') tab.goBack()
157 | if (type === 'history:forward') tab.goForward()
158 | if (type === 'window:next-tab') changeTab(1)
159 | if (type === 'window:previous-tab') changeTab(-1)
160 | if (type === 'help:report-issue') newTab(pkg.bugs.url)
161 | if (type === 'help:learn-more') newTab(pkg.homepage)
162 | })
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/appmenu.js:
--------------------------------------------------------------------------------
1 | var electron = require('electron')
2 | var Menu = electron.Menu
3 | var app = electron.app
4 | var windows = require('./windows')
5 |
6 | var template = [
7 | {
8 | label: 'File',
9 | submenu: [
10 | {
11 | label: 'New Tab',
12 | accelerator: 'CmdOrCtrl+T',
13 | click: function (item, focusedWindow) {
14 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'file:new-tab')
15 | }
16 | },
17 | {
18 | label: 'New Window',
19 | accelerator: 'CmdOrCtrl+N',
20 | click: function () { windows.create() }
21 | },
22 | {
23 | label: 'Reopen Closed Tab',
24 | accelerator: 'CmdOrCtrl+Shift+T',
25 | click: function (item, focusedWindow) {
26 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'file:reopen-closed-tab')
27 | },
28 | enabled: false
29 | },
30 | {
31 | label: 'Open File',
32 | accelerator: 'CmdOrCtrl+O',
33 | click: function (item, focusedWindow) {
34 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'file:open-file')
35 | },
36 | enabled: false
37 | },
38 | {
39 | label: 'Open Location',
40 | accelerator: 'CmdOrCtrl+L',
41 | click: function (item, focusedWindow) {
42 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'file:open-location')
43 | }
44 | },
45 | {
46 | type: 'separator'
47 | },
48 | {
49 | label: 'Close Window',
50 | accelerator: 'CmdOrCtrl+Shift+W',
51 | click: function (item, focusedWindow) {
52 | if (focusedWindow) focusedWindow.close()
53 | }
54 | },
55 | {
56 | label: 'Close Tab',
57 | accelerator: 'CmdOrCtrl+W',
58 | click: function (item, focusedWindow) {
59 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'file:close-tab')
60 | }
61 | }
62 | ]
63 | },
64 | {
65 | label: 'Edit',
66 | submenu: [
67 | {
68 | label: 'Undo',
69 | accelerator: 'CmdOrCtrl+Z',
70 | role: 'undo'
71 | },
72 | {
73 | label: 'Redo',
74 | accelerator: 'Shift+CmdOrCtrl+Z',
75 | role: 'redo'
76 | },
77 | {
78 | type: 'separator'
79 | },
80 | {
81 | label: 'Cut',
82 | accelerator: 'CmdOrCtrl+X',
83 | role: 'cut'
84 | },
85 | {
86 | label: 'Copy',
87 | accelerator: 'CmdOrCtrl+C',
88 | role: 'copy'
89 | },
90 | {
91 | label: 'Paste',
92 | accelerator: 'CmdOrCtrl+V',
93 | role: 'paste'
94 | },
95 | {
96 | label: 'Select All',
97 | accelerator: 'CmdOrCtrl+A',
98 | role: 'selectall'
99 | }
100 | ]
101 | },
102 | {
103 | label: 'View',
104 | submenu: [
105 | {
106 | label: 'Reload',
107 | accelerator: 'CmdOrCtrl+R',
108 | click: function (item, focusedWindow) {
109 | if (focusedWindow) focusedWindow.webContents.send('view:reload')
110 | }
111 | },
112 | {
113 | label: 'Hard Reload (Clear Cache)',
114 | accelerator: 'CmdOrCtrl+Shift+R',
115 | click: function (item, focusedWindow) {
116 | if (focusedWindow) focusedWindow.webContents.send('view:hard-reload')
117 | }
118 | },
119 | {
120 | label: 'Toggle Full Screen',
121 | accelerator: (function () {
122 | if (process.platform === 'darwin') return 'Ctrl+Command+F'
123 | return 'F11'
124 | })(),
125 | click: function (item, focusedWindow) {
126 | if (focusedWindow) focusedWindow.setFullScreen(!focusedWindow.isFullScreen())
127 | }
128 | },
129 | {
130 | label: 'Toggle Developer Tools',
131 | accelerator: (function () {
132 | if (process.platform === 'darwin') return 'Alt+Command+I'
133 | return 'Ctrl+Shift+I'
134 | })(),
135 | click: function (item, focusedWindow) {
136 | if (focusedWindow) focusedWindow.toggleDevTools()
137 | }
138 | }
139 | ]
140 | },
141 | {
142 | label: 'History',
143 | role: 'history',
144 | submenu: [
145 | {
146 | label: 'Back',
147 | accelerator: 'CmdOrCtrl+Left',
148 | click: function (item, focusedWindow) {
149 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'history:back')
150 | }
151 | },
152 | {
153 | label: 'Forward',
154 | accelerator: 'CmdOrCtrl+Right',
155 | click: function (item, focusedWindow) {
156 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'history:forward')
157 | }
158 | }
159 | ]
160 | },
161 | {
162 | label: 'Window',
163 | role: 'window',
164 | submenu: [
165 | {
166 | label: 'Minimize',
167 | accelerator: 'CmdOrCtrl+M',
168 | role: 'minimize'
169 | },
170 | {
171 | type: 'separator'
172 | },
173 | {
174 | label: 'Next Tab',
175 | accelerator: 'CmdOrCtrl+]',
176 | click: function (item, focusedWindow) {
177 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'window:next-tab')
178 | }
179 | },
180 | {
181 | label: 'Previous Tab',
182 | accelerator: 'CmdOrCtrl+[',
183 | click: function (item, focusedWindow) {
184 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'window:previous-tab')
185 | }
186 | }
187 | ]
188 | },
189 | {
190 | label: 'Help',
191 | role: 'help',
192 | submenu: [
193 | {
194 | label: 'Report an Issue...',
195 | click: function (item, focusedWindow) {
196 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'help:report-issue')
197 | }
198 | },
199 | {
200 | label: 'Learn More',
201 | click: function (item, focusedWindow) {
202 | if (focusedWindow) focusedWindow.webContents.send('appmenu', 'help:learn-more')
203 | }
204 | }
205 | ]
206 | }
207 | ]
208 |
209 | if (process.platform === 'darwin') {
210 | var name = 'Tabby'
211 | template.unshift({
212 | label: name,
213 | submenu: [
214 | {
215 | label: 'About ' + name,
216 | role: 'about'
217 | },
218 | {
219 | type: 'separator'
220 | },
221 | {
222 | label: 'Services',
223 | role: 'services',
224 | submenu: []
225 | },
226 | {
227 | type: 'separator'
228 | },
229 | {
230 | label: 'Hide ' + name,
231 | accelerator: 'Command+H',
232 | role: 'hide'
233 | },
234 | {
235 | label: 'Hide Others',
236 | accelerator: 'Command+Alt+H',
237 | role: 'hideothers'
238 | },
239 | {
240 | label: 'Show All',
241 | role: 'unhide'
242 | },
243 | {
244 | type: 'separator'
245 | },
246 | {
247 | label: 'Quit',
248 | accelerator: 'Command+Q',
249 | click: function () { app.quit() }
250 | }
251 | ]
252 | })
253 |
254 | template.filter(function (el) {
255 | return el.label === 'Window'
256 | })[0].submenu.push(
257 | {
258 | type: 'separator'
259 | },
260 | {
261 | label: 'Bring All to Front',
262 | role: 'front'
263 | }
264 | )
265 | }
266 |
267 | module.exports = function () {
268 | Menu.setApplicationMenu(Menu.buildFromTemplate(template))
269 | }
270 |
--------------------------------------------------------------------------------
/meny.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * meny 1.4
3 | * http://lab.hakim.se/meny
4 | * MIT licensed
5 | *
6 | * Created by Hakim El Hattab (http://hakim.se, @hakimel)
7 | */
8 |
9 | // Date.now polyfill
10 | if( typeof Date.now !== 'function' ) Date.now = function() { return new Date().getTime(); };
11 |
12 | var Meny = {
13 |
14 | // Creates a new instance of Meny
15 | create: function( options ) {
16 | return (function(){
17 |
18 | // Make sure the required arguments are defined
19 | if( !options || !options.menuElement || !options.contentsElement ) {
20 | throw 'You need to specify which menu and contents elements to use.';
21 | }
22 |
23 | // Make sure the menu and contents have the same parent
24 | if( options.menuElement.parentNode !== options.contentsElement.parentNode ) {
25 | throw 'The menu and contents elements must have the same parent.';
26 | }
27 |
28 | // Constants
29 | var POSITION_T = 'top',
30 | POSITION_R = 'right',
31 | POSITION_B = 'bottom',
32 | POSITION_L = 'left';
33 |
34 | // Feature detection for 3D transforms
35 | var supports3DTransforms = 'WebkitPerspective' in document.body.style ||
36 | 'MozPerspective' in document.body.style ||
37 | 'msPerspective' in document.body.style ||
38 | 'OPerspective' in document.body.style ||
39 | 'perspective' in document.body.style;
40 |
41 | // Default options, gets extended by passed in arguments
42 | var config = {
43 | width: 300,
44 | height: 300,
45 | position: POSITION_L,
46 | threshold: 40,
47 | angle: 30,
48 | overlap: 6,
49 | transitionDuration: '0.5s',
50 | transitionEasing: 'ease',
51 | gradient: 'rgba(0,0,0,0.20) 0%, rgba(0,0,0,0.65) 100%)',
52 | mouse: true,
53 | touch: true
54 | };
55 |
56 | // Cache references to DOM elements
57 | var dom = {
58 | menu: options.menuElement,
59 | contents: options.contentsElement,
60 | wrapper: options.menuElement.parentNode,
61 | cover: null
62 | };
63 |
64 | // State and input
65 | var indentX = dom.wrapper.offsetLeft,
66 | indentY = dom.wrapper.offsetTop,
67 | touchStartX = null,
68 | touchStartY = null,
69 | touchMoveX = null,
70 | touchMoveY = null,
71 | isOpen = false,
72 | isMouseDown = false;
73 |
74 | // Precalculated transform and style states
75 | var menuTransformOrigin,
76 | menuTransformClosed,
77 | menuTransformOpened,
78 | menuStyleClosed,
79 | menuStyleOpened,
80 |
81 | contentsTransformOrigin,
82 | contentsTransformClosed,
83 | contentsTransformOpened,
84 | contentsStyleClosed,
85 | contentsStyleOpened;
86 |
87 | var originalStyles = {},
88 | addedEventListeners = [];
89 |
90 | // Ongoing animations (for fallback mode)
91 | var menuAnimation,
92 | contentsAnimation,
93 | coverAnimation;
94 |
95 | configure( options );
96 |
97 | /**
98 | * Initializes Meny with the specified user options,
99 | * may be called multiple times as configuration changes.
100 | */
101 | function configure( o ) {
102 | // Extend the default config object with the passed in
103 | // options
104 | Meny.extend( config, o );
105 |
106 | setupPositions();
107 | setupWrapper();
108 | setupCover();
109 | setupMenu();
110 | setupContents();
111 |
112 | bindEvents();
113 | }
114 |
115 | /**
116 | * Prepares the transforms for the current positioning
117 | * settings.
118 | */
119 | function setupPositions() {
120 | menuTransformOpened = '';
121 | contentsTransformClosed = '';
122 | menuAngle = config.angle;
123 | contentsAngle = config.angle / -2;
124 |
125 | switch( config.position ) {
126 | case POSITION_T:
127 | // Primary transform:
128 | menuTransformOrigin = '50% 0%';
129 | menuTransformClosed = 'rotateX( ' + menuAngle + 'deg ) translateY( -100% ) translateY( '+ config.overlap +'px )';
130 | contentsTransformOrigin = '50% 0';
131 | contentsTransformOpened = 'translateY( '+ config.height +'px ) rotateX( ' + contentsAngle + 'deg )';
132 |
133 | // Position fallback:
134 | menuStyleClosed = { top: '-' + (config.height-config.overlap) + 'px' };
135 | menuStyleOpened = { top: '0px' };
136 | contentsStyleClosed = { top: '0px' };
137 | contentsStyleOpened = { top: config.height + 'px' };
138 | break;
139 |
140 | case POSITION_R:
141 | // Primary transform:
142 | menuTransformOrigin = '100% 50%';
143 | menuTransformClosed = 'rotateY( ' + menuAngle + 'deg ) translateX( 100% ) translateX( -2px ) scale( 1.01 )';
144 | contentsTransformOrigin = '100% 50%';
145 | contentsTransformOpened = 'translateX( -'+ config.width +'px ) rotateY( ' + contentsAngle + 'deg )';
146 |
147 | // Position fallback:
148 | menuStyleClosed = { right: '-' + (config.width-config.overlap) + 'px' };
149 | menuStyleOpened = { right: '0px' };
150 | contentsStyleClosed = { left: '0px' };
151 | contentsStyleOpened = { left: '-' + config.width + 'px' };
152 | break;
153 |
154 | case POSITION_B:
155 | // Primary transform:
156 | menuTransformOrigin = '50% 100%';
157 | menuTransformClosed = 'rotateX( ' + -menuAngle + 'deg ) translateY( 100% ) translateY( -'+ config.overlap +'px )';
158 | contentsTransformOrigin = '50% 100%';
159 | contentsTransformOpened = 'translateY( -'+ config.height +'px ) rotateX( ' + -contentsAngle + 'deg )';
160 |
161 | // Position fallback:
162 | menuStyleClosed = { bottom: '-' + (config.height-config.overlap) + 'px' };
163 | menuStyleOpened = { bottom: '0px' };
164 | contentsStyleClosed = { top: '0px' };
165 | contentsStyleOpened = { top: '-' + config.height + 'px' };
166 | break;
167 |
168 | default:
169 | // Primary transform:
170 | menuTransformOrigin = '100% 50%';
171 | menuTransformClosed = 'translateX( -100% ) translateX( '+ config.overlap +'px ) scale( 1.01 ) rotateY( ' + -menuAngle + 'deg )';
172 | contentsTransformOrigin = '0 50%';
173 | contentsTransformOpened = 'translateX( '+ config.width +'px ) rotateY( ' + -contentsAngle + 'deg )';
174 |
175 | // Position fallback:
176 | menuStyleClosed = { left: '-' + (config.width-config.overlap) + 'px' };
177 | menuStyleOpened = { left: '0px' };
178 | contentsStyleClosed = { left: '0px' };
179 | contentsStyleOpened = { left: config.width + 'px' };
180 | break;
181 | }
182 | }
183 |
184 | /**
185 | * The wrapper element holds the menu and contents.
186 | */
187 | function setupWrapper() {
188 | // Add a class to allow for custom styles based on
189 | // position
190 | Meny.addClass( dom.wrapper, 'meny-' + config.position );
191 |
192 | originalStyles.wrapper = dom.wrapper.style.cssText;
193 |
194 | dom.wrapper.style[ Meny.prefix( 'perspective' ) ] = '800px';
195 | dom.wrapper.style[ Meny.prefix( 'perspectiveOrigin' ) ] = contentsTransformOrigin;
196 | }
197 |
198 | /**
199 | * The cover is used to obfuscate the contents while
200 | * Meny is open.
201 | */
202 | function setupCover() {
203 | if( dom.cover ) {
204 | dom.cover.parentNode.removeChild( dom.cover );
205 | }
206 |
207 | dom.cover = document.createElement( 'div' );
208 |
209 | // Disabled until a falback fade in animation is added
210 | dom.cover.style.position = 'absolute';
211 | dom.cover.style.display = 'block';
212 | dom.cover.style.width = '100%';
213 | dom.cover.style.height = '100%';
214 | dom.cover.style.left = 0;
215 | dom.cover.style.top = 0;
216 | dom.cover.style.zIndex = 1000;
217 | dom.cover.style.visibility = 'hidden';
218 | dom.cover.style.opacity = 0;
219 |
220 | // Silence unimportant errors in IE8
221 | try {
222 | dom.cover.style.background = 'rgba( 0, 0, 0, 0.4 )';
223 | dom.cover.style.background = '-ms-linear-gradient('+ config.position +','+ config.gradient;
224 | dom.cover.style.background = '-moz-linear-gradient('+ config.position +','+ config.gradient;
225 | dom.cover.style.background = '-webkit-linear-gradient('+ config.position +','+ config.gradient;
226 | }
227 | catch( e ) {}
228 |
229 | if( supports3DTransforms ) {
230 | dom.cover.style[ Meny.prefix( 'transition' ) ] = 'all ' + config.transitionDuration +' '+ config.transitionEasing;
231 | }
232 |
233 | dom.contents.appendChild( dom.cover );
234 | }
235 |
236 | /**
237 | * The meny element that folds out upon activation.
238 | */
239 | function setupMenu() {
240 | // Shorthand
241 | var style = dom.menu.style;
242 |
243 | switch( config.position ) {
244 | case POSITION_T:
245 | style.width = '100%';
246 | style.height = config.height + 'px';
247 | break;
248 |
249 | case POSITION_R:
250 | style.right = '0';
251 | style.width = config.width + 'px';
252 | style.height = '100%';
253 | break;
254 |
255 | case POSITION_B:
256 | style.bottom = '0';
257 | style.width = '100%';
258 | style.height = config.height + 'px';
259 | break;
260 |
261 | case POSITION_L:
262 | style.width = config.width + 'px';
263 | style.height = '100%';
264 | break;
265 | }
266 |
267 | originalStyles.menu = style.cssText;
268 |
269 | style.position = 'fixed';
270 | style.display = 'block';
271 | style.zIndex = 1;
272 |
273 | if( supports3DTransforms ) {
274 | style[ Meny.prefix( 'transform' ) ] = menuTransformClosed;
275 | style[ Meny.prefix( 'transformOrigin' ) ] = menuTransformOrigin;
276 | style[ Meny.prefix( 'transition' ) ] = 'all ' + config.transitionDuration +' '+ config.transitionEasing;
277 | }
278 | else {
279 | Meny.extend( style, menuStyleClosed );
280 | }
281 | }
282 |
283 | /**
284 | * The contents element which gets pushed aside while
285 | * Meny is open.
286 | */
287 | function setupContents() {
288 | // Shorthand
289 | var style = dom.contents.style;
290 |
291 | originalStyles.contents = style.cssText;
292 |
293 | if( supports3DTransforms ) {
294 | style[ Meny.prefix( 'transform' ) ] = contentsTransformClosed;
295 | style[ Meny.prefix( 'transformOrigin' ) ] = contentsTransformOrigin;
296 | style[ Meny.prefix( 'transition' ) ] = 'all ' + config.transitionDuration +' '+ config.transitionEasing;
297 | }
298 | else {
299 | style.position = style.position.match( /relative|absolute|fixed/gi ) ? style.position : 'relative';
300 | Meny.extend( style, contentsStyleClosed );
301 | }
302 | }
303 |
304 | /**
305 | * Attaches all input event listeners.
306 | */
307 | function bindEvents() {
308 |
309 | if( 'ontouchstart' in window ) {
310 | if( config.touch ) {
311 | Meny.bindEvent( document, 'touchstart', onTouchStart );
312 | Meny.bindEvent( document, 'touchend', onTouchEnd );
313 | }
314 | else {
315 | Meny.unbindEvent( document, 'touchstart', onTouchStart );
316 | Meny.unbindEvent( document, 'touchend', onTouchEnd );
317 | }
318 | }
319 |
320 | if( config.mouse ) {
321 | Meny.bindEvent( document, 'mousedown', onMouseDown );
322 | Meny.bindEvent( document, 'mouseup', onMouseUp );
323 | Meny.bindEvent( document, 'mousemove', onMouseMove );
324 | }
325 | else {
326 | Meny.unbindEvent( document, 'mousedown', onMouseDown );
327 | Meny.unbindEvent( document, 'mouseup', onMouseUp );
328 | Meny.unbindEvent( document, 'mousemove', onMouseMove );
329 | }
330 | }
331 |
332 | /**
333 | * Expands the menu.
334 | */
335 | function open() {
336 | if( !isOpen ) {
337 | isOpen = true;
338 |
339 | Meny.addClass( dom.wrapper, 'meny-active' );
340 |
341 | dom.cover.style.height = dom.contents.scrollHeight + 'px';
342 | dom.cover.style.visibility = 'visible';
343 |
344 | // Use transforms and transitions if available...
345 | if( supports3DTransforms ) {
346 | // 'webkitAnimationEnd oanimationend msAnimationEnd animationend transitionend'
347 | Meny.bindEventOnce( dom.wrapper, 'transitionend', function() {
348 | Meny.dispatchEvent( dom.menu, 'opened' );
349 | } );
350 |
351 | dom.cover.style.opacity = 1;
352 |
353 | dom.contents.style[ Meny.prefix( 'transform' ) ] = contentsTransformOpened;
354 | dom.menu.style[ Meny.prefix( 'transform' ) ] = menuTransformOpened;
355 | }
356 | // ...fall back on JS animation
357 | else {
358 | menuAnimation && menuAnimation.stop();
359 | menuAnimation = Meny.animate( dom.menu, menuStyleOpened, 500 );
360 | contentsAnimation && contentsAnimation.stop();
361 | contentsAnimation = Meny.animate( dom.contents, contentsStyleOpened, 500 );
362 | coverAnimation && coverAnimation.stop();
363 | coverAnimation = Meny.animate( dom.cover, { opacity: 1 }, 500 );
364 | }
365 |
366 | Meny.dispatchEvent( dom.menu, 'open' );
367 | }
368 | }
369 |
370 | /**
371 | * Collapses the menu.
372 | */
373 | function close() {
374 | if( isOpen ) {
375 | isOpen = false;
376 |
377 | Meny.removeClass( dom.wrapper, 'meny-active' );
378 |
379 | // Use transforms and transitions if available...
380 | if( supports3DTransforms ) {
381 | // 'webkitAnimationEnd oanimationend msAnimationEnd animationend transitionend'
382 | Meny.bindEventOnce( dom.wrapper, 'transitionend', function() {
383 | Meny.dispatchEvent( dom.menu, 'closed' );
384 | } );
385 |
386 | dom.cover.style.visibility = 'hidden';
387 | dom.cover.style.opacity = 0;
388 |
389 | dom.contents.style[ Meny.prefix( 'transform' ) ] = contentsTransformClosed;
390 | dom.menu.style[ Meny.prefix( 'transform' ) ] = menuTransformClosed;
391 | }
392 | // ...fall back on JS animation
393 | else {
394 | menuAnimation && menuAnimation.stop();
395 | menuAnimation = Meny.animate( dom.menu, menuStyleClosed, 500 );
396 | contentsAnimation && contentsAnimation.stop();
397 | contentsAnimation = Meny.animate( dom.contents, contentsStyleClosed, 500 );
398 | coverAnimation && coverAnimation.stop();
399 | coverAnimation = Meny.animate( dom.cover, { opacity: 0 }, 500, function() {
400 | dom.cover.style.visibility = 'hidden';
401 | Meny.dispatchEvent( dom.menu, 'closed' );
402 | } );
403 | }
404 | Meny.dispatchEvent( dom.menu, 'close' );
405 | }
406 | }
407 |
408 | /**
409 | * Unbinds Meny and resets the DOM to the state it
410 | * was at before Meny was initialized.
411 | */
412 | function destroy() {
413 | dom.wrapper.style.cssText = originalStyles.wrapper
414 | dom.menu.style.cssText = originalStyles.menu;
415 | dom.contents.style.cssText = originalStyles.contents;
416 |
417 | if( dom.cover && dom.cover.parentNode ) {
418 | dom.cover.parentNode.removeChild( dom.cover );
419 | }
420 |
421 | Meny.unbindEvent( document, 'touchstart', onTouchStart );
422 | Meny.unbindEvent( document, 'touchend', onTouchEnd );
423 | Meny.unbindEvent( document, 'mousedown', onMouseDown );
424 | Meny.unbindEvent( document, 'mouseup', onMouseUp );
425 | Meny.unbindEvent( document, 'mousemove', onMouseMove );
426 |
427 | for( var i in addedEventListeners ) {
428 | this.removeEventListener( addedEventListeners[i][0], addedEventListeners[i][1] );
429 | }
430 |
431 | addedEventListeners = [];
432 | }
433 |
434 |
435 | /// INPUT: /////////////////////////////////
436 |
437 | function onMouseDown( event ) {
438 | isMouseDown = true;
439 | }
440 |
441 | function onMouseMove( event ) {
442 | // Prevent opening/closing when mouse is down since
443 | // the user may be selecting text
444 | if( !isMouseDown ) {
445 | var x = event.clientX - indentX,
446 | y = event.clientY - indentY;
447 |
448 | switch( config.position ) {
449 | case POSITION_T:
450 | if( y > config.height ) {
451 | close();
452 | }
453 | else if( y < config.threshold ) {
454 | open();
455 | }
456 | break;
457 |
458 | case POSITION_R:
459 | var w = dom.wrapper.offsetWidth;
460 | if( x < w - config.width ) {
461 | close();
462 | }
463 | else if( x > w - config.threshold ) {
464 | open();
465 | }
466 | break;
467 |
468 | case POSITION_B:
469 | var h = dom.wrapper.offsetHeight;
470 | if( y < h - config.height ) {
471 | close();
472 | }
473 | else if( y > h - config.threshold ) {
474 | open();
475 | }
476 | break;
477 |
478 | case POSITION_L:
479 | if( x > config.width ) {
480 | close();
481 | }
482 | else if( x < config.threshold ) {
483 | open();
484 | }
485 | break;
486 | }
487 | }
488 | }
489 |
490 | function onMouseUp( event ) {
491 | isMouseDown = false;
492 | }
493 |
494 | function onTouchStart( event ) {
495 | touchStartX = event.touches[0].clientX - indentX;
496 | touchStartY = event.touches[0].clientY - indentY;
497 | touchMoveX = null;
498 | touchMoveY = null;
499 |
500 | Meny.bindEvent( document, 'touchmove', onTouchMove );
501 | }
502 |
503 | function onTouchMove( event ) {
504 | touchMoveX = event.touches[0].clientX - indentX;
505 | touchMoveY = event.touches[0].clientY - indentY;
506 |
507 | var swipeMethod = null;
508 |
509 | // Check for swipe gestures in any direction
510 |
511 | if( Math.abs( touchMoveX - touchStartX ) > Math.abs( touchMoveY - touchStartY ) ) {
512 | if( touchMoveX < touchStartX - config.threshold ) {
513 | swipeMethod = onSwipeRight;
514 | }
515 | else if( touchMoveX > touchStartX + config.threshold ) {
516 | swipeMethod = onSwipeLeft;
517 | }
518 | }
519 | else {
520 | if( touchMoveY < touchStartY - config.threshold ) {
521 | swipeMethod = onSwipeDown;
522 | }
523 | else if( touchMoveY > touchStartY + config.threshold ) {
524 | swipeMethod = onSwipeUp;
525 | }
526 | }
527 |
528 | if( swipeMethod && swipeMethod() ) {
529 | event.preventDefault();
530 | }
531 | }
532 |
533 | function onTouchEnd( event ) {
534 | Meny.unbindEvent( document, 'touchmove', onTouchMove );
535 |
536 | // If there was no movement this was a tap
537 | if( touchMoveX === null && touchMoveY === null ) {
538 | onTap();
539 | }
540 | }
541 |
542 | function onTap() {
543 | var isOverContent = ( config.position === POSITION_T && touchStartY > config.height ) ||
544 | ( config.position === POSITION_R && touchStartX < dom.wrapper.offsetWidth - config.width ) ||
545 | ( config.position === POSITION_B && touchStartY < dom.wrapper.offsetHeight - config.height ) ||
546 | ( config.position === POSITION_L && touchStartX > config.width );
547 |
548 | if( isOverContent ) {
549 | close();
550 | }
551 | }
552 |
553 | function onSwipeLeft() {
554 | if( config.position === POSITION_R && isOpen ) {
555 | close();
556 | return true;
557 | }
558 | else if( config.position === POSITION_L && !isOpen ) {
559 | open();
560 | return true;
561 | }
562 | }
563 |
564 | function onSwipeRight() {
565 | if( config.position === POSITION_R && !isOpen ) {
566 | open();
567 | return true;
568 | }
569 | else if( config.position === POSITION_L && isOpen ) {
570 | close();
571 | return true;
572 | }
573 | }
574 |
575 | function onSwipeUp() {
576 | if( config.position === POSITION_B && isOpen ) {
577 | close();
578 | return true;
579 | }
580 | else if( config.position === POSITION_T && !isOpen ) {
581 | open();
582 | return true;
583 | }
584 | }
585 |
586 | function onSwipeDown() {
587 | if( config.position === POSITION_B && !isOpen ) {
588 | open();
589 | return true;
590 | }
591 | else if( config.position === POSITION_T && isOpen ) {
592 | close();
593 | return true;
594 | }
595 | }
596 |
597 |
598 | /// API: ///////////////////////////////////
599 |
600 | return {
601 | configure: configure,
602 |
603 | open: open,
604 | close: close,
605 | destroy: destroy,
606 |
607 | isOpen: function() {
608 | return isOpen;
609 | },
610 |
611 | /**
612 | * Forward event binding to the menu DOM element.
613 | */
614 | addEventListener: function( type, listener ) {
615 | addedEventListeners.push( [type, listener] );
616 | dom.menu && Meny.bindEvent( dom.menu, type, listener );
617 | },
618 | removeEventListener: function( type, listener ) {
619 | dom.menu && Meny.unbindEvent( dom.menu, type, listener );
620 | }
621 | };
622 |
623 | })();
624 | },
625 |
626 | /**
627 | * Helper method, changes an element style over time.
628 | */
629 | animate: function( element, properties, duration, callback ) {
630 | return (function() {
631 | // Will hold start/end values for all properties
632 | var interpolations = {};
633 |
634 | // Format properties
635 | for( var p in properties ) {
636 | interpolations[p] = {
637 | start: parseFloat( element.style[p] ) || 0,
638 | end: parseFloat( properties[p] ),
639 | unit: ( typeof properties[p] === 'string' && properties[p].match( /px|em|%/gi ) ) ? properties[p].match( /px|em|%/gi )[0] : ''
640 | };
641 | }
642 |
643 | var animationStartTime = Date.now(),
644 | animationTimeout;
645 |
646 | // Takes one step forward in the animation
647 | function step() {
648 | // Ease out
649 | var progress = 1 - Math.pow( 1 - ( ( Date.now() - animationStartTime ) / duration ), 5 );
650 |
651 | // Set style to interpolated value
652 | for( var p in interpolations ) {
653 | var property = interpolations[p];
654 | element.style[p] = property.start + ( ( property.end - property.start ) * progress ) + property.unit;
655 | }
656 |
657 | // Continue as long as we're not done
658 | if( progress < 1 ) {
659 | animationTimeout = setTimeout( step, 1000 / 60 );
660 | }
661 | else {
662 | callback && callback();
663 | stop();
664 | }
665 | }
666 |
667 | // Cancels the animation
668 | function stop() {
669 | clearTimeout( animationTimeout );
670 | }
671 |
672 | // Starts the animation
673 | step();
674 |
675 |
676 | /// API: ///////////////////////////////////
677 |
678 | return {
679 | stop: stop
680 | };
681 | })();
682 | },
683 |
684 | /**
685 | * Extend object a with the properties of object b.
686 | * If there's a conflict, object b takes precedence.
687 | */
688 | extend: function( a, b ) {
689 | for( var i in b ) {
690 | a[ i ] = b[ i ];
691 | }
692 | },
693 |
694 | /**
695 | * Prefixes a style property with the correct vendor.
696 | */
697 | prefix: function( property, el ) {
698 | var propertyUC = property.slice( 0, 1 ).toUpperCase() + property.slice( 1 ),
699 | vendors = [ 'Webkit', 'Moz', 'O', 'ms' ];
700 |
701 | for( var i = 0, len = vendors.length; i < len; i++ ) {
702 | var vendor = vendors[i];
703 |
704 | if( typeof ( el || document.body ).style[ vendor + propertyUC ] !== 'undefined' ) {
705 | return vendor + propertyUC;
706 | }
707 | }
708 |
709 | return property;
710 | },
711 |
712 | /**
713 | * Adds a class to the target element.
714 | */
715 | addClass: function( element, name ) {
716 | element.className = element.className.replace( /\s+$/gi, '' ) + ' ' + name;
717 | },
718 |
719 | /**
720 | * Removes a class from the target element.
721 | */
722 | removeClass: function( element, name ) {
723 | element.className = element.className.replace( name, '' );
724 | },
725 |
726 | /**
727 | * Adds an event listener in a browser safe way.
728 | */
729 | bindEvent: function( element, ev, fn ) {
730 | if( element.addEventListener ) {
731 | element.addEventListener( ev, fn, false );
732 | }
733 | else {
734 | element.attachEvent( 'on' + ev, fn );
735 | }
736 | },
737 |
738 | /**
739 | * Removes an event listener in a browser safe way.
740 | */
741 | unbindEvent: function( element, ev, fn ) {
742 | if( element.removeEventListener ) {
743 | element.removeEventListener( ev, fn, false );
744 | }
745 | else {
746 | element.detachEvent( 'on' + ev, fn );
747 | }
748 | },
749 |
750 | bindEventOnce: function ( element, ev, fn ) {
751 | var me = this;
752 | var listener = function() {
753 | me.unbindEvent( element, ev, listener );
754 | fn.apply( this, arguments );
755 | };
756 | this.bindEvent( element, ev, listener );
757 | },
758 |
759 | /**
760 | * Dispatches an event of the specified type from the
761 | * menu DOM element.
762 | */
763 | dispatchEvent: function( element, type, properties ) {
764 | if( element ) {
765 | var event = document.createEvent( "HTMLEvents", 1, 2 );
766 | event.initEvent( type, true, true );
767 | Meny.extend( event, properties );
768 | element.dispatchEvent( event );
769 | }
770 | },
771 |
772 | /**
773 | * Retrieves query string as a key/value hash.
774 | */
775 | getQuery: function() {
776 | var query = {};
777 |
778 | location.search.replace( /[A-Z0-9]+?=([\w|:|\/\.]*)/gi, function(a) {
779 | query[ a.split( '=' ).shift() ] = a.split( '=' ).pop();
780 | } );
781 |
782 | return query;
783 | }
784 |
785 | };
786 |
787 | module.exports = Meny;
788 |
789 |
--------------------------------------------------------------------------------