├── .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 | ![Tabby Screenshot](https://cloud.githubusercontent.com/assets/39759/13931478/bda8a1d4-ef60-11e5-92c4-2f78405918d5.png) 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 | --------------------------------------------------------------------------------