├── .gitignore ├── .npmignore ├── .babelrc ├── source ├── index.js ├── ConsoleController.js ├── util.js ├── compose.js ├── ResponsiveDualModeController.js ├── FakeWindow.js ├── BreadboardBuild.js ├── ComponentBreadboard.js ├── RawBreadboard.js ├── MDXBreadboard.js ├── Injectors.js └── Breadboard.js ├── package.json ├── README.md └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | source 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "latest"], 3 | "plugins": ["transform-class-properties", "transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | export { default as Breadboard } from './Breadboard' 2 | export { default as RawBreadboard } from './RawBreadboard' 3 | export { default as ComponentBreadboard } from './ComponentBreadboard' 4 | export { default as MDXBreadboard } from './MDXBreadboard' 5 | export { default as ResponsiveDualModeController } from './ResponsiveDualModeController' 6 | -------------------------------------------------------------------------------- /source/ConsoleController.js: -------------------------------------------------------------------------------- 1 | import { Controller } from 'hatt' 2 | 3 | export default class ConsoleController extends Controller { 4 | static actions = { 5 | log(...args) { 6 | this.logMessage('log', ...args) 7 | }, 8 | error(...args) { 9 | this.logMessage('error', ...args) 10 | }, 11 | warn(...args) { 12 | this.logMessage('warn', ...args) 13 | }, 14 | 15 | clear() { 16 | this.setState({ messages: [] }) 17 | } 18 | } 19 | 20 | static initialState = { 21 | messages: [], 22 | } 23 | 24 | logMessage(type, ...args) { 25 | this.setState({ 26 | messages: this.state.messages.concat({ type, args }) 27 | }) 28 | } 29 | 30 | output() { 31 | return { 32 | actions: this.actions, 33 | messages: this.state.messages, 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /source/util.js: -------------------------------------------------------------------------------- 1 | export function verifyThemePropTypes(props, propTypes) { 2 | // TODO. 3 | } 4 | 5 | export function verifyMissingProps(props, propNames) { 6 | // TODO. 7 | } 8 | 9 | // Returns a function, that, as long as it continues to be invoked, will not 10 | // be triggered. The function will be called after it stops being called for 11 | // N milliseconds. If `immediate` is passed, trigger the function on the 12 | // leading edge, instead of the trailing. 13 | // https://davidwalsh.name/javascript-debounce-function 14 | export function debounce(func, wait, immediate) { 15 | var timeout; 16 | return function() { 17 | var context = this, args = arguments; 18 | var later = function() { 19 | timeout = null; 20 | if (!immediate) func.apply(context, args); 21 | }; 22 | var callNow = immediate && !timeout; 23 | clearTimeout(timeout); 24 | timeout = setTimeout(later, wait); 25 | if (callNow) func.apply(context, args); 26 | }; 27 | }; -------------------------------------------------------------------------------- /source/compose.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | The MIT License (MIT) 4 | 5 | Copyright (c) 2015-present Dan Abramov 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | 13 | */ 14 | 15 | export default function compose(...funcs) { 16 | if (funcs.length === 0) { 17 | return arg => arg 18 | } 19 | 20 | if (funcs.length === 1) { 21 | return funcs[0] 22 | } 23 | 24 | return funcs.reduce((a, b) => (...args) => a(b(...args))) 25 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "armo-breadboard", 3 | "version": "0.2.5", 4 | "description": "Edit a live React component's source in real time.", 5 | "author": "James K Nelson ", 6 | "license": "MIT", 7 | "main": "lib/index.js", 8 | "scripts": { 9 | "clean": "rimraf lib", 10 | "build:watch": "cross-env BABEL_ENV=commonjs babel --watch --source-maps=inline -d lib/ source/", 11 | "build": "cross-env BABEL_ENV=commonjs babel source --out-dir lib", 12 | "prepublish": "npm run clean && npm run build" 13 | }, 14 | "keywords": [ 15 | "playground", 16 | "react", 17 | "component", 18 | "breadboard", 19 | "armo", 20 | "live" 21 | ], 22 | "peerDependencies": { 23 | "react": "^15.4.2", 24 | "react-dom": "^15.4.2" 25 | }, 26 | "devDependencies": { 27 | "babel-core": "^6.24.0", 28 | "babel-plugin-transform-class-properties": "^6.24.0", 29 | "babel-plugin-transform-es2015-modules-commonjs": "^6.24.0", 30 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 31 | "babel-preset-latest": "^6.24.0", 32 | "cross-env": "^3.1.4" 33 | }, 34 | "dependencies": { 35 | "babel-core": "^6.24.0", 36 | "babel-preset-latest": "^6.24.0", 37 | "babel-preset-react": "^6.24.0", 38 | "exenv": "^1.2.1", 39 | "hatt": "^0.2.1", 40 | "hoist-non-react-statics": "^1.2.0", 41 | "mdxc": "^1.0.0-beta.4", 42 | "prop-types": "^15.5.10", 43 | "react-controllers": "^0.1.1", 44 | "resize-observer-polyfill": "^1.4.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /source/ResponsiveDualModeController.js: -------------------------------------------------------------------------------- 1 | import { PureController } from 'react-controllers' 2 | 3 | 4 | export default class ResponsiveDualModeController extends PureController { 5 | static defaultProps = { 6 | /** 7 | * Selects the secondary pane to display in the case that the user is 8 | * viewing the source pane on a small screen, and then the screen 9 | * expands to allow a second pane. 10 | */ 11 | defaultSecondary: 'view', 12 | 13 | /** 14 | * The default mode to display upon load when the screen only contains 15 | * space for a single pane. 16 | */ 17 | defaultMode: 'source', 18 | 19 | /** 20 | * The maximum width for which only a single pane will be used. 21 | */ 22 | maxSinglePaneWidth: 999, 23 | } 24 | 25 | static actions = { 26 | selectMode(mode) { 27 | this.setState({ primary: mode }) 28 | }, 29 | selectTransformed() { 30 | this.setState({ primary: 'transformed' }) 31 | }, 32 | selectView() { 33 | this.setState({ primary: 'view' }) 34 | }, 35 | selectConsole() { 36 | this.setState({ primary: 'console' }) 37 | }, 38 | selectSource() { 39 | this.setState({ primary: 'source' }) 40 | }, 41 | } 42 | 43 | constructor(props) { 44 | super(props) 45 | 46 | this.state = { 47 | primary: props.defaultMode, 48 | } 49 | } 50 | 51 | output() { 52 | const props = this.props 53 | const primary = this.state.primary 54 | const modes = {} 55 | 56 | if (props.width !== undefined && props.width <= props.maxSinglePaneWidth) { 57 | modes[primary] = true 58 | } 59 | else { 60 | modes['source'] = true 61 | modes[primary === 'source' ? props.defaultSecondary : primary] = true 62 | } 63 | 64 | return Object.assign(modes, this.actions) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /source/FakeWindow.js: -------------------------------------------------------------------------------- 1 | export default class FakeWindow { 2 | constructor(console) { 3 | this.seq = 1 4 | 5 | this.timeouts = [] 6 | this.intervals = [] 7 | this.frames = [] 8 | 9 | this.actions = { 10 | console: console, 11 | 12 | setTimeout: (cb, ms) => { 13 | const id = window.setTimeout(cb, ms) 14 | this.timeouts.push(id) 15 | return id 16 | }, 17 | 18 | setInterval: (cb, ms) => { 19 | const id = window.setInterval(cb, ms) 20 | this.intervals.push(id) 21 | return id 22 | }, 23 | 24 | requestAnimationFrame: (cb) => { 25 | const id = window.requestAnimationFrame(cb) 26 | this.frames.push(id) 27 | return id 28 | }, 29 | 30 | fetch: (...args) => { 31 | const seq = this.seq 32 | return new Promise((resolve, reject) => 33 | window.fetch(...args).then( 34 | (...success) => { 35 | if (seq === this.seq) { 36 | resolve(...success) 37 | } 38 | }, 39 | (...failure) => { 40 | if (seq === this.seq) { 41 | reject(...failure) 42 | } 43 | } 44 | ) 45 | ) 46 | }, 47 | 48 | History: {}, 49 | } 50 | } 51 | 52 | reset() { 53 | for (let timeout of this.timeouts) { 54 | window.clearTimeout(timeout) 55 | } 56 | for (let interval of this.intervals) { 57 | window.clearInterval(interval) 58 | } 59 | for (let frame of this.frames) { 60 | window.cancelAnimationFrame(frame) 61 | } 62 | 63 | this.timeouts.length = 0 64 | this.intervals.length = 0 65 | this.frames.length = 0 66 | 67 | this.actions.console.clear() 68 | this.seq++ 69 | } 70 | 71 | destroy() { 72 | this.reset() 73 | this.actions.console = null 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /source/BreadboardBuild.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Handle the process of turning sources into a `render` function, memoizing 3 | * transforms and packed strings where appropriate to ensure that things aren't 4 | * needlessly rebuilt. 5 | * 6 | * A single Breadboard contains a single BreadboardBuild object. 7 | */ 8 | class BreadboardBuild { 9 | construtor(transforms, require, packer, renderToString) { 10 | 11 | } 12 | 13 | run(sources, shouldPack=true) { 14 | // - if sources are identical to previous sources, and a cached result 15 | // exists, just use it 16 | 17 | // - run transforms on source files based on patterns, memoizing a single 18 | // previous value per file. invalidate any cached packer result 19 | 20 | // - turn the transformed sources into a `render` file using the packer, 21 | // so long as shouldPack is true 22 | 23 | return { 24 | // A `(mountpoint, props) => void` function that renders one frame of 25 | // the app with the given props. This function may be called without 26 | // cleaning up previous frames if the props change. 27 | render, 28 | 29 | // A single string that includes all transformed sources 30 | packedSource, 31 | 32 | // A paused FakeWindow object 33 | fakeWindow, 34 | } 35 | } 36 | 37 | renderToString(sources) { 38 | 39 | } 40 | } 41 | 42 | 43 | 44 | function defaultPack(source, require, window) { 45 | try { 46 | const exports = {} 47 | const module = { exports: exports } 48 | 49 | const execute = new Function( 50 | 'window', 51 | 'setTimeout', 52 | 'setInterval', 53 | 'requestAnimationFrame', 54 | 'fetch', 55 | 'History', 56 | 'console', 57 | 'module', 58 | 'exports', 59 | 'require', 60 | source 61 | ) 62 | execute( 63 | window, 64 | window.setTimeout, 65 | window.setInterval, 66 | window.requestAnimationFrame, 67 | window.fetch, 68 | window.History, 69 | window.console, 70 | module, 71 | exports, 72 | require, 73 | ) 74 | 75 | const component = exports.default 76 | 77 | return (mount, props={}) => { 78 | if (component) { 79 | try { 80 | ReactDOM.render( 81 | React.createElement(component, props), 82 | mount 83 | ) 84 | } 85 | catch (err) { 86 | return err 87 | } 88 | } 89 | } 90 | } 91 | catch (err) { 92 | return () => err 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /source/ComponentBreadboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Breadboard from './Breadboard' 4 | import ResponsiveDualModeController from './ResponsiveDualModeController' 5 | import { transform } from 'babel-core' 6 | import latestPreset from 'babel-preset-latest' 7 | import reactPreset from 'babel-preset-react' 8 | 9 | 10 | export default class RawBreadboard extends Component { 11 | static propTypes = { 12 | /** 13 | * The default mode to display upon load when the screen only contains 14 | * space for a single pane. 15 | */ 16 | defaultMode: PropTypes.oneOf(['source', 'view', 'transformed', 'console']), 17 | 18 | /** 19 | * Selects the secondary pane to display in the case that the user is 20 | * viewing the source pane on a small screen, and then the screen 21 | * expands to allow a second pane. 22 | */ 23 | defaultSecondary: PropTypes.oneOf(['view', 'transformed', 'console']).isRequired, 24 | 25 | /** 26 | * The breadboard's theme. 27 | */ 28 | theme: PropTypes.shape({ 29 | renderBreadboard: PropTypes.func, 30 | renderEditor: PropTypes.func, 31 | }).isRequired, 32 | } 33 | 34 | static defaultProps = { 35 | defaultMode: 'source', 36 | defaultSecondary: 'view', 37 | } 38 | 39 | constructor(props) { 40 | super(props) 41 | 42 | this.modesController = new ResponsiveDualModeController({ 43 | maxSinglePaneWidth: props.theme.maxSinglePaneWidth, 44 | defaultSecondary: props.defaultSecondary, 45 | defaultMode: props.defaultMode, 46 | }) 47 | } 48 | 49 | componentWillReceiveProps(nextProps) { 50 | if (nextProps.theme.maxSinglePaneWidth !== this.props.theme.maxSinglePaneWidth) { 51 | this.modesController.environmentDidChange({ 52 | maxSinglePaneWidth: nextProps.theme.maxSinglePaneWidth, 53 | }) 54 | } 55 | } 56 | 57 | renderTheme = (props) => { 58 | return this.props.theme.renderBreadboard(Object.assign({}, props, { 59 | reactVersion: React.version, 60 | appId: this.props.appId, 61 | })) 62 | } 63 | 64 | render() { 65 | const { ...other } = this.props 66 | 67 | return ( 68 | 75 | ) 76 | } 77 | 78 | transform = (source) => { 79 | let transformed 80 | let error = null 81 | 82 | try { 83 | transformed = transform(source, { presets: [reactPreset, latestPreset] }).code 84 | } 85 | catch (e) { 86 | error = e 87 | } 88 | 89 | return { 90 | transformedSource: transformed, 91 | executableSource: transformed, 92 | error: error, 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /source/RawBreadboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Breadboard from './Breadboard' 4 | import { injectDimensions } from './Injectors' 5 | import ResponsiveDualModeController from './ResponsiveDualModeController' 6 | import { controlledBy } from 'react-controllers' 7 | import compose from './compose' 8 | import { transform } from 'babel-core' 9 | import latestPreset from 'babel-preset-latest' 10 | import reactPreset from 'babel-preset-react' 11 | 12 | 13 | function rawPrepare(source, require, window) { 14 | try { 15 | const exports = {} 16 | const module = { exports: exports } 17 | 18 | const execute = new Function( 19 | 'window', 20 | 'setTimeout', 21 | 'setInterval', 22 | 'requestAnimationFrame', 23 | 'fetch', 24 | 'History', 25 | 'console', 26 | 'module', 27 | 'exports', 28 | 'require', 29 | 'breadboard', 30 | 'React', 31 | 'ReactDOM', 32 | '__MOUNT__', 33 | source 34 | ) 35 | 36 | return (mount, props={}) => { 37 | try { 38 | execute( 39 | window, 40 | window.setTimeout, 41 | window.setInterval, 42 | window.requestAnimationFrame, 43 | window.fetch, 44 | window.History, 45 | window.console, 46 | module, 47 | exports, 48 | require, 49 | props, 50 | React, 51 | ReactDOM, 52 | mount 53 | ) 54 | } 55 | catch (err) { 56 | return err 57 | } 58 | } 59 | } 60 | catch (err) { 61 | return () => err 62 | } 63 | } 64 | 65 | 66 | const decorate = compose( 67 | injectDimensions.withConfiguration({ height: null }), 68 | controlledBy({ modes: ResponsiveDualModeController }) 69 | ) 70 | 71 | export default decorate(class RawBreadboard extends Component { 72 | static propTypes = { 73 | /** 74 | * When this id is used in a `document.getElementById` call, the entire 75 | * call will be replaced with the mountpoint's element. Note that this 76 | * means previews cannot be generated server-side. 77 | */ 78 | appId: PropTypes.string.isRequired, 79 | 80 | /** 81 | * The breadboard's theme. 82 | */ 83 | theme: PropTypes.shape({ 84 | renderBreadboard: PropTypes.func, 85 | renderEditor: PropTypes.func, 86 | }).isRequired, 87 | } 88 | 89 | static defaultProps = { 90 | appId: 'app', 91 | } 92 | 93 | renderTheme = (props) => { 94 | return this.props.theme.renderBreadboard(Object.assign({}, props, { 95 | reactVersion: React.version, 96 | appId: this.props.appId, 97 | })) 98 | } 99 | 100 | render() { 101 | return ( 102 | 110 | ) 111 | } 112 | 113 | transform = (source) => { 114 | let transformed 115 | let error = null 116 | 117 | const appPattern = new RegExp(`document\\s*.\\s*getElementById\\s*\\(\\s*['"]${this.props.appId}['"]\\s*\\)`, 'g') 118 | const sourceWithAppId = source.replace(appPattern, ' __MOUNT__ ') 119 | 120 | try { 121 | transformed = transform(sourceWithAppId, { presets: [reactPreset, latestPreset] }).code 122 | } 123 | catch (e) { 124 | error = e 125 | } 126 | 127 | return { 128 | transformedSource: transformed, 129 | executableSource: transformed, 130 | error: error, 131 | } 132 | } 133 | }) -------------------------------------------------------------------------------- /source/MDXBreadboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { Controller, createController } from 'hatt' 3 | import frontMatter from 'front-matter' 4 | import MDXC from 'mdxc' 5 | import { transform } from 'babel-core' 6 | import latestPreset from 'babel-preset-latest' 7 | import ResponsiveDualModeController from './ResponsiveDualModeController' 8 | import Breadboard from './Breadboard' 9 | 10 | 11 | const wrappedMDXC = new MDXC({ 12 | linkify: true, 13 | typographer: true, 14 | highlight: false, 15 | }) 16 | const unwrappedMDXC = new MDXC({ 17 | linkify: true, 18 | typographer: true, 19 | highlight: false, 20 | unwrapped: true, 21 | }) 22 | 23 | 24 | class ViewController extends Controller { 25 | static actions = { 26 | setValue(e) { 27 | this.setState({ 28 | value: e.target.value, 29 | }) 30 | }, 31 | } 32 | 33 | static initialState = { 34 | value: null, 35 | } 36 | 37 | output() { 38 | return { 39 | ...this.env, 40 | value: this.state.value, 41 | onChange: this.actions.setValue, 42 | } 43 | } 44 | } 45 | 46 | 47 | export default class MDXBreadboard extends Component { 48 | static propTypes = { 49 | /** 50 | * The default mode to display upon load when the screen only contains 51 | * space for a single pane. 52 | */ 53 | defaultMode: PropTypes.oneOf(['source', 'view', 'transformed', 'console']), 54 | 55 | /** 56 | * Selects the secondary pane to display in the case that the user is 57 | * viewing the source pane on a small screen, and then the screen 58 | * expands to allow a second pane. 59 | */ 60 | defaultSecondary: PropTypes.oneOf(['view', 'transformed', 'console']).isRequired, 61 | 62 | /** 63 | * Configures whether the wrapper code will be displayed within the 64 | * transformed view. 65 | */ 66 | defaultUnwrapped: PropTypes.bool, 67 | 68 | /** 69 | * Allows you to configure the factories of the rendered MDXDocument 70 | * object. 71 | */ 72 | factories: PropTypes.object, 73 | 74 | /** 75 | * A function that renders the breadboard given a set of state and 76 | * event handlers. 77 | */ 78 | theme: PropTypes.shape({ 79 | renderBreadboard: PropTypes.func, 80 | renderCode: PropTypes.func, 81 | renderEditor: PropTypes.func, 82 | }), 83 | } 84 | 85 | static defaultProps = { 86 | defaultMode: 'source', 87 | defaultSecondary: 'view', 88 | defaultUnwrapped: false, 89 | } 90 | 91 | constructor(props) { 92 | super(props) 93 | 94 | this.modesController = new ResponsiveDualModeController({ 95 | maxSinglePaneWidth: props.theme.maxSinglePaneWidth, 96 | defaultSecondary: props.defaultSecondary, 97 | defaultMode: props.defaultMode, 98 | }) 99 | 100 | this.viewController = createController(ViewController, { 101 | factories: { 102 | ...this.props.factories, 103 | codeBlock: this.renderCodeBlock, 104 | }, 105 | }) 106 | 107 | this.state = { 108 | unwrapped: this.props.defaultUnwrapped, 109 | transform: this.transform.bind(this, this.props.defaultUnwrapped) 110 | } 111 | } 112 | 113 | componentWillReceiveProps(nextProps) { 114 | if (nextProps.theme.maxSinglePaneWidth !== this.props.theme.maxSinglePaneWidth) { 115 | this.modesController.environmentDidChange({ 116 | maxSinglePaneWidth: nextProps.theme.maxSinglePaneWidth, 117 | }) 118 | } 119 | if (nextProps.factories !== this.props.factories) { 120 | this.viewController.setEnv({ 121 | factories: { 122 | ...this.props.factories, 123 | codeBlock: this.renderCodeBlock, 124 | }, 125 | }) 126 | } 127 | } 128 | 129 | componentWillUnmount() { 130 | this.viewController.destroy() 131 | } 132 | 133 | renderCodeBlock = (props, children) => { 134 | const language = props.className.replace(/^language-/, '') 135 | let renderBreadboard 136 | 137 | if (language.slice(0, 3) === 'mdx') { 138 | const optionStrings = language.slice(4).replace(/^\{|\s|\}$/g, '').split(',') 139 | const options = {} 140 | for (let str of optionStrings) { 141 | if (str.indexOf('=') === -1) { 142 | options[str] = true 143 | } 144 | else { 145 | const parts = str.split('=') 146 | options[parts[0]] = parts[1] 147 | } 148 | } 149 | renderBreadboard = (themeProps) => 150 | 158 | } 159 | 160 | return this.props.theme.renderCode({ language, renderBreadboard, source: children }) 161 | } 162 | 163 | renderTheme = (props) => { 164 | return this.props.theme.renderBreadboard(Object.assign({}, props, { 165 | defaultMode: this.props.defaultMode, 166 | defaultSecondary: this.props.defaultSecondary, 167 | 168 | unwrapped: this.state.unwrapped, 169 | onToggleWrapped: this.toggleWrapped, 170 | })) 171 | } 172 | 173 | render() { 174 | const { factories, defaultUnwrapped, ...other } = this.props 175 | 176 | return ( 177 | 185 | ) 186 | } 187 | 188 | toggleWrapped = () => { 189 | const newUnwrapped = !this.state.unwrapped 190 | 191 | this.setState({ 192 | unwrapped: newUnwrapped, 193 | transform: this.transform.bind(this, newUnwrapped) 194 | }) 195 | } 196 | 197 | transform = (unwrapped, source) => { 198 | const result = {} 199 | const data = frontMatter(source) 200 | const es6 = wrappedMDXC.render(data.body) 201 | const pretty = unwrapped ? unwrappedMDXC.render(data.body) : es6 202 | let runnableCode 203 | let error = null 204 | try { 205 | runnableCode = transform(es6, { presets: [latestPreset] }).code 206 | } 207 | catch (e) { 208 | error = e 209 | } 210 | 211 | return { 212 | transformedSource: pretty, 213 | executableSource: runnableCode, 214 | error, 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /source/Injectors.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ReactDOM from 'react-dom' 3 | import ExecutionEnvironment from 'exenv' 4 | import PropTypes from 'prop-types' 5 | import hoistNonReactStatics from 'hoist-non-react-statics' 6 | 7 | 8 | let breadboardResizeObserver 9 | if (ExecutionEnvironment.canUseDOM) { 10 | const ResizeObserver = require('resize-observer-polyfill').default 11 | 12 | class BreadboardResizeObserver { 13 | constructor() { 14 | this.callbacks = new Map 15 | this.observer = new ResizeObserver((entries) => { 16 | for (const entry of entries) { 17 | const callback = this.callbacks.get(entry.target) 18 | 19 | if (callback) { 20 | callback({ 21 | height: entry.contentRect.height, 22 | width: entry.contentRect.width, 23 | }) 24 | } 25 | } 26 | }) 27 | } 28 | 29 | observe(target, callback) { 30 | this.observer.observe(target) 31 | this.callbacks.set(target, callback) 32 | } 33 | 34 | unobserve(target, callback) { 35 | this.observer.unobserve(target) 36 | this.callbacks.delete(target, callback) 37 | } 38 | } 39 | 40 | breadboardResizeObserver = new BreadboardResizeObserver 41 | } 42 | 43 | 44 | /** 45 | * Inject the child element's width and height, as computed by a 46 | * ResizeObserver. 47 | */ 48 | export class InjectDimensions extends Component { 49 | static propTypes = { 50 | /** 51 | * The value to use for `height` before we are able to make a measurement. 52 | */ 53 | defaultHeight: PropTypes.number, 54 | 55 | /** 56 | * The value to use for `width` before we are able to make a measurement. 57 | */ 58 | defaultWidth: PropTypes.number, 59 | 60 | /** 61 | * If a number or `null`, the height will be passed directly to the child 62 | * element instead of being observed. 63 | */ 64 | height: PropTypes.number, 65 | 66 | /** 67 | * If a number or `null`, the width will be passed directly to the child 68 | * element instead of being observed. 69 | */ 70 | width: PropTypes.number, 71 | 72 | /** 73 | * This component expects a single child that is a React Element. 74 | */ 75 | children: PropTypes.element.isRequired, 76 | } 77 | 78 | constructor(props) { 79 | super(props) 80 | 81 | // The dimensions are not defined until we can measure them, or unless 82 | // a fixed value is provided. 83 | this.state = { 84 | observed: false, 85 | height: undefined, 86 | width: undefined, 87 | } 88 | } 89 | 90 | componentDidMount() { 91 | const shouldObserve = this.props.width === undefined || this.props.height === undefined 92 | 93 | if (shouldObserve) { 94 | this.observe() 95 | } 96 | } 97 | 98 | componentWillReceiveProps(nextProps) { 99 | const shouldObserve = nextProps.width === undefined || nextProps.height === undefined 100 | 101 | if (shouldObserve && !this.state.observed) { 102 | this.observe() 103 | } 104 | else if (!shouldObserve && this.state.observed) { 105 | this.unobserve() 106 | this.setState({ 107 | observed: false, 108 | }) 109 | } 110 | } 111 | componentDidUpdate(prevProps, prevState) { 112 | const shouldObserve = this.props.width === undefined || this.props.height === undefined 113 | 114 | if (this.domNode !== this.state.observed) { 115 | this.unobserve() 116 | if (this.domNode && shouldObserve) { 117 | this.observe() 118 | } 119 | } 120 | } 121 | componentWillUnmount() { 122 | this.unobserve() 123 | } 124 | 125 | shouldComponentUpdate(nextProps, nextState) { 126 | const measuredHeightChanged = nextState.height !== this.state.height 127 | const measuredWidthChanged = nextState.width !== this.state.width 128 | 129 | // don't cause an update when it originated from a resize observation, 130 | // but that observation is overriden by a forced width/height 131 | const insignificantMeasurementOccured = 132 | nextState.observed && this.state.observed && 133 | (measuredHeightChanged || measuredWidthChanged) && 134 | !( 135 | (measuredHeightChanged && nextProps.height === undefined) || 136 | (measuredWidthChanged && nextProps.width === undefined) 137 | ) 138 | 139 | return !insignificantMeasurementOccured 140 | } 141 | 142 | render() { 143 | const props = this.props 144 | const state = this.state 145 | 146 | return React.cloneElement( 147 | React.Children.only(props.children), 148 | { 149 | width: state.width === undefined ? props.defaultWidth : state.width, 150 | height: state.height === undefined ? props.defaultHeight : state.height, 151 | ref: this.receiveRef, 152 | } 153 | ) 154 | } 155 | 156 | receiveRef = (x) => { 157 | this.domNode = x && ReactDOM.findDOMNode(x) 158 | } 159 | 160 | handleResize = (measured) => { 161 | this.setState({ 162 | height: measured.height, 163 | width: measured.width, 164 | }) 165 | } 166 | 167 | observe() { 168 | breadboardResizeObserver.observe(this.domNode, this.handleResize) 169 | const measured = this.domNode.getBoundingClientRect() 170 | this.setState({ 171 | observed: this.domNode, 172 | height: measured.height, 173 | width: measured.width, 174 | }) 175 | } 176 | 177 | unobserve() { 178 | if (this.state.observed) { 179 | breadboardResizeObserver.unobserve(this.state.observed, this.handleResize) 180 | } 181 | } 182 | } 183 | 184 | export function injectDimensions(WrappedComponent) { 185 | function InjectDimensionsWrapper ({ defaultHeight, defaultWidth, height, width, ...other }) { 186 | return React.createElement(InjectDimensions, { defaultHeight, defaultWidth, height, width }, 187 | React.createElement(WrappedComponent, other) 188 | ) 189 | } 190 | 191 | hoistNonReactStatics(InjectDimensionsWrapper, WrappedComponent) 192 | 193 | return InjectDimensionsWrapper 194 | } 195 | 196 | injectDimensions.withConfiguration = function(forceProps) { 197 | return function injectDimensions(WrappedComponent) { 198 | function InjectDimensionsWrapper (props) { 199 | const { defaultHeight, defaultWidth, height, width, ...other } = Object.assign({}, props, forceProps) 200 | 201 | return React.createElement(InjectDimensions, { defaultHeight, defaultWidth, height, width }, 202 | React.createElement(WrappedComponent, other) 203 | ) 204 | } 205 | 206 | hoistNonReactStatics(InjectDimensionsWrapper, WrappedComponent) 207 | 208 | return InjectDimensionsWrapper 209 | } 210 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | armo-breadboard 2 | =============== 3 | 4 | [![npm version](https://img.shields.io/npm/v/armo-breadboard.svg)](https://www.npmjs.com/package/armo-breadboard) 5 | 6 | A themeable React component. Use it to edit a live React component's source in real time. 7 | 8 | Used on [reactarmory.com](http://reactarmory.com). **Only use this component to display your own code -- it is not safe for use with publicly submitted code. For public code, use a service like [codepen.io](http://codepen.io).** 9 | 10 | Installation 11 | ------------ 12 | 13 | ```bash 14 | yarn add armo-breadboard 15 | ``` 16 | 17 | Usage 18 | ----- 19 | 20 | There are currently three Breadboard components: 21 | 22 | - `RawBreadboard` 23 | 24 | Expects that the code will call `ReactDOM.render()` itself, with the result placed in `document.getElementById('app')`. Provides global `React` and `ReactDOM` objects. 25 | 26 | - `ComponentBreadboard` 27 | 28 | Expects that a script will import `React`, and export a default Component. Imports and renders the component. 29 | 30 | - `MDXBreadboard` 31 | 32 | Expects a Markdown document and compiles it to a React Component with [mdxc](https://github.com/jamesknelson/mdxc). 33 | 34 | ### Props 35 | 36 | Here is an overview of the props available for all Breadboard components. For full details on each component's available props, see the `propTypes` definition in the source. 37 | 38 | - `defaultSource` ***required*** 39 | 40 | The source to execute. Breadboards are uncontrolled; they currently do not emit events when the code updates/renders. PRs to add this functionality would be welcome. 41 | 42 | - `theme` ***required*** 43 | 44 | The theme object that actually renders the Breadboard. This is required as the Breadboard components themselves do not generate any HTML. For an example of a Breadboard theme, see the [theme](#themes) section. 45 | 46 | - `require` 47 | 48 | The `require` function that will be used when executing the Breadboard's source. Use this to configure how `import` statements work. 49 | 50 | By default, Breadboard provides a `require` function that just makes `react` available. 51 | 52 | - `defaultMode` 53 | 54 | Specifies the mode that the Breadboard will be in when loaded. Available options are: 55 | 56 | * `source` 57 | * `transformed` 58 | * `view` 59 | * `console` 60 | 61 | - `defaultSecondary` 62 | 63 | If your Breadboard element has enough space, it will split the view into two panels. In this case, the `source` panel will always be displayed. This option chooses a default for the second panel. All options from above are available -- except `source`. 64 | 65 | ### Themes 66 | 67 | Different websites call for default themes. Of course, CSS isn't always sufficient to make the theming changes that you'd like. Because of this, Breadboards do not generate *any* Markup themselves. Instead, they leave the markup generation to you, via *theme objects*. 68 | 69 | For an example of how theming can be used in practice, see [MDXC Playground](dump.jamesknelson.com/mdxc-playground.html). This page uses two Breadboard themes: 70 | 71 | - The "fullscreen" theme renders the document's source on the left, and the full document on the right. *([source](https://github.com/jamesknelson/mdxc-playground/blob/8924c21913ed568fbef8867463af3c10f6230422/source/fullscreenMDXBreadboardTheme.js))* 72 | - The "default" theme is used for embedded examples within the right pane. *([source](https://github.com/jamesknelson/mdxc-playground/blob/8924c21913ed568fbef8867463af3c10f6230422/source/defaultMDXBreadboardTheme.js))* 73 | 74 | The actual options available on a theme object differ between breadboards. For details, you'll currently need to view the source. 75 | 76 | #### Example 77 | 78 | This is an example of a theme for `RawBreadboard` and `ComponentBreadboard` that renders the editor using CodeMirror. This is used on [reactarmory.com](https://reactarmory.com) 79 | 80 | ```jsx 81 | import './defaultBreadboardTheme.less' 82 | import React, { Component, PropTypes } from 'react' 83 | import debounce from 'lodash.debounce' 84 | import codeMirror from 'codemirror' 85 | import createClassNamePrefixer from '../utils/createClassNamePrefixer' 86 | 87 | require("codemirror/mode/jsx/jsx") 88 | 89 | 90 | const cx = createClassNamePrefixer('defaultBreadboardTheme') 91 | 92 | 93 | export default { 94 | maxSinglePaneWidth: 800, 95 | 96 | renderBreadboard: function(props) { 97 | const { 98 | consoleMessages, 99 | transformedSource, 100 | transformError, 101 | executionError, 102 | 103 | renderEditorElement, 104 | renderMountElement, 105 | 106 | modes, 107 | modeActions, 108 | 109 | reactVersion, 110 | appId 111 | } = props 112 | 113 | const activeModeCount = Object.values(modes).reduce((acc, x) => acc + x || 0, 0) 114 | 115 | const sourceLayout = { 116 | position: 'relative', 117 | flexBasis: 600, 118 | flexGrow: 0, 119 | flexShrink: 0, 120 | } 121 | if (activeModeCount === 1) { 122 | sourceLayout.flexShrink = 1 123 | } 124 | 125 | const secondaryLayout = { 126 | position: 'relative', 127 | flexBasis: 600, 128 | flexGrow: 0, 129 | flexShrink: 1, 130 | overflow: 'auto', 131 | } 132 | 133 | return ( 134 |
135 | { (consoleMessages.length || activeModeCount == 1) && 136 | 145 | } 146 | { modes.source && 147 | renderEditorElement({ layout: sourceLayout }) 148 | } 149 | { // Always render the preview element, as the user's code may depend 150 | // on it being available. Hide it if it isn't selected. 151 |
152 | {renderMountElement()} 153 |
154 | } 155 | { modes.console && !transformError && !executionError && 156 | 161 | } 162 | { (transformError || executionError) && 163 |
164 |
165 |               Failed to Compile
166 |               {(transformError || executionError).toString()}
167 |             
168 |
169 | } 170 |
171 | ) 172 | }, 173 | 174 | renderEditor: function({ layout, value, onChange }) { 175 | return ( 176 | 182 | ) 183 | }, 184 | } 185 | 186 | const getType = function (el) { 187 | let t = typeof el; 188 | 189 | if (Array.isArray(el)) { 190 | t = "array"; 191 | } else if (el === null) { 192 | t = "null"; 193 | } 194 | 195 | return t; 196 | }; 197 | 198 | // Based on react-playground by Formidable Labs 199 | // See: https://github.com/FormidableLabs/component-playground/blob/master/src/components/es6-preview.jsx 200 | const wrapMap = { 201 | wrapnumber(num) { 202 | return {num}; 203 | }, 204 | 205 | wrapstring(str) { 206 | return {"'" + str + "'"}; 207 | }, 208 | 209 | wrapboolean(bool) { 210 | return {bool ? "true" : "false"}; 211 | }, 212 | 213 | wraparray(arr) { 214 | return ( 215 | 216 | {"["} 217 | {arr.map((entry, i) => { 218 | return ( 219 | 220 | {wrapMap["wrap" + getType(entry)](entry)} 221 | {i !== arr.length - 1 ? ", " : ""} 222 | 223 | ); 224 | })} 225 | {"]"} 226 | 227 | ); 228 | }, 229 | 230 | wrapobject(obj) { 231 | const pairs = []; 232 | let first = true; 233 | 234 | for (const key in obj) { 235 | pairs.push( 236 | 237 | 238 | {(first ? "" : ", ") + key} 239 | 240 | {": "} 241 | {wrapMap["wrap" + getType(obj[key])](obj[key])} 242 | 243 | ); 244 | 245 | first = false; 246 | } 247 | 248 | return {"Object {"}{pairs}{"}"}; 249 | }, 250 | 251 | wrapfunction() { 252 | return {"function"}; 253 | }, 254 | 255 | wrapnull() { 256 | return {"null"}; 257 | }, 258 | 259 | wrapundefined() { 260 | return {"undefined"}; 261 | } 262 | } 263 | 264 | 265 | function BreadboardConsole({ className, messages, style }) { 266 | return ( 267 |
268 | {messages.map(({ type, args }, i) => 269 |
270 | {args.map((arg, i) => 271 |
{wrapMap["wrap" + getType(arg)](arg)}
272 | )} 273 |
274 | )} 275 |
276 | ) 277 | } 278 | 279 | 280 | function normalizeLineEndings (str) { 281 | if (!str) return str; 282 | return str.replace(/\r\n|\r/g, '\n'); 283 | } 284 | 285 | // Based on these two files: 286 | // https://github.com/JedWatson/react-codemirror/blob/master/src/Codemirror.js 287 | // https://github.com/FormidableLabs/component-playground/blob/master/src/components/editor.jsx 288 | class JSXEditor extends Component { 289 | static propTypes = { 290 | theme: PropTypes.string, 291 | readOnly: PropTypes.bool, 292 | value: PropTypes.string, 293 | selectedLines: PropTypes.array, 294 | onChange: PropTypes.func, 295 | style: PropTypes.object, 296 | className: PropTypes.string 297 | } 298 | 299 | static defaultProps = { 300 | theme: "monokai", 301 | } 302 | 303 | state = { 304 | isFocused: false, 305 | } 306 | 307 | constructor(props) { 308 | super(props) 309 | 310 | this.componentWillReceiveProps = debounce(this.componentWillReceiveProps, 0) 311 | } 312 | 313 | componentDidMount() { 314 | const textareaNode = ReactDOM.findDOMNode(this.refs.textarea); 315 | const options = { 316 | mode: "jsx", 317 | lineNumbers: false, 318 | lineWrapping: false, 319 | smartIndent: false, 320 | matchBrackets: true, 321 | theme: this.props.theme, 322 | readOnly: this.props.readOnly, 323 | viewportMargin: Infinity, 324 | } 325 | 326 | this.codeMirror = codeMirror.fromTextArea(textareaNode, options); 327 | this.codeMirror.on('change', this.handleChange); 328 | this.codeMirror.on('focus', this.handleFocus.bind(this, true)); 329 | this.codeMirror.on('blur', this.handleFocus.bind(this, false)); 330 | this.codeMirror.on('scroll', this.handleScroll); 331 | this.codeMirror.setValue(this.props.defaultValue || this.props.value || ''); 332 | } 333 | 334 | componentWillReceiveProps(nextProps) { 335 | if (this.codeMirror && nextProps.value !== undefined && normalizeLineEndings(this.codeMirror.getValue()) !== normalizeLineEndings(nextProps.value)) { 336 | if (this.props.preserveScrollPosition) { 337 | var prevScrollPosition = this.codeMirror.getScrollInfo(); 338 | this.codeMirror.setValue(nextProps.value); 339 | this.codeMirror.scrollTo(prevScrollPosition.left, prevScrollPosition.top); 340 | } else { 341 | this.codeMirror.setValue(nextProps.value); 342 | } 343 | } 344 | } 345 | 346 | componentWillUnmount() { 347 | // is there a lighter-weight way to remove the cm instance? 348 | if (this.codeMirror) { 349 | this.codeMirror.toTextArea(); 350 | } 351 | } 352 | 353 | highlightSelectedLines = () => { 354 | if (Array.isArray(this.props.selectedLines)) { 355 | this.props.selectedLines.forEach(lineNumber => 356 | this.codeMirror.addLineClass(lineNumber, "wrap", "CodeMirror-activeline-background")) 357 | } 358 | } 359 | 360 | focus() { 361 | if (this.codeMirror) { 362 | this.codeMirror.focus() 363 | } 364 | } 365 | 366 | render() { 367 | return ( 368 |
369 |