├── .babelrc ├── .eslintrc ├── .gitignore ├── .stylelintrc ├── .travis.yml ├── Makefile ├── README.md ├── assets ├── icon.icns ├── icon.ico ├── icon.png └── kayero-logo.svg ├── doc └── screenshot.png ├── electron ├── child.plist ├── electron.html ├── index.js ├── kayero.plist └── parent.plist ├── package.json ├── prod.webpack.config.js ├── server.js ├── src ├── fonts │ ├── Amaranth-Regular.otf │ ├── SourceCodePro-Regular.otf │ ├── andada.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── js │ ├── Notebook.js │ ├── actions.js │ ├── app.js │ ├── bindStoreToMenu.js │ ├── code-mirror-linter.js │ ├── components │ │ ├── AddControls.js │ │ ├── Block.js │ │ ├── CodeBlock.js │ │ ├── Content.js │ │ ├── Datasources.js │ │ ├── Footer.js │ │ ├── GraphBlock.js │ │ ├── Header.js │ │ ├── Metadata.js │ │ ├── SaveDialog.js │ │ ├── TextBlock.js │ │ ├── Title.js │ │ └── visualiser │ │ │ ├── ArrayVisualiser.js │ │ │ ├── DefaultVisualiser.js │ │ │ ├── ObjectVisualiser.js │ │ │ └── Visualiser.js │ ├── config │ │ ├── development.config.js │ │ ├── index.js │ │ └── production.config.js │ ├── markdown.js │ ├── reducers │ │ ├── editorReducer.js │ │ ├── executionReducer.js │ │ ├── index.js │ │ └── notebookReducer.js │ ├── selectors.js │ └── util.js ├── scss │ ├── _base.scss │ ├── _editor.scss │ ├── _graphui.scss │ ├── _grids.scss │ ├── _save.scss │ ├── _shell.scss │ ├── _visualiser.scss │ ├── base16-tomorrow-light.scss │ ├── linter.scss │ └── main.scss └── templates │ ├── blank.md │ └── example.md ├── tests ├── actions.js ├── editorReducer.js ├── executionReducer.js ├── fixtures │ ├── extractCodeBlocks.md │ ├── index.html │ ├── index.md │ └── sampleNotebook.md ├── markdown.js ├── notebookReducer.js └── util.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:react/recommended", "standard", "plugin:import/warnings", "plugin:import/errors"], 3 | "parser": "babel-eslint", 4 | "plugins": [ 5 | "react" 6 | ], 7 | "globals": { 8 | "fetch": 2 9 | }, 10 | "ignorePath": ".gitignore", 11 | "rules" : { 12 | "no-new-func": 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # Build folders 36 | dist 37 | 38 | # Electron release directory 39 | release 40 | 41 | coverage 42 | .nyc_output 43 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "no-extra-semicolons": null, 5 | "value-no-vendor-prefix": true, 6 | "property-no-vendor-prefix": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | env: 5 | - CXX=g++-4.8 6 | addons: 7 | apt: 8 | sources: 9 | - ubuntu-toolchain-r-test 10 | packages: 11 | - g++-4.8 12 | cache: 13 | directories: 14 | - node_modules 15 | after_success: npm run coverage 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN_DIR ?= node_modules/.bin 2 | 3 | SRC_DIR ?= src 4 | BUILD_DIST ?= dist 5 | BUILD_TARGET ?= . 6 | RELEASE_DIST ?= release 7 | APP_VERSION ?= $(shell node -e "console.log(require('./package.json').version)") 8 | BUILD_FLAGS ?= --all --overwrite --prune --out=$(RELEASE_DIST) --extend-info=electron/kayero.plist --icon "./assets/icon" --app-category-type=public.app-category.developer-tools --app-bundle-id=me.dutour.mathieu.kayero --app-version=$(APP_VERSION) 9 | 10 | TEST_TARGET ?= tests/ 11 | TEST_FLAGS ?= --require babel-register 12 | 13 | P="\\033[34m[+]\\033[0m" 14 | 15 | # 16 | # CLEAN 17 | # 18 | 19 | clean: 20 | echo " $(P) Cleaning" 21 | rm -rf build/ 22 | 23 | # 24 | # BUILD 25 | # 26 | 27 | build: clean 28 | echo " $(P) Building" 29 | $(BIN_DIR)/webpack --config prod.webpack.config.js -p --progress --colors 30 | $(BIN_DIR)/electron-packager $(BUILD_TARGET) $(BUILD_FLAGS) 31 | 32 | build-watch: clean 33 | echo " $(P) Building forever" 34 | node server.js 35 | 36 | # 37 | # TEST 38 | # 39 | 40 | lint: 41 | echo " $(P) Linting" 42 | $(BIN_DIR)/eslint $(SRC_DIR) && $(BIN_DIR)/eslint $(TEST_TARGET) 43 | 44 | test: lint 45 | echo " $(P) Testing" 46 | NODE_ENV=test $(BIN_DIR)/nyc $(BIN_DIR)/ava $(TEST_TARGET) $(TEST_FLAGS) 47 | 48 | test-watch: 49 | echo " $(P) Testing forever" 50 | NODE_ENV=test $(BIN_DIR)/ava --watch $(TEST_TARGET) $(TEST_FLAGS) 51 | 52 | # 53 | # SIGN 54 | # 55 | 56 | sign: 57 | echo " $(P) Signing" 58 | $(BIN_DIR)/electron-osx-sign "release/Kayero-mas-x64/Kayero.app" --identity="3rd Party Mac Developer Application: Mathieu Dutour (8SEPFSC7S3)" --entitlements=electron/parent.plist --verbose 59 | $(BIN_DIR)/electron-osx-flat "release/Kayero-mas-x64/Kayero.app" --identity="3rd Party Mac Developer Installer: Mathieu Dutour (8SEPFSC7S3)" 60 | 61 | # 62 | # MAKEFILE 63 | # 64 | 65 | .PHONY: \ 66 | clean \ 67 | build build-watch \ 68 | lint test test-watch 69 | 70 | .SILENT: 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Kayero](https://cdn.rawgit.com/mathieudutour/kayero/master/assets/kayero-logo.svg) 2 | 3 | [![Build Status](https://travis-ci.org/mathieudutour/kayero.svg?branch=master)](https://travis-ci.org/mathieudutour/kayero) 4 | [![Dependency Status](https://david-dm.org/mathieudutour/kayero.svg)](https://david-dm.org/mathieudutour/kayero) 5 | 6 | Kayero is an interactive JavaScript notebooks editor, built on [Electron](https://github.com/atom/electron) and [Kajero](https://github.com/JoelOtter/kajero). 7 | 8 | You can view an online sample notebook [here](http://www.joelotter.com/kajero). 9 | 10 | ![](https://raw.githubusercontent.com/mathieudutour/kayero/master/doc/screenshot.png) 11 | 12 | ## Features 13 | 14 | - It's just Markdown - a Kayero notebook is just a Markdown document with a script attached. 15 | - Every notebook is fully editable and can be saved as a Markdown file. 16 | - JavaScript code blocks can be executed. They're treated as functions, with their return value visualised. Kayero can visualise arrays and objects, similar to the Chrome object inspector. 17 | - Code blocks can be set to run automatically when the notebook loads. They can also be set to hidden, so that only the result is visible. 18 | - Data sources can be defined. These will be automatically fetched when the notebook is loaded, and made available for use inside code blocks. A datasource can be either: 19 | - a url returning a json object 20 | - a mongodb URL (the db will be available as a [monk](https://github.com/Automattic/monk) instance) 21 | - Includes D3, NVD3 and [Jutsu](https://github.com/JoelOtter/jutsu), a very simple graphing library which uses Reshaper to transform arbitrary data into a form that can be graphed. 22 | 23 | 24 | ## Installing 25 | 26 | ### OS X 27 | 28 | [![mac app store logo](https://devimages.apple.com.edgekey.net/app-store/marketing/guidelines/mac/images/badge-download-on-the-mac-app-store.svg)](https://itunes.apple.com/us/app/kayero/id1134758887?ls=1&mt=12) 29 | 30 | or 31 | 32 | Download the latest [Kayero release](https://github.com/mathieudutour/kayero/releases/latest). 33 | 34 | ### Windows 35 | 36 | Download the latest [KayeroSetup.exe installer](https://github.com/mathieudutour/kayero/releases/latest). 37 | 38 | ## Building your own version 39 | 40 | 1. Clone the repository 41 | 2. Install the dependencies with `npm i` 42 | 2. Run `npm run build` to build the app. 43 | -------------------------------------------------------------------------------- /assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/assets/icon.icns -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/assets/icon.png -------------------------------------------------------------------------------- /assets/kayero-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/doc/screenshot.png -------------------------------------------------------------------------------- /electron/child.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.inherit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /electron/electron.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Kayero 6 | 7 | 8 |
9 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /electron/index.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, Menu, shell, autoUpdater } = require('electron') 2 | const windowStateKeeper = require('electron-window-state') 3 | 4 | const os = require('os') 5 | 6 | const platform = os.platform() + '_' + os.arch() 7 | const version = app.getVersion() 8 | 9 | let menu 10 | let template 11 | let mainWindow = null 12 | 13 | if (process.env.NODE_ENV === 'development') { 14 | require('electron-debug')() 15 | } 16 | 17 | app.on('window-all-closed', () => { 18 | if (process.platform !== 'darwin') app.quit() 19 | }) 20 | 21 | function execOnMainWindow (...args) { 22 | if (!mainWindow) { 23 | return initNewWindow(() => { 24 | mainWindow.webContents.send(...args) 25 | }) 26 | } 27 | mainWindow.webContents.send(...args) 28 | } 29 | 30 | app.on('open-file', function (event, pathToOpen) { 31 | event.preventDefault() 32 | execOnMainWindow('open-filename', pathToOpen) 33 | }) 34 | 35 | app.on('activate', function (event, hasVisibleWindows) { 36 | event.preventDefault() 37 | if (!hasVisibleWindows) { 38 | initNewWindow(() => app.focus()) 39 | } else { 40 | app.focus() 41 | } 42 | }) 43 | 44 | function initNewWindow (callback) { 45 | // Load the previous state with fallback to defaults 46 | const mainWindowState = windowStateKeeper({ 47 | defaultWidth: 1024, 48 | defaultHeight: 728 49 | }) 50 | 51 | mainWindow = new BrowserWindow({ 52 | 'x': mainWindowState.x, 53 | 'y': mainWindowState.y, 54 | 'width': mainWindowState.width, 55 | 'height': mainWindowState.height 56 | }) 57 | 58 | mainWindowState.manage(mainWindow) 59 | 60 | mainWindow.loadURL(`file://${__dirname}/electron.html`) 61 | 62 | mainWindow.webContents.on('did-finish-load', () => { 63 | mainWindow.show() 64 | mainWindow.focus() 65 | if (callback) { 66 | callback() 67 | } 68 | }) 69 | 70 | mainWindow.on('closed', () => { 71 | mainWindow = null 72 | }) 73 | 74 | if (process.env.NODE_ENV === 'development') { 75 | mainWindow.openDevTools() 76 | } 77 | } 78 | 79 | app.on('ready', () => { 80 | initNewWindow() 81 | 82 | if (process.env.NODE_ENV === 'development') { 83 | try { 84 | BrowserWindow.addDevToolsExtension('~/Library/Application Support/Google/Chrome/Default/Extensions/fmkadmapgofadopljbjfkapdkoienihi/0.14.11_0') 85 | } catch (e) { 86 | console.error(e) 87 | } 88 | } 89 | 90 | if (process.platform === 'darwin') { 91 | template = [{ 92 | label: 'Kayero', 93 | submenu: [{ 94 | label: 'About Kayero', 95 | role: 'about' 96 | }, { 97 | label: 'Check for update', 98 | click () { 99 | autoUpdater.checkForUpdates() 100 | } 101 | }, { 102 | type: 'separator' 103 | }, { 104 | label: 'Services', 105 | role: 'services', 106 | submenu: [] 107 | }, { 108 | type: 'separator' 109 | }, { 110 | label: 'Hide Kayero', 111 | accelerator: 'Command+H', 112 | role: 'hide' 113 | }, { 114 | label: 'Hide Others', 115 | accelerator: 'Command+Shift+H', 116 | role: 'hideothers' 117 | }, { 118 | label: 'Show All', 119 | role: 'unhide' 120 | }, { 121 | type: 'separator' 122 | }, { 123 | label: 'Quit', 124 | accelerator: 'Command+Q', 125 | click () { 126 | app.quit() 127 | } 128 | }] 129 | }, { 130 | label: 'File', 131 | submenu: [{ 132 | label: 'New File', 133 | accelerator: 'Command+N', 134 | click () { 135 | execOnMainWindow('new-file') 136 | } 137 | }, { 138 | label: 'Open...', 139 | accelerator: 'Command+O', 140 | click () { 141 | execOnMainWindow('open-file') 142 | } 143 | }, { 144 | type: 'separator' 145 | }, { 146 | label: 'Save', 147 | accelerator: 'Command+S', 148 | click () { 149 | execOnMainWindow('save-file') 150 | } 151 | }, { 152 | label: 'Save As...', 153 | accelerator: 'Shift+Command+S', 154 | click () { 155 | execOnMainWindow('save-as-file') 156 | } 157 | }] 158 | }, { 159 | label: 'Edit', 160 | submenu: [{ 161 | label: 'Toggle edit mode', 162 | accelerator: 'Command+E', 163 | click (e, focusedWindow) { 164 | execOnMainWindow('toggle-edit') 165 | } 166 | }, { 167 | label: 'Re-run the notebook', 168 | accelerator: 'Command+R', 169 | click (e, focusedWindow) { 170 | execOnMainWindow('re-run') 171 | } 172 | }, { 173 | type: 'separator' 174 | }, { 175 | label: 'Undo', 176 | accelerator: 'Command+Z', 177 | role: 'undo' 178 | }, { 179 | label: 'Redo', 180 | accelerator: 'Shift+Command+Z', 181 | role: 'redo' 182 | }, { 183 | type: 'separator' 184 | }, { 185 | label: 'Cut', 186 | accelerator: 'Command+X', 187 | role: 'cut' 188 | }, { 189 | label: 'Copy', 190 | accelerator: 'Command+C', 191 | role: 'copy' 192 | }, { 193 | label: 'Paste', 194 | accelerator: 'Command+V', 195 | role: 'paste' 196 | }, { 197 | label: 'Select All', 198 | accelerator: 'Command+A', 199 | role: 'selectall' 200 | }] 201 | }, { 202 | label: 'View', 203 | submenu: [{ 204 | label: 'Reload', 205 | accelerator: 'Command+Shift+R', 206 | click (e, focusedWindow) { 207 | if (focusedWindow) { 208 | focusedWindow.webContents.reload() 209 | } 210 | } 211 | }, { 212 | label: 'Toggle Full Screen', 213 | accelerator: 'Ctrl+Command+F', 214 | click (e, focusedWindow) { 215 | if (focusedWindow) { 216 | focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) 217 | } 218 | } 219 | }, { 220 | label: 'Toggle Developer Tools', 221 | accelerator: 'Alt+Command+I', 222 | click (e, focusedWindow) { 223 | if (focusedWindow) { 224 | focusedWindow.toggleDevTools() 225 | } 226 | } 227 | }] 228 | }, { 229 | label: 'Window', 230 | role: 'window', 231 | submenu: [{ 232 | label: 'Minimize', 233 | accelerator: 'Command+M', 234 | role: 'minimize' 235 | }, { 236 | label: 'Close', 237 | accelerator: 'Command+W', 238 | role: 'close' 239 | }, { 240 | type: 'separator' 241 | }, { 242 | label: 'Bring All to Front', 243 | selector: 'arrangeInFront:' 244 | }] 245 | }, { 246 | label: 'Help', 247 | role: 'help', 248 | submenu: [{ 249 | label: 'Learn More', 250 | click () { 251 | shell.openExternal('https://github.com/mathieudutour/kayero') 252 | } 253 | }, { 254 | label: 'Documentation', 255 | click () { 256 | shell.openExternal('https://github.com/mathieudutour/kayero/tree/master/docs#readme') 257 | } 258 | }, { 259 | label: 'Community Discussions', 260 | click () { 261 | shell.openExternal('https://github.com/mathieudutour/kayero/issues') 262 | } 263 | }, { 264 | label: 'Search Issues', 265 | click () { 266 | shell.openExternal('https://github.com/mathieudutour/kayero/issues') 267 | } 268 | }] 269 | }] 270 | 271 | menu = Menu.buildFromTemplate(template) 272 | Menu.setApplicationMenu(menu) 273 | } else { 274 | template = [{ 275 | label: '&File', 276 | submenu: [{ 277 | label: '&Open', 278 | accelerator: 'Ctrl+O' 279 | }, { 280 | label: '&Close', 281 | accelerator: 'Ctrl+W', 282 | click () { 283 | mainWindow.close() 284 | } 285 | }] 286 | }, { 287 | label: '&View', 288 | submenu: (process.env.NODE_ENV === 'development') ? [{ 289 | label: '&Reload', 290 | accelerator: 'Ctrl+R', 291 | click () { 292 | mainWindow.webContents.reload() 293 | } 294 | }, { 295 | label: 'Toggle &Full Screen', 296 | accelerator: 'F11', 297 | click () { 298 | mainWindow.setFullScreen(!mainWindow.isFullScreen()) 299 | } 300 | }, { 301 | label: 'Toggle &Developer Tools', 302 | accelerator: 'Alt+Ctrl+I', 303 | click () { 304 | mainWindow.toggleDevTools() 305 | } 306 | }] : [{ 307 | label: 'Toggle &Full Screen', 308 | accelerator: 'F11', 309 | click () { 310 | mainWindow.setFullScreen(!mainWindow.isFullScreen()) 311 | } 312 | }] 313 | }, { 314 | label: 'Help', 315 | submenu: [{ 316 | label: 'Learn More', 317 | click () { 318 | shell.openExternal('https://github.com/mathieudutour/kayero') 319 | } 320 | }, { 321 | label: 'Documentation', 322 | click () { 323 | shell.openExternal('https://github.com/mathieudutour/kayero/tree/master/docs#readme') 324 | } 325 | }, { 326 | label: 'Community Discussions', 327 | click () { 328 | shell.openExternal('https://github.com/mathieudutour/kayero/issues') 329 | } 330 | }, { 331 | label: 'Search Issues', 332 | click () { 333 | shell.openExternal('https://github.com/mathieudutour/kayero/issues') 334 | } 335 | }] 336 | }] 337 | menu = Menu.buildFromTemplate(template) 338 | mainWindow.setMenu(menu) 339 | 340 | autoUpdater.setFeedUrl('https://getkayero.herokuapp.com/update/' + platform + '/' + version) 341 | autoUpdater.checkForUpdates() 342 | } 343 | }) 344 | 345 | autoUpdater.on('error', (e) => { 346 | execOnMainWindow('log', 'error', e) 347 | }) 348 | 349 | autoUpdater.on('checking-for-update', (e) => { 350 | execOnMainWindow('log', 'checking-for-update', e) 351 | }) 352 | 353 | autoUpdater.on('update-available', (e) => { 354 | execOnMainWindow('log', version) 355 | execOnMainWindow('log', 'update-available', e) 356 | }) 357 | 358 | autoUpdater.on('update-available', (e) => { 359 | execOnMainWindow('log', version) 360 | execOnMainWindow('log', 'update-available', e) 361 | }) 362 | 363 | autoUpdater.on('update-downloaded', (e) => { 364 | execOnMainWindow('log', version) 365 | execOnMainWindow('log', 'update-downloaded', e) 366 | // autoUpdater.quitAndInstall() 367 | }) 368 | -------------------------------------------------------------------------------- /electron/kayero.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ElectronTeamID 6 | 8SEPFSC7S3 7 | CFBundleIdentifier 8 | me.dutour.mathieu.kayero 9 | CFBundleDocumentTypes 10 | 11 | 12 | CFBundleTypeExtensions 13 | 14 | md 15 | MD 16 | 17 | CFBundleTypeIconFile 18 | 19 | CFBundleTypeName 20 | MD 21 | CFBundleTypeRole 22 | Editor 23 | LSHandlerRank 24 | Alternate 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /electron/parent.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.files.user-selected.read-write 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kayero", 3 | "productName": "Kayero", 4 | "main": "electron/index.js", 5 | "version": "0.2.4", 6 | "description": "Interactive JavaScript notebooks with clever graphing", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/mathieudutour/kayero" 10 | }, 11 | "keywords": [ 12 | "interactive", 13 | "javscript", 14 | "markdown", 15 | "notebook", 16 | "mongodb" 17 | ], 18 | "author": "Mathieu Dutour ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/mathieudutour/kayero/issues" 22 | }, 23 | "homepage": "https://github.com/mathieudutour/kayero", 24 | "dependencies": { 25 | "clipboard": "^1.5.10", 26 | "electron-window-state": "3.0.3", 27 | "katex": "0.6.0", 28 | "markdown-it-katex": "2.0.1", 29 | "monk": "3.0.7" 30 | }, 31 | "devDependencies": { 32 | "ava": "^0.15.2", 33 | "babel-core": "^6.9.1", 34 | "babel-eslint": "^6.0.4", 35 | "babel-loader": "^6.2.4", 36 | "babel-preset-es2015": "^6.6.0", 37 | "babel-preset-react": "^6.5.0", 38 | "babel-register": "^6.7.2", 39 | "codecov": "^3.6.5", 40 | "codemirror": "^5.15.2", 41 | "coveralls": "^2.11.9", 42 | "css-loader": "^0.23.1", 43 | "electron-debug": "^1.0.0", 44 | "electron-osx-sign": "0.4.0-beta4", 45 | "electron-packager": "^7.0.3", 46 | "electron-prebuilt": "^1.2.2", 47 | "electron-rebuild": "^1.1.5", 48 | "eslint": "^2.12.0", 49 | "eslint-config-standard": "^5.3.1", 50 | "eslint-plugin-import": "^1.8.1", 51 | "eslint-plugin-promise": "^1.3.2", 52 | "eslint-plugin-react": "^5.1.1", 53 | "eslint-plugin-standard": "^1.3.2", 54 | "fetch-mock": "^4.4.0", 55 | "file-loader": "^0.8.5", 56 | "font-awesome": "^4.5.0", 57 | "front-matter": "^2.0.6", 58 | "highlight.js": "^9.2.0", 59 | "immutable": "^3.7.6", 60 | "jsdom": "^9.2.1", 61 | "json-loader": "^0.5.4", 62 | "jutsu": "^0.1.1", 63 | "markdown-it": "^6.0.1", 64 | "mock-require": "^1.3.0", 65 | "node-sass": "^3.7.0", 66 | "nyc": "^6.6.1", 67 | "query-string": "^4.1.0", 68 | "react": "^15.1.0", 69 | "react-codemirror": "^0.2.6", 70 | "react-dnd": "2.1.4", 71 | "react-dnd-html5-backend": "2.1.2", 72 | "react-dom": "^15.1.0", 73 | "react-hot-loader": "^1.3.0", 74 | "react-redux": "^4.4.1", 75 | "redux": "^3.3.1", 76 | "redux-mock-store": "^1.0.2", 77 | "redux-thunk": "^2.0.1", 78 | "request": "^2.72.0", 79 | "sass-loader": "^3.2.0", 80 | "sinon": "^1.17.4", 81 | "sinon-as-promised": "^4.0.0", 82 | "smolder": "^0.3.1", 83 | "style-loader": "^0.13.1", 84 | "stylelint": "^6.5.1", 85 | "stylelint-config-standard": "^8.0.0", 86 | "url-loader": "^0.5.7", 87 | "webpack": "^1.13.1", 88 | "webpack-dev-server": "^1.14.1", 89 | "webpack-target-electron-renderer": "^0.4.0", 90 | "whatwg-fetch": "^1.0.0", 91 | "write-file-webpack-plugin": "^3.1.8" 92 | }, 93 | "scripts": { 94 | "test": "make test", 95 | "start": "electron .", 96 | "watch": "make build-watch", 97 | "build": "make build && make sign", 98 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /prod.webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const ExternalsPlugin = webpack.ExternalsPlugin 4 | 5 | module.exports = { 6 | entry: [ 7 | './src/js/app' 8 | ], 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | filename: 'bundle.js', 12 | publicPath: '../dist/' 13 | }, 14 | plugins: [ 15 | new ExternalsPlugin('commonjs', [ 16 | 'monk' 17 | ]), 18 | new webpack.DefinePlugin({ 19 | 'process.env.NODE_ENV': '"production"' 20 | }) 21 | ], 22 | module: { 23 | loaders: [ 24 | { test: /\.css$/, loader: 'style-loader!css-loader' }, 25 | { 26 | test: /\.scss$/, 27 | loaders: ['style', 'css', 'sass'] 28 | }, 29 | { test: /\.png$/, loader: 'url-loader?limit=100000' }, 30 | { test: /\.jpg$/, loader: 'file-loader' }, 31 | { test: /\.json$/, loader: 'json-loader' }, 32 | { 33 | test: /\.(ttf|eot|svg|otf|woff(2)?)(\?[a-z0-9=&.]+)?$/, // font files 34 | loader: 'file-loader' 35 | }, 36 | { 37 | test: /\.js$/, 38 | loaders: ['react-hot', 'babel'], 39 | include: path.join(__dirname, 'src') 40 | } 41 | ] 42 | }, 43 | target: 'electron-renderer' 44 | } 45 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var WebpackDevServer = require('webpack-dev-server') 3 | var config = require('./webpack.config') 4 | 5 | new WebpackDevServer(webpack(config), { 6 | publicPath: '/dist/', 7 | hot: true, 8 | historyApiFallback: true 9 | }).listen(3002, 'localhost', function (err, result) { 10 | if (err) { 11 | return console.log(err) 12 | } 13 | console.log('Listening at http://localhost:3002/') 14 | }) 15 | -------------------------------------------------------------------------------- /src/fonts/Amaranth-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/Amaranth-Regular.otf -------------------------------------------------------------------------------- /src/fonts/SourceCodePro-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/SourceCodePro-Regular.otf -------------------------------------------------------------------------------- /src/fonts/andada.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/andada.otf -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mathieudutour/kayero/a042b82869505bfc1873a5cdcc3214d5586d1e86/src/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /src/js/Notebook.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import Header from './components/Header' 5 | import Content from './components/Content' 6 | import Footer from './components/Footer' 7 | import SaveDialog from './components/SaveDialog' 8 | import { fetchData, editBlock, openFile } from './actions' 9 | import { editorSelector } from './selectors' 10 | 11 | class Notebook extends Component { 12 | 13 | constructor (props) { 14 | super(props) 15 | this.deselectBlocks = this.deselectBlocks.bind(this) 16 | } 17 | 18 | componentWillMount () { 19 | this.props.dispatch(openFile()) 20 | } 21 | 22 | componentDidMount () { 23 | this.props.dispatch(fetchData()) 24 | } 25 | 26 | deselectBlocks () { 27 | this.props.dispatch(editBlock(null)) 28 | } 29 | 30 | render () { 31 | const { editable, saving, activeBlock } = this.props 32 | const cssClass = editable ? ' editable' : '' 33 | const notebookView = ( 34 |
35 |
36 |
37 | 38 |
39 |
40 | ) 41 | const saveView = ( 42 |
43 | 44 |
45 | ) 46 | const content = saving ? saveView : notebookView 47 | return ( 48 |
49 |
50 |   51 |
52 | {content} 53 |
54 | ) 55 | } 56 | 57 | } 58 | 59 | Notebook.propTypes = { 60 | activeBlock: React.PropTypes.string, 61 | saving: React.PropTypes.bool, 62 | editable: React.PropTypes.bool, 63 | dispatch: React.PropTypes.func 64 | } 65 | 66 | export default connect(editorSelector)(Notebook) 67 | -------------------------------------------------------------------------------- /src/js/actions.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import monk from 'monk' 4 | 5 | import Immutable from 'immutable' 6 | import Smolder from 'smolder' 7 | /* global d3, nv */ 8 | import Jutsu from 'jutsu' // Imports d3 and nv as globals 9 | import {remote as electron} from 'electron' // eslint-disable-line 10 | 11 | import { arrayToCSV } from './util' 12 | import { gistUrl, gistApi } from './config' // eslint-disable-line 13 | import { render } from './markdown' 14 | 15 | /* 16 | * Action types 17 | */ 18 | export const LOAD_MARKDOWN = 'LOAD_MARKDOWN' 19 | export const CODE_RUNNING = 'CODE_RUNNING' 20 | export const CODE_EXECUTED = 'CODE_EXECUTED' 21 | export const CODE_ERROR = 'CODE_ERROR' 22 | export const RECEIVED_DATA = 'RECEIVED_DATA' 23 | export const TOGGLE_EDIT = 'TOGGLE_EDIT' 24 | export const UPDATE_BLOCK = 'UPDATE_BLOCK' 25 | export const EDIT_BLOCK = 'EDIT_BLOCK' 26 | export const UPDATE_META = 'UPDATE_META' 27 | export const TOGGLE_META = 'TOGGLE_META' 28 | export const ADD_BLOCK = 'ADD_BLOCK' 29 | export const DELETE_BLOCK = 'DELETE_BLOCK' 30 | export const MOVE_BLOCK = 'MOVE_BLOCK' 31 | export const DELETE_DATASOURCE = 'DELETE_DATASOURCE' 32 | export const UPDATE_DATASOURCE = 'UPDATE_DATASOURCE' 33 | export const TOGGLE_SAVE = 'TOGGLE_SAVE' 34 | export const GIST_CREATED = 'GIST_CREATED' 35 | export const UNDO = 'UNDO' 36 | export const CHANGE_CODE_BLOCK_OPTION = 'CHANGE_CODE_BLOCK_OPTION' 37 | export const UPDATE_GRAPH_BLOCK_PROPERTY = 'UPDATE_GRAPH_BLOCK_PROPERTY' 38 | export const UPDATE_GRAPH_BLOCK_HINT = 'UPDATE_GRAPH_BLOCK_HINT' 39 | export const UPDATE_GRAPH_BLOCK_LABEL = 'UPDATE_GRAPH_BLOCK_LABEL' 40 | export const CLEAR_GRAPH_BLOCK_DATA = 'CLEAR_GRAPH_BLOCK_DATA' 41 | export const FILE_SAVED = 'FILE_SAVED' 42 | 43 | function readFileName (filename) { 44 | return new Promise((resolve, reject) => { 45 | fs.readFile(filename, 'utf8', (err, markdown) => { 46 | if (err) { 47 | return reject(err) 48 | } 49 | resolve(markdown) 50 | }) 51 | }) 52 | } 53 | 54 | export function newFile () { 55 | return (dispatch, getState) => { 56 | return Promise.resolve(dispatch({ 57 | type: LOAD_MARKDOWN, 58 | markdown: 59 | `--- 60 | ---` 61 | })).then(() => dispatch(fetchData())) 62 | } 63 | } 64 | 65 | export function openFileName (filename) { 66 | return (dispatch, getState) => { 67 | return Promise.resolve(filename) 68 | .then((filename) => { 69 | if (!filename) { throw new Error('no filename') } 70 | return readFileName(filename) 71 | }).then((markdown) => { 72 | electron.app.addRecentDocument(filename) 73 | electron.BrowserWindow.getFocusedWindow().setRepresentedFilename(filename) 74 | return dispatch({ 75 | type: LOAD_MARKDOWN, 76 | markdown, 77 | filename 78 | }) 79 | }).then(() => dispatch(fetchData())) 80 | } 81 | } 82 | 83 | export function openFile () { 84 | return (dispatch, getState) => { 85 | return new Promise((resolve) => { 86 | electron.dialog.showOpenDialog({ 87 | title: 'Open notebook', 88 | filters: [ 89 | {name: 'Notebooks', extensions: ['md']} 90 | ], 91 | properties: ['openFile'] 92 | }, resolve) 93 | }).then((filename) => { 94 | if (!filename || !filename[0]) { throw new Error('no filename') } 95 | return Promise.all([readFileName(filename[0]), Promise.resolve(filename[0])]) 96 | }).then(([markdown, filename]) => { 97 | electron.app.addRecentDocument(filename) 98 | electron.BrowserWindow.getFocusedWindow().setRepresentedFilename(filename) 99 | return dispatch({ 100 | type: LOAD_MARKDOWN, 101 | markdown, 102 | filename 103 | }) 104 | }).then(() => dispatch(fetchData())) 105 | } 106 | }; 107 | 108 | function initDBs (getState) { 109 | const executionState = getState().execution 110 | let data = {} 111 | if (executionState) { 112 | const immutableData = executionState.get('data') 113 | data = immutableData && immutableData.toJS() || {} 114 | } 115 | const notebook = getState().notebook 116 | let filePath 117 | if (notebook) { 118 | const metadata = notebook.get('metadata') 119 | filePath = metadata && metadata.get('path') 120 | } 121 | const dbs = Object.keys(data) 122 | .filter((k) => data[k] && data[k].__type === 'mongodb') 123 | .reduce((prev, k) => { 124 | if (!data[k].__secure) { 125 | prev[k] = monk(data[k].url) 126 | return prev 127 | } 128 | let uri = data[k].url.split('mongodb-secure://')[1] 129 | 130 | if (uri.indexOf('.') === 0) { // handle relative path 131 | if (filePath) { 132 | const directory = path.dirname(filePath) 133 | uri = path.join(directory, uri) 134 | } 135 | } 136 | 137 | const secret = JSON.parse(fs.readFileSync(uri, 'utf8')) 138 | if (Array.isArray(secret)) { 139 | prev[k] = monk(...secret) 140 | } else { 141 | prev[k] = monk(secret) 142 | } 143 | return prev 144 | }, {}) 145 | return dbs 146 | } 147 | 148 | function closeDBs (dbs) { 149 | Object.keys(dbs).forEach((k) => dbs[k].close()) 150 | return 151 | } 152 | 153 | export function executeCodeBlock (id, dbs) { 154 | return (dispatch, getState) => { 155 | dispatch(codeRunning(id)) 156 | const code = getState().notebook.getIn(['blocks', id, 'content']) 157 | const graphElement = document.getElementById('kayero-graph-' + id) 158 | 159 | const executionState = getState().execution 160 | const context = executionState.get('executionContext').toJS() 161 | const data = executionState.get('data').toJS() 162 | 163 | const soloExecution = !dbs 164 | if (!dbs) { 165 | dbs = initDBs(getState) 166 | } 167 | Object.keys(dbs).forEach((k) => { 168 | if (data[k] && data[k].__type === 'mongodb') { 169 | data[k] = dbs[k] 170 | } 171 | }) 172 | 173 | const jutsu = Smolder(Jutsu(graphElement)) 174 | 175 | return new Promise((resolve, reject) => { 176 | try { 177 | const result = new Function( 178 | ['d3', 'nv', 'graphs', 'data', 'graphElement'], code 179 | ).call( 180 | context, d3, nv, jutsu, data, graphElement 181 | ) 182 | resolve(result) 183 | } catch (err) { 184 | reject(err) 185 | } 186 | }) 187 | .then((result) => dispatch( 188 | codeExecuted(id, result, Immutable.fromJS(context)) 189 | )) 190 | .catch((err) => { 191 | console.error(err) 192 | dispatch(codeError(id, err)) 193 | }) 194 | .then(() => { 195 | if (soloExecution) { 196 | return closeDBs(dbs) 197 | } 198 | }) 199 | } 200 | } 201 | 202 | function codeRunning (id) { 203 | return { 204 | type: CODE_RUNNING, 205 | id 206 | } 207 | } 208 | 209 | function codeExecuted (id, result, context) { 210 | return { 211 | type: CODE_EXECUTED, 212 | id, 213 | data: result, 214 | context 215 | } 216 | } 217 | 218 | function codeError (id, err) { 219 | return { 220 | type: CODE_ERROR, 221 | id, 222 | data: err 223 | } 224 | } 225 | 226 | export function executeAuto () { 227 | return (dispatch, getState) => { 228 | const notebook = getState().notebook 229 | const blocks = notebook.get('blocks') 230 | const order = notebook.get('content') 231 | 232 | // init the database connections 233 | const dbs = initDBs(getState) 234 | 235 | // This slightly scary Promise chaining ensures that code blocks 236 | // are executed in order, even if they return Promises. 237 | return order.reduce((p, id) => { 238 | return p.then(() => { 239 | const option = blocks.getIn([id, 'option']) 240 | if (option === 'auto' || option === 'hidden') { 241 | return dispatch(executeCodeBlock(id, dbs)) 242 | } 243 | return Promise.resolve() 244 | }) 245 | }, Promise.resolve()).then(() => closeDBs(dbs)) 246 | } 247 | } 248 | 249 | function receivedData (name, data) { 250 | return { 251 | type: RECEIVED_DATA, 252 | name, 253 | data 254 | } 255 | } 256 | 257 | export function fetchData () { 258 | return (dispatch, getState) => { 259 | let proms = [] 260 | const currentData = getState().execution.get('data') 261 | getState().notebook.getIn(['metadata', 'datasources']) 262 | .forEach((url, name) => { 263 | if (currentData.has(name)) { return } 264 | if (url.indexOf('file://') === 0) { 265 | const filePath = getState().notebook.get('metadata').get('path') 266 | let directory = filePath.split('/') 267 | directory.pop() 268 | directory = directory.join('/') 269 | proms.push(Promise.resolve(window.require( 270 | path.join(directory, url.replace('file://', '')) 271 | )) 272 | .then(j => dispatch(receivedData(name, j)))) 273 | } else if (url.indexOf('mongodb://') === 0 || url.indexOf('mongodb-secure://') === 0) { 274 | proms.push(Promise.resolve({ 275 | __type: 'mongodb', 276 | __secure: url.indexOf('mongodb-secure://') === 0, 277 | url 278 | }).then(j => dispatch(receivedData(name, j)))) 279 | } else { 280 | proms.push( 281 | fetch(url) 282 | .then(response => response.json()) 283 | .then(j => dispatch(receivedData(name, j))) 284 | ) 285 | } 286 | } 287 | ) 288 | // When all data fetched, run all the auto-running code blocks. 289 | return Promise.all(proms).then(() => dispatch(executeAuto())) 290 | } 291 | } 292 | 293 | export function toggleEdit () { 294 | return { 295 | type: TOGGLE_EDIT 296 | } 297 | } 298 | 299 | export function updateBlock (id, text) { 300 | electron.BrowserWindow.getFocusedWindow().setDocumentEdited(true) 301 | return { 302 | type: UPDATE_BLOCK, 303 | id, 304 | text 305 | } 306 | }; 307 | 308 | export function updateTitle (text) { 309 | return { 310 | type: UPDATE_META, 311 | field: 'title', 312 | text 313 | } 314 | }; 315 | 316 | export function updateAuthor (text) { 317 | return { 318 | type: UPDATE_META, 319 | field: 'author', 320 | text 321 | } 322 | }; 323 | 324 | export function toggleFooter () { 325 | return { 326 | type: TOGGLE_META, 327 | field: 'showFooter' 328 | } 329 | }; 330 | 331 | export function addCodeBlock (id) { 332 | return { 333 | type: ADD_BLOCK, 334 | blockType: 'code', 335 | id 336 | } 337 | }; 338 | 339 | export function addTextBlock (id) { 340 | return { 341 | type: ADD_BLOCK, 342 | blockType: 'text', 343 | id 344 | } 345 | }; 346 | 347 | export function addGraphBlock (id) { 348 | return { 349 | type: ADD_BLOCK, 350 | blockType: 'graph', 351 | id 352 | } 353 | }; 354 | 355 | export function deleteBlock (id) { 356 | return { 357 | type: DELETE_BLOCK, 358 | id 359 | } 360 | }; 361 | 362 | export function moveBlock (id, nextIndex) { 363 | return { 364 | type: MOVE_BLOCK, 365 | id, 366 | nextIndex 367 | } 368 | } 369 | 370 | export function deleteDatasource (id) { 371 | return { 372 | type: DELETE_DATASOURCE, 373 | id 374 | } 375 | }; 376 | 377 | export function updateDatasource (id, url) { 378 | return { 379 | type: UPDATE_DATASOURCE, 380 | id, 381 | text: url 382 | } 383 | }; 384 | 385 | export function toggleSave () { 386 | return { 387 | type: TOGGLE_SAVE 388 | } 389 | }; 390 | 391 | function gistCreated (id) { 392 | return { 393 | type: GIST_CREATED, 394 | id 395 | } 396 | } 397 | 398 | function fileSaved (filename) { 399 | electron.BrowserWindow.getFocusedWindow().setRepresentedFilename(filename) 400 | electron.BrowserWindow.getFocusedWindow().setDocumentEdited(false) 401 | return { 402 | type: FILE_SAVED, 403 | filename 404 | } 405 | } 406 | 407 | export function saveGist (title, markdown) { 408 | return (dispatch, getState) => { 409 | return fetch(gistApi, { 410 | method: 'POST', 411 | headers: { 412 | 'Accept': 'application/json', 413 | 'Content-Type': 'application/json;charset=UTF-8' 414 | }, 415 | body: JSON.stringify({ 416 | description: title, 417 | 'public': true, 418 | files: { 419 | 'notebook.md': { 420 | content: markdown 421 | } 422 | } 423 | }) 424 | }) 425 | .then(response => response.json()) 426 | .then(gist => dispatch(gistCreated(gist.id))) 427 | } 428 | }; 429 | 430 | export function saveFile () { 431 | return (dispatch, getState) => { 432 | const notebook = getState().notebook 433 | const filePath = notebook.get('metadata').get('path') 434 | if (!filePath) { return dispatch(saveAsFile()) } 435 | return new Promise((resolve, reject) => { 436 | fs.writeFile(filePath, render(notebook), 'utf8', (err) => { 437 | if (err) { 438 | return reject(err) 439 | } 440 | resolve(filePath) 441 | }) 442 | }).then((filePath) => dispatch(fileSaved(filePath))) 443 | } 444 | }; 445 | 446 | export function saveAsFile () { 447 | return (dispatch, getState) => { 448 | return new Promise((resolve) => { 449 | electron.dialog.showSaveDialog({ 450 | title: 'Save notebook', 451 | filters: [ 452 | {name: 'Notebooks', extensions: ['md']} 453 | ] 454 | }, resolve) 455 | }).then((filename) => { 456 | if (!filename) { throw new Error('no filename') } 457 | return new Promise((resolve, reject) => { 458 | fs.writeFile(filename, render(getState().notebook), 'utf8', (err) => { 459 | if (err) { 460 | return reject(err) 461 | } 462 | resolve(filename) 463 | }) 464 | }) 465 | }).then((filename) => dispatch(fileSaved(filename))) 466 | } 467 | }; 468 | 469 | export function exportToCSV (data) { 470 | return (dispatch, getState) => { 471 | return Promise.all([ 472 | new Promise((resolve) => { 473 | electron.dialog.showSaveDialog({ 474 | title: 'Save data as CSV', 475 | filters: [ 476 | {name: 'Coma separated values', extensions: ['csv']} 477 | ] 478 | }, resolve) 479 | }), 480 | arrayToCSV(data) 481 | ]).then(([filename, csv]) => { 482 | if (!filename) { throw new Error('no filename') } 483 | return new Promise((resolve, reject) => { 484 | fs.writeFile(filename, csv, 'utf8', (err) => { 485 | if (err) { 486 | return reject(err) 487 | } 488 | resolve(filename) 489 | }) 490 | }) 491 | }).then((filename) => dispatch(fileSaved(filename))) 492 | } 493 | }; 494 | 495 | export function undo () { 496 | return { 497 | type: UNDO 498 | } 499 | } 500 | 501 | export function changeCodeBlockOption (id, option) { 502 | return { 503 | type: CHANGE_CODE_BLOCK_OPTION, 504 | id, 505 | option 506 | } 507 | } 508 | 509 | export function updateGraphType (id, graph) { 510 | return { 511 | type: UPDATE_GRAPH_BLOCK_PROPERTY, 512 | id: id, 513 | property: 'graphType', 514 | value: graph 515 | } 516 | } 517 | 518 | export function updateGraphDataPath (id, dataPath) { 519 | return { 520 | type: UPDATE_GRAPH_BLOCK_PROPERTY, 521 | id: id, 522 | property: 'dataPath', 523 | value: dataPath 524 | } 525 | } 526 | 527 | export function updateGraphHint (id, hint, value) { 528 | return { 529 | type: UPDATE_GRAPH_BLOCK_HINT, 530 | id: id, 531 | hint: hint, 532 | value: value 533 | } 534 | } 535 | 536 | export function updateGraphLabel (id, label, value) { 537 | return { 538 | type: UPDATE_GRAPH_BLOCK_LABEL, 539 | id, 540 | label, 541 | value 542 | } 543 | } 544 | 545 | export function compileGraphBlock (id) { 546 | return { 547 | type: UPDATE_GRAPH_BLOCK_PROPERTY, 548 | id: id, 549 | property: 'type', 550 | value: 'code' 551 | } 552 | } 553 | 554 | export function clearGraphData (id) { 555 | return { 556 | type: CLEAR_GRAPH_BLOCK_DATA, 557 | id 558 | } 559 | } 560 | 561 | export function editBlock (id) { 562 | return { 563 | type: EDIT_BLOCK, 564 | id 565 | } 566 | } 567 | -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch' 2 | import React from 'react' 3 | import { render } from 'react-dom' 4 | import { Provider } from 'react-redux' 5 | import { createStore, compose, applyMiddleware } from 'redux' 6 | import thunk from 'redux-thunk' 7 | import bindStoreToMenu from './bindStoreToMenu' 8 | 9 | import NotebookReducer from './reducers' 10 | import Notebook from './Notebook' 11 | 12 | require('../scss/main.scss') 13 | 14 | const store = compose( 15 | applyMiddleware(thunk) 16 | )(createStore)(NotebookReducer) 17 | 18 | bindStoreToMenu(store) 19 | 20 | render( 21 | 22 |
23 | 24 |
25 |
, 26 | document.getElementById('kayero') 27 | ) 28 | -------------------------------------------------------------------------------- /src/js/bindStoreToMenu.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' // eslint-disable-line 2 | import { openFile, saveFile, saveAsFile, openFileName, newFile, toggleEdit, fetchData } from './actions' 3 | 4 | export default function bindStoreToMenu (store) { 5 | ipcRenderer.on('new-file', (event) => { 6 | store.dispatch(newFile()) 7 | }) 8 | ipcRenderer.on('open-file', (event) => { 9 | store.dispatch(openFile()) 10 | }) 11 | ipcRenderer.on('open-filename', (event, filename) => { 12 | store.dispatch(openFileName(filename)) 13 | }) 14 | ipcRenderer.on('save-file', (event) => { 15 | store.dispatch(saveFile()) 16 | }) 17 | ipcRenderer.on('save-as-file', (event) => { 18 | store.dispatch(saveAsFile()) 19 | }) 20 | ipcRenderer.on('log', (...args) => { 21 | console.log(...args) 22 | }) 23 | ipcRenderer.on('toggle-edit', () => { 24 | store.dispatch(toggleEdit()) 25 | }) 26 | ipcRenderer.on('re-run', () => { 27 | store.dispatch(fetchData()) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/js/code-mirror-linter.js: -------------------------------------------------------------------------------- 1 | /* globals eslint */ 2 | 3 | import CodeMirror from 'codemirror' 4 | 5 | const defaultConfig = { 6 | parserOptions: { 7 | ecmaVersion: 6 8 | }, 9 | env: { 10 | es6: true, 11 | node: true 12 | }, 13 | 'rules': { 14 | 'accessor-pairs': 2, 15 | 'arrow-spacing': [2, { 'before': true, 'after': true }], 16 | 'block-spacing': [2, 'always'], 17 | 'brace-style': [2, '1tbs', { 'allowSingleLine': true }], 18 | 'camelcase': [2, { 'properties': 'never' }], 19 | 'comma-dangle': [2, 'never'], 20 | 'comma-spacing': [2, { 'before': false, 'after': true }], 21 | 'comma-style': [2, 'last'], 22 | 'constructor-super': 2, 23 | 'curly': [2, 'multi-line'], 24 | 'dot-location': [2, 'property'], 25 | 'eol-last': 0, 26 | 'eqeqeq': [2, 'allow-null'], 27 | 'generator-star-spacing': [2, { 'before': true, 'after': true }], 28 | 'handle-callback-err': [2, '^(err|error)$'], 29 | 'indent': [2, 2, { 'SwitchCase': 1 }], 30 | 'jsx-quotes': [2, 'prefer-single'], 31 | 'key-spacing': [2, { 'beforeColon': false, 'afterColon': true }], 32 | 'keyword-spacing': [2, { 'before': true, 'after': true }], 33 | 'new-cap': [2, { 'newIsCap': true, 'capIsNew': false }], 34 | 'new-parens': 2, 35 | 'no-array-constructor': 2, 36 | 'no-caller': 2, 37 | 'no-class-assign': 2, 38 | 'no-cond-assign': 2, 39 | 'no-const-assign': 2, 40 | 'no-control-regex': 2, 41 | 'no-debugger': 2, 42 | 'no-delete-var': 2, 43 | 'no-dupe-args': 2, 44 | 'no-dupe-class-members': 2, 45 | 'no-dupe-keys': 2, 46 | 'no-duplicate-case': 2, 47 | 'no-duplicate-imports': 2, 48 | 'no-empty-character-class': 2, 49 | 'no-empty-pattern': 2, 50 | 'no-eval': 2, 51 | 'no-ex-assign': 2, 52 | 'no-extend-native': 2, 53 | 'no-extra-bind': 2, 54 | 'no-extra-boolean-cast': 2, 55 | 'no-extra-parens': [2, 'functions'], 56 | 'no-fallthrough': 2, 57 | 'no-floating-decimal': 2, 58 | 'no-func-assign': 2, 59 | 'no-implied-eval': 2, 60 | 'no-inner-declarations': [2, 'functions'], 61 | 'no-invalid-regexp': 2, 62 | 'no-irregular-whitespace': 2, 63 | 'no-iterator': 2, 64 | 'no-label-var': 2, 65 | 'no-labels': [2, { 'allowLoop': false, 'allowSwitch': false }], 66 | 'no-lone-blocks': 2, 67 | 'no-mixed-spaces-and-tabs': 2, 68 | 'no-multi-spaces': 2, 69 | 'no-multi-str': 2, 70 | 'no-multiple-empty-lines': [2, { 'max': 1 }], 71 | 'no-native-reassign': 2, 72 | 'no-negated-in-lhs': 2, 73 | 'no-new': 2, 74 | 'no-new-func': 2, 75 | 'no-new-object': 2, 76 | 'no-new-require': 2, 77 | 'no-new-symbol': 2, 78 | 'no-new-wrappers': 2, 79 | 'no-obj-calls': 2, 80 | 'no-octal': 2, 81 | 'no-octal-escape': 2, 82 | 'no-path-concat': 2, 83 | 'no-proto': 2, 84 | 'no-redeclare': 2, 85 | 'no-regex-spaces': 2, 86 | 'no-return-assign': [2, 'except-parens'], 87 | 'no-self-assign': 2, 88 | 'no-self-compare': 2, 89 | 'no-sequences': 2, 90 | 'no-shadow-restricted-names': 2, 91 | 'no-spaced-func': 2, 92 | 'no-sparse-arrays': 2, 93 | 'no-this-before-super': 2, 94 | 'no-throw-literal': 2, 95 | 'no-trailing-spaces': 0, 96 | 'no-undef': 2, 97 | 'no-undef-init': 2, 98 | 'no-unexpected-multiline': 2, 99 | 'no-unmodified-loop-condition': 2, 100 | 'no-unneeded-ternary': [2, { 'defaultAssignment': false }], 101 | 'no-unreachable': 2, 102 | 'no-unsafe-finally': 2, 103 | 'no-unused-vars': [2, { 'vars': 'all', 'args': 'none' }], 104 | 'no-useless-call': 2, 105 | 'no-useless-computed-key': 2, 106 | 'no-useless-constructor': 2, 107 | 'no-useless-escape': 2, 108 | 'no-whitespace-before-property': 2, 109 | 'no-with': 2, 110 | 'one-var': [2, { 'initialized': 'never' }], 111 | 'operator-linebreak': [2, 'after', { 'overrides': { '?': 'before', ':': 'before' } }], 112 | 'padded-blocks': [2, 'never'], 113 | 'quotes': [2, 'single', 'avoid-escape'], 114 | 'semi': [2, 'never'], 115 | 'semi-spacing': [2, { 'before': false, 'after': true }], 116 | 'space-before-blocks': [2, 'always'], 117 | 'space-before-function-paren': [2, 'always'], 118 | 'space-in-parens': [2, 'never'], 119 | 'space-infix-ops': 2, 120 | 'space-unary-ops': [2, { 'words': true, 'nonwords': false }], 121 | 'spaced-comment': [2, 'always', { 'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ','] }], 122 | 'template-curly-spacing': [2, 'never'], 123 | 'use-isnan': 2, 124 | 'valid-typeof': 2, 125 | 'wrap-iife': [2, 'any'], 126 | 'yield-star-spacing': [2, 'both'], 127 | 'yoda': [2, 'never'] 128 | } 129 | } 130 | 131 | function validator (text, options) { 132 | text = 'exports = function(d3, nv, graphs, data, reshaper, graphElement) {\n ' + text.replace(/\n/g, '\n ') + '\n}' 133 | const config = defaultConfig 134 | const errors = window.eslint && eslint.verify(text, config) || [] 135 | return errors.map((error) => { 136 | return { 137 | message: error.message, 138 | severity: getSeverity(error), 139 | from: getPos(error, true), 140 | to: getPos(error, false) 141 | } 142 | }) 143 | } 144 | 145 | function getPos (error, from) { 146 | let line = error.line - 2 147 | let ch = (from ? error.column : error.column + 1) - 2 148 | if (error.node && error.node.loc) { 149 | line = (from ? error.node.loc.start.line - 1 : error.node.loc.end.line - 1) - 1 150 | ch = (from ? error.node.loc.start.column : error.node.loc.end.column) - 2 151 | } 152 | return CodeMirror.Pos(line, ch) 153 | } 154 | 155 | function getSeverity (error) { 156 | switch (error.severity) { 157 | case 1: 158 | return 'warning' 159 | case 2: 160 | return 'error' 161 | default: 162 | return 'error' 163 | } 164 | } 165 | 166 | CodeMirror.registerHelper('lint', 'javascript', validator) 167 | -------------------------------------------------------------------------------- /src/js/components/AddControls.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { addCodeBlock, addTextBlock, addGraphBlock } from '../actions' 3 | 4 | export default class AddControls extends Component { 5 | 6 | constructor (props) { 7 | super(props) 8 | this.addCodeBlock = this.addCodeBlock.bind(this) 9 | this.addTextBlock = this.addTextBlock.bind(this) 10 | this.addGraphBlock = this.addGraphBlock.bind(this) 11 | } 12 | 13 | addCodeBlock () { 14 | this.props.dispatch(addCodeBlock(this.props.id)) 15 | } 16 | 17 | addTextBlock () { 18 | this.props.dispatch(addTextBlock(this.props.id)) 19 | } 20 | 21 | addGraphBlock () { 22 | this.props.dispatch(addGraphBlock(this.props.id)) 23 | } 24 | 25 | render () { 26 | const {editable} = this.props 27 | if (!editable) { 28 | return null 29 | } 30 | return ( 31 |
32 | 33 | 34 | 35 |
36 | ) 37 | } 38 | 39 | } 40 | 41 | AddControls.propTypes = { 42 | id: React.PropTypes.string, 43 | editable: React.PropTypes.bool, 44 | dispatch: React.PropTypes.func 45 | } 46 | -------------------------------------------------------------------------------- /src/js/components/Block.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { findDOMNode } from 'react-dom' 3 | import Codemirror from 'react-codemirror' 4 | import { DragSource, DropTarget } from 'react-dnd' 5 | import 'codemirror/mode/javascript/javascript' 6 | import 'codemirror/mode/markdown/markdown' 7 | import 'codemirror/addon/lint/lint' 8 | import '../code-mirror-linter' 9 | import { 10 | updateBlock, deleteBlock, moveBlock, editBlock 11 | } from '../actions' 12 | import {remote} from 'electron' // eslint-disable-line 13 | const {Menu, MenuItem} = remote 14 | 15 | const dragSource = { 16 | beginDrag (props) { 17 | return { 18 | id: props.id, 19 | index: props.index 20 | } 21 | } 22 | } 23 | 24 | const dragTarget = { 25 | hover (props, monitor, component) { 26 | const dragIndex = monitor.getItem().index 27 | const hoverIndex = props.index 28 | 29 | // Don't replace items with themselves 30 | if (dragIndex === hoverIndex) { 31 | return 32 | } 33 | 34 | // Determine rectangle on screen 35 | const hoverBoundingRect = findDOMNode(component).getBoundingClientRect() 36 | 37 | // Get vertical middle 38 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2 39 | 40 | // Determine mouse position 41 | const clientOffset = monitor.getClientOffset() 42 | 43 | // Get pixels to the top 44 | const hoverClientY = clientOffset.y - hoverBoundingRect.top 45 | 46 | // Only perform the move when the mouse has crossed half of the items height 47 | // When dragging downwards, only move when the cursor is below 50% 48 | // When dragging upwards, only move when the cursor is above 50% 49 | 50 | // Dragging downwards 51 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { 52 | return 53 | } 54 | 55 | // Dragging upwards 56 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { 57 | return 58 | } 59 | 60 | // Time to actually perform the action 61 | props.dispatch(moveBlock(monitor.getItem().id, hoverIndex)) 62 | } 63 | } 64 | 65 | export default class Block extends Component { 66 | 67 | constructor (props) { 68 | super(props) 69 | this.enterEdit = this.enterEdit.bind(this) 70 | this.textChanged = this.textChanged.bind(this) 71 | this.getButtons = this.getButtons.bind(this) 72 | this.deleteBlock = this.deleteBlock.bind(this) 73 | this.handleContextMenu = this.handleContextMenu.bind(this) 74 | } 75 | 76 | enterEdit (e) { 77 | const { dispatch, block, editable } = this.props 78 | if (editable) { 79 | e.stopPropagation && e.stopPropagation() 80 | this.setState({ 81 | text: block.get('content') 82 | }) 83 | dispatch(editBlock(block.get('id'))) 84 | } 85 | } 86 | 87 | textChanged (text) { 88 | this.setState({text}) 89 | } 90 | 91 | componentDidUpdate () { 92 | if (this.refs.editarea) { 93 | this.refs.editarea.focus() 94 | const domNode = findDOMNode(this.refs.editarea) 95 | if (domNode.scrollIntoViewIfNeeded) { 96 | findDOMNode(this.refs.editarea).scrollIntoViewIfNeeded(false) 97 | } 98 | } 99 | } 100 | 101 | componentWillReceiveProps (newProps) { 102 | if (this.props.editing && !newProps.editing && 103 | this.props.block.get('content') === newProps.block.get('content')) { 104 | // If exiting edit mode, save text (unless it's an undo)) 105 | this.props.dispatch( 106 | updateBlock(this.props.block.get('id'), this.state.text) 107 | ) 108 | } 109 | } 110 | 111 | deleteBlock () { 112 | this.props.dispatch(deleteBlock(this.props.block.get('id'))) 113 | } 114 | 115 | handleContextMenu (e) { 116 | e.preventDefault() 117 | const { dispatch, id, index, editable } = this.props 118 | const menu = new Menu() 119 | if (editable) { 120 | menu.append(new MenuItem({ 121 | label: 'Move block up', 122 | click () { dispatch(moveBlock(id, index - 1)) } 123 | })) 124 | menu.append(new MenuItem({ 125 | label: 'Move block down', 126 | click () { dispatch(moveBlock(id, index + 1)) } 127 | })) 128 | menu.append(new MenuItem({type: 'separator'})) 129 | menu.append(new MenuItem({ 130 | label: 'Edit block', 131 | click: this.enterEdit.bind(this) 132 | })) 133 | } 134 | if (this.setContextMenuActions) { 135 | this.setContextMenuActions(menu, MenuItem, editable) 136 | } 137 | if (editable) { 138 | menu.append(new MenuItem({type: 'separator'})) 139 | menu.append(new MenuItem({ 140 | label: 'Delete block', 141 | click () { dispatch(deleteBlock(id)) } 142 | })) 143 | } 144 | 145 | menu.popup(remote.getCurrentWindow()) 146 | } 147 | 148 | getButtons () { 149 | if (!this.props.editable) { 150 | return null 151 | } 152 | let buttons = [ 153 | 155 | ] 156 | return buttons 157 | } 158 | 159 | render () { 160 | const { block, editable, editing, connectDragSource, connectDropTarget, isDragging } = this.props 161 | if (!(editable && editing)) { 162 | if (!editable) { 163 | return this.renderViewerMode() 164 | } 165 | return connectDragSource(connectDropTarget(this.renderViewerMode(isDragging))) 166 | } 167 | const isCodeBlock = block.get('type') === 'code' 168 | const options = { 169 | mode: isCodeBlock ? 'javascript' : 'markdown', 170 | theme: 'base16-tomorrow-light', 171 | lineNumbers: true, 172 | gutters: ['CodeMirror-lint-markers'], 173 | indentUnit: 2, 174 | lint: isCodeBlock, 175 | extraKeys: { 176 | Tab: (cm) => { 177 | const spaces = Array(cm.getOption('indentUnit') + 1).join(' ') 178 | cm.replaceSelection(spaces) 179 | } 180 | } 181 | } 182 | return ( 183 |
{ e.stopPropagation() }}> 184 | 186 |
187 | ) 188 | } 189 | 190 | } 191 | 192 | Block.propTypes = { 193 | block: React.PropTypes.object, 194 | editable: React.PropTypes.bool, 195 | editing: React.PropTypes.bool, 196 | isFirst: React.PropTypes.bool, 197 | isLast: React.PropTypes.bool, 198 | dispatch: React.PropTypes.func, 199 | index: React.PropTypes.number.isRequired, 200 | id: React.PropTypes.string.isRequired, 201 | connectDragSource: React.PropTypes.func.isRequired, 202 | connectDropTarget: React.PropTypes.func.isRequired, 203 | isDragging: React.PropTypes.bool 204 | } 205 | 206 | export const dragAndDropWrapper = (component) => { 207 | return DropTarget('block', dragTarget, connect => ({ 208 | connectDropTarget: connect.dropTarget() 209 | }))( 210 | DragSource('block', dragSource, (connect, monitor) => ({ 211 | connectDragSource: connect.dragSource(), 212 | isDragging: monitor.isDragging() 213 | }))(component)) 214 | } 215 | -------------------------------------------------------------------------------- /src/js/components/CodeBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MarkdownIt from 'markdown-it' 3 | import Block, { dragAndDropWrapper } from './Block' 4 | import Visualiser from './visualiser/Visualiser' 5 | import { codeToText, highlight } from '../util' 6 | import { 7 | executeCodeBlock, changeCodeBlockOption, clearGraphData 8 | } from '../actions' 9 | 10 | const md = new MarkdownIt({highlight}) 11 | 12 | export class CodeBlock extends Block { 13 | 14 | constructor (props) { 15 | super(props) 16 | this.clickPlay = this.clickPlay.bind(this) 17 | this.clickOption = this.clickOption.bind(this) 18 | this.getRunButton = this.getRunButton.bind(this) 19 | this.getOptionButton = this.getOptionButton.bind(this) 20 | this.setContextMenuActions = this.setContextMenuActions.bind(this) 21 | } 22 | 23 | rawMarkup (codeBlock) { 24 | return { 25 | __html: md.render(codeToText(codeBlock)) 26 | } 27 | } 28 | 29 | clickPlay () { 30 | const { dispatch, block } = this.props 31 | dispatch(executeCodeBlock(block.get('id'))) 32 | } 33 | 34 | clickOption () { 35 | const { dispatch, block } = this.props 36 | dispatch(changeCodeBlockOption(block.get('id'))) 37 | } 38 | 39 | getOptionButton () { 40 | const option = this.props.block.get('option') 41 | if (!this.props.editable) { 42 | return null 43 | } 44 | let icon, text 45 | switch (option) { 46 | case 'runnable': 47 | icon = 'users' 48 | text = 'Code is run by readers, by clicking the play button.' 49 | break 50 | case 'auto': 51 | icon = 'gear' 52 | text = 'Code is run when the notebook is loaded.' 53 | break 54 | case 'hidden': 55 | icon = 'user-secret' 56 | text = 'Code is run when the notebook is loaded, and only the results are displayed.' 57 | break 58 | default: 59 | return null 60 | } 61 | return ( 62 | 63 | ) 64 | } 65 | 66 | getRunButton () { 67 | const option = this.props.block.get('option') 68 | const icon = this.props.hasBeenRun ? 'fa-refresh' : 'fa-play-circle-o' 69 | const showIconOptions = ['runnable', 'auto', 'hidden'] 70 | if (showIconOptions.indexOf(option) > -1) { 71 | return ( 72 | 74 | ) 75 | } 76 | } 77 | 78 | componentDidMount () { 79 | const { dispatch, block } = this.props 80 | if (block.get('graphType')) { 81 | dispatch(clearGraphData(block.get('id'))) 82 | dispatch(executeCodeBlock(block.get('id'))) 83 | } 84 | } 85 | 86 | setContextMenuActions (menu, MenuItem) { 87 | const { dispatch, block } = this.props 88 | const option = block.get('option') 89 | menu.append(new MenuItem({type: 'separator'})) 90 | menu.append(new MenuItem({ 91 | label: 'Runnable', 92 | type: 'checkbox', 93 | checked: option === 'runnable', 94 | click () { dispatch(changeCodeBlockOption(block.get('id'), 'runnable')) } 95 | })) 96 | menu.append(new MenuItem({ 97 | label: 'Autorun', 98 | type: 'checkbox', 99 | checked: option === 'auto', 100 | click () { dispatch(changeCodeBlockOption(block.get('id'), 'auto')) } 101 | })) 102 | menu.append(new MenuItem({ 103 | label: 'Hidden autorun', 104 | type: 'checkbox', 105 | checked: option === 'hidden', 106 | click () { dispatch(changeCodeBlockOption(block.get('id'), 'hidden')) } 107 | })) 108 | } 109 | 110 | renderViewerMode (isDragging) { 111 | const { block, hasBeenRun, result, editable, isRunning } = this.props 112 | let buttons = this.getButtons() 113 | const runButton = this.getRunButton() 114 | const optionButton = this.getOptionButton() 115 | const hideBlock = !editable && block.get('option') === 'hidden' 116 | const containerClass = hideBlock ? ' hiddenCode' : '' 117 | const draggingClass = isDragging ? ' dragging' : '' 118 | if (buttons == null) { 119 | buttons = [runButton, optionButton] 120 | } else { 121 | buttons.unshift(optionButton) 122 | buttons.unshift(runButton) 123 | } 124 | 125 | /* eslint-disable react/no-danger */ 126 | return ( 127 |
128 |
129 |
130 | {buttons} 131 |
132 |
133 |
134 | 137 | 145 | ) 146 | } 147 | 148 | } 149 | 150 | export default dragAndDropWrapper(CodeBlock) 151 | -------------------------------------------------------------------------------- /src/js/components/Content.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { DragDropContext } from 'react-dnd' 4 | import HTML5Backend from 'react-dnd-html5-backend' 5 | import { contentSelector } from '../selectors' 6 | import TextBlock from './TextBlock' 7 | import CodeBlock from './CodeBlock' 8 | import GraphBlock from './GraphBlock' 9 | import AddControls from './AddControls' 10 | 11 | class Content extends Component { 12 | 13 | render () { 14 | const { 15 | dispatch, content, results, blocksExecuted, editable, activeBlock, blocksRunning 16 | } = this.props 17 | let blocks = [] 18 | for (let i = 0; i < content.size; i++) { 19 | const block = content.get(i) 20 | const id = block.get('id') 21 | const isFirst = (i === 0) 22 | const isLast = (i === content.size - 1) 23 | blocks.push( 24 | 26 | ) 27 | switch (block.get('type')) { 28 | case 'text': 29 | blocks.push( 30 | 33 | ) 34 | break 35 | default: 36 | const hasBeenRun = blocksExecuted.includes(id) 37 | const isRunning = blocksRunning.includes(id) 38 | const result = results.get(id) 39 | const BlockClass = block.get('type') === 'code' ? CodeBlock : GraphBlock 40 | blocks.push( 41 | 46 | ) 47 | } 48 | } 49 | blocks.push( 50 | 51 | ) 52 | return
{blocks}
53 | } 54 | 55 | } 56 | 57 | Content.propTypes = { 58 | content: React.PropTypes.object, 59 | results: React.PropTypes.object, 60 | blocksExecuted: React.PropTypes.object, 61 | editable: React.PropTypes.bool, 62 | activeBlock: React.PropTypes.string, 63 | blocksRunning: React.PropTypes.object, 64 | dispatch: React.PropTypes.func 65 | } 66 | 67 | export default DragDropContext(HTML5Backend)(connect(contentSelector)(Content)) 68 | -------------------------------------------------------------------------------- /src/js/components/Datasources.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { deleteDatasource, updateDatasource, fetchData } from '../actions' 3 | 4 | export default class Datasources extends Component { 5 | 6 | constructor (props) { 7 | super(props) 8 | this.deleteSource = this.deleteSource.bind(this) 9 | this.updateSource = this.updateSource.bind(this) 10 | this.addSource = this.addSource.bind(this) 11 | } 12 | 13 | deleteSource (name) { 14 | this.props.dispatch(deleteDatasource(name)) 15 | } 16 | 17 | updateSource (name) { 18 | this.props.dispatch( 19 | updateDatasource(name, this.refs['set-' + name].value) 20 | ) 21 | this.props.dispatch(fetchData()) 22 | } 23 | 24 | addSource () { 25 | const name = this.refs['new-name'].value 26 | const url = this.refs['new-url'].value 27 | if (name === '' || name === undefined || url === '' || url === undefined) { 28 | return 29 | } 30 | this.props.dispatch(updateDatasource(name, url)) 31 | this.refs['new-name'].value = '' 32 | this.refs['new-url'].value = '' 33 | this.props.dispatch(fetchData()) 34 | } 35 | 36 | render () { 37 | const { datasources } = this.props 38 | let result = [] 39 | for (let [name, source] of datasources) { 40 | result.push( 41 |
42 | this.deleteSource(name)} title='Remove datasource' /> 44 |
45 |

{name}

46 |
47 |
48 | this.updateSource(name)} /> 50 |
51 |
52 | ) 53 | } 54 | return ( 55 |
56 | {result} 57 |
58 | 60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 | ) 69 | } 70 | } 71 | 72 | Datasources.propTypes = { 73 | datasources: React.PropTypes.object, 74 | dispatch: React.PropTypes.func 75 | } 76 | -------------------------------------------------------------------------------- /src/js/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { metadataSelector } from '../selectors' 4 | import config from '../config' // eslint-disable-line 5 | 6 | class Footer extends Component { 7 | 8 | render () { 9 | const { metadata } = this.props 10 | if (!metadata.get('showFooter')) { 11 | return
 
12 | } 13 | const originalTitle = metadata.getIn(['original', 'title']) 14 | const originalUrl = metadata.getIn(['original', 'url']) 15 | let original 16 | if (originalTitle !== undefined && originalUrl !== undefined) { 17 | original = ( 18 | 19 |   20 | Forked from {originalTitle}. 21 | 22 | ) 23 | } 24 | return ( 25 |
26 |
27 | {original} 28 | 29 |   30 | Made with Kayero. 31 | 32 |
33 | ) 34 | } 35 | 36 | } 37 | 38 | Footer.propTypes = { 39 | metadata: React.PropTypes.object 40 | } 41 | 42 | export default connect(metadataSelector)(Footer) 43 | -------------------------------------------------------------------------------- /src/js/components/GraphBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Immutable from 'immutable' 3 | import { connect } from 'react-redux' 4 | import { dragAndDropWrapper } from './Block' 5 | import {CodeBlock} from './CodeBlock' 6 | import Visualiser from './visualiser/Visualiser' 7 | import { dataSelector } from '../selectors' 8 | import { highlight } from '../util' 9 | import { 10 | executeCodeBlock, updateGraphType, 11 | updateGraphDataPath, updateGraphHint, 12 | updateGraphLabel, compileGraphBlock 13 | } from '../actions' 14 | import Jutsu from 'jutsu' 15 | 16 | class GraphBlock extends CodeBlock { 17 | 18 | constructor (props) { 19 | super(props) 20 | this.state = { 21 | showHintDialog: Immutable.Map() 22 | } 23 | this.getCssClass = this.getCssClass.bind(this) 24 | this.setGraph = this.setGraph.bind(this) 25 | this.setDataPath = this.setDataPath.bind(this) 26 | this.getHintInputs = this.getHintInputs.bind(this) 27 | this.getAxisLabelInputs = this.getAxisLabelInputs.bind(this) 28 | this.updateHint = this.updateHint.bind(this) 29 | this.setHint = this.setHint.bind(this) 30 | this.updateLabel = this.updateLabel.bind(this) 31 | this.toggleHintDialog = this.toggleHintDialog.bind(this) 32 | this.saveAsCode = this.saveAsCode.bind(this) 33 | this.smolderSchema = Jutsu().__SMOLDER_SCHEMA 34 | this.needsRerun = false 35 | } 36 | 37 | getCssClass (button) { 38 | const css = 'graph-type' 39 | if (this.props.block.get('graphType') === button) { 40 | return css + ' selected' 41 | } 42 | return css 43 | } 44 | 45 | setGraph (graph) { 46 | const { dispatch, block } = this.props 47 | dispatch(updateGraphType(block.get('id'), graph)) 48 | } 49 | 50 | setDataPath (_, path) { 51 | const { dispatch, block } = this.props 52 | dispatch(updateGraphDataPath(block.get('id'), path)) 53 | } 54 | 55 | componentDidMount () { 56 | this.props.dispatch(executeCodeBlock(this.props.block.get('id'))) 57 | this.selectedData = this.props.data 58 | } 59 | 60 | componentWillReceiveProps (newProps) { 61 | const code = this.props.block.get('content') 62 | const path = this.props.block.get('dataPath') 63 | const newBlock = newProps.block 64 | const newCode = newBlock.get('content') 65 | if (code !== newCode || (this.props.editable !== newProps.editable)) { 66 | this.needsRerun = true 67 | } 68 | if (path !== newBlock.get('dataPath')) { 69 | this.selectedData = new Function( 70 | ['data'], 'return ' + newBlock.get('dataPath') 71 | ).call({}, newProps.data) 72 | } 73 | } 74 | 75 | componentDidUpdate () { 76 | if (this.needsRerun) { 77 | this.needsRerun = false 78 | this.props.dispatch(executeCodeBlock(this.props.block.get('id'))) 79 | } 80 | } 81 | 82 | updateHint (hint) { 83 | const value = this.refs['set-' + hint].value 84 | this.setHint(hint, value, true) 85 | } 86 | 87 | setHint (hint, value, dontUpdateInput) { 88 | const { dispatch, block } = this.props 89 | dispatch(updateGraphHint(block.get('id'), hint, value)) 90 | if (!dontUpdateInput) { 91 | this.refs['set-' + hint].value = value 92 | } 93 | } 94 | 95 | updateLabel (axis) { 96 | const { dispatch, block } = this.props 97 | const value = this.refs['set-axis-' + axis].value 98 | dispatch(updateGraphLabel(block.get('id'), axis, value)) 99 | } 100 | 101 | toggleHintDialog (hint) { 102 | this.setState({ 103 | showHintDialog: this.state.showHintDialog.set( 104 | hint, !this.state.showHintDialog.get(hint) 105 | ) 106 | }) 107 | } 108 | 109 | saveAsCode () { 110 | this.props.dispatch(compileGraphBlock(this.props.block.get('id'))) 111 | } 112 | 113 | getHintInputs () { 114 | let results = [] 115 | const block = this.props.block 116 | const hints = Object.getOwnPropertyNames( 117 | this.smolderSchema[block.get('graphType')].data[0] 118 | ).sort() 119 | for (let hint of hints) { 120 | const showDialog = this.state.showHintDialog.get(hint) 121 | results.push( 122 |
123 | this.toggleHintDialog(hint)} title='Choose hint from data' /> 125 |
126 |

{hint}

127 |
128 |
129 | this.updateHint(hint)} 131 | placeholder='No hint given' /> 132 |
133 | {showDialog && 134 | { this.setHint(hint, key) }} /> 136 | } 137 |
138 | ) 139 | } 140 | return results 141 | } 142 | 143 | getAxisLabelInputs () { 144 | const block = this.props.block 145 | if (block.get('graphType') === 'pieChart') { 146 | return null 147 | } 148 | const inputs = [] 149 | const icons = { 150 | x: 'right', 151 | y: 'up' 152 | } 153 | for (let axis of ['x', 'y']) { 154 | inputs.push( 155 |
156 | 159 |
160 |

{axis}

161 |
162 |
163 | { this.updateLabel(axis) }} 165 | placeholder='No label' /> 166 |
167 |
168 | ) 169 | } 170 | return ( 171 |
172 |
173 |

Axis Labels

174 | {inputs} 175 |
176 | ) 177 | } 178 | 179 | render () { 180 | const { block, connectDragSource, connectDropTarget, isDragging } = this.props 181 | if (!this.props.editable) { 182 | return this.renderViewerMode() 183 | } 184 | const id = block.get('id') 185 | const buttons = this.getButtons() 186 | buttons.push( 187 | 189 | ) 190 | /* eslint-disable react/no-danger */ 191 | return connectDragSource(connectDropTarget( 192 |
193 |
194 | {buttons} 195 |
196 | this.setGraph('pieChart')}> 198 | Pie chart 199 | 200 | this.setGraph('barChart')}> 202 | Bar chart 203 | 204 | this.setGraph('lineChart')}> 206 | Line graph 207 | 208 |

Data

209 | 213 |
214 |

Hints

215 | {this.getHintInputs()} 216 | {this.getAxisLabelInputs()} 217 |
218 |

Preview

219 |
222 |         
223 |
224 | )) 225 | } 226 | 227 | } 228 | 229 | export default dragAndDropWrapper(connect(dataSelector)(GraphBlock)) 230 | -------------------------------------------------------------------------------- /src/js/components/Header.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import Title from './Title' 4 | import Metadata from './Metadata' 5 | import { metadataSelector } from '../selectors' 6 | import { toggleEdit, toggleSave, undo, fetchData } from '../actions' 7 | 8 | class Header extends Component { 9 | 10 | constructor (props) { 11 | super(props) 12 | this.toggleEditClicked = this.toggleEditClicked.bind(this) 13 | this.toggleSaveClicked = this.toggleSaveClicked.bind(this) 14 | this.undoClicked = this.undoClicked.bind(this) 15 | } 16 | 17 | toggleEditClicked () { 18 | this.props.dispatch(toggleEdit()) 19 | } 20 | 21 | toggleSaveClicked () { 22 | this.props.dispatch(toggleSave()) 23 | } 24 | 25 | undoClicked () { 26 | this.props.dispatch(undo()) 27 | this.props.dispatch(fetchData()) 28 | } 29 | 30 | render () { 31 | const { metadata, editable, undoSize, dispatch } = this.props 32 | const title = metadata.get('title') 33 | const icon = editable ? 'fa-newspaper-o' : 'fa-pencil' 34 | document.title = title 35 | const saveButton = ( 36 | 38 | ) 39 | const undoButton = ( 40 | 41 | ) 42 | const changesMade = editable && undoSize > 0 43 | return ( 44 |
45 | 46 | <span className='controls'> 47 | {changesMade ? undoButton : null} 48 | {changesMade ? saveButton : null} 49 | <i className={'fa ' + icon} onClick={this.toggleEditClicked} 50 | title={editable ? 'Exit edit mode' : 'Enter edit mode'} /> 51 | </span> 52 | <Metadata editable={editable} metadata={metadata} dispatch={dispatch} /> 53 | </div> 54 | ) 55 | } 56 | 57 | } 58 | 59 | Header.propTypes = { 60 | metadata: React.PropTypes.object, 61 | editable: React.PropTypes.bool, 62 | undoSize: React.PropTypes.number, 63 | dispatch: React.PropTypes.func 64 | } 65 | 66 | export default connect(metadataSelector)(Header) 67 | -------------------------------------------------------------------------------- /src/js/components/Metadata.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { updateAuthor, toggleFooter } from '../actions' 3 | import Datasources from './Datasources' 4 | 5 | export default class Metadata extends Component { 6 | 7 | constructor (props) { 8 | super(props) 9 | this.updateAuthor = this.updateAuthor.bind(this) 10 | this.toggleFooter = this.toggleFooter.bind(this) 11 | } 12 | 13 | updateAuthor () { 14 | this.props.dispatch(updateAuthor(this.refs.authorField.value)) 15 | } 16 | 17 | toggleFooter () { 18 | this.props.dispatch(toggleFooter()) 19 | } 20 | 21 | render () { 22 | const { editable, metadata, dispatch } = this.props 23 | const author = metadata.get('author') 24 | if (editable) { 25 | const iconFooter = metadata.get('showFooter') ? 'check-circle' : 'circle-o' 26 | return ( 27 | <div className='metadata'> 28 | <div className='metadata-row'> 29 | <i className='fa fa-user' /> 30 | <input type='text' defaultValue={author} 31 | ref='authorField' onBlur={this.updateAuthor} title='Author' /> 32 | </div> 33 | <div className='metadata-row'> 34 | <i className={'fa fa-' + iconFooter + ' clickable'} 35 | onClick={this.toggleFooter} /> 36 | <span>Show footer</span> 37 | </div> 38 | <hr/> 39 | <p>Data sources</p> 40 | <Datasources dispatch={dispatch} 41 | datasources={metadata.get('datasources')} /> 42 | </div> 43 | ) 44 | } 45 | return ( 46 | <div className='metadata'> 47 | <span className='metadata-item'> 48 | <i className='fa fa-user' />{'\u00a0' + author} 49 | </span> 50 | </div> 51 | ) 52 | } 53 | 54 | } 55 | 56 | Metadata.propTypes = { 57 | metadata: React.PropTypes.object, 58 | editable: React.PropTypes.bool, 59 | dispatch: React.PropTypes.func 60 | } 61 | -------------------------------------------------------------------------------- /src/js/components/SaveDialog.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import Clipboard from 'clipboard' 4 | import { saveSelector } from '../selectors' 5 | import { render } from '../markdown' 6 | import { toggleSave, saveGist, saveAsFile } from '../actions' 7 | import { renderHTML } from '../util' 8 | 9 | class SaveDialog extends Component { 10 | 11 | constructor (props) { 12 | super(props) 13 | this.close = this.close.bind(this) 14 | this.getCssClass = this.getCssClass.bind(this) 15 | this.setMode = this.setMode.bind(this) 16 | this.state = {mode: 'md'} 17 | } 18 | 19 | close () { 20 | this.props.dispatch(toggleSave()) 21 | } 22 | 23 | componentDidMount () { 24 | this.clipboard = new Clipboard('.clipboard-button', { 25 | text: () => { 26 | const markdown = render(this.props.notebook) 27 | switch (this.state.mode) { 28 | case 'html': 29 | return renderHTML(markdown) 30 | case 'gist': 31 | return this.props.notebook.getIn(['metadata', 'gistUrl']) 32 | default: 33 | return markdown 34 | } 35 | } 36 | }).on('error', () => { 37 | console.warn("Clipboard isn't supported on your browser.") 38 | }) 39 | } 40 | 41 | componentWillUnmount () { 42 | this.clipboard.destroy() 43 | } 44 | 45 | getCssClass (button) { 46 | const css = 'export-option' 47 | if (this.state.mode === button) { 48 | return css + ' selected' 49 | } 50 | return css 51 | } 52 | 53 | setMode (newMode) { 54 | this.setState({mode: newMode}) 55 | // Create the Gist if necessary 56 | if (newMode === 'gist' && !this.props.notebook.getIn(['metadata', 'gistUrl'])) { 57 | this.props.dispatch(saveGist( 58 | this.props.notebook.getIn(['metadata', 'title']), 59 | render(this.props.notebook) 60 | )) 61 | } else if (newMode === 'file') { 62 | this.props.dispatch(saveAsFile( 63 | this.props.notebook.getIn(['metadata', 'path']), 64 | render(this.props.notebook) 65 | )) 66 | } 67 | } 68 | 69 | render () { 70 | const { notebook } = this.props 71 | const gistUrl = notebook.getIn(['metadata', 'gistUrl']) 72 | const markdown = render(notebook) 73 | const text = (this.state.mode === 'html') ? renderHTML(markdown) : markdown 74 | const textContent = ( 75 | <div> 76 | <textarea readOnly value={text} /> 77 | <span className='clipboard-button'> 78 | Copy to clipboard <i className='fa fa-clipboard'></i> 79 | </span> 80 | </div> 81 | ) 82 | const gistContent = ( 83 | <div> 84 | <div className='pure-g'> 85 | <div className='pure-u-1 pure-u-md-1-3'> 86 | <p>Your unique notebook URL:</p> 87 | </div> 88 | <div className='pure-u-1 pure-u-md-2-3'> 89 | <input type='text' readOnly 90 | value={gistUrl} placeholder='Loading...' /> 91 | </div> 92 | </div> 93 | <span className='clipboard-button'> 94 | Copy to clipboard <i className='fa fa-clipboard'></i> 95 | </span> 96 | </div> 97 | ) 98 | return ( 99 | <div className='save-dialog'> 100 | <h1>Export notebook</h1> 101 | <p>Here you can export your edited notebook as Markdown or HTML. You can also host it as a Gist, to get a unique URL for your notebook.</p> 102 | <i className='fa fa-times-circle-o close-button' onClick={this.close} 103 | title='Back to notebook' /> 104 | <span className={this.getCssClass('md')} onClick={() => this.setMode('md')}> 105 | <i className='fa fa-file-text-o' /> Markdown 106 | </span> 107 | <span className={this.getCssClass('html')} onClick={() => this.setMode('html')}> 108 | <i className='fa fa-code' /> HTML 109 | </span> 110 | <span className={this.getCssClass('gist')} onClick={() => this.setMode('gist')}> 111 | <i className='fa fa-github' /> Export to Gist 112 | </span> 113 | <span className={this.getCssClass('file')} onClick={() => this.setMode('file')}> 114 | <i className='fa fa-file-code-o' /> Export to file 115 | </span> 116 | {this.state.mode === 'gist' ? gistContent : textContent} 117 | <div className='footer'> </div> 118 | </div> 119 | ) 120 | } 121 | 122 | } 123 | 124 | SaveDialog.propTypes = { 125 | notebook: React.PropTypes.object, 126 | dispatch: React.PropTypes.func 127 | } 128 | 129 | export default connect(saveSelector)(SaveDialog) 130 | -------------------------------------------------------------------------------- /src/js/components/TextBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MarkdownIt from 'markdown-it' 3 | import latex from 'markdown-it-katex' 4 | import Block, { dragAndDropWrapper } from './Block' 5 | import { highlight } from '../util' 6 | 7 | const md = new MarkdownIt({highlight, html: true}) 8 | md.use(latex) 9 | 10 | class TextBlock extends Block { 11 | 12 | rawMarkup (markdown) { 13 | return {__html: md.render(markdown)} 14 | } 15 | 16 | renderViewerMode (isDragging) { 17 | const { block } = this.props 18 | const buttons = this.getButtons() 19 | const draggingClass = isDragging ? ' dragging' : '' 20 | /* eslint-disable react/no-danger */ 21 | return ( 22 | <div className={'text-block' + draggingClass} onContextMenu={this.handleContextMenu}> 23 | <div className='editor-buttons'> 24 | {buttons} 25 | </div> 26 | <div className='text-block-content' 27 | dangerouslySetInnerHTML={this.rawMarkup(block.get('content'))} 28 | onClick={this.enterEdit}> 29 | </div> 30 | </div> 31 | ) 32 | } 33 | 34 | } 35 | 36 | export default dragAndDropWrapper(TextBlock) 37 | -------------------------------------------------------------------------------- /src/js/components/Title.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { updateTitle } from '../actions' 3 | 4 | class Title extends Component { 5 | 6 | constructor (props) { 7 | super(props) 8 | this.exitEdit = this.exitEdit.bind(this) 9 | } 10 | 11 | exitEdit () { 12 | this.props.dispatch(updateTitle(this.refs.titleField.value)) 13 | } 14 | 15 | render () { 16 | const { title, editable } = this.props 17 | if (editable) { 18 | return ( 19 | <h1> 20 | <input type='text' className='title-field' 21 | placeholder='Notebook title' 22 | defaultValue={title} 23 | ref='titleField' title='Notebook title' 24 | onBlur={this.exitEdit} /> 25 | </h1> 26 | ) 27 | } 28 | return ( 29 | <h1> 30 | {title} 31 | </h1> 32 | ) 33 | } 34 | 35 | } 36 | 37 | Title.propTypes = { 38 | title: React.PropTypes.string, 39 | editable: React.PropTypes.bool, 40 | dispatch: React.PropTypes.func 41 | } 42 | 43 | export default Title 44 | -------------------------------------------------------------------------------- /src/js/components/visualiser/ArrayVisualiser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { exportToCSV } from '../../actions' 4 | import { selectComponent, getSpacing } from './Visualiser' 5 | 6 | class ArrayVisualiser extends Component { 7 | 8 | constructor (props) { 9 | super(props) 10 | this.collapse = this.collapse.bind(this) 11 | this.state = {open: false} 12 | } 13 | 14 | collapse () { 15 | this.setState({open: !this.state.open}) 16 | } 17 | 18 | render () { 19 | const { data, indent, useHljs, name, path, click = () => {} } = this.props 20 | let items = [] 21 | for (let i = 0; this.state.open && i < data.length; i++) { 22 | var item = data[i] 23 | var VisualiserComponent = selectComponent(item) 24 | items.push( 25 | <VisualiserComponent 26 | key={String(i)} 27 | data={item} 28 | name={String(i)} 29 | indent={indent === 0 ? indent + 2 : indent + 1} 30 | useHljs={useHljs} 31 | click={click} 32 | path={path + '[' + i + ']'} /> 33 | ) 34 | } 35 | 36 | let arrow 37 | let spaces = getSpacing(indent) 38 | if (data.length > 0) { 39 | arrow = this.state.open ? '\u25bc' : '\u25b6' 40 | if (spaces.length >= 2) { 41 | // Space for arrow 42 | spaces = spaces.slice(2) 43 | } 44 | } 45 | let key = <span className='visualiser-spacing'>{'\u00a0'}</span> 46 | if (name) { 47 | key = ( 48 | <span className='visualiser-spacing'> 49 | {'\u00a0'} 50 | <span className='visualiser-key' onClick={() => click(name, path)}> 51 | {name} 52 | </span> 53 | {':\u00a0'} 54 | </span> 55 | ) 56 | } 57 | 58 | return ( 59 | <div className='array-visualiser'> 60 | <i className='fa fa-download export-to-csv' 61 | onClick={() => this.props.dispatch(exportToCSV(data))} title='Export to CSV'> 62 | </i> 63 | <span className='visualiser-row'> 64 | <span className='visualiser-spacing'>{spaces}</span> 65 | <span className='visualiser-arrow' onClick={this.collapse}>{arrow}</span> 66 | {key} 67 | <span className={useHljs ? 'hljs-keyword' : ''}>Array</span> 68 | <span>{'[' + data.length + ']'}</span> 69 | </span> 70 | {items} 71 | </div> 72 | ) 73 | } 74 | } 75 | 76 | ArrayVisualiser.propTypes = { 77 | data: React.PropTypes.array, 78 | indent: React.PropTypes.number, 79 | useHljs: React.PropTypes.string, 80 | name: React.PropTypes.string, 81 | path: React.PropTypes.string, 82 | click: React.PropTypes.func, 83 | dispatch: React.PropTypes.func 84 | } 85 | 86 | export default connect()(ArrayVisualiser) 87 | -------------------------------------------------------------------------------- /src/js/components/visualiser/DefaultVisualiser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { typeString, getSpacing } from './Visualiser' 3 | 4 | function buildCssClass (type, useHljs) { 5 | let cssSuffix 6 | switch (type) { 7 | case 'String': 8 | cssSuffix = 'string'; break 9 | case 'Number': 10 | cssSuffix = 'number'; break 11 | case 'Boolean': 12 | cssSuffix = 'literal'; break 13 | case 'Function': 14 | cssSuffix = 'keyword'; break 15 | default: 16 | cssSuffix = 'text'; break 17 | } 18 | let cssClass = 'visualiser-' + cssSuffix 19 | if (useHljs) { 20 | cssClass += ' hljs-' + cssSuffix 21 | } 22 | return cssClass 23 | } 24 | 25 | export default class DefaultVisualiser extends Component { 26 | 27 | render () { 28 | const { data, indent, name, useHljs, path, click = () => {} } = this.props 29 | const type = typeString(data) 30 | const repr = (type === 'String') ? "'" + String(data) + "'" 31 | : (type === 'Function') ? 'function()' : String(data) 32 | const cssClass = buildCssClass(type, useHljs) 33 | let key = <span className='visualiser-spacing'></span> 34 | if (name) { 35 | key = ( 36 | <span className='visualiser-spacing'> 37 | <span className='visualiser-key' onClick={() => click(name, path)}> 38 | {name} 39 | </span> 40 | {':\u00a0'} 41 | </span> 42 | ) 43 | } 44 | const spaces = getSpacing(indent) 45 | 46 | return ( 47 | <div className='default-visualiser'> 48 | <span className='visualiser-row'> 49 | <span className='visualiser-spacing'>{spaces}</span> 50 | {key} 51 | <span className={cssClass}>{repr}</span> 52 | </span> 53 | </div> 54 | ) 55 | } 56 | 57 | } 58 | 59 | DefaultVisualiser.propTypes = { 60 | data: React.PropTypes.oneOfType([ 61 | React.PropTypes.string, 62 | React.PropTypes.number, 63 | React.PropTypes.func, 64 | React.PropTypes.instanceOf(Error), 65 | React.PropTypes.instanceOf(Date) 66 | ]), 67 | indent: React.PropTypes.number, 68 | useHljs: React.PropTypes.string, 69 | name: React.PropTypes.string, 70 | path: React.PropTypes.string, 71 | click: React.PropTypes.func 72 | } 73 | -------------------------------------------------------------------------------- /src/js/components/visualiser/ObjectVisualiser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { getSpacing, selectComponent } from './Visualiser' 3 | 4 | export default class ObjectVisualiser extends Component { 5 | 6 | constructor (props) { 7 | super(props) 8 | this.collapse = this.collapse.bind(this) 9 | this.state = {open: false} 10 | } 11 | 12 | collapse () { 13 | this.setState({open: !this.state.open}) 14 | } 15 | 16 | render () { 17 | const { data, name, indent, useHljs, path, click = () => {} } = this.props 18 | const keys = Object.getOwnPropertyNames(data) 19 | let items = [] 20 | for (let i = 0; this.state.open && i < keys.length; i++) { 21 | var item = data[keys[i]] 22 | var VisualiserComponent = selectComponent(item) 23 | items.push( 24 | <VisualiserComponent 25 | key={String(i)} 26 | data={item} 27 | name={keys[i]} 28 | indent={indent === 0 ? indent + 2 : indent + 1} 29 | useHljs={useHljs} 30 | click={click} 31 | path={path + '.' + keys[i]} /> 32 | ) 33 | } 34 | let arrow 35 | let spaces = getSpacing(indent) 36 | if (keys.length > 0) { 37 | arrow = this.state.open ? '\u25bc' : '\u25b6' 38 | if (spaces.length >= 2) { 39 | // Space for arrow 40 | spaces = spaces.slice(2) 41 | } 42 | } 43 | let key = <span className='visualiser-spacing'>{'\u00a0'}</span> 44 | if (name) { 45 | key = ( 46 | <span className='visualiser-spacing'> 47 | {'\u00a0'} 48 | <span className='visualiser-key' onClick={() => click(name, path)}> 49 | {name} 50 | </span> 51 | {':\u00a0'} 52 | </span> 53 | ) 54 | } 55 | 56 | return ( 57 | <div className='object-visualiser'> 58 | <span className='visualiser-row'> 59 | <span className='visualiser-spacing'>{spaces}</span> 60 | <span className='visualiser-arrow' onClick={this.collapse}>{arrow}</span> 61 | {key} 62 | <span className={useHljs ? 'hljs-keyword' : ''}>Object</span> 63 | <span>{'{}'}</span> 64 | </span> 65 | {items} 66 | </div> 67 | ) 68 | } 69 | 70 | } 71 | 72 | ObjectVisualiser.propTypes = { 73 | data: React.PropTypes.object, 74 | indent: React.PropTypes.number, 75 | useHljs: React.PropTypes.string, 76 | name: React.PropTypes.string, 77 | path: React.PropTypes.string, 78 | click: React.PropTypes.func 79 | } 80 | -------------------------------------------------------------------------------- /src/js/components/visualiser/Visualiser.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Root class with utility functions 3 | */ 4 | 5 | import React, { Component } from 'react' 6 | import DefaultVisualiser from './DefaultVisualiser' 7 | import ObjectVisualiser from './ObjectVisualiser' 8 | import ArrayVisualiser from './ArrayVisualiser' 9 | 10 | const SPACING = 2 11 | 12 | export function typeString (item) { 13 | var typeString = Object.prototype.toString.call(item) 14 | return typeString.split(' ')[1].split(']')[0] 15 | } 16 | 17 | export function selectComponent (data) { 18 | if (data instanceof Error) { 19 | return DefaultVisualiser 20 | } 21 | switch (typeString(data)) { 22 | case 'Object': 23 | return ObjectVisualiser 24 | case 'Array': 25 | return ArrayVisualiser 26 | default: 27 | return DefaultVisualiser 28 | } 29 | } 30 | 31 | export function getSpacing (indent) { 32 | if (indent < 1) return '' 33 | let spaces = indent * SPACING 34 | var result = '' 35 | for (let i = 0; i < spaces; i++) { 36 | result += '\u00a0' 37 | } 38 | return result 39 | } 40 | 41 | export default class Visualiser extends Component { 42 | 43 | render () { 44 | const { data, useHljs, click, path, name } = this.props 45 | const VisualiserComponent = selectComponent(data) 46 | return ( 47 | <div className='visualiser'> 48 | <VisualiserComponent 49 | data={data} 50 | indent={0} 51 | useHljs={useHljs} 52 | click={click} 53 | name={name} 54 | path={path} /> 55 | </div> 56 | ) 57 | } 58 | } 59 | 60 | Visualiser.propTypes = { 61 | data: React.PropTypes.any, 62 | indent: React.PropTypes.number, 63 | useHljs: React.PropTypes.string, 64 | name: React.PropTypes.string, 65 | path: React.PropTypes.string, 66 | click: React.PropTypes.func 67 | } 68 | -------------------------------------------------------------------------------- /src/js/config/development.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | kayeroHomepage: '/', 3 | gistUrl: 'https://gist.githubusercontent.com/anonymous/', 4 | gistApi: 'https://api.github.com/gists', 5 | cssUrl: 'dist/main.css', 6 | scriptUrl: 'dist/bundle.js' 7 | } 8 | -------------------------------------------------------------------------------- /src/js/config/index.js: -------------------------------------------------------------------------------- 1 | var env = process.env.NODE_ENV || 'development' 2 | 3 | var config = { 4 | test: require('./development.config'), 5 | development: require('./development.config'), 6 | production: require('./production.config') 7 | } 8 | 9 | module.exports = config[env] 10 | -------------------------------------------------------------------------------- /src/js/config/production.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | kayeroHomepage: 'http://www.joelotter.com/kayero/', 3 | gistUrl: 'https://gist.githubusercontent.com/anonymous/', 4 | gistApi: 'https://api.github.com/gists', 5 | cssUrl: 'http://www.joelotter.com/kayero/dist/main.css', 6 | scriptUrl: 'http://www.joelotter.com/kayero/dist/bundle.js' 7 | } 8 | -------------------------------------------------------------------------------- /src/js/markdown.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it' 2 | import latex from 'markdown-it-katex' 3 | import fm from 'front-matter' 4 | import Immutable from 'immutable' 5 | 6 | import { codeToText } from './util' 7 | 8 | const markdownIt = new MarkdownIt() 9 | markdownIt.use(latex) 10 | 11 | /* 12 | * Extracts a code block (Immutable Map) from the 13 | * block-parsed Markdown. 14 | */ 15 | function extractCodeBlock (token) { 16 | const info = token.info.split(';').map(s => s.trim()) 17 | const language = info[0] || undefined 18 | const option = info[1] || undefined 19 | if (['runnable', 'auto', 'hidden'].indexOf(option) < 0) { 20 | // If not an executable block, we just want to represent as Markdown. 21 | return null 22 | } 23 | return Immutable.fromJS({ 24 | type: 'code', 25 | content: token.content.trim(), 26 | language, 27 | option 28 | }) 29 | } 30 | 31 | function flushTextBlock (counter, blocks, blockOrder, text) { 32 | if (!text.match(/\S+/)) { 33 | return 34 | } 35 | const id = String(counter) 36 | blockOrder.push(id) 37 | blocks[id] = Immutable.fromJS({ 38 | type: 'text', 39 | id: id, 40 | content: text.trim() 41 | }) 42 | } 43 | 44 | function extractBlocks (md) { 45 | const rgx = /(```\w+;\s*?(?:runnable|auto|hidden)\s*?[\n\r]+[\s\S]*?^\s*?```\s*?$)/gm 46 | const parts = md.split(rgx) 47 | 48 | let blockCounter = 0 49 | let currentString = '' 50 | const blockOrder = [] 51 | const blocks = {} 52 | 53 | for (let i = 0; i < parts.length; i++) { 54 | const part = parts[i] 55 | const tokens = markdownIt.parse(parts[i]) 56 | if (tokens.length === 1 && tokens[0].type === 'fence') { 57 | const block = extractCodeBlock(tokens[0]) 58 | // If it's an executable block 59 | if (block) { 60 | // Flush the current text to a text block 61 | flushTextBlock(blockCounter, blocks, blockOrder, currentString) 62 | currentString = '' 63 | blockCounter++ 64 | 65 | // Then add the code block 66 | const id = String(blockCounter) 67 | blockOrder.push(id) 68 | blocks[id] = block.set('id', id) 69 | blockCounter++ 70 | continue 71 | } 72 | } 73 | // If it isn't an executable code block, just add 74 | // to the current text block; 75 | currentString += part 76 | } 77 | flushTextBlock(blockCounter, blocks, blockOrder, currentString) 78 | 79 | return { 80 | content: blockOrder, 81 | blocks 82 | } 83 | } 84 | 85 | export function parse (md, filename) { 86 | // Separate front-matter and body 87 | const doc = fm(md) 88 | const {content, blocks} = extractBlocks(doc.body) 89 | 90 | return Immutable.fromJS({ 91 | metadata: { 92 | title: doc.attributes.title, 93 | author: doc.attributes.author, 94 | datasources: doc.attributes.datasources || {}, 95 | original: doc.attributes.original, 96 | showFooter: doc.attributes.show_footer !== false, 97 | path: filename 98 | }, 99 | content, 100 | blocks 101 | }) 102 | } 103 | 104 | /* 105 | * Functions for rendering blocks back into Markdown 106 | */ 107 | 108 | function renderDatasources (datasources) { 109 | let rendered = 'datasources:\n' 110 | datasources.map((url, name) => { 111 | rendered += ' ' + name + ': "' + url + '"\n' 112 | }) 113 | return rendered 114 | } 115 | 116 | function renderMetadata (metadata) { 117 | let rendered = '---\n' 118 | if (metadata.get('title') !== undefined) { 119 | rendered += 'title: "' + metadata.get('title') + '"\n' 120 | } 121 | if (metadata.get('author') !== undefined) { 122 | rendered += 'author: "' + metadata.get('author') + '"\n' 123 | } 124 | const datasources = metadata.get('datasources') 125 | if (datasources && datasources.size > 0) { 126 | rendered += renderDatasources(datasources) 127 | } 128 | const original = metadata.get('original') 129 | if (original && original.get('title') && original.get('url')) { 130 | rendered += 'original:\n' 131 | rendered += ' title: "' + original.get('title') + '"\n' 132 | rendered += ' url: "' + original.get('url') + '"\n' 133 | } 134 | if (metadata.get('showFooter') !== undefined) { 135 | rendered += 'show_footer: ' + metadata.get('showFooter') + '\n' 136 | } 137 | return rendered + '---\n\n' 138 | } 139 | 140 | function renderBlock (block) { 141 | if (block.get('type') === 'text') { 142 | return block.get('content') 143 | } 144 | return codeToText(block, true) 145 | } 146 | 147 | function renderBody (blocks, blockOrder) { 148 | return blockOrder 149 | .map((id) => blocks.get(id)) 150 | .map(renderBlock) 151 | .join('\n\n') + '\n' 152 | } 153 | 154 | export function render (notebook) { 155 | let rendered = '' 156 | rendered += renderMetadata(notebook.get('metadata')) 157 | rendered += renderBody(notebook.get('blocks'), notebook.get('content')) 158 | return rendered 159 | } 160 | -------------------------------------------------------------------------------- /src/js/reducers/editorReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import { 3 | TOGGLE_EDIT, TOGGLE_SAVE, EDIT_BLOCK, 4 | FILE_SAVED, LOAD_MARKDOWN, 5 | UPDATE_BLOCK, UPDATE_META, ADD_BLOCK, DELETE_BLOCK, MOVE_BLOCK, DELETE_DATASOURCE, UPDATE_DATASOURCE, CHANGE_CODE_BLOCK_OPTION 6 | } from '../actions' 7 | 8 | /* 9 | * This reducer simply keeps track of the state of the editor. 10 | */ 11 | const defaultEditor = Immutable.Map({ 12 | editable: false, 13 | saving: false, 14 | activeBlock: null, 15 | unsavedChanges: false 16 | }) 17 | 18 | export default function editor (state = defaultEditor, action = {}) { 19 | switch (action.type) { 20 | case TOGGLE_EDIT: 21 | return state.set('editable', !state.get('editable')) 22 | case TOGGLE_SAVE: 23 | return state.set('saving', !state.get('saving')) 24 | case EDIT_BLOCK: 25 | return state.set('activeBlock', action.id) 26 | case FILE_SAVED: 27 | case LOAD_MARKDOWN: 28 | return state.set('unsavedChanges', false) 29 | case UPDATE_BLOCK: 30 | case UPDATE_META: 31 | case ADD_BLOCK: 32 | case DELETE_BLOCK: 33 | case MOVE_BLOCK: 34 | case DELETE_DATASOURCE: 35 | case UPDATE_DATASOURCE: 36 | case CHANGE_CODE_BLOCK_OPTION: 37 | return state.set('unsavedChanges', true) 38 | default: 39 | return state 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/js/reducers/executionReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import { 3 | RECEIVED_DATA, 4 | CODE_RUNNING, 5 | CODE_EXECUTED, 6 | CODE_ERROR, 7 | UPDATE_BLOCK, 8 | DELETE_BLOCK, 9 | DELETE_DATASOURCE, 10 | UPDATE_DATASOURCE, 11 | LOAD_MARKDOWN 12 | } from '../actions' 13 | 14 | /* 15 | * This reducer handles the state of execution of code blocks - 16 | * retaining results, carrying context around, and making note 17 | * of which blocks have and haven't been executed. It's also 18 | * where the obtained data is stored. 19 | */ 20 | export const initialState = Immutable.Map({ 21 | executionContext: Immutable.Map(), 22 | data: Immutable.Map(), 23 | results: Immutable.Map(), 24 | blocksExecuted: Immutable.Set(), 25 | blocksRunning: Immutable.Set() 26 | }) 27 | 28 | export default function execution (state = initialState, action = {}) { 29 | const { id, name, data, context } = action 30 | switch (action.type) { 31 | case LOAD_MARKDOWN: 32 | return initialState 33 | case CODE_RUNNING: 34 | return state.set('blocksRunning', state.get('blocksRunning').add(id)) 35 | case CODE_EXECUTED: 36 | return state 37 | .setIn(['results', id], data) 38 | .set('blocksExecuted', state.get('blocksExecuted').add(id)) 39 | .set('blocksRunning', state.get('blocksRunning').delete(id)) 40 | .set('executionContext', context) 41 | case CODE_ERROR: 42 | return state 43 | .setIn(['results', id], data) 44 | .set('blocksRunning', state.get('blocksRunning').delete(id)) 45 | .set('blocksExecuted', state.get('blocksExecuted').add(id)) 46 | case RECEIVED_DATA: 47 | return state.setIn(['data', name], Immutable.fromJS(data)) 48 | case UPDATE_BLOCK: 49 | case DELETE_BLOCK: 50 | return state 51 | .set('blocksRunning', state.get('blocksRunning').delete(id)) 52 | .set('blocksExecuted', state.get('blocksExecuted').remove(id)) 53 | .removeIn(['results', id]) 54 | case UPDATE_DATASOURCE: 55 | case DELETE_DATASOURCE: 56 | return state.deleteIn(['data', id]) 57 | default: 58 | return state 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | import notebook from './notebookReducer' 4 | import execution from './executionReducer' 5 | import editor from './editorReducer' 6 | 7 | export default combineReducers({ 8 | notebook, 9 | execution, 10 | editor 11 | }) 12 | 13 | -------------------------------------------------------------------------------- /src/js/reducers/notebookReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import Jutsu from 'jutsu' 3 | import { parse } from '../markdown' 4 | import { kayeroHomepage } from '../config' // eslint-disable-line 5 | import { 6 | LOAD_MARKDOWN, 7 | FILE_SAVED, 8 | UPDATE_BLOCK, 9 | UPDATE_META, 10 | TOGGLE_META, 11 | ADD_BLOCK, 12 | DELETE_BLOCK, 13 | MOVE_BLOCK, 14 | DELETE_DATASOURCE, 15 | UPDATE_DATASOURCE, 16 | GIST_CREATED, 17 | UNDO, 18 | CHANGE_CODE_BLOCK_OPTION, 19 | UPDATE_GRAPH_BLOCK_PROPERTY, 20 | UPDATE_GRAPH_BLOCK_HINT, 21 | UPDATE_GRAPH_BLOCK_LABEL, 22 | CLEAR_GRAPH_BLOCK_DATA 23 | } from '../actions' 24 | 25 | /* 26 | * This reducer handles the state of the notebook's actual content, 27 | * obtained by parsing Markdown. This is kept separate from the execution 28 | * state to help with implementing 'undo' in the editor. 29 | */ 30 | export const initialState = Immutable.Map({ 31 | metadata: Immutable.fromJS({ 32 | datasources: {} 33 | }), 34 | content: Immutable.List(), 35 | blocks: Immutable.Map(), 36 | undoStack: Immutable.List() 37 | }) 38 | 39 | export default function notebook (state = initialState, action = {}) { 40 | const { id, text, field, blockType, nextIndex, option } = action 41 | const content = state.get('content') 42 | let newState 43 | switch (action.type) { 44 | case LOAD_MARKDOWN: 45 | return parse(action.markdown, action.filename).set('undoStack', state.get('undoStack')) 46 | case FILE_SAVED: 47 | return state.setIn(['metadata', 'path'], action.filename) 48 | case UPDATE_BLOCK: 49 | return handleChange( 50 | state, state.setIn(['blocks', id, 'content'], text) 51 | ) 52 | case UPDATE_META: 53 | return handleChange( 54 | state, state.setIn(['metadata', field], text) 55 | ) 56 | case TOGGLE_META: 57 | return handleChange( 58 | state, state.setIn(['metadata', field], !state.getIn(['metadata', field])) 59 | ) 60 | case ADD_BLOCK: 61 | const newId = getNewId(content) 62 | let newBlock = {type: blockType, id: newId} 63 | if (blockType === 'code') { 64 | newBlock.content = '// New code block' 65 | newBlock.language = 'javascript' 66 | newBlock.option = 'runnable' 67 | } else if (blockType === 'graph') { 68 | newBlock.language = 'javascript' 69 | newBlock.option = 'runnable' 70 | newBlock.content = 'return graphs.pieChart(data);' 71 | newBlock.graphType = 'pieChart' 72 | newBlock.dataPath = 'data' 73 | newBlock.hints = Immutable.fromJS({ 74 | label: '', 75 | value: '', 76 | x: '', 77 | y: '' 78 | }) 79 | newBlock.labels = Immutable.fromJS({ 80 | x: '', 81 | y: '' 82 | }) 83 | } else { 84 | newBlock.content = 'New text block' 85 | } 86 | newState = handleChange( 87 | state, state.setIn(['blocks', newId], Immutable.fromJS(newBlock)) 88 | ) 89 | if (id === undefined) { 90 | return newState.set('content', content.push(newId)) 91 | } 92 | return newState.set('content', content.insert(content.indexOf(id), newId)) 93 | case DELETE_BLOCK: 94 | return handleChange( 95 | state, 96 | state.deleteIn(['blocks', id]).set( 97 | 'content', content.delete(content.indexOf(id)) 98 | ) 99 | ) 100 | case MOVE_BLOCK: 101 | const index = content.indexOf(id) 102 | if (index === nextIndex || typeof nextIndex === 'undefined') { 103 | return state 104 | } 105 | if (index > nextIndex) { // going up 106 | return handleChange( 107 | state, state.set('content', content.slice(0, Math.max(nextIndex, 0)) 108 | .push(id) 109 | .concat(content.slice(nextIndex, index)) 110 | .concat(content.slice(index + 1))) 111 | ) 112 | } else { // going down 113 | return handleChange( 114 | state, state.set('content', content.slice(0, Math.max(index, 0)) 115 | .concat(content.slice(index + 1, nextIndex + 1)) 116 | .push(id) 117 | .concat(content.slice(nextIndex + 1))) 118 | ) 119 | } 120 | case DELETE_DATASOURCE: 121 | return handleChange( 122 | state, state.deleteIn(['metadata', 'datasources', id]) 123 | ) 124 | case UPDATE_DATASOURCE: 125 | return handleChange( 126 | state, state.setIn(['metadata', 'datasources', id], text) 127 | ) 128 | case GIST_CREATED: 129 | return state.setIn(['metadata', 'gistUrl'], kayeroHomepage + '?id=' + id) 130 | case UNDO: 131 | return undo(state) 132 | case CHANGE_CODE_BLOCK_OPTION: 133 | return handleChange(state, state.setIn( 134 | ['blocks', id, 'option'], 135 | option || getNewOption(state.getIn(['blocks', id, 'option'])) 136 | )) 137 | case UPDATE_GRAPH_BLOCK_PROPERTY: 138 | newState = state.setIn( 139 | ['blocks', id, action.property], action.value 140 | ) 141 | return handleChange(state, newState.setIn( 142 | ['blocks', id, 'content'], 143 | generateCode(newState.getIn(['blocks', id])) 144 | )) 145 | case UPDATE_GRAPH_BLOCK_HINT: 146 | newState = state.setIn( 147 | ['blocks', id, 'hints', action.hint], action.value 148 | ) 149 | return handleChange(state, newState.setIn( 150 | ['blocks', id, 'content'], 151 | generateCode(newState.getIn(['blocks', id])) 152 | )) 153 | case UPDATE_GRAPH_BLOCK_LABEL: 154 | newState = state.setIn( 155 | ['blocks', id, 'labels', action.label], action.value 156 | ) 157 | return handleChange(state, newState.setIn( 158 | ['blocks', id, 'content'], 159 | generateCode(newState.getIn(['blocks', id])) 160 | )) 161 | case CLEAR_GRAPH_BLOCK_DATA: 162 | return state.setIn( 163 | ['blocks', id], 164 | state.getIn(['blocks', id]) 165 | .remove('hints') 166 | .remove('graphType').remove('labels') 167 | .remove('dataPath') 168 | ) 169 | default: 170 | return state 171 | } 172 | } 173 | 174 | function generateCode (block) { 175 | return 'return graphs.' + block.get('graphType') + 176 | '(' + block.get('dataPath') + getLabels(block) + 177 | getHints(block) + ');' 178 | } 179 | 180 | function getHints (block) { 181 | const hints = block.get('hints') 182 | const schema = Jutsu().__SMOLDER_SCHEMA[block.get('graphType')].data[0] 183 | const result = [] 184 | const keys = Object.keys(schema).sort() 185 | for (let i = 0; i < keys.length; i++) { 186 | const hint = keys[i] 187 | const value = hints.get(hint) 188 | if (value) { 189 | result.push(hint + ": '" + value + "'") 190 | } 191 | } 192 | if (result.length === 0) { 193 | return '' 194 | } 195 | return ', {' + result.join(', ') + '}' 196 | } 197 | 198 | function getLabels (block) { 199 | if (block.get('graphType') === 'pieChart') { 200 | return '' 201 | } 202 | const labels = block.get('labels') 203 | return ', ' + 204 | [labels.get('x'), labels.get('y')] 205 | .map((label) => "'" + label + "'") 206 | .join(', ') 207 | } 208 | 209 | function getNewId (content) { 210 | var id = 0 211 | while (content.contains(String(id))) { 212 | id++ 213 | } 214 | return String(id) 215 | } 216 | 217 | function getNewOption (option) { 218 | const options = ['runnable', 'auto', 'hidden'] 219 | const i = options.indexOf(option) 220 | return options[(i + 1) % options.length] 221 | } 222 | 223 | /* 224 | * Handles changes, if they exist, by pushing to the undo stack. 225 | */ 226 | function handleChange (currentState, newState) { 227 | if (currentState.equals(newState)) { 228 | return newState 229 | } 230 | let result = newState.set( 231 | 'undoStack', 232 | newState.get('undoStack').push(currentState.remove('undoStack')) 233 | ).deleteIn( 234 | ['metadata', 'gistUrl'] 235 | ) 236 | 237 | // If it's the first change, update the parent link. 238 | if (currentState.get('undoStack').size === 0) { 239 | result = result.setIn(['metadata', 'original'], Immutable.fromJS({ 240 | title: currentState.getIn(['metadata', 'title']), 241 | url: window.location.href 242 | })) 243 | } 244 | return result 245 | } 246 | 247 | function undo (state) { 248 | if (state.get('undoStack').size === 0) { 249 | return state 250 | } 251 | return state.get('undoStack').last() 252 | .set('undoStack', state.get('undoStack').pop()) 253 | } 254 | -------------------------------------------------------------------------------- /src/js/selectors.js: -------------------------------------------------------------------------------- 1 | export const metadataSelector = state => { 2 | return { 3 | metadata: state.notebook.get('metadata'), 4 | undoSize: state.notebook.get('undoStack').size 5 | } 6 | } 7 | 8 | export const contentSelector = state => { 9 | return { 10 | content: state.notebook.get('content').map( 11 | num => state.notebook.getIn(['blocks', num]) 12 | ), 13 | results: state.execution.get('results'), 14 | blocksExecuted: state.execution.get('blocksExecuted'), 15 | blocksRunning: state.execution.get('blocksRunning') 16 | } 17 | } 18 | 19 | export const editorSelector = state => { 20 | return state.editor.toJS() 21 | } 22 | 23 | export const saveSelector = state => { 24 | return {notebook: state.notebook} 25 | } 26 | 27 | export const dataSelector = state => { 28 | return {data: state.execution.get('data').toJS()} 29 | } 30 | -------------------------------------------------------------------------------- /src/js/util.js: -------------------------------------------------------------------------------- 1 | import hljs from 'highlight.js' 2 | import config from './config' // eslint-disable-line 3 | 4 | export function codeToText (codeBlock, includeOption) { 5 | let result = '```' 6 | result += codeBlock.get('language') 7 | const option = codeBlock.get('option') 8 | if (includeOption && option) { 9 | result += '; ' + option 10 | } 11 | result += '\n' 12 | result += codeBlock.get('content') 13 | result += '\n```' 14 | return result 15 | } 16 | 17 | export function highlight (str, lang) { 18 | if (lang && hljs.getLanguage(lang)) { 19 | try { 20 | return hljs.highlight(lang, str).value 21 | } catch (__) {} 22 | } 23 | return '' // use external default escaping 24 | } 25 | 26 | export function renderHTML (markdown) { 27 | let result = '<!DOCTYPE html>\n<html>\n <head>\n' 28 | result += ' <meta name="viewport" content="width=device-width, initial-scale=1">\n' 29 | result += ' <meta http-equiv="content-type" content="text/html; charset=UTF8">\n' 30 | result += ' <link rel="stylesheet" href="' + config.cssUrl + '">\n' 31 | result += ' </head>\n <body>\n <script type="text/markdown" id="kayero-md">\n' 32 | result += markdown.split('\n').map((line) => { 33 | if (line.match(/\S+/m)) { 34 | return ' ' + line 35 | } 36 | return '' 37 | }).join('\n') 38 | result += ' </script>\n' 39 | result += ' <div id="kayero"></div>\n' 40 | result += ' <script type="text/javascript" src="' + config.scriptUrl + '"></script>\n' 41 | result += ' </body>\n</html>\n' 42 | return result 43 | } 44 | 45 | export function arrayToCSV (data) { 46 | return new Promise((resolve, reject) => { 47 | let CSV = '' 48 | let header = '' 49 | Object.keys(data[0]).forEach(colName => { 50 | header += colName + ',' 51 | }) 52 | header = header.slice(0, -1) 53 | CSV += header + '\r\n' 54 | data.forEach((rowData) => { 55 | let row = '' 56 | Object.keys(rowData).forEach(colName => { 57 | row += '"' + rowData[colName] + '",' 58 | }) 59 | row.slice(0, -1) 60 | CSV += row + '\r\n' 61 | }) 62 | 63 | resolve(CSV) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/scss/_base.scss: -------------------------------------------------------------------------------- 1 | $background-col: #fff; 2 | $text-black: #444; 3 | $text-grey: #646464; 4 | $text-light: #999; 5 | $background-grey: #eee; 6 | $background-dark-grey: #ddd; 7 | 8 | $size-sm: 35.5em; 9 | $size-md: 48em; 10 | $size-lg: 64em; 11 | $size-xl: 80em; 12 | 13 | @font-face { 14 | font-family: "Andada"; 15 | src: local('Andada'), url("../fonts/andada.otf") format('opentype'); 16 | } 17 | 18 | @font-face { 19 | font-family: "Amaranth"; 20 | src: local('Amaranth'), url("../fonts/Amaranth-Regular.otf") format('opentype'); 21 | } 22 | 23 | @font-face { 24 | font-family: "Source Code Pro"; 25 | src: local('Source Code Pro'), url("../fonts/SourceCodePro-Regular.otf") format('opentype'); 26 | } 27 | 28 | @mixin respond-to($size) { 29 | @media only screen and (min-width: $size) { @content; } 30 | } 31 | 32 | @mixin block-border { 33 | border: 1px dashed $text-light; 34 | } 35 | -------------------------------------------------------------------------------- /src/scss/_editor.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | .editable { 4 | input { 5 | @include block-border; 6 | display: inherit; 7 | color: inherit; 8 | font-family: inherit; 9 | font-size: inherit; 10 | font-weight: inherit; 11 | width: 100%; 12 | outline: none; 13 | } 14 | 15 | .dragging { 16 | opacity: 0; 17 | } 18 | 19 | .clickable { 20 | cursor: pointer; 21 | } 22 | 23 | .edit-box { 24 | @include block-border; 25 | margin: 1em 0; 26 | padding: 0.5em 0.5em 0.5em 0; 27 | 28 | .CodeMirror { 29 | font-family: 'Source Code Pro', monospace; 30 | font-size: 0.8em; 31 | } 32 | } 33 | 34 | .text-block { 35 | @include block-border; 36 | padding: 0.5em; 37 | .text-block-content { 38 | *:first-child { 39 | margin-top: 0; 40 | } 41 | *:last-child { 42 | margin-bottom: 0; 43 | } 44 | } 45 | margin: 1em 0; 46 | } 47 | 48 | .metadata { 49 | 50 | .metadata-row { 51 | margin: 0.5em 0; 52 | span { 53 | margin-left: 15px; 54 | } 55 | input { 56 | margin-left: 15px; 57 | padding: 0.25em; 58 | box-sizing: border-box; 59 | display: inline-block; 60 | width: calc(100% - 100px); 61 | } 62 | } 63 | .datasource { 64 | margin: 0.5em 0; 65 | .fa { 66 | text-align: center; 67 | margin-top: 0.5em; 68 | cursor: pointer; 69 | 70 | @include respond-to($size-md) { 71 | margin-top: 0.25em; 72 | } 73 | } 74 | input { 75 | width: 100%; 76 | padding: 0.25em; 77 | margin: 0.25em 0; 78 | box-sizing: border-box; 79 | 80 | @include respond-to($size-md) { 81 | margin: 0; 82 | } 83 | } 84 | .source-name input { 85 | @include respond-to($size-md) { 86 | width: 95%; 87 | } 88 | } 89 | p { 90 | margin: 0.5em 0.25em; 91 | 92 | @include respond-to($size-md) { 93 | margin: 0.25em; 94 | } 95 | } 96 | } 97 | } 98 | 99 | .add-controls { 100 | overflow: hidden; 101 | i { 102 | margin-left: 0.5em; 103 | float: right; 104 | } 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/scss/_graphui.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | .graph-creator { 4 | @include block-border; 5 | padding: 0.5em; 6 | margin: 1em 0; 7 | font-family: 'Amaranth', sans-serif; 8 | 9 | .editor-buttons { 10 | margin-top: 0; 11 | } 12 | 13 | .graph-type { 14 | @include block-border; 15 | padding: 0.25em; 16 | cursor: pointer; 17 | display: inline-block; 18 | margin-bottom: 0.5em; 19 | margin-right: 0.5em; 20 | color: $text-light; 21 | 22 | &.selected { 23 | @include block-border; 24 | font-weight: bold; 25 | color: $text-black; 26 | border: 1px dashed $text-black; 27 | cursor: default; 28 | } 29 | } 30 | 31 | p { 32 | margin: 0.5em 0.25em; 33 | 34 | @include respond-to($size-md) { 35 | margin: 0.25em; 36 | } 37 | } 38 | 39 | .hint { 40 | margin: 0.5em 0; 41 | 42 | .fa { 43 | text-align: center; 44 | margin-top: 0.5em; 45 | 46 | @include respond-to($size-md) { 47 | margin-top: 0.25em; 48 | } 49 | } 50 | 51 | input { 52 | width: 100%; 53 | padding: 0.25em; 54 | margin: 0.25em 0; 55 | box-sizing: border-box; 56 | 57 | @include respond-to($size-md) { 58 | margin: 0; 59 | } 60 | } 61 | 62 | .visualiser { 63 | margin-top: 0.5em; 64 | } 65 | } 66 | 67 | .visualiser { 68 | background-color: $background-grey; 69 | padding: 0.5em; 70 | margin-bottom: 0.5em; 71 | 72 | .visualiser-key { 73 | cursor: pointer; 74 | } 75 | } 76 | 77 | pre { 78 | font-family: 'Source Code Pro', monospace; 79 | font-size: 0.8em; 80 | } 81 | 82 | .graph-preview svg { 83 | width: 100%; 84 | &.nvd3-svg { 85 | height: inherit; 86 | } 87 | text { 88 | fill: $text-black; 89 | } 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /src/scss/_save.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | .save-dialog { 4 | 5 | .close-button { 6 | float: right; 7 | margin-top: 0.75em; 8 | margin-right: 0.25em; 9 | cursor: pointer; 10 | } 11 | 12 | .export-option, 13 | .clipboard-button { 14 | @include block-border; 15 | font-family: 'Amaranth', sans-serif; 16 | padding: 0.25em; 17 | cursor: pointer; 18 | display: inline-block; 19 | 20 | &.selected { 21 | @include block-border; 22 | font-weight: bold; 23 | color: $text-black; 24 | border: 1px dashed $text-black; 25 | cursor: default; 26 | } 27 | } 28 | 29 | textarea { 30 | height: 400px; 31 | box-sizing: border-box; 32 | } 33 | 34 | .export-option { 35 | margin-top: 0.5em; 36 | margin-right: 0.5em; 37 | color: $text-light; 38 | } 39 | 40 | .clipboard-button { 41 | font-weight: bold; 42 | float: right; 43 | } 44 | 45 | input { 46 | @include block-border; 47 | width: 100%; 48 | box-sizing: border-box; 49 | padding: 0.25em; 50 | margin-top: 0.25em; 51 | margin-bottom: 0.5em; 52 | display: inherit; 53 | color: inherit; 54 | font-family: inherit; 55 | font-size: inherit; 56 | font-weight: inherit; 57 | width: 100%; 58 | outline: none; 59 | cursor: text; 60 | } 61 | 62 | .pure-g { 63 | margin-top: 0.75em; 64 | p { 65 | font-family: 'Amaranth', sans-serif; 66 | } 67 | } 68 | 69 | p { 70 | clear: both; 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/scss/_shell.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | body { 4 | background: $background-col; 5 | color: $text-black; 6 | font-family: "Andada", serif; 7 | 8 | @include respond-to($size-md) { 9 | font-size: 1.2em; 10 | } 11 | } 12 | 13 | .metadata { 14 | font-size: 1em; 15 | font-family: "Amaranth", sans-serif; 16 | 17 | .metadata-sep { 18 | display: none; 19 | 20 | @include respond-to($size-md) { 21 | display: inline; 22 | } 23 | } 24 | 25 | .metadata-item { 26 | display: block; 27 | margin: 0.5em 0; 28 | 29 | @include respond-to($size-md) { 30 | display: inline; 31 | } 32 | } 33 | } 34 | 35 | .controls { 36 | float: right; 37 | margin: 0 0.5em; 38 | cursor: pointer; 39 | i { 40 | margin-left: 0.5em; 41 | } 42 | } 43 | 44 | .editor-buttons { 45 | float: right; 46 | margin-bottom: 1px; // Fixes overlap bug on Safari 47 | margin-top: -0.2em; // Account for font size difference 48 | i { 49 | margin-left: 0.25em; 50 | cursor: pointer; 51 | } 52 | } 53 | 54 | code { 55 | font-family: "Source Code Pro", monospace; 56 | font-size: 0.8em; 57 | } 58 | 59 | .offset-col { 60 | display: none; 61 | 62 | @include respond-to($size-md) { 63 | display: inline-block; 64 | } 65 | } 66 | 67 | hr { 68 | display: block; 69 | height: 1px; 70 | border: 0; 71 | border-top: 2px dashed $text-light; 72 | margin: 1.5em 0; 73 | padding: 0; 74 | } 75 | 76 | .top-sep { 77 | border-top: 2px dashed $text-black; 78 | margin: 1.5em 0; 79 | } 80 | 81 | h1 { 82 | font-size: 2.5em; 83 | } 84 | 85 | a { 86 | text-decoration: none; 87 | border-bottom: 1px dashed $text-light; 88 | font-weight: bold; 89 | color: $text-black; 90 | } 91 | 92 | a:visited { 93 | color: inherit; 94 | } 95 | 96 | img { 97 | max-width: 100%; 98 | } 99 | 100 | pre, 101 | .codeBlock, 102 | .resultBlock, 103 | .graphBlock { 104 | @include block-border; 105 | overflow: auto; 106 | padding: 0.5em; 107 | } 108 | 109 | .codeContainer { 110 | margin: 1em 0; 111 | pre { 112 | padding: 0; 113 | border: none; 114 | overflow: auto; 115 | margin: 0; 116 | } 117 | 118 | .graphBlock, 119 | .resultBlock { 120 | border-top: none; 121 | } 122 | 123 | .graphBlock > * { 124 | margin: 0 auto; 125 | } 126 | 127 | .graphBlock:empty { 128 | display: none; 129 | } 130 | 131 | .graphBlock svg { 132 | width: 100%; 133 | &.nvd3-svg { 134 | height: inherit; 135 | } 136 | text { 137 | fill: $text-black; 138 | } 139 | } 140 | 141 | .resultBlock { 142 | background-color: $background-grey; 143 | } 144 | 145 | } 146 | 147 | .hiddenCode { 148 | .codeBlock { 149 | display: none; 150 | } 151 | 152 | .resultBlock, 153 | .graphBlock { 154 | border-top: 1px dashed $text-light; 155 | } 156 | 157 | .graphBlock { 158 | border-bottom: none; 159 | } 160 | } 161 | 162 | textarea { 163 | @include block-border; 164 | width: 100%; 165 | color: $text-black; 166 | padding: 0.5em; 167 | outline: none; 168 | height: 300px; 169 | font-family: 'Source Code Pro', monospace; 170 | font-size: 0.8em; 171 | resize: none; 172 | margin: 1em 0; 173 | } 174 | 175 | .footer { 176 | padding-bottom: 1.5em; 177 | .footer-row { 178 | display: block; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/scss/_visualiser.scss: -------------------------------------------------------------------------------- 1 | .visualiser { 2 | font-family: 'Source Code Pro', monospace; 3 | font-size: 0.8em; 4 | white-space: pre; 5 | overflow: auto; 6 | 7 | .visualiser-spacing { 8 | cursor: default; 9 | } 10 | 11 | .visualiser-arrow { 12 | cursor: pointer; 13 | } 14 | 15 | .visualiser-row { 16 | display: inline-block; 17 | } 18 | 19 | .export-to-csv { 20 | float: right; 21 | cursor: pointer; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/scss/base16-tomorrow-light.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Made with colours from Highlight.js. 3 | Who in turn got them from Base16. 4 | https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow.css 5 | */ 6 | 7 | .cm-s-base16-tomorrow-light { 8 | *.CodeMirror { 9 | background: #fff; 10 | color: #444; 11 | } 12 | 13 | div.CodeMirror-selected { background: #e0e0e0 !important; } 14 | 15 | .CodeMirror-gutters { 16 | background: #fff; 17 | border-right: 0; 18 | } 19 | 20 | .CodeMirror-linenumber { color: #b4b7b4; } 21 | 22 | .CodeMirror-cursor { border-left: 1px solid #969896 !important; } 23 | 24 | span.cm-comment { color: #8e908c; } 25 | span.cm-atom { color: #b294bb; } 26 | span.cm-number { color: #f5871f; } 27 | 28 | span.cm-property, 29 | span.cm-attribute { color: #444; } 30 | span.cm-keyword { color: #8959a8; } 31 | span.cm-string { color: #718c00; } 32 | 33 | span.cm-variable { color: #444; } 34 | span.cm-variable-2 { color: #444; } 35 | span.cm-def { color: #4271ae; } 36 | span.cm-error { 37 | background: #c66; 38 | color: #969896; 39 | } 40 | span.cm-bracket { color: #282a2e; } 41 | span.cm-tag { color: #c66; } 42 | span.cm-link { color: #b294bb; } 43 | 44 | .CodeMirror-matchingbracket { 45 | text-decoration: underline; 46 | color: white !important; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/scss/linter.scss: -------------------------------------------------------------------------------- 1 | /* The lint marker gutter */ 2 | .CodeMirror-lint-markers { 3 | width: 16px; 4 | } 5 | 6 | .CodeMirror-lint-tooltip { 7 | color: #282c34; 8 | border-radius: 4.5px; 9 | border-top-left-radius: 0; 10 | background: #bdc5d4; 11 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); 12 | overflow: hidden; 13 | padding: 2px 5px; 14 | position: fixed; 15 | white-space: pre-wrap; 16 | z-index: 100; 17 | max-width: 200px; 18 | max-width: 600px; 19 | opacity: 0; 20 | transition: opacity 0.4s; 21 | font-size: 12px; 22 | line-height: 22px; 23 | } 24 | 25 | .CodeMirror-lint-mark-error, 26 | .CodeMirror-lint-mark-warning { 27 | background-position: left bottom; 28 | background-repeat: repeat-x; 29 | } 30 | 31 | .CodeMirror-lint-mark-error { 32 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg=="); 33 | } 34 | 35 | .CodeMirror-lint-mark-warning { 36 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII="); 37 | } 38 | 39 | .CodeMirror-lint-marker-error, 40 | .CodeMirror-lint-marker-warning { 41 | height: 8px; 42 | width: 8px; 43 | border-radius: 50%; 44 | display: inline-block; 45 | cursor: pointer; 46 | margin-left: 12px; 47 | position: relative; 48 | top: -2px; 49 | } 50 | 51 | .CodeMirror-lint-message-error::before, 52 | .CodeMirror-lint-message-warning::before { 53 | display: inline-block; 54 | border-radius: 3px; 55 | overflow: hidden; 56 | text-overflow: ellipsis; 57 | font-size: 0.8em; 58 | min-width: 1.6em; 59 | padding: 0.4em 0.6em; 60 | position: relative; 61 | margin-right: 5px; 62 | top: 2px; 63 | line-height: 1em; 64 | } 65 | 66 | .CodeMirror-lint-marker-error, 67 | .CodeMirror-lint-message-error::before { 68 | background: #d92626; 69 | } 70 | 71 | .CodeMirror-lint-message-error::before { 72 | content: 'Error'; 73 | color: white; 74 | } 75 | 76 | .CodeMirror-lint-marker-warning, 77 | .CodeMirror-lint-message-warning::before { 78 | background: #cc8533; 79 | } 80 | 81 | .CodeMirror-lint-message-warning::before { 82 | content: 'Warning'; 83 | color: white; 84 | } 85 | -------------------------------------------------------------------------------- /src/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/font-awesome/scss/font-awesome'; 2 | @import '../../node_modules/highlight.js/styles/tomorrow'; 3 | @import '../../node_modules/nvd3/build/nv.d3'; 4 | @import '../../node_modules/codemirror/lib/codemirror'; 5 | @import '../../node_modules/katex/dist/katex.min.css'; 6 | @import 'base16-tomorrow-light'; 7 | @import 'grids'; 8 | @import 'base'; 9 | @import 'shell'; 10 | @import 'visualiser'; 11 | @import 'editor'; 12 | @import 'save'; 13 | @import 'graphui'; 14 | @import 'linter'; 15 | -------------------------------------------------------------------------------- /src/templates/blank.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Blank Kayero notebook" 3 | author: "Mathieu DUtour" 4 | show_footer: false 5 | --- 6 | 7 | ## I'm a blank notebook. 8 | 9 | This is a Kayero notebook, but there's nothing in it yet. Click the edit button on the top-right to get started! 10 | -------------------------------------------------------------------------------- /src/templates/example.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Kayero" 3 | author: "Joel Auterson" 4 | datasources: 5 | joelotter: "https://api.github.com/users/joelotter/repos" 6 | popular: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc" 7 | original: 8 | title: "Blank Kayero notebook" 9 | url: "http://www.joelotter.com/kayero/blank" 10 | show_footer: true 11 | --- 12 | 13 | [![npm](https://img.shields.io/npm/v/kayero.svg?maxAge=2592000)](https://www.npmjs.com/package/kayero) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/mathieudutour/kayero/master/LICENSE) [![GitHub stars](https://img.shields.io/github/stars/mathieudutour/kayero.svg)](https://github.com/mathieudutour/kayero/stargazers) [![Join the chat at https://gitter.im/mathieudutour/kayero](https://badges.gitter.im/mathieudutour/kayero.svg)](https://gitter.im/mathieudutour/kayero?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 14 | 15 | **Kayero** is designed to make it really easy for anyone to create good-looking, responsive, interactive documents. 16 | 17 | All Kayero notebooks are editable in-line - including the one you're reading right now. Just click the pencil icon in the top-right to get started. 18 | 19 | This notebook is an interactive run-through of Kayero's features. If you'd rather just grab the source code, [it's on GitHub](https://github.com/mathieudutour/kayero). 20 | 21 | ## It's just Markdown 22 | 23 | If you view the page source for this notebook, you'll notice that it's really just a Markdown document with a script attached. This is because the notebooks are fully rendered in-browser - there's no backend required, so you can host them on GitHub pages or wherever you like. 24 | 25 | Markdown is great for writing documents, and this helps create very readable notebooks. 26 | 27 | - You 28 | - Can 29 | - Write 30 | - Lists 31 | 32 | You can write code samples too! 33 | 34 | ```go 35 | package main 36 | 37 | import "fmt" 38 | 39 | func main() { 40 | fmt.Println("This is some Go.") 41 | } 42 | ``` 43 | 44 | The Kayero package on [npm](https://www.npmjs.com/package/kayero) contains some command-line tools to create notebooks out of Markdown files. However, you might not want to create a notebook from scratch, so... 45 | 46 | ## Every notebook is editable 47 | 48 | Clicking the pencil icon in the top-right puts Kayero into edit mode. Every notebook can be edited - you can move text and code blocks around, add new data sources, change the title and author, and add your own content. 49 | 50 | Once you've made your changes, clicking the save icon will allow you to export your new notebook as Markdown or HTML. 51 | 52 | Notebooks can also be exported as Gists. This saves the generated Markdown on GitHub's [Gist](https://gist.github.com/) service, and provides a unique URL which can be used to access the saved notebook. This means you don't need to host the notebook yourself. 53 | 54 | A notebook's footer will contain a link to the parent notebook - the one the notebook was forked from. This footer can be turned off in the editor if you don't want it. 55 | 56 | ## Interactive code samples 57 | 58 | JavaScript code samples in Kayero notebooks can be executed by clicking the play button. 59 | 60 | ```javascript; runnable 61 | return 1 + 2; 62 | ``` 63 | 64 | Code blocks can be set to run automatically, when the notebook is loaded. 65 | 66 | ```javascript; auto 67 | return "Like this one."; 68 | ``` 69 | 70 | They can also be set to 'hidden' - only the result will be visible, the code itself will be hidden (though is still viewable and editable in the editor). 71 | 72 | ```javascript; hidden 73 | return "This is a hidden code block." 74 | ``` 75 | 76 | It's possible to pass data between different code blocks by using **this**. For example, in the following blocks, the first defines a variable and the second does something with it. 77 | 78 | ```javascript; auto 79 | this.number = 100; 80 | return this.number; 81 | ``` 82 | 83 | ```javascript; auto 84 | return this.number + 100; 85 | ``` 86 | 87 | Want to run something asynchronous? No problem - return a Promise and it'll be resolved. 88 | 89 | ```javascript; runnable 90 | return new Promise(function (resolve) { 91 | setTimeout(function() { 92 | resolve("This took three seconds."); 93 | }, 3000); 94 | }); 95 | ``` 96 | 97 | Kayero includes a built-in data visualiser, similar to Chrome's object inspector. This means your code blocks can return whatever data you need, not just primitives. 98 | 99 | For example, here's an array: 100 | 101 | ```javascript; auto 102 | var toSplit = "This is a string. We're going to split it into an array."; 103 | return toSplit.split(' '); 104 | ``` 105 | 106 | And an object: 107 | 108 | ```javascript; auto 109 | var person = { 110 | name: 'Joel', 111 | age: 22, 112 | projects: { 113 | termloop: 'https://github.com/JoelOtter/termloop', 114 | kayero: 'https://github.com/mathieudutour/kayero' 115 | } 116 | }; 117 | 118 | return person; 119 | ``` 120 | 121 | ## Working with data sources 122 | 123 | Kayero allows users to define JSON data sources in the editor. These are fetched when the notebook is first loaded, and made available in code blocks via the **data** object. 124 | 125 | ```javascript; auto 126 | return data; 127 | ``` 128 | 129 | In this notebook, the data source **joelotter** retrieves my repository data via the GitHub API. We can write code blocks to work with this data. 130 | 131 | ```javascript; runnable 132 | return data.joelotter.map( 133 | function (repo) { 134 | return repo.name; 135 | } 136 | ); 137 | ``` 138 | 139 | ### Reshaper 140 | 141 | Kayero also includes [Reshaper](http://www.joelotter.com/reshaper), a library which can automatically reshape data to match a provided schema. 142 | 143 | You can read more about Reshaper by following the link above. Here are a couple of quick examples, using the GitHub data. 144 | 145 | ```javascript; runnable 146 | var schema = ['String']; 147 | return reshaper(data.joelotter, schema); 148 | ``` 149 | 150 | Reshaper has correctly produced an array of strings, which is what we asked for. However, we didn't tell it exactly what data we're interested in, so it's used the first string it could find. 151 | 152 | We can give Reshaper a **hint** as to which part of the data we care about. Let's get an array of the repo names. 153 | 154 | ```javascript; runnable 155 | var schema = ['String']; 156 | return reshaper(data.joelotter, schema, 'name'); 157 | ``` 158 | 159 | We can provide a more complex schema for a more useful resulting data structure. Let's get the name and number of stargazers for each repo. 160 | 161 | ```javascript; runnable 162 | // Reshaper can use object keys as hints 163 | var schema = [{ 164 | name: 'String', 165 | stargazers_count: 'Number' 166 | }]; 167 | 168 | return reshaper(data.joelotter, schema); 169 | ``` 170 | 171 | Check the [Reshaper documentation](http://www.joelotter.com/reshaper) for more examples. 172 | 173 | ## Graphs 174 | 175 | When working with data sources, a very common task would be to use the data to produce graphs. Kayero provides a few options for this. 176 | 177 | ### D3 178 | 179 | [D3](http://d3js.org) is the web's favourite library for creating interactive graphics. It's available for use in code blocks. 180 | 181 | Every code block has a DOM node called **graphElement**. Users can draw to this with D3. 182 | 183 | ```javascript; runnable 184 | // Remove any old SVGs for re-running 185 | d3.select(graphElement).selectAll('*').remove(); 186 | 187 | var sampleSVG = d3.select(graphElement) 188 | .append("svg") 189 | .attr("width", 100) 190 | .attr("height", 100); 191 | 192 | sampleSVG.append("circle") 193 | .style("stroke", "gray") 194 | .style("fill", "white") 195 | .attr("r", 40) 196 | .attr("cx", 50) 197 | .attr("cy", 50) 198 | .on("mouseover", function(){d3.select(this).style("fill", "aliceblue");}) 199 | .on("mouseout", function(){d3.select(this).style("fill", "white");}) 200 | .on("mousedown", animateFirstStep); 201 | 202 | function animateFirstStep(){ 203 | d3.select(this) 204 | .transition() 205 | .delay(0) 206 | .duration(1000) 207 | .attr("r", 10) 208 | .each("end", animateSecondStep); 209 | }; 210 | 211 | function animateSecondStep(){ 212 | d3.select(this) 213 | .transition() 214 | .duration(1000) 215 | .attr("r", 40); 216 | }; 217 | 218 | return "Try clicking the circle!"; 219 | ``` 220 | 221 | ### NVD3 222 | 223 | D3 is incredibly powerful, but creating complex drawings can be daunting. [NVD3](http://nvd3.org/) provides some nice pre-built graphs, and is also included in Kayero. 224 | 225 | The following code will generate and draw a random scatter plot. 226 | 227 | ```javascript; runnable 228 | d3.select(graphElement).selectAll('*').remove(); 229 | d3.select(graphElement).append('svg').attr("width", "100%"); 230 | 231 | nv.addGraph(function() { 232 | var chart = nv.models.scatter() 233 | .margin({top: 20, right: 20, bottom: 20, left: 20}) 234 | .pointSize(function(d) { return d.z }) 235 | .useVoronoi(false); 236 | d3.select(graphElement).selectAll("svg") 237 | .datum(randomData()) 238 | .transition().duration(500) 239 | .call(chart); 240 | nv.utils.windowResize(chart.update); 241 | return chart; 242 | }); 243 | 244 | function randomData() { 245 | var data = []; 246 | for (i = 0; i < 2; i++) { 247 | data.push({ 248 | key: 'Group ' + i, 249 | values: [] 250 | }); 251 | for (j = 0; j < 100; j++) { 252 | data[i].values.push({x: Math.random(), y: Math.random(), z: Math.random()}); 253 | } 254 | } 255 | return data; 256 | } 257 | return "Try clicking the rerun button!"; 258 | ``` 259 | 260 | ### Jutsu 261 | 262 | Even NVD3 may be too difficult to use for those with little coding experience. This is why Kayero includes [Jutsu](http://www.joelotter.com/jutsu), a very simple graphing library. 263 | 264 | Jutsu uses Reshaper internally, via a library wrapper called [Smolder](https://github.com/JoelOtter/smolder). This means you can throw whatever data you like at it, and Jutsu will attempt to make a graph with that data. You can also provide hints. 265 | 266 | Additionally, the Kayero editor includes a GUI for graph creation. This makes it possible to create graphs from data without writing any code at all. Try it out! 267 | 268 | For our examples, let's look at the data provided by the **popular** data source. This contains data on the 30 most popular GitHub repos of 2016, by number of stargazers. 269 | 270 | ```javascript; runnable 271 | return data.popular; 272 | ``` 273 | 274 | Let's create a pie chart using this data. 275 | 276 | ```javascript; auto 277 | return graphs.pieChart(data.popular); 278 | ``` 279 | 280 | It's made a pie chart - but, like in the first Reshaper example, we didn't tell it what data we're interested in. Let's use the repo name as a label, and plot a pie chart of the number of open issues each repo has. 281 | 282 | As well as using strings as before, we can provide hints in the form of arrays or objects. 283 | 284 | ```javascript; auto 285 | return graphs.pieChart(data.popular, {label: 'name', value: 'open_issues'}); 286 | ``` 287 | 288 | Jutsu functions will return the reshaped data used in the graph. 289 | 290 | The pie chart is a little hard to read. Let's do a bar chart instead. As well as hints, we can provide axis labels: 291 | 292 | ```javascript; auto 293 | return graphs.barChart( 294 | data.popular, 'Repo', 'Number of open issues', 295 | {label: 'name', value: 'open_issues'} 296 | ); 297 | ``` 298 | 299 | Line graphs can be used to investigate trends. Is there any correlation between the number of stargazers a repo has, and the number of open issues? 300 | 301 | ```javascript; auto 302 | return graphs.lineChart( 303 | data.popular, 'Stargazers', 'Open issues', 304 | {label: 'name', x: 'stargazers_count', y: 'open_issues'} 305 | ); 306 | ``` 307 | 308 | It's a little hard to tell. Let's use a scatter plot instead. 309 | 310 | ```javascript; auto 311 | return graphs.scatterPlot( 312 | data.popular, 'Stargazers', 'Open issues', 313 | {label: 'name', x: 'stargazers_count', y: 'open_issues'} 314 | ); 315 | ``` 316 | 317 | There doesn't seem to be any correlation! 318 | 319 | ## Questions? Feedback? 320 | 321 | Please feel free to file an [issue](https://github.com/mathieudutour/kayero/issues) with any you may have. If you're having a problem with the way Jutsu or Reshaper restructures your data, it's probably better to file an issue in [Reshaper](https://github.com/JoelOtter/reshaper) itself. 322 | 323 | Kayero is a part of my Master's project at Imperial College London, and as part of my evaluation I'd really love to hear any feedback you might have. Feel free to [shoot me an email](mailto:joel.auterson@gmail.com). 324 | 325 | I hope you find Kayero useful! 326 | -------------------------------------------------------------------------------- /tests/actions.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import Immutable from 'immutable' 4 | import configureMockStore from 'redux-mock-store' 5 | import thunk from 'redux-thunk' 6 | import fetchMock from 'fetch-mock' 7 | import requireMock from 'mock-require' 8 | 9 | requireMock('electron', { 10 | remote: { 11 | dialog: { 12 | showOpenDialog (_, callback) { 13 | callback() 14 | } 15 | }, 16 | app: { 17 | addRecentDocument () {} 18 | }, 19 | BrowserWindow: { 20 | getFocusedWindow () { 21 | return { 22 | setRepresentedFilename () {}, 23 | setDocumentEdited () {} 24 | } 25 | } 26 | } 27 | } 28 | }) 29 | 30 | import { gistUrl, gistApi } from '../src/js/config' // eslint-disable-line 31 | const actions = require('../src/js/actions') 32 | 33 | const middlewares = [thunk] 34 | const mockStore = configureMockStore(middlewares) 35 | 36 | // Mock stuff for execution 37 | global.document = require('jsdom').jsdom('<body></body>') 38 | global.window = document.defaultView 39 | global.nv = {} 40 | 41 | test.afterEach.always((t) => { 42 | fetchMock.restore() 43 | }) 44 | 45 | test('should create RECEIVED_DATA, trigger auto exec when data is received with URLs', (t) => { 46 | fetchMock.mock('http://example.com/data1', {body: {thing: 'data1'}}) 47 | .mock('http://example.com/data2', {body: {thing: 'data2'}}) 48 | 49 | const store = mockStore({ 50 | notebook: Immutable.fromJS({ 51 | metadata: { 52 | datasources: { 53 | one: 'http://example.com/data1', 54 | two: 'http://example.com/data2' 55 | } 56 | }, 57 | blocks: { 58 | '12': { 59 | option: 'auto' 60 | } 61 | }, 62 | content: ['12'] 63 | }), 64 | execution: Immutable.fromJS({ 65 | data: {}, 66 | executionContext: {} 67 | }) 68 | }) 69 | 70 | const expecteds = [ 71 | {type: actions.RECEIVED_DATA, name: 'one', data: {thing: 'data1'}}, 72 | {type: actions.RECEIVED_DATA, name: 'two', data: {thing: 'data2'}} 73 | ] 74 | 75 | return store.dispatch(actions.fetchData()) 76 | .then(() => { 77 | t.deepEqual(store.getActions().slice(0, 2), expecteds) 78 | t.is(store.getActions().length, 4) 79 | t.is(store.getActions()[2].type, actions.CODE_RUNNING) 80 | t.is(store.getActions()[3].type, actions.CODE_EXECUTED) 81 | }) 82 | }) 83 | 84 | test('should create RECEIVED_DATA, trigger auto exec when data is received with mongo connection', (t) => { 85 | const store = mockStore({ 86 | notebook: Immutable.fromJS({ 87 | metadata: { 88 | datasources: { 89 | one: 'mongodb://localhost', 90 | two: 'mongodb-secure://./secret.json' 91 | } 92 | }, 93 | blocks: { 94 | '12': { 95 | option: 'auto' 96 | } 97 | }, 98 | content: ['12'] 99 | }), 100 | execution: Immutable.fromJS({ 101 | data: {}, 102 | executionContext: {} 103 | }) 104 | }) 105 | 106 | const expecteds = [ 107 | {type: actions.RECEIVED_DATA, name: 'one', data: { 108 | __type: 'mongodb', 109 | __secure: false, 110 | url: 'mongodb://localhost' 111 | }}, 112 | {type: actions.RECEIVED_DATA, name: 'two', data: { 113 | __type: 'mongodb', 114 | __secure: true, 115 | url: 'mongodb-secure://./secret.json' 116 | }} 117 | ] 118 | 119 | return store.dispatch(actions.fetchData()) 120 | .then(() => { 121 | t.deepEqual(store.getActions().slice(0, 2), expecteds) 122 | t.is(store.getActions().length, 4) 123 | t.is(store.getActions()[2].type, actions.CODE_RUNNING) 124 | t.is(store.getActions()[3].type, actions.CODE_EXECUTED) 125 | }) 126 | }) 127 | 128 | test('should not fetch data unless necessary', (t) => { 129 | fetchMock.mock('http://example.com/data1', {body: {thing: 'data1'}}) 130 | .mock('http://example.com/data2', {body: {thing: 'data2'}}) 131 | 132 | const store = mockStore({ 133 | notebook: Immutable.fromJS({ 134 | metadata: { 135 | datasources: { 136 | one: 'http://example.com/data1', 137 | two: 'http://example.com/data2' 138 | } 139 | }, 140 | blocks: {}, 141 | content: [] 142 | }), 143 | execution: Immutable.fromJS({ 144 | data: {one: 'hooray'} 145 | }) 146 | }) 147 | 148 | const expected = [ 149 | {type: actions.RECEIVED_DATA, name: 'two', data: {thing: 'data2'}} 150 | ] 151 | 152 | return store.dispatch(actions.fetchData()) 153 | .then(() => { 154 | t.deepEqual(store.getActions(), expected) 155 | }) 156 | }) 157 | 158 | test('should save a gist and return a GIST_CREATED action', (t) => { 159 | fetchMock.mock(gistApi, 'POST', { 160 | id: 'test_gist_id' 161 | }) 162 | 163 | const store = mockStore({}) 164 | const expected = [{type: actions.GIST_CREATED, id: 'test_gist_id'}] 165 | 166 | return store.dispatch(actions.saveGist('title', '## markdown')) 167 | .then(() => { 168 | t.deepEqual(store.getActions(), expected) 169 | }) 170 | }) 171 | 172 | test('should create CODE_EXECUTED on successful block execution', (t) => { 173 | const store = mockStore({ 174 | notebook: Immutable.fromJS({ 175 | blocks: { 176 | '0': { 177 | type: 'code', 178 | language: 'javascript', 179 | option: 'runnable', 180 | content: 'return 1 + 2;' 181 | } 182 | } 183 | }), 184 | execution: Immutable.fromJS({ 185 | data: {}, 186 | executionContext: {} 187 | }) 188 | }) 189 | 190 | const expected = [{ 191 | type: actions.CODE_RUNNING, 192 | id: '0' 193 | }, { 194 | type: actions.CODE_EXECUTED, 195 | id: '0', 196 | data: 3, 197 | context: Immutable.fromJS({}) 198 | }] 199 | 200 | return store.dispatch(actions.executeCodeBlock('0')) 201 | .then(() => { 202 | t.deepEqual(store.getActions(), expected) 203 | }) 204 | }) 205 | 206 | test('should create CODE_ERROR on error in block execution', (t) => { 207 | const store = mockStore({ 208 | notebook: Immutable.fromJS({ 209 | blocks: { 210 | '0': { 211 | type: 'code', 212 | language: 'javascript', 213 | option: 'runnable', 214 | content: 'some bullshit;' 215 | } 216 | } 217 | }), 218 | execution: Immutable.fromJS({ 219 | data: {}, 220 | executionContext: {} 221 | }) 222 | }) 223 | 224 | const expected = [{ 225 | type: actions.CODE_RUNNING, 226 | id: '0' 227 | }, { 228 | type: actions.CODE_ERROR, 229 | id: '0', 230 | data: Error('SyntaxError: Unexpected identifier') 231 | }] 232 | 233 | return store.dispatch(actions.executeCodeBlock('0')) 234 | .then(() => { 235 | t.deepEqual(store.getActions(), expected) 236 | }) 237 | }) 238 | 239 | test('should pass context along for use with "this"', (t) => { 240 | const store = mockStore({ 241 | notebook: Immutable.fromJS({ 242 | blocks: { 243 | '0': { 244 | type: 'code', 245 | language: 'javascript', 246 | option: 'runnable', 247 | content: 'this.number = 100; return 5;' 248 | } 249 | } 250 | }), 251 | execution: Immutable.fromJS({ 252 | data: {}, 253 | executionContext: {} 254 | }) 255 | }) 256 | 257 | const expected = [{ 258 | type: actions.CODE_RUNNING, 259 | id: '0' 260 | }, { 261 | type: actions.CODE_EXECUTED, 262 | id: '0', 263 | context: Immutable.Map({number: 100}), 264 | data: 5 265 | }] 266 | 267 | return store.dispatch(actions.executeCodeBlock('0')) 268 | .then(() => { 269 | t.deepEqual(store.getActions(), expected) 270 | }) 271 | }) 272 | 273 | test('should make context contents available in code blocks', (t) => { 274 | const store = mockStore({ 275 | notebook: Immutable.fromJS({ 276 | blocks: { 277 | '0': { 278 | type: 'code', 279 | language: 'javascript', 280 | option: 'runnable', 281 | content: 'return this.number;' 282 | } 283 | } 284 | }), 285 | execution: Immutable.fromJS({ 286 | data: {}, 287 | executionContext: {number: 100} 288 | }) 289 | }) 290 | 291 | const expected = [{ 292 | type: actions.CODE_RUNNING, 293 | id: '0' 294 | }, { 295 | type: actions.CODE_EXECUTED, 296 | id: '0', 297 | context: Immutable.Map({number: 100}), 298 | data: 100 299 | }] 300 | 301 | return store.dispatch(actions.executeCodeBlock('0')) 302 | .then(() => { 303 | t.deepEqual(store.getActions(), expected) 304 | }) 305 | }) 306 | 307 | test('should resolve returned promises', (t) => { 308 | const store = mockStore({ 309 | notebook: Immutable.fromJS({ 310 | blocks: { 311 | '0': { 312 | type: 'code', 313 | language: 'javascript', 314 | option: 'runnable', 315 | content: 'return Promise.resolve(5);' 316 | } 317 | } 318 | }), 319 | execution: Immutable.fromJS({ 320 | data: {}, 321 | executionContext: {} 322 | }) 323 | }) 324 | 325 | const expected = [{ 326 | type: actions.CODE_RUNNING, 327 | id: '0' 328 | }, { 329 | type: actions.CODE_EXECUTED, 330 | id: '0', 331 | context: Immutable.Map(), 332 | data: 5 333 | }] 334 | 335 | return store.dispatch(actions.executeCodeBlock('0')) 336 | .then(() => { 337 | t.deepEqual(store.getActions(), expected) 338 | }) 339 | }) 340 | 341 | test('should auto execute auto and hidden code blocks', (t) => { 342 | const store = mockStore({ 343 | notebook: Immutable.fromJS({ 344 | blocks: { 345 | '0': { 346 | type: 'code', 347 | language: 'javascript', 348 | option: 'auto', 349 | content: 'return Promise.resolve(5);' 350 | }, 351 | '1': { 352 | type: 'code', 353 | language: 'javascript', 354 | option: 'runnable', 355 | content: 'return 10;' 356 | }, 357 | '2': { 358 | type: 'code', 359 | language: 'javascript', 360 | option: 'hidden', 361 | content: 'return 15;' 362 | } 363 | }, 364 | content: ['0', '1', '2'] 365 | }), 366 | execution: Immutable.fromJS({ 367 | data: {}, 368 | executionContext: {} 369 | }) 370 | }) 371 | 372 | const expected = [{ 373 | type: actions.CODE_RUNNING, 374 | id: '0' 375 | }, { 376 | type: actions.CODE_EXECUTED, 377 | id: '0', 378 | context: Immutable.Map(), 379 | data: 5 380 | }, { 381 | type: actions.CODE_RUNNING, 382 | id: '2' 383 | }, { 384 | type: actions.CODE_EXECUTED, 385 | id: '2', 386 | context: Immutable.Map(), 387 | data: 15 388 | }] 389 | 390 | return store.dispatch(actions.executeAuto()) 391 | .then(() => { 392 | t.deepEqual(store.getActions(), expected) 393 | }) 394 | }) 395 | 396 | test('should create an action for toggling the editor', (t) => { 397 | const expected = { 398 | type: actions.TOGGLE_EDIT 399 | } 400 | t.deepEqual(actions.toggleEdit(), expected) 401 | }) 402 | 403 | test('should create an action for updating a block', (t) => { 404 | const id = '12' 405 | const text = '## some markdown' 406 | const expected = { 407 | type: actions.UPDATE_BLOCK, 408 | id, 409 | text 410 | } 411 | t.deepEqual(actions.updateBlock(id, text), expected) 412 | }) 413 | 414 | test('should create an action for adding a new text block', (t) => { 415 | const id = '12' 416 | const expected = { 417 | type: actions.ADD_BLOCK, 418 | blockType: 'text', 419 | id 420 | } 421 | t.deepEqual(actions.addTextBlock(id), expected) 422 | }) 423 | 424 | test('should create an action for adding a new code block', (t) => { 425 | const id = '12' 426 | const expected = { 427 | type: actions.ADD_BLOCK, 428 | blockType: 'code', 429 | id 430 | } 431 | t.deepEqual(actions.addCodeBlock(id), expected) 432 | }) 433 | 434 | test('should create an action for deleting a block', (t) => { 435 | const id = '12' 436 | const expected = { 437 | type: actions.DELETE_BLOCK, 438 | id 439 | } 440 | t.deepEqual(actions.deleteBlock(id), expected) 441 | }) 442 | 443 | test('should create an action for updating the title', (t) => { 444 | const text = 'New title' 445 | const expected = { 446 | type: actions.UPDATE_META, 447 | field: 'title', 448 | text 449 | } 450 | t.deepEqual(actions.updateTitle(text), expected) 451 | }) 452 | 453 | test('should create an action for updating the author', (t) => { 454 | const text = 'New author' 455 | const expected = { 456 | type: actions.UPDATE_META, 457 | field: 'author', 458 | text 459 | } 460 | t.deepEqual(actions.updateAuthor(text), expected) 461 | }) 462 | 463 | test('should create an action for toggling the footer', (t) => { 464 | const expected = { 465 | type: actions.TOGGLE_META, 466 | field: 'showFooter' 467 | } 468 | t.deepEqual(actions.toggleFooter(), expected) 469 | }) 470 | 471 | test('should create an action for moving a block', (t) => { 472 | const id = '12' 473 | const nextIndex = 3 474 | const expected = { 475 | type: actions.MOVE_BLOCK, 476 | id, 477 | nextIndex 478 | } 479 | t.deepEqual(actions.moveBlock(id, nextIndex), expected) 480 | }) 481 | 482 | test('should create an action for updating a datasource', (t) => { 483 | const name = 'github' 484 | const url = 'http://github.com' 485 | const expected = { 486 | type: actions.UPDATE_DATASOURCE, 487 | id: name, 488 | text: url 489 | } 490 | t.deepEqual(actions.updateDatasource(name, url), expected) 491 | }) 492 | 493 | test('should create an action for deleting a datasource', (t) => { 494 | const name = 'github' 495 | const expected = { 496 | type: actions.DELETE_DATASOURCE, 497 | id: name 498 | } 499 | t.deepEqual(actions.deleteDatasource(name), expected) 500 | }) 501 | 502 | test('should create an action to toggle the save form', (t) => { 503 | const expected = { 504 | type: actions.TOGGLE_SAVE 505 | } 506 | t.deepEqual(actions.toggleSave(), expected) 507 | }) 508 | 509 | test('should create an action for undo', (t) => { 510 | const expected = { 511 | type: actions.UNDO 512 | } 513 | t.deepEqual(actions.undo(), expected) 514 | }) 515 | 516 | test('should create an action for changing code block option', (t) => { 517 | const expected = { 518 | type: actions.CHANGE_CODE_BLOCK_OPTION, 519 | id: 'testId', 520 | option: undefined 521 | } 522 | t.deepEqual(actions.changeCodeBlockOption('testId'), expected) 523 | }) 524 | 525 | test('should create an action for changing code block option with a specified option', (t) => { 526 | const expected = { 527 | type: actions.CHANGE_CODE_BLOCK_OPTION, 528 | id: 'testId', 529 | option: 'runnable' 530 | } 531 | t.deepEqual(actions.changeCodeBlockOption('testId', 'runnable'), expected) 532 | }) 533 | 534 | test('should create an action for creating a graph block', (t) => { 535 | const expd = { 536 | type: actions.ADD_BLOCK, 537 | blockType: 'graph', 538 | id: '12' 539 | } 540 | t.deepEqual(actions.addGraphBlock('12'), expd) 541 | }) 542 | 543 | test('should create an action for changing graph type', (t) => { 544 | const expd = { 545 | type: actions.UPDATE_GRAPH_BLOCK_PROPERTY, 546 | property: 'graphType', 547 | value: 'pieChart', 548 | id: '12' 549 | } 550 | t.deepEqual(actions.updateGraphType('12', 'pieChart'), expd) 551 | }) 552 | 553 | test('should create an action for updating graph block data path', (t) => { 554 | const expd = { 555 | type: actions.UPDATE_GRAPH_BLOCK_PROPERTY, 556 | property: 'dataPath', 557 | value: 'data.popular', 558 | id: '12' 559 | } 560 | t.deepEqual(actions.updateGraphDataPath('12', 'data.popular'), expd) 561 | }) 562 | 563 | test('should create an action for updating graph block hint', (t) => { 564 | const expd = { 565 | type: actions.UPDATE_GRAPH_BLOCK_HINT, 566 | hint: 'label', 567 | value: 'name', 568 | id: '12' 569 | } 570 | t.deepEqual(actions.updateGraphHint('12', 'label', 'name'), expd) 571 | }) 572 | 573 | test('should create an action for updating graph block label', (t) => { 574 | const expd = { 575 | type: actions.UPDATE_GRAPH_BLOCK_LABEL, 576 | label: 'x', 577 | value: 'Repos', 578 | id: '12' 579 | } 580 | t.deepEqual(actions.updateGraphLabel('12', 'x', 'Repos'), expd) 581 | }) 582 | 583 | test('should create an action for saving graph block to code', (t) => { 584 | const expd = { 585 | type: actions.UPDATE_GRAPH_BLOCK_PROPERTY, 586 | property: 'type', 587 | value: 'code', 588 | id: '12' 589 | } 590 | t.deepEqual(actions.compileGraphBlock('12'), expd) 591 | }) 592 | 593 | test('should create an action for clearing graph data', (t) => { 594 | const expd = { 595 | type: actions.CLEAR_GRAPH_BLOCK_DATA, 596 | id: '12' 597 | } 598 | t.deepEqual(actions.clearGraphData('12'), expd) 599 | }) 600 | 601 | test('should create an action for editing a block', (t) => { 602 | const expd = { 603 | type: actions.EDIT_BLOCK, 604 | id: '12' 605 | } 606 | t.deepEqual(actions.editBlock('12'), expd) 607 | }) 608 | -------------------------------------------------------------------------------- /tests/editorReducer.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import Immutable from 'immutable' 4 | import requireMock from 'mock-require' 5 | 6 | requireMock('electron', { 7 | remote: { 8 | dialog: { 9 | showOpenDialog (_, callback) { 10 | callback() 11 | } 12 | } 13 | } 14 | }) 15 | 16 | const reducer = require('../src/js/reducers/editorReducer').default 17 | const actions = require('../src/js/actions') 18 | 19 | test('should do nothing for an unhandled action type', (t) => { 20 | t.deepEqual(reducer(Immutable.Map({editable: false}), {type: 'FAKE_ACTION'}), 21 | Immutable.Map({editable: false})) 22 | }) 23 | 24 | test('should toggle editor state for TOGGLE_EDIT', (t) => { 25 | t.deepEqual( 26 | reducer(Immutable.Map({editable: false}), {type: actions.TOGGLE_EDIT}).toJS(), 27 | {editable: true}) 28 | t.deepEqual( 29 | reducer(Immutable.Map({editable: true}), {type: actions.TOGGLE_EDIT}).toJS(), 30 | {editable: false}) 31 | }) 32 | 33 | test('should return the inital state', (t) => { 34 | t.deepEqual(reducer(), Immutable.Map({ 35 | editable: false, 36 | saving: false, 37 | activeBlock: null, 38 | unsavedChanges: false 39 | })) 40 | }) 41 | 42 | test('should toggle save state for TOGGLE_SAVE', (t) => { 43 | t.deepEqual(reducer(Immutable.Map({saving: false}), {type: actions.TOGGLE_SAVE}).toJS(), 44 | {saving: true}) 45 | t.deepEqual(reducer(Immutable.Map({saving: true}), {type: actions.TOGGLE_SAVE}).toJS(), 46 | {saving: false}) 47 | }) 48 | 49 | test('should set the editing block on EDIT_BLOCK', (t) => { 50 | t.deepEqual(reducer(Immutable.Map({activeBlock: null}), {type: actions.EDIT_BLOCK, id: '12'}).toJS(), 51 | {activeBlock: '12'}) 52 | }) 53 | -------------------------------------------------------------------------------- /tests/executionReducer.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import Immutable from 'immutable' 4 | import requireMock from 'mock-require' 5 | 6 | requireMock('electron', { 7 | remote: { 8 | dialog: { 9 | showOpenDialog (_, callback) { 10 | callback() 11 | } 12 | } 13 | } 14 | }) 15 | 16 | const reducer = require('../src/js/reducers/executionReducer').default 17 | const initialState = require('../src/js/reducers/executionReducer').initialState 18 | const actions = require('../src/js/actions') 19 | 20 | test('should return the initial state', (t) => { 21 | t.is(reducer(), initialState) 22 | }) 23 | 24 | test('should reset the execution state when loading another file', (t) => { 25 | const beforeState = initialState 26 | .setIn(['results', '12'], 120) 27 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12')) 28 | t.is(reducer(beforeState, {type: actions.LOAD_MARKDOWN}), initialState) 29 | }) 30 | 31 | test('should update the data on received data', (t) => { 32 | const action = { 33 | type: actions.RECEIVED_DATA, 34 | name: 'github', 35 | data: {repos: 12} 36 | } 37 | const newState = initialState.setIn(['data', 'github', 'repos'], 12) 38 | t.deepEqual(reducer(initialState, action).toJS(), newState.toJS()) 39 | }) 40 | 41 | test('should clear block results and executed state on update', (t) => { 42 | const action = { 43 | type: actions.UPDATE_BLOCK, 44 | id: '12' 45 | } 46 | const beforeState = initialState 47 | .setIn(['results', '12'], 120) 48 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12')) 49 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS()) 50 | }) 51 | 52 | test('should clear block results and executed state on update', (t) => { 53 | const action = { 54 | type: actions.DELETE_BLOCK, 55 | id: '12' 56 | } 57 | const beforeState = initialState 58 | .setIn(['results', '12'], 120) 59 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12')) 60 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS()) 61 | }) 62 | 63 | test('should clear datasource data when the datasource is deleted', (t) => { 64 | const action = { 65 | type: actions.DELETE_DATASOURCE, 66 | id: 'github' 67 | } 68 | const beforeState = initialState.setIn(['data', 'github', 'repos'], 12) 69 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS()) 70 | }) 71 | 72 | test('should clear datasource data when the datasource is updated', (t) => { 73 | const action = { 74 | type: actions.UPDATE_DATASOURCE, 75 | id: 'github' 76 | } 77 | const beforeState = initialState.setIn(['data', 'github', 'repos'], 12) 78 | t.deepEqual(reducer(beforeState, action).toJS(), initialState.toJS()) 79 | }) 80 | 81 | test('should update result, executed and context on CODE_EXECUTED', (t) => { 82 | const action = { 83 | type: actions.CODE_EXECUTED, 84 | id: '99', 85 | data: 3, 86 | context: Immutable.Map({number: 10}) 87 | } 88 | const expected = initialState.setIn(['results', '99'], 3) 89 | .set('blocksExecuted', initialState.get('blocksExecuted').add('99')) 90 | .set('executionContext', Immutable.Map({number: 10})) 91 | t.deepEqual(reducer(initialState, action).toJS(), expected.toJS()) 92 | }) 93 | 94 | test('should update result and executed on CODE_ERROR', (t) => { 95 | const action = { 96 | type: actions.CODE_ERROR, 97 | id: '99', 98 | data: 'Some error' 99 | } 100 | const expected = initialState.setIn(['results', '99'], 'Some error') 101 | .set('blocksExecuted', initialState.get('blocksExecuted').add('99')) 102 | t.deepEqual(reducer(initialState, action).toJS(), expected.toJS()) 103 | }) 104 | -------------------------------------------------------------------------------- /tests/fixtures/extractCodeBlocks.md: -------------------------------------------------------------------------------- 1 | ### Some markdown 2 | 3 | The below is a code sample 4 | 5 | ```javascript;hidden 6 | console.log("Hello!"); 7 | ``` 8 | 9 | This is a non-js sample. 10 | 11 | ``` 12 | print "Non-js block" 13 | ``` 14 | 15 | This is a JS sample with no attrs 16 | 17 | ```javascript 18 | return 1 + 1; 19 | ``` 20 | 21 | Done! 22 | -------------------------------------------------------------------------------- /tests/fixtures/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta name="viewport" content="width=device-width, initial-scale=1"> 5 | <meta http-equiv="content-type" content="text/html; charset=UTF8"> 6 | <link rel="stylesheet" href="dist/main.css"> 7 | </head> 8 | <body> 9 | <script type="text/markdown" id="kayero-md"> 10 | --- 11 | title: "Sample Kayero notebook" 12 | author: "Joel Auterson" 13 | datasources: 14 | joelotter: "https://api.github.com/users/joelotter/repos" 15 | popular: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc" 16 | extra: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc&per_page=100" 17 | original: 18 | title: "Blank Kayero notebook" 19 | url: "http://www.joelotter.com/kayero/blank" 20 | show_footer: true 21 | --- 22 | 23 | This is an example of a notebook written in **Kayero**. Hopefully it'll give you a bit of insight into what Kayero is, and what you might be able to use it for! 24 | 25 | Kayero is designed to make it really easy for anyone to create good-looking, responsive, data-rich documents. They're totally viewable in a web browser - no backend necessary beyond what's needed to host the page - and can contain interactive code samples, written in JavaScript. 26 | 27 | They've also got some nice graphing and data visualisation capabilities - we'll look at that in a bit. 28 | 29 | (If you were wondering, _'kayero'_ is the Esperanto word for _'notebook'_.) 30 | 31 | Let's have a look at some features. 32 | 33 | ## It's just Markdown 34 | 35 | Go ahead and take a look at the [page source](view-source:http://www.joelotter.com/kayero). You'll notice that the notebook is really just a Markdown document with some bookending HTML tags - it's the Kayero script that does all the work of rendering. You've got all the usability of Markdown to play with. 36 | 37 | - You 38 | - can 39 | - make 40 | - lists! 41 | 42 | \[ Escapes work \] 43 | 44 | There is also inline code, like `console.log('thing')`. 45 | 46 | <i>HTML is allowed, as per the Markdown spec.</i> 47 | 48 | <pre style="width: 50%;"><code>print "You can use inline HTML to get styling, which is neat.</code></pre> 49 | 50 | ```python 51 | print "You can have code, with syntax highlighting." 52 | print "This is Python - it can't be run in the browser." 53 | ``` 54 | 55 | ```javascript; runnable 56 | return "Javascript can be, though. Click play!"; 57 | ``` 58 | 59 | Because it's just Markdown, and all the work is done by a script in the browser, it's really easy for users to create new notebooks from scratch. But you might not want to create from scratch, so... 60 | 61 | ## Every notebook is editable 62 | 63 | You might have noticed the little pencil icon in the top-right. Give it a poke! You'll find yourself in the editor interface. Every single Kayero notebook is fully editable right in the browser. Once you've made your changes, you can export it as a new HTML or Markdown document. 64 | 65 | The notebooks also contain a link to their parent page. It's in the footer of this page if you want to have a look! If users don't want this footer, it can be turned off in the editor. 66 | 67 | This is all very well, but the notebooks are supposed to be _interactive_. 68 | 69 | ## Running code 70 | 71 | Authors need to be able to put code samples in their documents. If these samples are in JavaScript, they can be run by the users. Here's a very simple example, which squares and sums the numbers up to 10. 72 | 73 | ```javascript; runnable 74 | var result = 0; 75 | for (var i = 1; i <= 10; i++) { 76 | result += i * i; 77 | } 78 | return result; 79 | ``` 80 | 81 | Code samples are written (and run) as functions, and the function's returned value is displayed to the user in the box below the code. What if we want to share information between code samples, though? 82 | 83 | In Kayero, the keyword **this** refers to the global context, which is passed around between samples. We can assign something onto the context, and then access it in another sample. 84 | 85 | ```javascript; runnable 86 | this.number = 100; 87 | return this.number; 88 | ``` 89 | 90 | We can now sum the squares of numbers up to the number we defined in the previous code block. 91 | 92 | ```javascript; runnable 93 | var result = 0; 94 | for (var i = 10; i <= this.number; i++) { 95 | result += i * i; 96 | } 97 | return result; 98 | ``` 99 | 100 | ```javascript; runnable 101 | this.number *= 2; 102 | return this.number; 103 | ``` 104 | 105 | Try playing around with running these code samples in different orders, to see how the results change. 106 | 107 | ## Working with data 108 | 109 | If you had a look in the editor, you'll have noticed that users can define _data sources_ - these are URLs of public JSON data. This data is automatically fetched, and put into the **data** object, which is made available in code samples. 110 | 111 | The **joelotter** data source is my GitHub repository information. Let's get the names of my repositories. 112 | 113 | ```javascript; runnable 114 | return data.joelotter.map(function(repo) { 115 | return repo.name; 116 | }); 117 | ``` 118 | 119 | You'll notice that Kayero can visualise whatever data you throw at it - it's not just strings and numbers! Here's the whole of my repository data to demonstrate. 120 | 121 | ```javascript; runnable 122 | return data.joelotter; 123 | ``` 124 | 125 | This isn't necessarily the most attractive or user-friendly way to look at data, though. 126 | 127 | ## Graphs 128 | 129 | Kayero gives users access to [d3](https://d3js.org/), the web's favourite graphing library. 130 | 131 | ```javascript; runnable 132 | // Remove any old SVGs for re-running 133 | d3.select(graphElement).selectAll('*').remove(); 134 | 135 | var sampleSVG = d3.select(graphElement) 136 | .append("svg") 137 | .attr("width", 100) 138 | .attr("height", 100); 139 | 140 | sampleSVG.append("circle") 141 | .style("stroke", "gray") 142 | .style("fill", "white") 143 | .attr("r", 40) 144 | .attr("cx", 50) 145 | .attr("cy", 50) 146 | .on("mouseover", function(){d3.select(this).style("fill", "aliceblue");}) 147 | .on("mouseout", function(){d3.select(this).style("fill", "white");}) 148 | .on("mousedown", animateFirstStep); 149 | 150 | function animateFirstStep(){ 151 | d3.select(this) 152 | .transition() 153 | .delay(0) 154 | .duration(1000) 155 | .attr("r", 10) 156 | .each("end", animateSecondStep); 157 | }; 158 | 159 | function animateSecondStep(){ 160 | d3.select(this) 161 | .transition() 162 | .duration(1000) 163 | .attr("r", 40); 164 | }; 165 | 166 | return "Try clicking the circle!"; 167 | ``` 168 | 169 | Users get access to **d3**, which is the library itself, and **graphElement**, which is a reference to the element where the graph is drawn. 170 | 171 | d3 is incredibly powerful, but may be too complex for many users. To help out with this, Kayero also includes [NVD3](http://nvd3.org/), which provides some nice pre-built graphs for d3. The code below generates a random scatter graph - try it! 172 | 173 | ```javascript; runnable 174 | d3.select(graphElement).selectAll('*').remove(); 175 | d3.select(graphElement).append('svg').attr("width", "100%"); 176 | 177 | nv.addGraph(function() { 178 | var chart = nv.models.scatter() 179 | .margin({top: 20, right: 20, bottom: 20, left: 20}) 180 | .pointSize(function(d) { return d.z }) 181 | .useVoronoi(false); 182 | d3.select(graphElement).selectAll("svg") 183 | .datum(randomData()) 184 | .transition().duration(500) 185 | .call(chart); 186 | nv.utils.windowResize(chart.update); 187 | return chart; 188 | }); 189 | 190 | function randomData() { 191 | var data = []; 192 | for (i = 0; i < 2; i++) { 193 | data.push({ 194 | key: 'Group ' + i, 195 | values: [] 196 | }); 197 | for (j = 0; j < 100; j++) { 198 | data[i].values.push({x: Math.random(), y: Math.random(), z: Math.random()}); 199 | } 200 | } 201 | return data; 202 | } 203 | return "Try clicking the rerun button!"; 204 | ``` 205 | 206 | This is useful too, but what about those users with little-to-no coding experience? 207 | 208 | ## Jutsu 209 | 210 | Kayero includes Jutsu, a very simple graphing library built with support for [Smolder](https://www.github.com/JoelOtter/smolder). 211 | 212 | Smolder is a 'type system' (not really, but I'm not sure what to call it) for JavaScript, which will attempt to automatically restructure arbitrary data to fit a provided schema for a function. The actual reshaping is done by a library called, predictably, [Reshaper](https://www.github.com/JoelOtter/reshaper). 213 | 214 | From a user's perspective, the details don't really matter. Let's use Jutsu (available in Kayero code samples as **graphs**) to create a pie chart, based on the most popular GitHub repositories of 2016. 215 | 216 | ```javascript; runnable 217 | // Here's what the 'popular' data looks like before it's reshaped. 218 | return data.popular; 219 | ``` 220 | 221 | ```javascript; runnable 222 | // The graph functions return the reshaped data, so we can see 223 | // what's going on. 224 | return graphs.pieChart(data.popular); 225 | ``` 226 | 227 | It's worked! Smolder knows that a pie chart needs labels and numerical values, so it's reshaped the data to get these. 228 | 229 | However, it's picked the first number it could find for the value, which in this case looks to be the repo IDs. This isn't really useful for a pie chart! We'd rather look at something like the number of stargazers. We can pass in a 'hint', to tell Jutsu which value we care about. 230 | 231 | ```javascript; runnable 232 | return graphs.pieChart(data.popular, 'stargazers_count'); 233 | ``` 234 | 235 | We can give multiple hints. Let's say we want to use the name of the repository. 236 | 237 | ```javascript; runnable 238 | return graphs.pieChart(data.popular, ['name', 'stargazers_count']); 239 | ``` 240 | 241 | Good, that's a bit more readable. 242 | 243 | It's kind of hard to compare the stargazers counts in a pie chart - they're all relatively similar. Let's try a bar chart instead. 244 | 245 | ```javascript; runnable 246 | return graphs.barChart(data.popular, 'Repo', 'Stargazers', ['name', 'stargazers_count']); 247 | ``` 248 | 249 | This is a bit more useful. We can put labels on the axes too, to make sure the graph is easy to understand. 250 | 251 | The idea is that it should be possible to use Kayero to investigate and write about trends in data. Let's conduct a toy investigation of our own - is there any relation between a repository's star count and the number of open issues it has? 252 | 253 | Let's try a line graph. 254 | 255 | ```javascript; runnable 256 | return graphs.lineChart( 257 | data.popular.items, 'Open Issues', 'Stargazers', 258 | ['open_issues', 'stargazers_count', 'name'] 259 | ); 260 | ``` 261 | 262 | The extra hint, _name_, is used to provide labels for the data points. All the graphs are interactive - try mousing over them. 263 | 264 | It's pretty easy to see which repository has the most open issues (for me it's chakra-core; it might have changed by the time you read this!) and which has the most stargazers. However, it's hard to see a trend here. 265 | 266 | A much better graph for investigating correlation is a scatter plot. 267 | 268 | ```javascript; runnable 269 | return graphs.scatterPlot( 270 | data.popular.items, 'Open Issues', 'Stargazers', 271 | ['open_issues', 'stargazers_count', 'name'] 272 | ); 273 | ``` 274 | 275 | There might be a trend there, but it's hard to see. Maybe we need more data. 276 | 277 | The GitHub API lets us request up to 100 results per page, with a default of 30. While the **popular** data source just uses the default, I've also included **extra**, which has 100. Let's try our scatter plot with 100 data points! 278 | 279 | ```javascript; runnable 280 | return graphs.scatterPlot( 281 | data.extra.items, 'Open Issues', 'Stargazers', 282 | ['open_issues', 'stargazers_count', 'name'] 283 | ); 284 | ``` 285 | 286 | This is a little better. We can see there might be a slight positive correlation, though there are a lot of outliers. 287 | 288 | ## What's next? 289 | 290 | Hopefully this notebook has given you a decent explanation of what Kayero is for. Here are the next things needing done: 291 | 292 | - Exporting the notebook 293 | - Making Reshaper smarter 294 | - More graphs 295 | - Exporting to Gist (if there's time!) 296 | 297 | Why not try making your own notebook? This one is forked from a [blank notebook](http://www.joelotter.com/kayero/blank) - have a play with the editor! 298 | </script> 299 | <div id="kayero"></div> 300 | <script type="text/javascript" src="dist/bundle.js"></script> 301 | </body> 302 | </html> 303 | -------------------------------------------------------------------------------- /tests/fixtures/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Sample Kayero notebook" 3 | author: "Joel Auterson" 4 | datasources: 5 | joelotter: "https://api.github.com/users/joelotter/repos" 6 | popular: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc" 7 | extra: "https://api.github.com/search/repositories?q=created:>2016-01-01&sort=stars&order=desc&per_page=100" 8 | original: 9 | title: "Blank Kayero notebook" 10 | url: "http://www.joelotter.com/kayero/blank" 11 | show_footer: true 12 | --- 13 | 14 | This is an example of a notebook written in **Kayero**. Hopefully it'll give you a bit of insight into what Kayero is, and what you might be able to use it for! 15 | 16 | Kayero is designed to make it really easy for anyone to create good-looking, responsive, data-rich documents. They're totally viewable in a web browser - no backend necessary beyond what's needed to host the page - and can contain interactive code samples, written in JavaScript. 17 | 18 | They've also got some nice graphing and data visualisation capabilities - we'll look at that in a bit. 19 | 20 | (If you were wondering, _'kayero'_ is the Esperanto word for _'notebook'_.) 21 | 22 | Let's have a look at some features. 23 | 24 | ## It's just Markdown 25 | 26 | Go ahead and take a look at the [page source](view-source:http://www.joelotter.com/kayero). You'll notice that the notebook is really just a Markdown document with some bookending HTML tags - it's the Kayero script that does all the work of rendering. You've got all the usability of Markdown to play with. 27 | 28 | - You 29 | - can 30 | - make 31 | - lists! 32 | 33 | \[ Escapes work \] 34 | 35 | There is also inline code, like `console.log('thing')`. 36 | 37 | <i>HTML is allowed, as per the Markdown spec.</i> 38 | 39 | <pre style="width: 50%;"><code>print "You can use inline HTML to get styling, which is neat.</code></pre> 40 | 41 | ```python 42 | print "You can have code, with syntax highlighting." 43 | print "This is Python - it can't be run in the browser." 44 | ``` 45 | 46 | ```javascript; runnable 47 | return "Javascript can be, though. Click play!"; 48 | ``` 49 | 50 | Because it's just Markdown, and all the work is done by a script in the browser, it's really easy for users to create new notebooks from scratch. But you might not want to create from scratch, so... 51 | 52 | ## Every notebook is editable 53 | 54 | You might have noticed the little pencil icon in the top-right. Give it a poke! You'll find yourself in the editor interface. Every single Kayero notebook is fully editable right in the browser. Once you've made your changes, you can export it as a new HTML or Markdown document. 55 | 56 | The notebooks also contain a link to their parent page. It's in the footer of this page if you want to have a look! If users don't want this footer, it can be turned off in the editor. 57 | 58 | This is all very well, but the notebooks are supposed to be _interactive_. 59 | 60 | ## Running code 61 | 62 | Authors need to be able to put code samples in their documents. If these samples are in JavaScript, they can be run by the users. Here's a very simple example, which squares and sums the numbers up to 10. 63 | 64 | ```javascript; runnable 65 | var result = 0; 66 | for (var i = 1; i <= 10; i++) { 67 | result += i * i; 68 | } 69 | return result; 70 | ``` 71 | 72 | Code samples are written (and run) as functions, and the function's returned value is displayed to the user in the box below the code. What if we want to share information between code samples, though? 73 | 74 | In Kayero, the keyword **this** refers to the global context, which is passed around between samples. We can assign something onto the context, and then access it in another sample. 75 | 76 | ```javascript; runnable 77 | this.number = 100; 78 | return this.number; 79 | ``` 80 | 81 | We can now sum the squares of numbers up to the number we defined in the previous code block. 82 | 83 | ```javascript; runnable 84 | var result = 0; 85 | for (var i = 10; i <= this.number; i++) { 86 | result += i * i; 87 | } 88 | return result; 89 | ``` 90 | 91 | ```javascript; runnable 92 | this.number *= 2; 93 | return this.number; 94 | ``` 95 | 96 | Try playing around with running these code samples in different orders, to see how the results change. 97 | 98 | ## Working with data 99 | 100 | If you had a look in the editor, you'll have noticed that users can define _data sources_ - these are URLs of public JSON data. This data is automatically fetched, and put into the **data** object, which is made available in code samples. 101 | 102 | The **joelotter** data source is my GitHub repository information. Let's get the names of my repositories. 103 | 104 | ```javascript; runnable 105 | return data.joelotter.map(function(repo) { 106 | return repo.name; 107 | }); 108 | ``` 109 | 110 | You'll notice that Kayero can visualise whatever data you throw at it - it's not just strings and numbers! Here's the whole of my repository data to demonstrate. 111 | 112 | ```javascript; runnable 113 | return data.joelotter; 114 | ``` 115 | 116 | This isn't necessarily the most attractive or user-friendly way to look at data, though. 117 | 118 | ## Graphs 119 | 120 | Kayero gives users access to [d3](https://d3js.org/), the web's favourite graphing library. 121 | 122 | ```javascript; runnable 123 | // Remove any old SVGs for re-running 124 | d3.select(graphElement).selectAll('*').remove(); 125 | 126 | var sampleSVG = d3.select(graphElement) 127 | .append("svg") 128 | .attr("width", 100) 129 | .attr("height", 100); 130 | 131 | sampleSVG.append("circle") 132 | .style("stroke", "gray") 133 | .style("fill", "white") 134 | .attr("r", 40) 135 | .attr("cx", 50) 136 | .attr("cy", 50) 137 | .on("mouseover", function(){d3.select(this).style("fill", "aliceblue");}) 138 | .on("mouseout", function(){d3.select(this).style("fill", "white");}) 139 | .on("mousedown", animateFirstStep); 140 | 141 | function animateFirstStep(){ 142 | d3.select(this) 143 | .transition() 144 | .delay(0) 145 | .duration(1000) 146 | .attr("r", 10) 147 | .each("end", animateSecondStep); 148 | }; 149 | 150 | function animateSecondStep(){ 151 | d3.select(this) 152 | .transition() 153 | .duration(1000) 154 | .attr("r", 40); 155 | }; 156 | 157 | return "Try clicking the circle!"; 158 | ``` 159 | 160 | Users get access to **d3**, which is the library itself, and **graphElement**, which is a reference to the element where the graph is drawn. 161 | 162 | d3 is incredibly powerful, but may be too complex for many users. To help out with this, Kayero also includes [NVD3](http://nvd3.org/), which provides some nice pre-built graphs for d3. The code below generates a random scatter graph - try it! 163 | 164 | ```javascript; runnable 165 | d3.select(graphElement).selectAll('*').remove(); 166 | d3.select(graphElement).append('svg').attr("width", "100%"); 167 | 168 | nv.addGraph(function() { 169 | var chart = nv.models.scatter() 170 | .margin({top: 20, right: 20, bottom: 20, left: 20}) 171 | .pointSize(function(d) { return d.z }) 172 | .useVoronoi(false); 173 | d3.select(graphElement).selectAll("svg") 174 | .datum(randomData()) 175 | .transition().duration(500) 176 | .call(chart); 177 | nv.utils.windowResize(chart.update); 178 | return chart; 179 | }); 180 | 181 | function randomData() { 182 | var data = []; 183 | for (i = 0; i < 2; i++) { 184 | data.push({ 185 | key: 'Group ' + i, 186 | values: [] 187 | }); 188 | for (j = 0; j < 100; j++) { 189 | data[i].values.push({x: Math.random(), y: Math.random(), z: Math.random()}); 190 | } 191 | } 192 | return data; 193 | } 194 | return "Try clicking the rerun button!"; 195 | ``` 196 | 197 | This is useful too, but what about those users with little-to-no coding experience? 198 | 199 | ## Jutsu 200 | 201 | Kayero includes Jutsu, a very simple graphing library built with support for [Smolder](https://www.github.com/JoelOtter/smolder). 202 | 203 | Smolder is a 'type system' (not really, but I'm not sure what to call it) for JavaScript, which will attempt to automatically restructure arbitrary data to fit a provided schema for a function. The actual reshaping is done by a library called, predictably, [Reshaper](https://www.github.com/JoelOtter/reshaper). 204 | 205 | From a user's perspective, the details don't really matter. Let's use Jutsu (available in Kayero code samples as **graphs**) to create a pie chart, based on the most popular GitHub repositories of 2016. 206 | 207 | ```javascript; runnable 208 | // Here's what the 'popular' data looks like before it's reshaped. 209 | return data.popular; 210 | ``` 211 | 212 | ```javascript; runnable 213 | // The graph functions return the reshaped data, so we can see 214 | // what's going on. 215 | return graphs.pieChart(data.popular); 216 | ``` 217 | 218 | It's worked! Smolder knows that a pie chart needs labels and numerical values, so it's reshaped the data to get these. 219 | 220 | However, it's picked the first number it could find for the value, which in this case looks to be the repo IDs. This isn't really useful for a pie chart! We'd rather look at something like the number of stargazers. We can pass in a 'hint', to tell Jutsu which value we care about. 221 | 222 | ```javascript; runnable 223 | return graphs.pieChart(data.popular, 'stargazers_count'); 224 | ``` 225 | 226 | We can give multiple hints. Let's say we want to use the name of the repository. 227 | 228 | ```javascript; runnable 229 | return graphs.pieChart(data.popular, ['name', 'stargazers_count']); 230 | ``` 231 | 232 | Good, that's a bit more readable. 233 | 234 | It's kind of hard to compare the stargazers counts in a pie chart - they're all relatively similar. Let's try a bar chart instead. 235 | 236 | ```javascript; runnable 237 | return graphs.barChart(data.popular, 'Repo', 'Stargazers', ['name', 'stargazers_count']); 238 | ``` 239 | 240 | This is a bit more useful. We can put labels on the axes too, to make sure the graph is easy to understand. 241 | 242 | The idea is that it should be possible to use Kayero to investigate and write about trends in data. Let's conduct a toy investigation of our own - is there any relation between a repository's star count and the number of open issues it has? 243 | 244 | Let's try a line graph. 245 | 246 | ```javascript; runnable 247 | return graphs.lineChart( 248 | data.popular.items, 'Open Issues', 'Stargazers', 249 | ['open_issues', 'stargazers_count', 'name'] 250 | ); 251 | ``` 252 | 253 | The extra hint, _name_, is used to provide labels for the data points. All the graphs are interactive - try mousing over them. 254 | 255 | It's pretty easy to see which repository has the most open issues (for me it's chakra-core; it might have changed by the time you read this!) and which has the most stargazers. However, it's hard to see a trend here. 256 | 257 | A much better graph for investigating correlation is a scatter plot. 258 | 259 | ```javascript; runnable 260 | return graphs.scatterPlot( 261 | data.popular.items, 'Open Issues', 'Stargazers', 262 | ['open_issues', 'stargazers_count', 'name'] 263 | ); 264 | ``` 265 | 266 | There might be a trend there, but it's hard to see. Maybe we need more data. 267 | 268 | The GitHub API lets us request up to 100 results per page, with a default of 30. While the **popular** data source just uses the default, I've also included **extra**, which has 100. Let's try our scatter plot with 100 data points! 269 | 270 | ```javascript; runnable 271 | return graphs.scatterPlot( 272 | data.extra.items, 'Open Issues', 'Stargazers', 273 | ['open_issues', 'stargazers_count', 'name'] 274 | ); 275 | ``` 276 | 277 | This is a little better. We can see there might be a slight positive correlation, though there are a lot of outliers. 278 | 279 | ## What's next? 280 | 281 | Hopefully this notebook has given you a decent explanation of what Kayero is for. Here are the next things needing done: 282 | 283 | - Exporting the notebook 284 | - Making Reshaper smarter 285 | - More graphs 286 | - Exporting to Gist (if there's time!) 287 | 288 | Why not try making your own notebook? This one is forked from a [blank notebook](http://www.joelotter.com/kayero/blank) - have a play with the editor! 289 | -------------------------------------------------------------------------------- /tests/fixtures/sampleNotebook.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "A sample notebook" 3 | author: "Joel Auterson" 4 | show_footer: true 5 | --- 6 | 7 | ## This is a sample Notebook 8 | 9 | It _should_ get correctly parsed. 10 | 11 | [This is a link](http://github.com) 12 | 13 | ![Image, with alt](https://github.com/thing.jpg "Optional title") 14 | ![](https://github.com/thing.jpg) 15 | 16 | ```python 17 | print "Non-runnable code sample" 18 | ``` 19 | 20 | And finally a runnable one... 21 | 22 | ```javascript; runnable 23 | console.log("Runnable"); 24 | ``` 25 | 26 | ``` 27 | Isolated non-runnable 28 | ``` 29 | -------------------------------------------------------------------------------- /tests/markdown.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import fs from 'fs' 4 | import Immutable from 'immutable' 5 | import { parse, render } from '../src/js/markdown' 6 | 7 | function loadMarkdown (filename) { 8 | return fs.readFileSync('./fixtures/' + filename + '.md').toString() 9 | } 10 | 11 | // Mock stuff for execution 12 | global.document = require('jsdom').jsdom('<body></body>') 13 | global.window = document.defaultView 14 | global.nv = {} 15 | 16 | const sampleNotebook = Immutable.fromJS({ 17 | metadata: { 18 | title: 'A sample notebook', 19 | author: 'Joel Auterson', 20 | datasources: {}, 21 | original: undefined, 22 | showFooter: true, 23 | path: undefined 24 | }, 25 | content: [ '0', '1', '2' ], 26 | blocks: { 27 | '0': { 28 | type: 'text', 29 | id: '0', 30 | content: '## This is a sample Notebook\n\nIt _should_ get correctly parsed.\n\n[This is a link](http://github.com)\n\n![Image, with alt](https://github.com/thing.jpg "Optional title")\n![](https://github.com/thing.jpg)\n\n```python\nprint "Non-runnable code sample"\n```\n\nAnd finally a runnable one...' 31 | }, '1': { 32 | type: 'code', 33 | content: 'console.log("Runnable");', 34 | language: 'javascript', 35 | option: 'runnable', 36 | id: '1' 37 | }, '2': { 38 | type: 'text', 39 | id: '2', 40 | content: '```\nIsolated non-runnable\n```' 41 | } 42 | } 43 | }) 44 | 45 | test('correctly parses sample markdown', (t) => { 46 | const sampleMd = loadMarkdown('sampleNotebook') 47 | t.deepEqual(parse(sampleMd).toJS(), sampleNotebook.toJS()) 48 | }) 49 | 50 | test('uses placeholders for a blank document', (t) => { 51 | const expected = Immutable.fromJS({ 52 | metadata: { 53 | title: undefined, 54 | author: undefined, 55 | showFooter: true, 56 | original: undefined, 57 | datasources: {}, 58 | path: undefined 59 | }, 60 | blocks: {}, 61 | content: [] 62 | }) 63 | t.deepEqual(parse('').toJS(), expected.toJS()) 64 | }) 65 | 66 | test('should correctly render a sample notebook', (t) => { 67 | const sampleMd = loadMarkdown('sampleNotebook') 68 | t.deepEqual(render(sampleNotebook), sampleMd) 69 | }) 70 | 71 | test('should correctly render an empty notebook', (t) => { 72 | const nb = Immutable.fromJS({ 73 | metadata: {}, 74 | blocks: {}, 75 | content: [] 76 | }) 77 | const expected = '---\n---\n\n\n' 78 | t.deepEqual(render(nb), expected) 79 | }) 80 | 81 | test('should render a parsed notebook to the original markdown', (t) => { 82 | const sampleMd = loadMarkdown('index') 83 | t.deepEqual(render(parse(sampleMd)), sampleMd) 84 | }) 85 | -------------------------------------------------------------------------------- /tests/util.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import fs from 'fs' 4 | import Immutable from 'immutable' 5 | import * as util from '../src/js/util' 6 | 7 | // Mock stuff for execution 8 | global.document = require('jsdom').jsdom('<body></body>') 9 | global.window = document.defaultView 10 | global.nv = {} 11 | 12 | test('should correctly transform a code block to text', (t) => { 13 | const codeBlock = Immutable.fromJS({ 14 | type: 'code', 15 | language: 'javascript', 16 | option: 'hidden', 17 | content: 'return 1 + 2;' 18 | }) 19 | const expected = '```javascript\nreturn 1 + 2;\n```' 20 | t.is(util.codeToText(codeBlock), expected) 21 | }) 22 | 23 | test('should include option if includeOption is true ', (t) => { 24 | const codeBlock = Immutable.fromJS({ 25 | type: 'code', 26 | language: 'javascript', 27 | option: 'hidden', 28 | content: 'return 1 + 2;' 29 | }) 30 | const expected = '```javascript; hidden\nreturn 1 + 2;\n```' 31 | t.is(util.codeToText(codeBlock, true), expected) 32 | }) 33 | 34 | test('correctly highlights code', (t) => { 35 | const expected = '<span class="hljs-built_in">console</span>' + 36 | '.log(<span class="hljs-string">"hello"</span>);' 37 | t.is(util.highlight('console.log("hello");', 'javascript'), expected) 38 | }) 39 | 40 | test('returns nothing for an unsupported language', (t) => { 41 | t.is(util.highlight('rubbish', 'dfhjf'), '') 42 | }) 43 | 44 | test('should correctly render the index.html from its markdown', (t) => { 45 | const indexMd = fs.readFileSync('./fixtures/index.md').toString() 46 | const indexHTML = fs.readFileSync('./fixtures/index.html').toString() 47 | t.is(util.renderHTML(indexMd), indexHTML) 48 | }) 49 | 50 | test.todo('arrayToCSV') 51 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const ExternalsPlugin = webpack.ExternalsPlugin 4 | const WriteFilePlugin = require('write-file-webpack-plugin') 5 | 6 | module.exports = { 7 | devtool: 'eval-source-map', 8 | entry: [ 9 | 'webpack-dev-server/client?http://localhost:3002', 10 | 'webpack/hot/only-dev-server', 11 | './src/js/app' 12 | ], 13 | output: { 14 | path: path.join(__dirname, 'dist'), 15 | filename: 'bundle.js', 16 | publicPath: 'http://localhost:3002/dist/' 17 | }, 18 | plugins: [ 19 | new webpack.HotModuleReplacementPlugin(), 20 | new ExternalsPlugin('commonjs', [ 21 | 'monk' 22 | ]), 23 | new WriteFilePlugin() 24 | ], 25 | module: { 26 | loaders: [ 27 | { test: /\.css$/, loader: 'style-loader!css-loader' }, 28 | { 29 | test: /\.scss$/, 30 | loaders: ['style', 'css', 'sass'] 31 | }, 32 | { test: /\.png$/, loader: 'url-loader?limit=100000' }, 33 | { test: /\.jpg$/, loader: 'file-loader' }, 34 | { test: /\.json$/, loader: 'json-loader' }, 35 | { 36 | test: /\.(ttf|eot|svg|otf|woff(2)?)(\?[a-z0-9=&.]+)?$/, // font files 37 | loader: 'file-loader' 38 | }, 39 | { 40 | test: /\.js$/, 41 | loaders: ['react-hot', 'babel'], 42 | include: path.join(__dirname, 'src') 43 | } 44 | ] 45 | }, 46 | target: 'electron-renderer' 47 | } 48 | --------------------------------------------------------------------------------