├── .babelrc ├── doc └── screenshot.png ├── src ├── fonts │ ├── andada.otf │ ├── Amaranth-Regular.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ ├── SourceCodePro-Regular.otf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── js │ ├── config │ │ ├── index.js │ │ ├── development.config.js │ │ └── production.config.js │ ├── reducers │ │ ├── index.js │ │ ├── editorReducer.js │ │ ├── executionReducer.js │ │ ├── editorReducer-spec.js │ │ ├── executionReducer-spec.js │ │ └── notebookReducer.js │ ├── app.js │ ├── selectors.js │ ├── components │ │ ├── TextBlock.js │ │ ├── Title.js │ │ ├── Footer.js │ │ ├── AddControls.js │ │ ├── visualiser │ │ │ ├── Visualiser.js │ │ │ ├── DefaultVisualiser.js │ │ │ ├── ArrayVisualiser.js │ │ │ └── ObjectVisualiser.js │ │ ├── Header.js │ │ ├── Metadata.js │ │ ├── Content.js │ │ ├── Datasources.js │ │ ├── Block.js │ │ ├── CodeBlock.js │ │ ├── SaveDialog.js │ │ └── GraphBlock.js │ ├── Notebook.js │ ├── util.js │ ├── util-spec.js │ ├── markdown-spec.js │ ├── markdown.js │ └── actions.js ├── scss │ ├── _visualiser.scss │ ├── main.scss │ ├── _base.scss │ ├── base16-tomorrow-light.css │ ├── _save.scss │ ├── _graphui.scss │ ├── _editor.scss │ ├── _shell.scss │ └── _grids.scss ├── blank.html └── index.html ├── .eslintrc ├── test ├── extractCodeBlocks.md ├── sampleNotebook.md ├── index.md └── index.html ├── .gitignore ├── package.json ├── bin └── cmd.js ├── Gulpfile.js ├── README.md └── LICENSE /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/doc/screenshot.png -------------------------------------------------------------------------------- /src/fonts/andada.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/andada.otf -------------------------------------------------------------------------------- /src/fonts/Amaranth-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/Amaranth-Regular.otf -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /src/fonts/SourceCodePro-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/SourceCodePro-Regular.otf -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /src/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoelOtter/kajero/HEAD/src/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /src/js/config/index.js: -------------------------------------------------------------------------------- 1 | var env = process.env.NODE_ENV || 'development'; 2 | 3 | var config = { 4 | development: require('./development.config'), 5 | production: require('./production.config') 6 | }; 7 | 8 | module.exports = config[env]; 9 | -------------------------------------------------------------------------------- /src/js/config/development.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | kajeroHomepage: '/', 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 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "settings": { 8 | "ecmascript": 6, 9 | "jsx": true 10 | }, 11 | "plugins": [ 12 | "react" 13 | ], 14 | "ignorePath": ".gitignore" 15 | } 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/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 | -------------------------------------------------------------------------------- /src/js/config/production.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | kajeroHomepage: 'http://www.joelotter.com/kajero/', 3 | gistUrl: 'https://gist.githubusercontent.com/anonymous/', 4 | gistApi: 'https://api.github.com/gists', 5 | cssUrl: 'http://www.joelotter.com/kajero/dist/main.css', 6 | scriptUrl: 'http://www.joelotter.com/kajero/dist/bundle.js' 7 | } 8 | -------------------------------------------------------------------------------- /src/scss/_visualiser.scss: -------------------------------------------------------------------------------- 1 | .visualiser { 2 | font-family: 'Source Code Pro', monospace; 3 | font-size: .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 | -------------------------------------------------------------------------------- /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 'base16-tomorrow-light'; 6 | @import 'grids'; 7 | @import 'base'; 8 | @import 'shell'; 9 | @import 'visualiser'; 10 | @import 'editor'; 11 | @import 'save'; 12 | @import 'graphui'; 13 | -------------------------------------------------------------------------------- /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 | 8 | import NotebookReducer from './reducers'; 9 | import Notebook from './Notebook'; 10 | 11 | const store = compose( 12 | applyMiddleware(thunk) 13 | )(createStore)(NotebookReducer); 14 | 15 | render( 16 | 17 |
18 | 19 |
20 |
, 21 | document.getElementById('kajero') 22 | ); 23 | -------------------------------------------------------------------------------- /test/sampleNotebook.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "A sample notebook" 3 | author: "Joel Auterson" 4 | created: "Mon Apr 18 2016 21:48:01 GMT+0100 (BST)" 5 | show_footer: true 6 | --- 7 | 8 | ## This is a sample Notebook 9 | 10 | It _should_ get correctly parsed. 11 | 12 | [This is a link](http://github.com) 13 | 14 | ![Image, with alt](https://github.com/thing.jpg "Optional title") 15 | ![](https://github.com/thing.jpg) 16 | 17 | ```python 18 | print "Non-runnable code sample" 19 | ``` 20 | 21 | And finally a runnable one... 22 | 23 | ```javascript; runnable 24 | console.log("Runnable"); 25 | ``` 26 | 27 | ``` 28 | Isolated non-runnable 29 | ``` 30 | -------------------------------------------------------------------------------- /.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 | src/dist 37 | -------------------------------------------------------------------------------- /src/js/reducers/editorReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { 3 | TOGGLE_EDIT, TOGGLE_SAVE, EDIT_BLOCK 4 | } from '../actions'; 5 | 6 | /* 7 | * This reducer simply keeps track of the state of the editor. 8 | */ 9 | const defaultEditor = Immutable.Map({ 10 | editable: false, 11 | saving: false, 12 | activeBlock: null 13 | }); 14 | 15 | export default function editor(state = defaultEditor, action) { 16 | switch (action.type) { 17 | case TOGGLE_EDIT: 18 | return state.set('editable', !state.get('editable')); 19 | case TOGGLE_SAVE: 20 | return state.set('saving', !state.get('saving')); 21 | case EDIT_BLOCK: 22 | return state.set('activeBlock', action.id); 23 | default: 24 | return state; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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 | }; 16 | }; 17 | 18 | export const editorSelector = state => { 19 | return state.editor.toJS(); 20 | }; 21 | 22 | export const saveSelector = state => { 23 | return {notebook: state.notebook}; 24 | }; 25 | 26 | export const dataSelector = state => { 27 | return {data: state.execution.get('data').toJS()}; 28 | }; 29 | -------------------------------------------------------------------------------- /src/scss/_base.scss: -------------------------------------------------------------------------------- 1 | $background-col: #fff; 2 | $text-black: #444444; 3 | $text-grey: #646464; 4 | $text-light: #999999; 5 | $background-grey: #eeeeee; 6 | $background-dark-grey: #dddddd; 7 | 8 | $size-sm: 35.5em; 9 | $size-md: 48em; 10 | $size-lg: 64em; 11 | $size-xl: 80em; 12 | 13 | 14 | @font-face { 15 | font-family: "Andada"; 16 | src: local('Andada'), url("../fonts/andada.otf") format('opentype'); 17 | } 18 | 19 | @font-face { 20 | font-family: "Amaranth"; 21 | src: local('Amaranth'), url("../fonts/Amaranth-Regular.otf") format('opentype'); 22 | } 23 | 24 | @font-face { 25 | font-family: "Source Code Pro"; 26 | src: local('Source Code Pro'), url("../fonts/SourceCodePro-Regular.otf") format('opentype'); 27 | } 28 | 29 | @mixin respond-to($size) { 30 | @media only screen and (min-width: $size) { @content } 31 | } 32 | 33 | @mixin block-border { 34 | border: 1px dashed $text-light; 35 | } 36 | -------------------------------------------------------------------------------- /src/js/components/TextBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MarkdownIt from 'markdown-it'; 3 | import Block from './Block'; 4 | import { highlight } from '../util'; 5 | 6 | const md = new MarkdownIt({highlight, html: true}); 7 | 8 | class TextBlock extends Block { 9 | 10 | constructor(props) { 11 | super(props); 12 | } 13 | 14 | rawMarkup(markdown) { 15 | return {__html: md.render(markdown)}; 16 | } 17 | 18 | renderViewerMode() { 19 | const { block } = this.props; 20 | const buttons = this.getButtons(); 21 | return ( 22 |
23 |
24 | {buttons} 25 |
26 |
29 |
30 |
31 | ); 32 | } 33 | 34 | } 35 | 36 | export default 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 |

20 | 26 |

27 | ); 28 | } 29 | return ( 30 |

31 | {title} 32 |

33 | ); 34 | } 35 | 36 | } 37 | 38 | export default Title; 39 | -------------------------------------------------------------------------------- /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'; 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 Kajero. 31 | 32 |
33 | ); 34 | } 35 | 36 | } 37 | 38 | export default connect(metadataSelector)(Footer); 39 | -------------------------------------------------------------------------------- /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 | 34 | 35 | 37 | 38 | 40 | 41 |
42 | ); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/scss/base16-tomorrow-light.css: -------------------------------------------------------------------------------- 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.CodeMirror {background: #ffffff; color: #444;} 8 | .cm-s-base16-tomorrow-light div.CodeMirror-selected {background: #e0e0e0 !important;} 9 | .cm-s-base16-tomorrow-light .CodeMirror-gutters {background: #ffffff; border-right: 0px;} 10 | .cm-s-base16-tomorrow-light .CodeMirror-linenumber {color: #b4b7b4;} 11 | .cm-s-base16-tomorrow-light .CodeMirror-cursor {border-left: 1px solid #969896 !important;} 12 | 13 | .cm-s-base16-tomorrow-light span.cm-comment {color: #8e908c;} 14 | .cm-s-base16-tomorrow-light span.cm-atom {color: #b294bb;} 15 | .cm-s-base16-tomorrow-light span.cm-number {color: #f5871f;} 16 | 17 | .cm-s-base16-tomorrow-light span.cm-property, .cm-s-base16-tomorrow-light span.cm-attribute {color: #444;} 18 | .cm-s-base16-tomorrow-light span.cm-keyword {color: #8959a8;} 19 | .cm-s-base16-tomorrow-light span.cm-string {color: #718c00;} 20 | 21 | .cm-s-base16-tomorrow-light span.cm-variable {color: #444;} 22 | .cm-s-base16-tomorrow-light span.cm-variable-2 {color: #444;} 23 | .cm-s-base16-tomorrow-light span.cm-def {color: #4271ae;} 24 | .cm-s-base16-tomorrow-light span.cm-error {background: #cc6666; color: #969896;} 25 | .cm-s-base16-tomorrow-light span.cm-bracket {color: #282a2e;} 26 | .cm-s-base16-tomorrow-light span.cm-tag {color: #cc6666;} 27 | .cm-s-base16-tomorrow-light span.cm-link {color: #b294bb;} 28 | 29 | .cm-s-base16-tomorrow-light .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} 30 | -------------------------------------------------------------------------------- /src/scss/_save.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | .save-dialog { 4 | 5 | .close-button { 6 | float: right; 7 | margin-top: .75em; 8 | margin-right: .25em; 9 | cursor: pointer; 10 | } 11 | 12 | .export-option, .clipboard-button { 13 | font-family: 'Amaranth', sans-serif; 14 | @include block-border; 15 | padding: .25em; 16 | cursor: pointer; 17 | display: inline-block; 18 | 19 | &.selected { 20 | font-weight: bold; 21 | color: $text-black; 22 | @include block-border; 23 | border: 1px dashed $text-black; 24 | cursor: default; 25 | } 26 | } 27 | 28 | textarea { 29 | height: 400px; 30 | box-sizing: border-box; 31 | } 32 | 33 | .export-option { 34 | margin-top: .5em; 35 | margin-right: .5em; 36 | color: $text-light; 37 | } 38 | 39 | .clipboard-button { 40 | font-weight: bold; 41 | float: right; 42 | } 43 | 44 | input { 45 | width: 100%; 46 | @include block-border; 47 | box-sizing: border-box; 48 | padding: .25em; 49 | margin-top: .25em; 50 | margin-bottom: .5em; 51 | display: inherit; 52 | color: inherit; 53 | font-family: inherit; 54 | font-size: inherit; 55 | font-weight: inherit; 56 | width: 100%; 57 | outline: none; 58 | cursor: text; 59 | } 60 | 61 | .pure-g { 62 | margin-top: .75em; 63 | p { 64 | font-family: 'Amaranth', sans-serif; 65 | } 66 | } 67 | 68 | p { 69 | clear: both; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /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 | 32 | export function getSpacing (indent) { 33 | if (indent < 1) return ''; 34 | let spaces = indent * SPACING; 35 | var result = ''; 36 | for (let i = 0; i < spaces; i++) { 37 | result += '\u00a0'; 38 | } 39 | return result; 40 | } 41 | 42 | export default class Visualiser extends Component { 43 | 44 | render() { 45 | const { data, useHljs, click, path, name } = this.props; 46 | const VisualiserComponent = selectComponent(data); 47 | return ( 48 |
49 | 57 |
58 | ); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/js/reducers/executionReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { 3 | RECEIVED_DATA, 4 | CODE_EXECUTED, 5 | CODE_ERROR, 6 | UPDATE_BLOCK, 7 | DELETE_BLOCK, 8 | DELETE_DATASOURCE, 9 | UPDATE_DATASOURCE, 10 | } from '../actions'; 11 | 12 | /* 13 | * This reducer handles the state of execution of code blocks - 14 | * retaining results, carrying context around, and making note 15 | * of which blocks have and haven't been executed. It's also 16 | * where the obtained data is stored. 17 | */ 18 | export const initialState = Immutable.Map({ 19 | executionContext: Immutable.Map(), 20 | data: Immutable.Map(), 21 | results: Immutable.Map(), 22 | blocksExecuted: Immutable.Set() 23 | }); 24 | 25 | export default function execution(state = initialState, action) { 26 | const { id, code, text, name, data, context } = action; 27 | switch (action.type) { 28 | case CODE_EXECUTED: 29 | return state 30 | .setIn(['results', id], data) 31 | .set('blocksExecuted', state.get('blocksExecuted').add(id)) 32 | .set('executionContext', context); 33 | case CODE_ERROR: 34 | return state 35 | .setIn(['results', id], data) 36 | .set('blocksExecuted', state.get('blocksExecuted').add(id)); 37 | case RECEIVED_DATA: 38 | return state.setIn(['data', name], Immutable.fromJS(data)); 39 | case UPDATE_BLOCK: 40 | case DELETE_BLOCK: 41 | return state 42 | .set('blocksExecuted', state.get('blocksExecuted').remove(id)) 43 | .removeIn(['results', id]); 44 | case UPDATE_DATASOURCE: 45 | case DELETE_DATASOURCE: 46 | return state.deleteIn(['data', id]); 47 | default: 48 | return state; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 { loadMarkdown, fetchData, editBlock } 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(loadMarkdown()); 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 |
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 | export default connect(editorSelector)(Notebook); 60 | -------------------------------------------------------------------------------- /src/js/reducers/editorReducer-spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Immutable from 'immutable'; 3 | import * as actions from '../actions'; 4 | import reducer from './editorReducer'; 5 | 6 | describe('editor reducer', () => { 7 | 8 | it('should do nothing for an unhandled action type', () => { 9 | expect(reducer(Immutable.Map({editable: false}), {type: 'FAKE_ACTION'})) 10 | .to.eql(Immutable.Map({editable: false})); 11 | }); 12 | 13 | it('should toggle editor state for TOGGLE_EDIT', () => { 14 | expect( 15 | reducer( 16 | Immutable.Map({editable: false}), 17 | {type: actions.TOGGLE_EDIT} 18 | ).equals(Immutable.Map({editable: true})) 19 | ).to.be.true; 20 | expect( 21 | reducer( 22 | Immutable.Map({editable: true}), 23 | {type: actions.TOGGLE_EDIT} 24 | ).equals(Immutable.Map({editable: false})) 25 | ).to.be.true; 26 | }); 27 | 28 | it('should return the inital state', () => { 29 | expect(reducer(undefined, {})).to.eql(Immutable.Map({ 30 | editable: false, 31 | saving: false, 32 | activeBlock: null 33 | })); 34 | }); 35 | 36 | it('should toggle save state for TOGGLE_SAVE', () => { 37 | expect( 38 | reducer( 39 | Immutable.Map({saving: false}), 40 | {type: actions.TOGGLE_SAVE} 41 | ).equals(Immutable.Map({saving: true})) 42 | ).to.be.true; 43 | expect( 44 | reducer( 45 | Immutable.Map({saving: true}), 46 | {type: actions.TOGGLE_SAVE} 47 | ).equals(Immutable.Map({saving: false})) 48 | ).to.be.true; 49 | }); 50 | 51 | it('should set the editing block on EDIT_BLOCK', () => { 52 | expect(reducer( 53 | Immutable.Map({activeBlock: null}), 54 | {type: actions.EDIT_BLOCK, id: '12'} 55 | ).equals(Immutable.Map({activeBlock: '12'}))).to.be.true; 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /src/scss/_graphui.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | .graph-creator { 4 | 5 | @include block-border; 6 | padding: .5em; 7 | margin: 1em 0; 8 | font-family: 'Amaranth', sans-serif; 9 | 10 | .editor-buttons { 11 | margin-top: 0; 12 | } 13 | 14 | .graph-type { 15 | @include block-border; 16 | padding: .25em; 17 | cursor: pointer; 18 | display: inline-block; 19 | margin-bottom: .5em; 20 | margin-right: .5em; 21 | color: $text-light; 22 | 23 | &.selected { 24 | font-weight: bold; 25 | color: $text-black; 26 | @include block-border; 27 | border: 1px dashed $text-black; 28 | cursor: default; 29 | } 30 | } 31 | 32 | p { 33 | margin: .5em .25em; 34 | @include respond-to($size-md) { 35 | margin: .25em; 36 | } 37 | } 38 | 39 | .hint { 40 | margin: .5em 0; 41 | 42 | .fa { 43 | text-align: center; 44 | margin-top: 0.5em; 45 | @include respond-to($size-md) { 46 | margin-top: .25em; 47 | } 48 | } 49 | 50 | input { 51 | width: 100%; 52 | padding: .25em; 53 | margin: .25em 0; 54 | box-sizing: border-box; 55 | @include respond-to($size-md) { 56 | margin: 0; 57 | } 58 | } 59 | 60 | .visualiser { 61 | margin-top: .5em; 62 | } 63 | } 64 | 65 | .visualiser { 66 | background-color: $background-grey; 67 | padding: .5em; 68 | margin-bottom: .5em; 69 | 70 | .visualiser-key { 71 | cursor: pointer; 72 | } 73 | } 74 | 75 | pre { 76 | font-family: 'Source Code Pro', monospace; 77 | font-size: .8em; 78 | } 79 | 80 | .graph-preview svg { 81 | width: 100%; 82 | &.nvd3-svg { 83 | height: inherit; 84 | } 85 | text { 86 | fill: $text-black; 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kajero", 3 | "version": "0.2.0", 4 | "description": "Interactive JavaScript notebooks with clever graphing", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/JoelOtter/kajero" 8 | }, 9 | "keywords": [ 10 | "notebook" 11 | ], 12 | "preferGlobal": true, 13 | "bin": { 14 | "kajero": "bin/cmd.js" 15 | }, 16 | "author": "Joel Auterson ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/JoelOtter/kajero/issues" 20 | }, 21 | "homepage": "https://github.com/JoelOtter/kajero", 22 | "dependencies": { 23 | "request": "^2.72.0" 24 | }, 25 | "devDependencies": { 26 | "babel-preset-es2015": "^6.6.0", 27 | "babel-preset-react": "^6.5.0", 28 | "babel-register": "^6.7.2", 29 | "babelify": "^7.2.0", 30 | "browser-sync": "^2.11.2", 31 | "browserify": "^13.0.0", 32 | "chai": "^3.5.0", 33 | "clipboard": "^1.5.10", 34 | "coveralls": "^2.11.9", 35 | "envify": "^3.4.0", 36 | "fetch-mock": "^4.4.0", 37 | "font-awesome": "^4.5.0", 38 | "front-matter": "^2.0.6", 39 | "gulp": "^3.9.1", 40 | "gulp-jsx-coverage": "^0.3.8", 41 | "gulp-sass": "^2.2.0", 42 | "gulp-util": "^3.0.7", 43 | "highlight.js": "^9.2.0", 44 | "immutable": "^3.7.6", 45 | "jsdom": "^8.4.0", 46 | "jutsu": "^0.1.1", 47 | "markdown-it": "^6.0.1", 48 | "mocha": "^2.4.5", 49 | "mocha-jsdom": "^1.1.0", 50 | "query-string": "^4.1.0", 51 | "react": "^0.14.8", 52 | "react-codemirror": "^0.2.6", 53 | "react-dom": "^0.14.8", 54 | "react-redux": "^4.4.1", 55 | "redux": "^3.3.1", 56 | "redux-mock-store": "^1.0.2", 57 | "redux-thunk": "^2.0.1", 58 | "reshaper": "^0.3.1", 59 | "sinon": "^1.17.3", 60 | "smolder": "^0.3.1", 61 | "uglifyify": "^3.0.1", 62 | "vinyl-source-stream": "^1.1.0", 63 | "watchify": "^3.7.0", 64 | "whatwg-fetch": "^0.11.0" 65 | }, 66 | "scripts": { 67 | "test": "./node_modules/mocha/bin/mocha --compilers js:babel-register --recursive \"./src/**/*-spec.js\"", 68 | "test-cov": "./node_modules/.bin/gulp test-cov" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/js/components/visualiser/DefaultVisualiser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { selectComponent, 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 } = this.props; 29 | let click = this.props.click; 30 | if (click === undefined) { 31 | click = () => {}; 32 | } 33 | const type = typeString(data); 34 | const repr = (type === 'String') ? "'" + String(data) + "'" : 35 | (type === 'Function') ? 'function()' : String(data); 36 | const cssClass = buildCssClass(type, useHljs); 37 | let key = ; 38 | if (name) { 39 | key = ( 40 | 41 | click(name, path)}> 42 | {name} 43 | 44 | {':\u00a0'} 45 | 46 | ); 47 | } 48 | const spaces = getSpacing(indent); 49 | 50 | return ( 51 |
52 | 53 | {spaces} 54 | {key} 55 | {repr} 56 | 57 |
58 | ); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /bin/cmd.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | var request = require('request'); 4 | var fs = require('fs'); 5 | 6 | if (process.argv.length < 3) { 7 | console.error("You must specify a Markdown file as a command-line argument."); 8 | return; 9 | } 10 | 11 | if (process.argv[2] !== 'html' && process.argv[2] !== 'publish') { 12 | console.error("Unrecognised command. Available commands:\nhtml\npublish"); 13 | return 14 | } 15 | 16 | var command = process.argv[2]; 17 | var md = fs.readFileSync(process.argv[3]).toString(); 18 | 19 | var f = (command === 'html') ? saveHTML : saveGist; 20 | f(md); 21 | 22 | function saveHTML(markdown) { 23 | var result = "\n\n \n"; 24 | result += ' \n'; 25 | result += ' \n'; 26 | result += ' \n'; 27 | result += ' \n \n \n'; 35 | result += '
\n'; 36 | result += ' \n'; 37 | result += ' \n\n'; 38 | console.log(result); 39 | } 40 | 41 | function saveGist(markdown) { 42 | var options = { 43 | uri: 'https://api.github.com/gists', 44 | method: 'POST', 45 | headers: { 46 | 'User-Agent': 'Kajero' 47 | }, 48 | json: { 49 | description: 'Kajero notebook', 50 | public: true, 51 | files: { 52 | 'notebook.md': { 53 | content: md 54 | } 55 | } 56 | } 57 | }; 58 | 59 | request(options, function(err, response, body) { 60 | console.log('http://www.joelotter.com/kajero/?id=' + body.id); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /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 | ); 40 | const undoButton = ( 41 | 42 | 43 | ); 44 | const changesMade = editable && undoSize > 0; 45 | return ( 46 |
47 | 48 | <span className="controls"> 49 | {changesMade ? undoButton : null} 50 | {changesMade ? saveButton : null} 51 | <i className={'fa ' + icon} onClick={this.toggleEditClicked} 52 | title={editable ? "Exit edit mode" : "Enter edit mode"}> 53 | </i> 54 | </span> 55 | <Metadata editable={editable} metadata={metadata} dispatch={dispatch} /> 56 | </div> 57 | ); 58 | } 59 | 60 | } 61 | 62 | export default connect(metadataSelector)(Header); 63 | -------------------------------------------------------------------------------- /src/js/util.js: -------------------------------------------------------------------------------- 1 | import hljs from 'highlight.js'; 2 | import config from './config'; 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 | const sep = '; '; 10 | result += '; ' + option; 11 | } 12 | result += "\n"; 13 | result += codeBlock.get('content'); 14 | result += "\n```"; 15 | return result; 16 | } 17 | 18 | 19 | export function highlight(str, lang) { 20 | if (lang && hljs.getLanguage(lang)) { 21 | try { 22 | return hljs.highlight(lang, str).value; 23 | } catch (__) {} 24 | } 25 | return ''; // use external default escaping 26 | } 27 | 28 | export function extractMarkdownFromHTML() { 29 | let text = document.getElementById('kajero-md').text; 30 | const lines = text.split("\n"); 31 | let leadingSpaces; 32 | // Find line where front-matter starts 33 | for (let line = 0; line < lines.length; line++) { 34 | leadingSpaces = lines[line].indexOf('-'); 35 | if (leadingSpaces > -1) { 36 | text = lines.splice(line).join("\n"); 37 | break; 38 | } 39 | } 40 | const re = new RegExp("^ {" + leadingSpaces + "}", "gm"); 41 | return text.replace(re, ""); 42 | } 43 | 44 | export function renderHTML(markdown) { 45 | let result = "<!DOCTYPE html>\n<html>\n <head>\n"; 46 | result += ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'; 47 | result += ' <meta http-equiv="content-type" content="text/html; charset=UTF8">\n'; 48 | result += ' <link rel="stylesheet" href="' + config.cssUrl + '">\n'; 49 | result += ' </head>\n <body>\n <script type="text/markdown" id="kajero-md">\n'; 50 | result += markdown.split('\n').map((line) => { 51 | if (line.match(/\S+/m)) { 52 | return ' ' + line; 53 | } 54 | return ''; 55 | }).join('\n'); 56 | result += ' </script>\n'; 57 | result += ' <div id="kajero"></div>\n'; 58 | result += ' <script type="text/javascript" src="' + config.scriptUrl + '"></script>\n'; 59 | result += ' </body>\n</html>\n'; 60 | return result; 61 | } 62 | -------------------------------------------------------------------------------- /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 | const date = metadata.get('created'); 25 | if (editable) { 26 | const iconFooter = metadata.get('showFooter') ? 'check-circle' : 'circle-o'; 27 | return ( 28 | <div className="metadata"> 29 | <div className="metadata-row"> 30 | <i className="fa fa-user"></i> 31 | <input type="text" defaultValue={author} 32 | ref="authorField" onBlur={this.updateAuthor} title="Author" /> 33 | </div> 34 | <div className="metadata-row"> 35 | <i className={'fa fa-' + iconFooter + ' clickable'} 36 | onClick={this.toggleFooter} > 37 | </i> 38 | <span>Show footer</span> 39 | </div> 40 | <hr/> 41 | <p>Data sources</p> 42 | <Datasources dispatch={dispatch} 43 | datasources={metadata.get('datasources')} /> 44 | </div> 45 | ); 46 | } 47 | return ( 48 | <div className="metadata"> 49 | <span className="metadata-item"> 50 | <i className="fa fa-user"></i>{'\u00a0' + author} 51 | </span> 52 | <span className="metadata-sep">{'\u00a0//\u00a0'}</span> 53 | <span className="metadata-item"> 54 | <i className="fa fa-clock-o"></i>{'\u00a0' + date} 55 | </span> 56 | </div> 57 | ); 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/js/components/Content.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { contentSelector } from '../selectors'; 4 | import TextBlock from './TextBlock'; 5 | import CodeBlock from './CodeBlock'; 6 | import GraphBlock from './GraphBlock'; 7 | import AddControls from './AddControls'; 8 | 9 | class Content extends Component { 10 | 11 | render() { 12 | const { 13 | dispatch, content, results, blocksExecuted, editable, activeBlock 14 | } = this.props; 15 | let blocks = []; 16 | for (let i = 0; i < content.size; i++) { 17 | const block = content.get(i); 18 | const id = block.get('id'); 19 | const isFirst = (i === 0); 20 | const isLast = (i === content.size - 1); 21 | blocks.push( 22 | <AddControls key={'add' + i} dispatch={dispatch} 23 | id={block.get('id')} editable={editable} /> 24 | ); 25 | switch(block.get('type')) { 26 | case 'text': 27 | blocks.push( 28 | <TextBlock editable={editable} dispatch={dispatch} 29 | block={block} key={String(i)} isFirst={isFirst} 30 | isLast={isLast} editing={id === activeBlock} 31 | /> 32 | ); 33 | break; 34 | default: 35 | const hasBeenRun = blocksExecuted.includes(id); 36 | const result = results.get(id); 37 | const BlockClass = block.get('type') === 'code' ? 38 | CodeBlock : GraphBlock; 39 | blocks.push( 40 | <BlockClass 41 | block={block} result={result} editable={editable} 42 | key={String(i)} hasBeenRun={hasBeenRun} dispatch={dispatch} 43 | isFirst={isFirst} isLast={isLast} 44 | editing={id === activeBlock} 45 | /> 46 | ); 47 | } 48 | } 49 | blocks.push( 50 | <AddControls key="add-end" dispatch={dispatch} editable={editable} /> 51 | ); 52 | return <div>{blocks}</div>; 53 | } 54 | 55 | } 56 | 57 | export default connect(contentSelector)(Content); 58 | -------------------------------------------------------------------------------- /src/blank.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <title>Blank Kajero notebook 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 31 | 32 | 33 | 45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/js/components/visualiser/ArrayVisualiser.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { selectComponent, getSpacing } from './Visualiser'; 3 | 4 | export default class ArrayVisualiser 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, indent, useHljs, name, path } = this.props; 18 | let click = this.props.click; 19 | if (click === undefined) { 20 | click = () => {}; 21 | } 22 | let items = []; 23 | for (let i = 0; this.state.open && i < data.length; i++) { 24 | var item = data[i]; 25 | var VisualiserComponent = selectComponent(item); 26 | items.push( 27 | 36 | ); 37 | } 38 | 39 | let arrow; 40 | let spaces = getSpacing(indent); 41 | if (data.length > 0) { 42 | arrow = this.state.open ? '\u25bc' : '\u25b6'; 43 | if (spaces.length >= 2) { 44 | // Space for arrow 45 | spaces = spaces.slice(2); 46 | } 47 | } 48 | let key = {'\u00a0'}; 49 | if (name) { 50 | key = ( 51 | 52 | {'\u00a0'} 53 | click(name, path)}> 54 | {name} 55 | 56 | {':\u00a0'} 57 | 58 | ); 59 | } 60 | 61 | return ( 62 |
63 | 64 | {spaces} 65 | {arrow} 66 | {key} 67 | Array 68 | {'[' + data.length + ']'} 69 | 70 | {items} 71 |
72 | ); 73 | 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /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 } = this.props; 18 | let click = this.props.click; 19 | if (click === undefined) { 20 | click = () => {}; 21 | } 22 | const keys = Object.getOwnPropertyNames(data); 23 | let items = []; 24 | for (let i = 0; this.state.open && i < keys.length; i++) { 25 | var item = data[keys[i]]; 26 | var VisualiserComponent = selectComponent(item); 27 | items.push( 28 | 37 | ); 38 | } 39 | let arrow; 40 | let spaces = getSpacing(indent); 41 | if (keys.length > 0) { 42 | arrow = this.state.open ? '\u25bc' : '\u25b6'; 43 | if (spaces.length >= 2) { 44 | // Space for arrow 45 | spaces = spaces.slice(2); 46 | } 47 | } 48 | let key = {'\u00a0'}; 49 | if (name) { 50 | key = ( 51 | 52 | {'\u00a0'} 53 | click(name, path)}> 54 | {name} 55 | 56 | {':\u00a0'} 57 | 58 | ); 59 | } 60 | 61 | return ( 62 |
63 | 64 | {spaces} 65 | {arrow} 66 | {key} 67 | Object 68 | {'{}'} 69 | 70 | {items} 71 |
72 | ); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /src/scss/_editor.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | .editable { 4 | input { 5 | display: inherit; 6 | color: inherit; 7 | font-family: inherit; 8 | font-size: inherit; 9 | font-weight: inherit; 10 | width: 100%; 11 | outline: none; 12 | @include block-border; 13 | } 14 | 15 | .clickable { 16 | cursor: pointer; 17 | } 18 | 19 | .edit-box { 20 | @include block-border; 21 | margin: 1em 0; 22 | padding: 0.5em 0.5em 0.5em 0; 23 | 24 | .CodeMirror { 25 | font-family: 'Source Code Pro', monospace; 26 | font-size: 0.8em; 27 | } 28 | } 29 | 30 | .text-block { 31 | padding: .5em; 32 | @include block-border; 33 | .text-block-content { 34 | *:first-child { 35 | margin-top: 0; 36 | } 37 | *:last-child { 38 | margin-bottom: 0; 39 | } 40 | } 41 | margin: 1em 0; 42 | } 43 | 44 | .metadata { 45 | 46 | .metadata-row { 47 | margin: .5em 0; 48 | span { 49 | margin-left: 15px; 50 | } 51 | input { 52 | width: 200px; 53 | margin-left: 15px; 54 | display: inline; 55 | padding: .25em; 56 | box-sizing: border-box; 57 | @include respond-to($size-md) { 58 | width: 400px; 59 | } 60 | } 61 | } 62 | 63 | .datasource { 64 | margin: 0.5em 0; 65 | .fa { 66 | text-align: center; 67 | margin-top: 0.5em; 68 | cursor: pointer; 69 | @include respond-to($size-md) { 70 | margin-top: .25em; 71 | } 72 | } 73 | input { 74 | width: 100%; 75 | padding: .25em; 76 | margin: .25em 0; 77 | box-sizing: border-box; 78 | @include respond-to($size-md) { 79 | margin: 0; 80 | } 81 | } 82 | .source-name input { 83 | @include respond-to($size-md) { 84 | width: 95%; 85 | } 86 | } 87 | p { 88 | margin: .5em .25em; 89 | @include respond-to($size-md) { 90 | margin: .25em; 91 | } 92 | } 93 | } 94 | } 95 | 96 | .add-controls { 97 | overflow: hidden; 98 | i { 99 | margin-left: .5em; 100 | float: right; 101 | } 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/js/util-spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Immutable from 'immutable'; 3 | import jsdom from 'mocha-jsdom'; 4 | import sinon from 'sinon'; 5 | import fs from 'fs'; 6 | import * as util from './util'; 7 | 8 | describe('util', () => { 9 | 10 | describe('codeToText', () => { 11 | 12 | it('should correctly transform a code block to text', () => { 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 | expect(util.codeToText(codeBlock)).to.equal(expected); 21 | }); 22 | 23 | it('should include option if includeOption is true ', () => { 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 | expect(util.codeToText(codeBlock, true)).to.equal(expected); 32 | }); 33 | 34 | }); 35 | 36 | describe('highlight', () => { 37 | 38 | it('correctly highlights code', () => { 39 | const expected = 'console' + 40 | '.log("hello");'; 41 | expect(util.highlight('console.log("hello");', 'javascript')) 42 | .to.equal(expected); 43 | }); 44 | 45 | it('returns nothing for an unsupported language', () => { 46 | expect(util.highlight('rubbish', 'dfhjf')).to.equal(''); 47 | }); 48 | }); 49 | 50 | describe('extractMarkdownFromHTML', () => { 51 | 52 | before(() => { 53 | sinon.stub(document, "getElementById").returns({ 54 | text: '\n ---\n ---\n\n ## This has spaces\n\n Are they removed?' 55 | }); 56 | }); 57 | 58 | after(() => { 59 | document.getElementById.restore() 60 | }); 61 | 62 | it('correctly removes indentation when loading Markdown from HTML', () => { 63 | const expected = '---\n---\n\n## This has spaces\n\n Are they removed?'; 64 | expect(util.extractMarkdownFromHTML()).to.equal(expected); 65 | }); 66 | 67 | }); 68 | 69 | describe('renderHTML', () => { 70 | 71 | it('should correctly render the index.html from its markdown', () => { 72 | const indexMd = fs.readFileSync('./test/index.md').toString(); 73 | const indexHTML = fs.readFileSync('./test/index.html').toString(); 74 | expect(util.renderHTML(indexMd)).to.equal(indexHTML); 75 | }); 76 | 77 | }); 78 | 79 | }); 80 | -------------------------------------------------------------------------------- /Gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var gutil = require('gulp-util'); 3 | var watchify = require('watchify'); 4 | var babelify = require('babelify'); 5 | var browserify = require('browserify'); 6 | var browserSync = require('browser-sync').create(); 7 | var source = require('vinyl-source-stream'); 8 | var sass = require('gulp-sass'); 9 | 10 | var isProduction = process.env.NODE_ENV === 'production'; 11 | 12 | // Input file 13 | watchify.args.debug = (!isProduction); 14 | var bundler = watchify(browserify('./src/js/app.js', watchify.args)); 15 | 16 | // Babel transform (for ES6) 17 | bundler.transform(babelify.configure({ 18 | sourceMapRelative: 'src/js', 19 | presets: ['es2015', 'react'] 20 | })); 21 | 22 | bundler.transform('envify'); 23 | bundler.transform({ 24 | global: isProduction, 25 | ignore: [ 26 | '**/jutsu/lib/**' 27 | ] 28 | }, 'uglifyify'); 29 | 30 | // Recompile on updates. 31 | bundler.on('update', bundle); 32 | 33 | function bundle() { 34 | gutil.log("Recompiling JS..."); 35 | 36 | return bundler.bundle() 37 | .on('error', function(err) { 38 | gutil.log(err.message); 39 | browserSync.notify("Browserify error!"); 40 | this.emit("end"); 41 | }) 42 | .pipe(source('bundle.js')) 43 | .pipe(gulp.dest('./src/dist')) 44 | .pipe(browserSync.stream({once: true})); 45 | } 46 | 47 | // Gulp task aliases 48 | 49 | gulp.task('bundle', function() { 50 | return bundle(); 51 | }); 52 | 53 | gulp.task('sass', function() { 54 | return gulp.src('./src/scss/*.scss') 55 | .pipe(sass({ 56 | outputStyle: 'compressed', 57 | noCache: true 58 | })) 59 | .on('error', function(err) { 60 | gutil.log(err.message); 61 | }) 62 | .pipe(gulp.dest('./src/dist')) 63 | .pipe(browserSync.stream({once: true})); 64 | }); 65 | 66 | // Bundle and serve page 67 | gulp.task('default', ['sass', 'bundle'], function() { 68 | gulp.watch('./src/scss/*.scss', ['sass']); 69 | browserSync.init({ 70 | server: './src' 71 | }); 72 | }); 73 | 74 | /* 75 | * Testing 76 | */ 77 | 78 | gulp.task('test-cov', require('gulp-jsx-coverage').createTask({ 79 | src: './src/**/*-spec.js', 80 | istanbul: { 81 | preserveComments: true, 82 | coverageVariable: '__MY_TEST_COVERAGE__', 83 | exclude: /node_modules|-spec|jutsu|reshaper|smolder/ 84 | }, 85 | threshold: { 86 | type: 'lines', 87 | min: 90 88 | }, 89 | transpile: { 90 | babel: { 91 | exclude: /node_modules/ 92 | } 93 | }, 94 | coverage: { 95 | reporters: ['text-summary', 'json', 'lcov'], 96 | directory: 'coverage' 97 | }, 98 | mocha: { 99 | reporter: 'spec' 100 | } 101 | })); 102 | -------------------------------------------------------------------------------- /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 |
46 |

{name}

47 |
48 |
49 | this.updateSource(name)} /> 51 |
52 |
53 | ); 54 | } 55 | return ( 56 |
57 | {result} 58 |
59 | 61 | 62 |
63 | 64 |
65 |
66 | 67 |
68 |
69 |
70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kajero 2 | 3 | [![npm](https://img.shields.io/npm/v/kajero.svg?maxAge=2592000)](https://www.npmjs.com/package/kajero) [![Join the chat at https://gitter.im/JoelOtter/kajero](https://badges.gitter.im/JoelOtter/kajero.svg)](https://gitter.im/JoelOtter/kajero?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | Interactive JavaScript notebooks with clever graphing. 6 | 7 | You can view a sample notebook [here](http://www.joelotter.com/kajero). 8 | 9 | ![](https://raw.githubusercontent.com/JoelOtter/kajero/master/doc/screenshot.png) 10 | 11 | ## Features 12 | 13 | - It's just Markdown - a Kajero notebook is just a Markdown document with a script attached. 14 | - Every notebook is fully editable in the browser, and can be saved as Markdown or HTML. 15 | - Notebooks can also be published as Gists, generating a unique URL for your notebook. 16 | - JavaScript code blocks can be executed. They're treated as functions, with their return value visualised. Kajero 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. 19 | - Includes [Reshaper](https://github.com/JoelOtter/reshaper), for automatic reshaping of structured data. 20 | - 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. 21 | 22 | ### Related projects 23 | 24 | - [Reshaper](https://github.com/JoelOtter/reshaper) - reshape data to match a schema 25 | - [Smolder](https://github.com/JoelOtter/smolder) - a library wrapper that attempts to reshape data going into your functions, using Reshaper 26 | - [Jutsu](https://github.com/JoelOtter/jutsu) - a simple graphing library, with support for Smolder 27 | 28 | ## Contributing 29 | 30 | Issues and Pull Requests are both extremely welcome! 31 | 32 | ## Command-line tools 33 | 34 | Kajero includes a couple of simple command-line tools for users who don't want to use the inline editor to create their notebooks. 35 | 36 | ### Installation 37 | 38 | `npm install -g kajero`, or clone this repository. 39 | 40 | You can build the JS library by running `npm install`, followed by `gulp`. For a production build, `NODE_ENV=production gulp`. 41 | 42 | ### Commands 43 | 44 | You can generate new notebooks directly from Markdown files without using the web editor. 45 | 46 | - `kajero html [file.md]` 47 | 48 | Will output generated HTML of a new notebook. You can pipe it to a file like this: 49 | 50 | `kajero html [file.md] > output.html` 51 | 52 | - `kajero publish [file.md]` 53 | 54 | Will publish your notebook as a gist, and return a unique URL to your new notebook. You don't need to build the JS library for these scripts to work. 55 | 56 | ### Running tests 57 | 58 | Run the unit tests with `npm test`. 59 | 60 | For coverage reporting, run with `npm run test-cov`. Note that the coverage percentages may not be exactly correct - this is because Istanbul runs over the compiled ES5 code, rather than the ES6 source. 61 | -------------------------------------------------------------------------------- /src/scss/_shell.scss: -------------------------------------------------------------------------------- 1 | @import 'base'; 2 | 3 | body { 4 | background: $background-col; 5 | color: $text-black; 6 | font-family: "Andada", serif; 7 | @include respond-to($size-md) { 8 | font-size: 1.2em; 9 | } 10 | } 11 | 12 | .metadata { 13 | font-size: 1em; 14 | font-family: "Amaranth", sans-serif; 15 | 16 | .metadata-sep { 17 | display: none; 18 | @include respond-to($size-md) { 19 | display: inline; 20 | } 21 | } 22 | 23 | .metadata-item { 24 | display: block; 25 | margin: .5em 0; 26 | @include respond-to($size-md) { 27 | display: inline; 28 | } 29 | } 30 | } 31 | 32 | .controls { 33 | float: right; 34 | margin: 0 .5em; 35 | cursor: pointer; 36 | i { 37 | margin-left: .5em; 38 | } 39 | } 40 | 41 | .editor-buttons { 42 | float: right; 43 | margin-bottom: 1px; // Fixes overlap bug on Safari 44 | margin-top: -0.2em; // Account for font size difference 45 | i { 46 | margin-left: .25em; 47 | cursor: pointer; 48 | } 49 | } 50 | 51 | code { 52 | font-family: "Source Code Pro", monospace; 53 | font-size: .8em; 54 | } 55 | 56 | .offset-col { 57 | display: none; 58 | @include respond-to($size-md) { 59 | display: inline-block; 60 | } 61 | } 62 | 63 | hr { 64 | display: block; 65 | height: 1px; 66 | border: 0; 67 | border-top: 2px dashed $text-light; 68 | margin: 1.5em 0; 69 | padding: 0; 70 | } 71 | 72 | .top-sep { 73 | border-top: 2px dashed $text-black; 74 | margin: 1.5em 0; 75 | } 76 | 77 | h1 { 78 | font-size: 2.5em; 79 | } 80 | 81 | a { 82 | text-decoration: none; 83 | border-bottom: 1px dashed $text-light; 84 | font-weight: bold; 85 | color: $text-black; 86 | } 87 | 88 | a:visited { 89 | color: inherit; 90 | } 91 | 92 | img { 93 | max-width: 100%; 94 | } 95 | 96 | pre, .codeBlock, .resultBlock, .graphBlock { 97 | overflow: auto; 98 | padding: .5em; 99 | @include block-border; 100 | } 101 | 102 | 103 | .codeContainer { 104 | margin: 1em 0; 105 | pre { 106 | padding: 0; 107 | border: none; 108 | overflow: auto; 109 | margin: 0; 110 | } 111 | 112 | .graphBlock, .resultBlock { 113 | border-top: none; 114 | } 115 | 116 | .graphBlock > * { 117 | margin: 0 auto; 118 | } 119 | 120 | .graphBlock:empty { 121 | display: none; 122 | } 123 | 124 | .graphBlock svg { 125 | width: 100%; 126 | &.nvd3-svg { 127 | height: inherit; 128 | } 129 | text { 130 | fill: $text-black; 131 | } 132 | } 133 | 134 | .resultBlock { 135 | background-color: $background-grey; 136 | } 137 | 138 | } 139 | 140 | .hiddenCode { 141 | .codeBlock { 142 | display: none; 143 | } 144 | 145 | .resultBlock, .graphBlock { 146 | border-top: 1px dashed $text-light; 147 | } 148 | 149 | .graphBlock { 150 | border-bottom: none; 151 | } 152 | } 153 | 154 | textarea { 155 | width: 100%; 156 | color: $text-black; 157 | padding: .5em; 158 | outline: none; 159 | height: 300px; 160 | @include block-border; 161 | font-family: 'Source Code Pro', monospace; 162 | font-size: .8em; 163 | resize: none; 164 | margin: 1em 0; 165 | } 166 | 167 | 168 | .footer { 169 | padding-bottom: 1.5em; 170 | .footer-row { 171 | display: block; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/js/reducers/executionReducer-spec.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { expect } from 'chai'; 3 | import jsdom from 'mocha-jsdom'; 4 | import reducer, { initialState } from './executionReducer'; 5 | import * as actions from '../actions'; 6 | 7 | describe('execution reducer', () => { 8 | 9 | it('should return the initial state', () => { 10 | expect(reducer(undefined, {})).to.eql(initialState); 11 | }); 12 | 13 | it('should update the data on received data', () => { 14 | const action = { 15 | type: actions.RECEIVED_DATA, 16 | name: 'github', 17 | data: {repos: 12} 18 | }; 19 | const newState = initialState.setIn(['data', 'github', 'repos'], 12); 20 | expect(reducer(initialState, action).toJS()).to.eql(newState.toJS()); 21 | }); 22 | 23 | it('should clear block results and executed state on update', () => { 24 | const action = { 25 | type: actions.UPDATE_BLOCK, 26 | id: '12' 27 | }; 28 | const beforeState = initialState 29 | .setIn(['results', '12'], 120) 30 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12')); 31 | expect(reducer(beforeState, action).toJS()).to.eql(initialState.toJS()); 32 | }); 33 | 34 | it('should clear block results and executed state on update', () => { 35 | const action = { 36 | type: actions.DELETE_BLOCK, 37 | id: '12' 38 | }; 39 | const beforeState = initialState 40 | .setIn(['results', '12'], 120) 41 | .set('blocksExecuted', initialState.get('blocksExecuted').add('12')); 42 | expect(reducer(beforeState, action).toJS()).to.eql(initialState.toJS()); 43 | }); 44 | 45 | it('should clear datasource data when the datasource is deleted', () => { 46 | const action = { 47 | type: actions.DELETE_DATASOURCE, 48 | id: 'github' 49 | }; 50 | const beforeState = initialState.setIn(['data', 'github', 'repos'], 12); 51 | expect(reducer(beforeState, action).toJS()).to.eql(initialState.toJS()); 52 | }); 53 | 54 | it('should clear datasource data when the datasource is updated', () => { 55 | const action = { 56 | type: actions.UPDATE_DATASOURCE, 57 | id: 'github' 58 | }; 59 | const beforeState = initialState.setIn(['data', 'github', 'repos'], 12); 60 | expect(reducer(beforeState, action).toJS()).to.eql(initialState.toJS()); 61 | }); 62 | 63 | it('should update result, executed and context on CODE_EXECUTED', () => { 64 | const action = { 65 | type: actions.CODE_EXECUTED, 66 | id: '99', 67 | data: 3, 68 | context: Immutable.Map({number: 10}) 69 | }; 70 | const expected = initialState.setIn( 71 | ['results', '99'], 3 72 | ).set( 73 | 'blocksExecuted', initialState.get('blocksExecuted').add('99') 74 | ).set( 75 | 'executionContext', Immutable.Map({number: 10}) 76 | ); 77 | expect(reducer(initialState, action).toJS()).to.eql(expected.toJS()); 78 | }); 79 | 80 | it('should update result and executed on CODE_ERROR', () => { 81 | const action = { 82 | type: actions.CODE_ERROR, 83 | id: '99', 84 | data: 'Some error' 85 | }; 86 | const expected = initialState.setIn( 87 | ['results', '99'], 'Some error' 88 | ).set( 89 | 'blocksExecuted', initialState.get('blocksExecuted').add('99') 90 | ); 91 | expect(reducer(initialState, action).toJS()).to.eql(expected.toJS()); 92 | }); 93 | 94 | }); 95 | -------------------------------------------------------------------------------- /src/js/markdown-spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import fs from 'fs'; 3 | import Immutable from 'immutable'; 4 | import { parse, render, extractCodeBlocks } from './markdown'; 5 | 6 | function loadMarkdown(filename) { 7 | return fs.readFileSync('./test/' + filename + '.md').toString(); 8 | } 9 | 10 | describe('markdown', () => { 11 | 12 | const sampleNotebook = Immutable.fromJS({ 13 | metadata: { 14 | title: 'A sample notebook', 15 | author: 'Joel Auterson', 16 | created: new Date(Date.parse("Mon Apr 18 2016 21:48:01 GMT+0100 (BST)")), 17 | showFooter: true, 18 | original: undefined, 19 | datasources: {} 20 | }, 21 | content: ['0', '1', '2'], 22 | blocks: { 23 | '0': { 24 | type: 'text', 25 | id: '0', 26 | content: '## This is a sample Notebook\n\n' + 27 | 'It _should_ get correctly parsed.\n\n' + 28 | '[This is a link](http://github.com)\n\n' + 29 | '![Image, with alt](https://github.com/thing.jpg "Optional title")\n' + 30 | '![](https://github.com/thing.jpg)\n\n' + 31 | '```python\nprint "Non-runnable code sample"\n```\n\n' + 32 | 'And finally a runnable one...' 33 | }, 34 | '1': { 35 | type: 'code', 36 | id: '1', 37 | language: 'javascript', 38 | option: 'runnable', 39 | content: 'console.log("Runnable");' 40 | }, 41 | '2': { 42 | type: 'text', 43 | id: '2', 44 | content: '```\nIsolated non-runnable\n```' 45 | } 46 | } 47 | }); 48 | 49 | describe('parse', () => { 50 | 51 | it('correctly parses sample markdown', () => { 52 | const sampleMd = loadMarkdown('sampleNotebook'); 53 | expect(parse(sampleMd).toJS()).to.eql(sampleNotebook.toJS()); 54 | }); 55 | 56 | it('uses placeholders for a blank document', () => { 57 | const expected = Immutable.fromJS({ 58 | metadata: { 59 | title: undefined, 60 | author: undefined, 61 | created: undefined, 62 | showFooter: true, 63 | original: undefined, 64 | datasources: {} 65 | }, 66 | blocks: {}, 67 | content: [] 68 | }); 69 | expect(parse('').toJS()).to.eql(expected.toJS()); 70 | }); 71 | 72 | }); 73 | 74 | describe('render', () => { 75 | 76 | it('should correctly render a sample notebook', () => { 77 | const sampleMd = loadMarkdown('sampleNotebook'); 78 | expect(render(sampleNotebook)).to.equal(sampleMd); 79 | }); 80 | 81 | it('should correctly render an empty notebook', () => { 82 | const nb = Immutable.fromJS({ 83 | metadata: {}, 84 | blocks: {}, 85 | content: [] 86 | }); 87 | const expected = '---\n---\n\n\n'; 88 | expect(render(nb)).to.equal(expected); 89 | }); 90 | 91 | }); 92 | 93 | describe('parse and render', () => { 94 | 95 | it('should render a parsed notebook to the original markdown', () => { 96 | const sampleMd = loadMarkdown('index'); 97 | expect(render(parse(sampleMd))).to.equal(sampleMd); 98 | }); 99 | 100 | }); 101 | 102 | }); 103 | -------------------------------------------------------------------------------- /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 'codemirror/mode/javascript/javascript'; 5 | import 'codemirror/mode/markdown/markdown'; 6 | import { 7 | updateBlock, deleteBlock, moveBlockUp, moveBlockDown, editBlock 8 | } from '../actions'; 9 | 10 | export default class Block extends Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | this.enterEdit = this.enterEdit.bind(this); 15 | this.textChanged = this.textChanged.bind(this); 16 | this.getButtons = this.getButtons.bind(this); 17 | this.deleteBlock = this.deleteBlock.bind(this); 18 | this.moveBlockUp = this.moveBlockUp.bind(this); 19 | this.moveBlockDown = this.moveBlockDown.bind(this); 20 | } 21 | 22 | enterEdit(e) { 23 | if (this.props.editable) { 24 | e.stopPropagation(); 25 | const { dispatch, block } = this.props; 26 | this.setState({ 27 | text: block.get('content') 28 | }); 29 | dispatch(editBlock(block.get('id'))); 30 | } 31 | } 32 | 33 | textChanged(text) { 34 | this.setState({text}); 35 | } 36 | 37 | componentDidUpdate() { 38 | if (this.refs.editarea) { 39 | this.refs.editarea.focus(); 40 | const domNode = findDOMNode(this.refs.editarea); 41 | if (domNode.scrollIntoViewIfNeeded) { 42 | findDOMNode(this.refs.editarea).scrollIntoViewIfNeeded(false); 43 | } 44 | } 45 | } 46 | 47 | componentWillReceiveProps(newProps) { 48 | if (this.props.editing && !newProps.editing && 49 | this.props.block.get('content') === newProps.block.get('content')) { 50 | // If exiting edit mode, save text (unless it's an undo)) 51 | this.props.dispatch( 52 | updateBlock(this.props.block.get('id'), this.state.text) 53 | ); 54 | } 55 | } 56 | 57 | deleteBlock() { 58 | this.props.dispatch(deleteBlock(this.props.block.get('id'))); 59 | } 60 | 61 | moveBlockUp() { 62 | this.props.dispatch(moveBlockUp(this.props.block.get('id'))); 63 | } 64 | 65 | moveBlockDown() { 66 | this.props.dispatch(moveBlockDown(this.props.block.get('id'))); 67 | } 68 | 69 | getButtons() { 70 | if (!this.props.editable) { 71 | return null; 72 | } 73 | let buttons = []; 74 | if (!this.props.isLast) { 75 | buttons.push( 76 | 78 | 79 | ); 80 | } 81 | if (!this.props.isFirst) { 82 | buttons.push( 83 | 85 | 86 | ); 87 | } 88 | buttons.push( 89 | 91 | ) 92 | ; 93 | return buttons; 94 | } 95 | 96 | render() { 97 | const { block, editable, editing } = this.props; 98 | if (!(editable && editing)) { 99 | return this.renderViewerMode(); 100 | } 101 | const isCodeBlock = block.get('type') === 'code'; 102 | const options = { 103 | mode: isCodeBlock ? 'javascript' : 'markdown', 104 | theme: 'base16-tomorrow-light', 105 | lineNumbers: true, 106 | indentUnit: 4, 107 | extraKeys: { 108 | Tab: (cm) => { 109 | var spaces = Array(cm.getOption("indentUnit") + 1).join(" "); 110 | cm.replaceSelection(spaces); 111 | } 112 | } 113 | }; 114 | return ( 115 |
{e.stopPropagation()}}> 116 | 118 |
119 | ); 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/js/components/CodeBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MarkdownIt from 'markdown-it'; 3 | import Block 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 | 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 | } 21 | 22 | rawMarkup(codeBlock) { 23 | return { 24 | __html: md.render(codeToText(codeBlock)) 25 | }; 26 | } 27 | 28 | clickPlay() { 29 | const { dispatch, block } = this.props; 30 | dispatch(executeCodeBlock(block.get('id'))); 31 | } 32 | 33 | clickOption() { 34 | const { dispatch, block } = this.props; 35 | dispatch(changeCodeBlockOption(block.get('id'))); 36 | } 37 | 38 | getOptionButton() { 39 | const option = this.props.block.get('option'); 40 | if (!this.props.editable) { 41 | return null; 42 | } 43 | let icon, text; 44 | switch(option) { 45 | case 'runnable': 46 | icon = 'users'; 47 | text = 'Code is run by readers, by clicking the play button.'; 48 | break; 49 | case 'auto': 50 | icon = 'gear'; 51 | text = 'Code is run when the notebook is loaded.'; 52 | break; 53 | case 'hidden': 54 | icon = 'user-secret'; 55 | text = 'Code is run when the notebook is loaded, and only the results are displayed.'; 56 | break; 57 | default: 58 | return null; 59 | } 60 | return ( 61 | 63 | 64 | ); 65 | } 66 | 67 | getRunButton() { 68 | const option = this.props.block.get('option'); 69 | const icon = this.props.hasBeenRun ? "fa-refresh" : "fa-play-circle-o"; 70 | const showIconOptions = ['runnable', 'auto', 'hidden']; 71 | if (showIconOptions.indexOf(option) > -1) { 72 | return ( 73 | 75 | 76 | ); 77 | } 78 | } 79 | 80 | componentDidMount() { 81 | const { dispatch, block } = this.props; 82 | if (block.get('graphType')) { 83 | dispatch(clearGraphData(block.get('id'))); 84 | dispatch(executeCodeBlock(block.get('id'))); 85 | } 86 | } 87 | 88 | renderViewerMode() { 89 | const { block, hasBeenRun, result, editable } = this.props; 90 | let buttons = this.getButtons(); 91 | const runButton = this.getRunButton(); 92 | const optionButton = this.getOptionButton(); 93 | const hideBlock = !editable && block.get('option') === 'hidden'; 94 | const containerClass = hideBlock ? ' hiddenCode' : ''; 95 | if (buttons == null) { 96 | buttons = [runButton, optionButton]; 97 | } else { 98 | buttons.unshift(optionButton); 99 | buttons.unshift(runButton); 100 | } 101 | return ( 102 |
103 |
104 |
105 | {buttons} 106 |
107 |
109 |
110 |
111 | 114 | 123 |
124 | ); 125 | } 126 | 127 | } 128 | 129 | export default CodeBlock; 130 | -------------------------------------------------------------------------------- /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 } from '../actions'; 7 | import { renderHTML } from '../util'; 8 | import { saveGist } from '../actions'; 9 | 10 | class SaveDialog extends Component { 11 | 12 | constructor(props) { 13 | super(props); 14 | this.close = this.close.bind(this); 15 | this.getCssClass = this.getCssClass.bind(this); 16 | this.setMode = this.setMode.bind(this); 17 | this.state = {mode: 'md'}; 18 | } 19 | 20 | close() { 21 | this.props.dispatch(toggleSave()); 22 | } 23 | 24 | componentDidMount() { 25 | this.clipboard = new Clipboard('.clipboard-button', { 26 | text: () => { 27 | const markdown = render(this.props.notebook); 28 | switch (this.state.mode) { 29 | case 'html': 30 | return renderHTML(markdown); 31 | case 'gist': 32 | return this.props.notebook.getIn(['metadata', 'gistUrl']); 33 | default: 34 | return markdown; 35 | } 36 | } 37 | }) 38 | .on('error', () => { 39 | console.warn("Clipboard isn't supported on your browser."); 40 | }); 41 | } 42 | 43 | componentWillUnmount() { 44 | this.clipboard.destroy(); 45 | } 46 | 47 | getCssClass(button) { 48 | const css = 'export-option'; 49 | if (this.state.mode === button) { 50 | return css + ' selected'; 51 | } 52 | return css; 53 | } 54 | 55 | setMode(newMode) { 56 | this.setState({mode: newMode}); 57 | // Create the Gist if necessary 58 | if (newMode === 'gist' && !this.props.notebook.getIn(['metadata', 'gistUrl'])) { 59 | this.props.dispatch(saveGist( 60 | this.props.notebook.getIn(['metadata', 'title']), 61 | render(this.props.notebook) 62 | )); 63 | } 64 | } 65 | 66 | render() { 67 | const { notebook } = this.props; 68 | const gistUrl = notebook.getIn(['metadata', 'gistUrl']); 69 | const markdown = render(notebook); 70 | const text = (this.state.mode === 'html') ? renderHTML(markdown) : markdown; 71 | const textContent = ( 72 |
73 |