├── .eslintignore ├── .gitignore ├── postcss.config.js ├── .storybook ├── config.js ├── preview-head.html └── webpack.config.js ├── src ├── katex.js ├── components │ ├── InsertKatexButton.js │ ├── KatexOutput.js │ └── TeXBlock.js ├── modifiers │ ├── insertTeXBlock.js │ └── removeTeXBlock.js ├── styles.css └── index.js ├── .babelrc ├── .npmignore ├── .editorconfig ├── scripts └── concatCssFiles.js ├── .eslintrc ├── stories ├── index.js └── ConfiguredEditor.js ├── CHANGELOG.md ├── webpack.config.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | lib/** 3 | scripts/** 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/* 2 | /.idea/ 3 | node_modules 4 | /storybook-static/ 5 | /lib-css/ 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | module.exports = { 4 | plugins: [ 5 | require('autoprefixer')({ browsers: ['> 1%'] }) 6 | ] 7 | }; 8 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | function loadStories() { 4 | require('../stories'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /src/katex.js: -------------------------------------------------------------------------------- 1 | import katex from 'katex'; 2 | 3 | /** 4 | * A simple katex wrapper to enable injecting katex from outside this plugin 5 | */ 6 | 7 | export default { 8 | render: katex.render, 9 | __parse: katex.__parse, 10 | }; 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"], 3 | "env": { 4 | "production": { 5 | "plugins": [ 6 | [ 7 | "babel-plugin-webpack-loaders", 8 | { 9 | "config": "${WEBPACK_CONFIG}", 10 | "verbose": true 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | # Commenting this out is preferred by some people, see 3 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 4 | node_modules 5 | 6 | # sources 7 | webpack.config.js 8 | .babelrc 9 | .gitignore 10 | README.md 11 | CHANGELOG.md 12 | 13 | # NPM debug 14 | npm-debug.log 15 | /.idea/ 16 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /scripts/concatCssFiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const dirname = path.join(process.argv[2], 'lib-css'); // where to place webpack files 4 | var content = ''; // eslint-disable-line no-var 5 | 6 | const filenames = fs.readdirSync(dirname); 7 | filenames.forEach((filename) => { 8 | const filePath = path.join(dirname, filename); 9 | content += fs.readFileSync(filePath, 'utf-8'); 10 | }); 11 | 12 | const outputFilePath = path.join(process.argv[2], 'lib', 'plugin.css'); 13 | fs.writeFileSync(outputFilePath, content); 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "plugins": [ "mocha" ], 4 | "env": { 5 | "browser": true, 6 | "mocha": true, 7 | "node": true 8 | }, 9 | "extends": "airbnb", 10 | "rules": { 11 | "arrow-parens": 0, 12 | "max-len": 0, 13 | "comma-dangle": 0, 14 | "new-cap": 0, 15 | "react/prop-types": 0, 16 | "react/forbid-prop-types": 0, 17 | "react/prefer-stateless-function": 0, 18 | "react/jsx-filename-extension": 0, 19 | "import/no-extraneous-dependencies": 0, 20 | "import/prefer-default-export": 0, 21 | "jsx-a11y/no-static-element-interactions": 0, 22 | "class-methods-use-this": 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /stories/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import ConfiguredEditor from './ConfiguredEditor'; 6 | 7 | storiesOf('Katex editor', module) 8 | .add('Without mathinput', () => ( 9 |
10 | 11 |
12 | )) 13 | .add('With mathinput', () => ) 14 | .add('With asciimath', () => ( 15 |
16 | 17 |

18 | To learn more about asciimath see:{' '} 19 | http://asciimath.org/ 20 |

21 |

22 | A custom translator like asciimath in combination with MathInput is not 23 | supported. 24 |

25 |
26 | )); 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## 1.3.1 7 | 8 | - Prevent unnecessary TeX block rerendering (https://github.com/letranloc/draft-js-katex-plugin/pull/10) 9 | 10 | ## 1.3.0 11 | 12 | - Change button-type of the add-katex-button to type "button" https://github.com/letranloc/draft-js-katex-plugin/pull/8 13 | - Fix storybook build (https://github.com/letranloc/draft-js-katex-plugin/pull/7) 14 | - Move math-input out of package (https://github.com/letranloc/math-input). 15 | 16 | ## 1.2.0 17 | 18 | - Add support for using other notations than Latex and lazy loading katex and the library. 19 | 20 | ## 1.1.0 21 | 22 | - Integrate with Khan's MathInput library 23 | 24 | ## 1.0.0 25 | 26 | - Initialize plugin. 27 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | var path = require('path'); 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | var autoprefixer = require('autoprefixer'); 5 | var webpack = require('webpack'); 6 | 7 | module.exports = { 8 | output: { 9 | publicPath: '/', 10 | libraryTarget: 'commonjs2', // necessary for the babel plugin 11 | path: path.join(__dirname, 'lib-css'), // where to place webpack files 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.css$/, 17 | loader: ExtractTextPlugin.extract({ 18 | fallback: 'style-loader', 19 | use: 'css-loader?modules&importLoaders=1&localIdentName=draftJsKatexPlugin__[local]__[hash:base64:5]!postcss-loader' 20 | }), 21 | }, 22 | ], 23 | }, 24 | plugins: [ 25 | new ExtractTextPlugin(`${path.parse(process.argv[2]).name}.css`), 26 | new webpack.LoaderOptionsPlugin({ 27 | options: { 28 | context: __dirname, 29 | postcss: [ 30 | autoprefixer({ browsers: ['> 1%'] }) 31 | ] 32 | } 33 | }) 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/InsertKatexButton.js: -------------------------------------------------------------------------------- 1 | import React, { Children, Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import unionClassNames from 'union-class-names'; 4 | import insertTeXBlock from '../modifiers/insertTeXBlock'; 5 | 6 | export default class InsertKatexButton extends Component { 7 | static propTypes = { 8 | children: PropTypes.node, 9 | initialValue: PropTypes.string, 10 | translator: PropTypes.func.isRequired, 11 | theme: PropTypes.any, 12 | }; 13 | 14 | static defaultProps = { 15 | initialValue: null, 16 | tex: null, 17 | theme: null, 18 | children: null 19 | }; 20 | 21 | onClick = () => { 22 | const { store, translator, initialValue } = this.props; 23 | const editorState = store.getEditorState(); 24 | store.setEditorState(insertTeXBlock(editorState, translator, initialValue)); 25 | }; 26 | 27 | render() { 28 | const { theme = {}, className, children, defaultContent } = this.props; 29 | const combinedClassName = unionClassNames(theme.insertButton, className); 30 | const content = Children.count(children) ? children : defaultContent; 31 | 32 | return ( 33 | 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/modifiers/insertTeXBlock.js: -------------------------------------------------------------------------------- 1 | import { EditorState, AtomicBlockUtils } from 'draft-js'; 2 | 3 | let count = 0; 4 | const examples = [ 5 | 'f(x)=\\frac{ax^2}{y}+bx+c', 6 | 7 | 'P(E) = \\binom{n}{k} p^k (1-p)^{ n-k}', 8 | 9 | '\\frac{1}{(\\sqrt{\\phi \\sqrt{5}}-\\phi) e^{\\frac25 \\pi}} =\n' + 10 | '1+\\frac{e^{-2\\pi}} {1+\\frac{e^{-4\\pi}} {1+\\frac{e^{-6\\pi}}\n' + 11 | '{1+\\frac{e^{-8\\pi}} {1+\\ldots} } } }', 12 | ]; 13 | 14 | export default function insertTeXBlock(editorState, translator, tex, displayMode = true) { 15 | let texContent = tex; 16 | if (!texContent) { 17 | const nextFormula = count % examples.length; 18 | count += 1; 19 | texContent = examples[nextFormula]; 20 | } 21 | 22 | // maybe insertTeXBlock should have a separate argument for inputvalue. 23 | const contentState = editorState.getCurrentContent(); 24 | const newContentState = contentState.createEntity('KateX', 'IMMUTABLE', { 25 | value: translator(texContent), 26 | inputValue: texContent, 27 | displayMode, 28 | }); 29 | 30 | const newEditorState = AtomicBlockUtils.insertAtomicBlock( 31 | editorState, 32 | newContentState.getLastCreatedEntityKey(), 33 | ' ' 34 | ); 35 | 36 | return EditorState.forceSelection(newEditorState, editorState.getCurrentContent().getSelectionAfter()); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/KatexOutput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class KatexOutput extends React.Component { 5 | static propTypes = { 6 | value: PropTypes.string.isRequired, 7 | katex: PropTypes.object.isRequired, 8 | displayMode: PropTypes.bool.isRequired, 9 | onClick: PropTypes.func, 10 | }; 11 | static defaultProps = { 12 | onClick: () => { 13 | }, 14 | }; 15 | 16 | constructor(props) { 17 | super(props); 18 | this.timer = null; 19 | } 20 | 21 | componentDidMount() { 22 | this.update(); 23 | } 24 | 25 | componentWillReceiveProps({ value }) { 26 | if (value !== this.props.value) { 27 | this.update(); 28 | } 29 | } 30 | 31 | componentWillUnmount() { 32 | clearTimeout(this.timer); 33 | this.timer = null; 34 | } 35 | 36 | update = () => { 37 | const { katex } = this.props; 38 | if (this.timer) { 39 | clearTimeout(this.timer); 40 | } 41 | 42 | this.timer = setTimeout(() => { 43 | katex.render(this.props.value, this.container, { 44 | displayMode: this.props.displayMode, 45 | }); 46 | }, 0); 47 | }; 48 | 49 | render() { 50 | return ( 51 |
{ 53 | this.container = container; 54 | }} 55 | onClick={this.props.onClick} 56 | /> 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/modifiers/removeTeXBlock.js: -------------------------------------------------------------------------------- 1 | import { 2 | Modifier, 3 | EditorState, 4 | SelectionState 5 | } from 'draft-js'; 6 | 7 | export default (editorState: Object, blockKey: String) => { 8 | let content = editorState.getCurrentContent(); 9 | const newSelection = new SelectionState({ 10 | anchorKey: blockKey, 11 | anchorOffset: 0, 12 | focusKey: blockKey, 13 | focusOffset: 0, 14 | }); 15 | 16 | const afterKey = content.getKeyAfter(blockKey); 17 | const afterBlock = content.getBlockForKey(afterKey); 18 | let targetRange; 19 | 20 | // Only if the following block the last with no text then the whole block 21 | // should be removed. Otherwise the block should be reduced to an unstyled block 22 | // without any characters. 23 | if (afterBlock && 24 | afterBlock.getType() === 'unstyled' && 25 | afterBlock.getLength() === 0 && 26 | afterBlock === content.getBlockMap().last()) { 27 | targetRange = new SelectionState({ 28 | anchorKey: blockKey, 29 | anchorOffset: 0, 30 | focusKey: afterKey, 31 | focusOffset: 0, 32 | }); 33 | } else { 34 | targetRange = new SelectionState({ 35 | anchorKey: blockKey, 36 | anchorOffset: 0, 37 | focusKey: blockKey, 38 | focusOffset: 1, 39 | }); 40 | } 41 | 42 | // change the blocktype and remove the characterList entry with the sticker 43 | content = Modifier.setBlockType( 44 | content, 45 | targetRange, 46 | 'unstyled' 47 | ); 48 | content = Modifier.removeRange(content, targetRange, 'backward'); 49 | 50 | // force to new selection 51 | const newState = EditorState.push(editorState, content, 'remove-range'); 52 | return EditorState.forceSelection(newState, newSelection); 53 | }; 54 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // you can use this file to add your custom webpack plugins, loaders and anything you like. 2 | // This is just the basic way to add additional webpack configurations. 3 | // For more information refer the docs: https://storybook.js.org/docs/react-storybook/configurations/custom-webpack-config 4 | 5 | // IMPORTANT 6 | // When you add this file, we won't add the default configurations which is similar 7 | // to "React Create App". This only has babel loader to load JavaScript. 8 | const path = require('path'); 9 | 10 | module.exports = { 11 | plugins: [ 12 | // your custom plugins 13 | ], 14 | module: { 15 | rules: [ 16 | /* 17 | { 18 | test: /\.scss$/, 19 | loaders: ["style-loader", "css-loader", "sass-loader", "postcss-loader"], 20 | include: path.resolve(__dirname, '../') 21 | }, 22 | */ 23 | { 24 | test: /\.css$/, 25 | use: [ 26 | { 27 | loader: 'style-loader', 28 | }, 29 | { 30 | loader: 'css-loader', 31 | options: { 32 | modules: true, 33 | importLoaders: 1, 34 | localIdentName: 'draftJsKatexPlugin__[local]__[hash:base64:5]', 35 | }, 36 | }, 37 | ], 38 | }, 39 | ], 40 | /* 41 | loaders: [ 42 | 43 | { 44 | test: /\.css$/, 45 | use: [ 46 | { 47 | loader: "style-loader" 48 | }, 49 | { 50 | loader: "css-loader", 51 | options: { 52 | modules: true, 53 | importLoaders: 1, 54 | localIdentName: 'draftJsKatexPlugin__[local]__[hash:base64:5]', 55 | }, 56 | }, 57 | 'postcss-loader' 58 | ] 59 | }] 60 | */ 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .tex { 2 | text-align: center; 3 | background-color: #fff; 4 | cursor: pointer; 5 | margin: 20px auto; 6 | /*padding: 20px;*/ 7 | -webkit-transition: background-color 0.2s fade-in-out; 8 | user-select: none; 9 | -webkit-user-select: none; 10 | } 11 | 12 | .activeTeX { 13 | color: #888; 14 | } 15 | 16 | .panel { 17 | font-family: 'Helvetica', sans-serif; 18 | font-weight: 200; 19 | } 20 | 21 | .panel .texValue { 22 | border: 1px solid #e1e1e1; 23 | display: block; 24 | font-family: 'Inconsolata', 'Menlo', monospace; 25 | font-size: 14px; 26 | height: 110px; 27 | margin: 20px auto 10px; 28 | outline: none; 29 | padding: 14px; 30 | resize: none; 31 | -webkit-box-sizing: border-box; 32 | width: 500px; 33 | } 34 | 35 | .buttons { 36 | text-align: center; 37 | } 38 | 39 | .saveButton, 40 | .removeButton { 41 | background-color: #fff; 42 | border: 1px solid #0a0; 43 | cursor: pointer; 44 | font-family: 'Helvetica', 'Arial', sans-serif; 45 | font-size: 16px; 46 | font-weight: 200; 47 | margin: 10px auto; 48 | padding: 6px; 49 | -webkit-border-radius: 3px; 50 | width: 100px; 51 | } 52 | 53 | .removeButton { 54 | border-color: #aaa; 55 | color: #999; 56 | margin-left: 8px; 57 | } 58 | 59 | .invalidButton { 60 | background-color: #eee; 61 | border-color: #a00; 62 | color: #666; 63 | } 64 | 65 | .insertButton { 66 | box-sizing: border-box; 67 | border: 1px solid #ddd; 68 | height: 1.5em; 69 | color: #888; 70 | border-radius: 1.5em; 71 | line-height: 1.2em; 72 | cursor: pointer; 73 | background-color: #fff; 74 | width: 2.5em; 75 | font-weight: bold; 76 | font-size: 1.5em; 77 | padding: 0; 78 | margin: 0; 79 | } 80 | 81 | .insertButton:focus { 82 | background-color: #eee; 83 | color: #999; 84 | outline: 0; /* reset for :focus */ 85 | } 86 | 87 | .insertButton:hover { 88 | background-color: #eee; 89 | color: #999; 90 | } 91 | 92 | .insertButton:active { 93 | background-color: #ddd; 94 | color: #777; 95 | } 96 | 97 | .insertButton:disabled { 98 | background-color: #F5F5F5; 99 | color: #ccc; 100 | } 101 | -------------------------------------------------------------------------------- /stories/ConfiguredEditor.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import asciimath2latex from 'asciimath-to-latex'; 3 | import { EditorState } from 'draft-js'; 4 | 5 | import Editor from 'draft-js-plugins-editor'; 6 | 7 | import MathInput from 'math-input/dist/components/app'; 8 | import createKaTeXPlugin from '../src/index'; 9 | import '../src/styles.css'; 10 | 11 | import katex from '../src/katex'; 12 | 13 | const katexTheme = { 14 | insertButton: 'Button Button-small Button-insert', 15 | }; 16 | 17 | function configuredEditor(props) { 18 | const kaTeXPlugin = createKaTeXPlugin({ 19 | // the configs here are mainly to show you that it is possible. Feel free to use w/o config 20 | doneContent: { valid: 'Close', invalid: 'Invalid syntax' }, 21 | katex, // <-- required 22 | MathInput: props.withMathInput ? MathInput : null, 23 | removeContent: 'Remove', 24 | theme: katexTheme, 25 | translator: props.withAsciimath ? asciimath2latex : null, 26 | }); 27 | 28 | const plugins = [kaTeXPlugin]; 29 | 30 | const baseEditorProps = Object.assign({ 31 | plugins, 32 | }); 33 | 34 | return { baseEditorProps, InsertButton: kaTeXPlugin.InsertButton }; 35 | } 36 | 37 | export default class ConfiguredEditor extends Component { 38 | static propTypes = {}; 39 | 40 | constructor(props) { 41 | super(props); 42 | const { baseEditorProps, InsertButton } = configuredEditor(props); 43 | this.baseEditorProps = baseEditorProps; 44 | this.InsertButton = InsertButton; 45 | this.state = { editorState: EditorState.createEmpty() }; 46 | } 47 | 48 | componentDidMount() { 49 | this.focus(); 50 | } 51 | 52 | // use this when triggering a button that only changes editorstate 53 | onEditorStateChange = editorState => { 54 | this.setState(() => ({ editorState })); 55 | }; 56 | 57 | focus = () => { 58 | this.editor.focus(); 59 | }; 60 | 61 | render() { 62 | const { InsertButton } = this; 63 | 64 | return ( 65 |
66 |

DraftJS KaTeX Plugin

67 |
68 | 69 | Insert ascii math 70 |
71 | this.editor = element} 74 | editorState={this.state.editorState} 75 | onChange={this.onEditorStateChange} 76 | /> 77 |
78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draft-js-katex-plugin", 3 | "description": "Katex Plugin for DraftJS", 4 | "version": "1.3.1", 5 | "author": { 6 | "name": "letranloc", 7 | "email": "letranloc1994@gmail.com", 8 | "url": "https://letranloc.com" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/letranloc/draft-js-katex-plugin/issues" 12 | }, 13 | "dependencies": { 14 | "aphrodite": "^1.2.3", 15 | "decorate-component-with-props": "^1.0.2", 16 | "draft-js": ">=0.9.1", 17 | "jquery": "^3.2.1", 18 | "katex": "^0.8.3", 19 | "math-input": "github:letranloc/math-input#web-support", 20 | "mathquill": "^0.10.1-a", 21 | "react-addons-css-transition-group": "^15.6.0", 22 | "react-addons-pure-render-mixin": "^15.6.0", 23 | "react-redux": "^5.0.6", 24 | "redux": "^3.7.2", 25 | "union-class-names": "^1.0.0" 26 | }, 27 | "devDependencies": { 28 | "@storybook/react": "^3.2.8", 29 | "asciimath-to-latex": "^0.3.2", 30 | "autoprefixer": "^6.7.7", 31 | "babel-cli": "^6.14.0", 32 | "babel-core": "^6.14.0", 33 | "babel-eslint": "^7.1.1", 34 | "babel-loader": "^6.2.5", 35 | "babel-plugin-webpack-loaders": "^0.9.0", 36 | "babel-polyfill": "^6.13.0", 37 | "babel-preset-es2015": "^6.14.0", 38 | "babel-preset-react": "^6.11.1", 39 | "babel-preset-stage-0": "^6.5.0", 40 | "css-loader": "^0.27.2", 41 | "draft-js-plugins-editor": "2.0.0-rc7", 42 | "eslint": "^3.5.0", 43 | "eslint-config-airbnb": "^14.1.0", 44 | "eslint-plugin-import": "^2.2.0", 45 | "eslint-plugin-jsx-a11y": "^4.0.0", 46 | "eslint-plugin-mocha": "^4.5.1", 47 | "eslint-plugin-react": "^6.2.0", 48 | "extract-text-webpack-plugin": "^2.1.0", 49 | "flow-bin": "^0.41.0", 50 | "lint-staged": "^3.0.1", 51 | "postcss-loader": "^1.3.3", 52 | "prop-types": "^15.6.0", 53 | "react": "^15.4.2", 54 | "react-dom": "^15.4.2", 55 | "rimraf": "^2.6.1", 56 | "style-loader": "^0.13.2", 57 | "webpack": "^2.2.1" 58 | }, 59 | "homepage": "https://github.com/letranloc/draft-js-katex-plugin", 60 | "keywords": [ 61 | "components", 62 | "draft", 63 | "editor", 64 | "react", 65 | "react-component", 66 | "ux", 67 | "widget", 68 | "wysiwyg" 69 | ], 70 | "license": "MIT", 71 | "lint-staged": { 72 | "lint:eslint": "*.js" 73 | }, 74 | "main": "lib/index.js", 75 | "peerDependencies": { 76 | "react": "^15.0.0", 77 | "react-dom": "^15.0.0" 78 | }, 79 | "pre-commit": "lint:staged", 80 | "repository": { 81 | "type": "git", 82 | "url": "https://github.com/letranloc/draft-js-katex-plugin.git" 83 | }, 84 | "scripts": { 85 | "build": "npm run clean && npm run build:js && npm run build:css", 86 | "build:css": "node ./scripts/concatCssFiles $(pwd) && rimraf lib-css", 87 | "build:js": "WEBPACK_CONFIG=$(pwd)/webpack.config.js BABEL_DISABLE_CACHE=1 BABEL_ENV=production NODE_ENV=production babel --out-dir='lib' --ignore='__test__/*' src", 88 | "clean": "rimraf lib storybook-static", 89 | "lint": "npm run lint:eslint", 90 | "lint:eslint": "eslint --rule 'mocha/no-exclusive-tests:2' ./", 91 | "lint:eslint:fix": "eslint --fix --rule 'mocha/no-exclusive-tests:2' ./", 92 | "lint:staged": "lint-staged", 93 | "prepublish": "npm run build", 94 | "storybook": "start-storybook -p 6006", 95 | "build-storybook": "WEBPACK_CONFIG=$(pwd)/webpack.config.js BABEL_DISABLE_CACHE=1 build-storybook -c .storybook -o storybook-static" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { EditorState } from 'draft-js'; 2 | import decorateComponentWithProps from 'decorate-component-with-props'; 3 | import TeXBlock from './components/TeXBlock'; 4 | import removeTeXBlock from './modifiers/removeTeXBlock'; 5 | import InsertButton from './components/InsertKatexButton'; 6 | 7 | import styles from './styles.css'; 8 | 9 | function noopTranslator(tex) { 10 | return tex; 11 | } 12 | 13 | const defaultTheme = { 14 | tex: styles.tex, 15 | activeTex: styles.activeTeX, 16 | panel: styles.panel, 17 | texValue: styles.texValue, 18 | buttons: styles.buttons, 19 | saveButton: styles.saveButton, 20 | removeButton: styles.removeButton, 21 | invalidButton: styles.invalidButton, 22 | insertButton: styles.insertButton 23 | }; 24 | 25 | export default (config = {}) => { 26 | const theme = Object.assign(defaultTheme, config.theme || {}); 27 | const insertContent = config.insertContent || 'Ω'; 28 | const doneContent = config.doneContent || { 29 | valid: 'Done', 30 | invalid: 'Invalid TeX', 31 | }; 32 | const removeContent = config.removeContent || 'Remove'; 33 | const translator = config.translator || noopTranslator; 34 | const katex = config.katex; 35 | 36 | if (!katex || !katex.render) { 37 | throw new Error('Invalid katex plugin provided!'); 38 | } 39 | 40 | const store = { 41 | getEditorState: undefined, 42 | setEditorState: undefined, 43 | getReadOnly: undefined, 44 | setReadOnly: undefined, 45 | onChange: undefined, 46 | }; 47 | 48 | const liveTeXEdits = new Map(); 49 | 50 | const component = decorateComponentWithProps(TeXBlock, { 51 | theme, 52 | store, 53 | doneContent, 54 | removeContent, 55 | translator, 56 | katex, 57 | MathInput: config.MathInput, 58 | }); 59 | 60 | return { 61 | initialize: ({ getEditorState, setEditorState, getReadOnly, setReadOnly }) => { 62 | store.getEditorState = getEditorState; 63 | store.setEditorState = setEditorState; 64 | store.getReadOnly = getReadOnly; 65 | store.setReadOnly = setReadOnly; 66 | }, 67 | 68 | blockRendererFn: block => { 69 | if (block.getType() === 'atomic') { 70 | const entity = store 71 | .getEditorState() 72 | .getCurrentContent() 73 | .getEntity(block.getEntityAt(0)); 74 | const type = entity.getType(); 75 | 76 | if (type === 'KateX') { 77 | return { 78 | component, 79 | editable: false, 80 | props: { 81 | onStartEdit: blockKey => { 82 | liveTeXEdits.set(blockKey, true); 83 | store.setReadOnly(liveTeXEdits.size); 84 | }, 85 | 86 | onFinishEdit: (blockKey, newEditorState) => { 87 | liveTeXEdits.delete(blockKey); 88 | store.setReadOnly(liveTeXEdits.size); 89 | store.setEditorState(EditorState.forceSelection(newEditorState, newEditorState.getSelection())); 90 | }, 91 | 92 | onRemove: blockKey => { 93 | liveTeXEdits.delete(blockKey); 94 | store.setReadOnly(liveTeXEdits.size); 95 | 96 | const editorState = store.getEditorState(); 97 | const newEditorState = removeTeXBlock(editorState, blockKey); 98 | store.setEditorState(newEditorState); 99 | }, 100 | }, 101 | }; 102 | } 103 | } 104 | return null; 105 | }, 106 | InsertButton: decorateComponentWithProps(InsertButton, { 107 | theme, 108 | store, 109 | translator, 110 | defaultContent: insertContent, 111 | }), 112 | }; 113 | }; 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DraftJS KaTeX Plugin 2 | 3 | *This is a plugin for the `draft-js-plugins-editor`.* 4 | 5 | This plugin insert and render LaTeX using [KaTeX](https://github.com/Khan/KaTeX), modified from [TeX example](https://github.com/facebook/draft-js/tree/master/examples/draft-0-10-0/tex). 6 | 7 | - Integrated with [Khan/math-input](https://github.com/Khan/math-input). 8 | 9 | [![NPM](https://nodei.co/npm/draft-js-katex-plugin.png?downloads=true)](https://www.npmjs.com/package/draft-js-katex-plugin) 10 | 11 | ## Usage 12 | 13 | Add MathQuill libs if you want to use MathInput: 14 | ```html 15 | 16 | 17 | ``` 18 | 19 | Add plugin 20 | ```js 21 | import createKaTeXPlugin from 'draft-js-katex-plugin'; 22 | import katex from 'katex' 23 | 24 | const kaTeXPlugin = createKaTeXPlugin({katex}); 25 | 26 | const { InsertButton } = kaTeXPlugin; 27 | ``` 28 | 29 | With MathInput: 30 | 31 | ```js 32 | import createKaTeXPlugin from 'draft-js-katex-plugin'; 33 | import katex from 'katex' 34 | import MathInput from '../src/components/math-input/components/app'; 35 | 36 | 37 | const kaTeXPlugin = createKaTeXPlugin({katex, MathInput}); 38 | 39 | const { InsertButton } = kaTeXPlugin; 40 | ``` 41 | 42 | There are more examples in the `stories/index.js` file. 43 | 44 | ## Configuration options: 45 | 46 | Here shown with defaults: 47 | ```js 48 | { 49 | katex, // the katex object or a wrapper defining render() and __parse(). 50 | 51 | doneContent: { 52 | valid: 'Done', 53 | invalid: 'Invalid TeX', 54 | }, 55 | 56 | MathInput: null, // Sett to the MathInput element to use MathInput 57 | removeContent: 'Remove', 58 | theme: { 59 | // CSS classes, either you define them or they come from the plugin.css import 60 | saveButton: '', 61 | removeButton: '', 62 | invalidButton: '', 63 | buttons: '', 64 | } 65 | // function (string) -> string. 66 | translator: null, 67 | } 68 | ``` 69 | 70 | 71 | ## Loading katex async 72 | If you want to load katex in the background instead of right away, then you can do this by wrapping the katex object that you pass into the plugin: 73 | 74 | ```js 75 | //file: asyncKatex.js 76 | let katex = null 77 | const renderQueue = [] 78 | 79 | System.import(/* webpackChunkName: 'katex' */ 'katex') 80 | .then(function methodName(module) { 81 | katex = module.default 82 | }) 83 | .then(() => { 84 | console.log('Katex loaded, ', renderQueue) 85 | if (renderQueue.length) { 86 | const now = Date.now() 87 | renderQueue.map(([d, expression, baseNode, options]) => { 88 | if (now - d < 4000) { 89 | katex.render(expression, baseNode, options) 90 | } 91 | }) 92 | } 93 | }) 94 | 95 | export default { 96 | render: (expression, baseNode, options) => { 97 | if (katex) { 98 | return katex.render(expression, baseNode, options) 99 | } 100 | 101 | renderQueue.push([Date.now(), expression, baseNode, options]) 102 | }, 103 | // parse is only used by this plugin to check syntax validity. 104 | __parse: (expression, options) => { 105 | if (katex) { 106 | return katex.parse(expression, options) 107 | } 108 | return null 109 | } 110 | } 111 | 112 | 113 | ``` 114 | 115 | Store this in a separate file and and pass it to the plugin config: 116 | 117 | ```js 118 | import createKaTeXPlugin from 'draft-js-katex-plugin'; 119 | import katex from './asyncKatex' 120 | 121 | const kaTeXPlugin = createKaTeXPlugin({katex}); 122 | 123 | ``` 124 | -------------------------------------------------------------------------------- /src/components/TeXBlock.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import unionClassNames from 'union-class-names'; 3 | import KatexOutput from './KatexOutput'; 4 | 5 | export default class TeXBlock extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { editMode: false }; 9 | } 10 | 11 | onClick = () => { 12 | if (this.state.editMode || this.props.store.getReadOnly()) { 13 | return; 14 | } 15 | this.setState( 16 | { 17 | editMode: true, 18 | ...this.getValue(), 19 | }, 20 | () => { 21 | this.startEdit(); 22 | } 23 | ); 24 | }; 25 | 26 | onValueChange = evt => { 27 | const value = evt.target.value; 28 | this.onMathInputChange(value); 29 | }; 30 | 31 | onFocus = () => { 32 | if (this.callbacks.blur) { 33 | this.callbacks.blur(); 34 | } 35 | }; 36 | 37 | onMathInputChange = inputValue => { 38 | let invalid = false; 39 | const value = this.props.translator(inputValue); 40 | try { 41 | this.props.katex.__parse(value); // eslint-disable-line no-underscore-dangle 42 | } catch (e) { 43 | invalid = true; 44 | } finally { 45 | this.setState({ 46 | invalidTeX: invalid, 47 | value, 48 | inputValue, 49 | }); 50 | } 51 | }; 52 | 53 | getValue = () => { 54 | const contentState = this.props.store.getEditorState().getCurrentContent(); 55 | return contentState.getEntity(this.props.block.getEntityAt(0)).getData(); 56 | }; 57 | 58 | callbacks = {}; 59 | 60 | startEdit = () => { 61 | const { block, blockProps } = this.props; 62 | blockProps.onStartEdit(block.getKey()); 63 | }; 64 | 65 | finishEdit = newContentState => { 66 | const { block, blockProps } = this.props; 67 | blockProps.onFinishEdit(block.getKey(), newContentState); 68 | }; 69 | 70 | remove = () => { 71 | const { block, blockProps } = this.props; 72 | blockProps.onRemove(block.getKey()); 73 | }; 74 | 75 | save = () => { 76 | const { block, store } = this.props; 77 | 78 | const entityKey = block.getEntityAt(0); 79 | const editorState = store.getEditorState(); 80 | 81 | const contentState = editorState.getCurrentContent(); 82 | 83 | contentState.mergeEntityData(entityKey, { 84 | value: this.state.value, 85 | inputValue: this.state.inputValue, 86 | }); 87 | 88 | this.setState( 89 | { 90 | invalidTeX: false, 91 | editMode: false, 92 | value: null, 93 | }, 94 | this.finishEdit.bind(this, editorState) 95 | ); 96 | }; 97 | 98 | render() { 99 | const { theme, doneContent, removeContent, katex } = this.props; 100 | 101 | let texContent = null; 102 | if (this.state.editMode) { 103 | if (this.state.invalidTeX) { 104 | texContent = ''; 105 | } else { 106 | texContent = this.state.value; 107 | } 108 | } else { 109 | texContent = this.getValue().value; 110 | } 111 | const displayMode = this.getValue().displayMode; 112 | 113 | let className = theme.tex; 114 | if (this.state.editMode) { 115 | className = unionClassNames(className, theme.activeTeX); 116 | } 117 | 118 | let editPanel = null; 119 | if (this.state.editMode) { 120 | let buttonClass = theme.saveButton; 121 | if (this.state.invalidTeX) { 122 | buttonClass = unionClassNames(buttonClass, theme.invalidButton); 123 | } 124 | 125 | editPanel = ( 126 |
127 |