├── .babelrc ├── .eslintrc.json ├── .gitignore ├── README.md ├── demo ├── _prism.scss ├── application.jsx ├── application.scss ├── html-editor.jsx ├── index.html ├── markdown-editor.jsx └── state-to-gfm.js ├── images └── demo.gif ├── package.json └── src ├── create-markless-plugin.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaFeatures": { 9 | "experimentalObjectRestSpread": true, 10 | "jsx": true 11 | }, 12 | "sourceType": "module" 13 | }, 14 | "plugins": [ 15 | "react" 16 | ], 17 | "rules": { 18 | "indent": [ 19 | "error", 20 | 2 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "quotes": [ 27 | "error", 28 | "double" 29 | ], 30 | "semi": [ 31 | "error", 32 | "always" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs 2 | /lib 3 | /node_modules 4 | /npm-debug.log 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # draft-js-markless-plugin 2 | 3 | [![npm](https://img.shields.io/npm/v/draft-js-markless-plugin.svg)](https://www.npmjs.com/package/draft-js-markless-plugin) 4 | 5 | A plugin for draft-js that allows you to create a markdown-like keybinding WYSIWYG editor. 6 | 7 | ## Demo 8 | 9 | https://r7kamura.github.io/draft-js-markless-plugin/ 10 | 11 | [![demo](/images/demo.gif)](https://r7kamura.github.io/draft-js-markless-plugin/) 12 | -------------------------------------------------------------------------------- /demo/_prism.scss: -------------------------------------------------------------------------------- 1 | ../node_modules/prismjs/themes/prism.css -------------------------------------------------------------------------------- /demo/application.jsx: -------------------------------------------------------------------------------- 1 | import { ContentState, EditorState } from "draft-js"; 2 | import { stateFromMarkdown } from "@r7kamura/draft-js-import-markdown"; 3 | import stateToMarkdown from "./state-to-gfm"; 4 | import HtmlEditor from "./html-editor.jsx"; 5 | import MarkdownEditor from "./markdown-editor.jsx"; 6 | import React from "react"; 7 | import ReactDOM from "react-dom"; 8 | 9 | class Root extends React.Component { 10 | constructor(...args) { 11 | super(...args); 12 | this.state = { 13 | htmlEditorState: EditorState.createWithContent( 14 | stateFromMarkdown(this.props.initialValue) 15 | ), 16 | htmlActive: true, 17 | }; 18 | } 19 | 20 | onHtmlEditorStateChange(editorState) { 21 | this.setState({ htmlEditorState: editorState }); 22 | } 23 | 24 | onMarkdownEditorStateChange(editorState) { 25 | this.setState({ markdownEditorState: editorState }); 26 | } 27 | 28 | onHtmlTabClicked() { 29 | if (!this.state.htmlActive) { 30 | this.setState({ 31 | htmlActive: true, 32 | htmlEditorState: EditorState.createWithContent( 33 | stateFromMarkdown( 34 | this.state.markdownEditorState.getCurrentContent().getPlainText() 35 | ) 36 | ), 37 | }); 38 | } 39 | } 40 | 41 | onMarkdownTabClicked() { 42 | if (this.state.htmlActive) { 43 | this.setState({ 44 | htmlActive: false, 45 | markdownEditorState: EditorState.createWithContent( 46 | ContentState.createFromText( 47 | stateToMarkdown( 48 | this.state.htmlEditorState.getCurrentContent() 49 | ) 50 | ) 51 | ), 52 | }); 53 | } 54 | } 55 | 56 | render() { 57 | return( 58 |
59 |
60 |
61 |

62 | r7kamura/draft-js-markless-plugin 63 |

64 |

65 | A plugin for draft-js that allows you to create a markdown-like keybinding WYSIWYG editor. 66 |

67 |
68 |
69 |
70 |
71 | 79 |
80 | { 81 | this.state.htmlActive && 82 | 86 | } 87 | { 88 | !this.state.htmlActive && 89 | 93 | } 94 |
95 |
96 |
97 |
98 | ); 99 | } 100 | } 101 | 102 | const initialValue = ` 103 | # draft-js-markless-plugin 104 | 105 | draft-js-markless-plugin is a plugin for draft-js that allows you to create a markdown-like keybinding WYSIWYG editor. 106 | 107 | 1. Markdown-like keybindings 108 | 2. Nice default behaviors for writing text 109 | 3. Built on draft.js 110 | 111 | ![demo](https://raw.githubusercontent.com/r7kamura/draft-js-markless-plugin/master/images/demo.gif) 112 | 113 | ## Repository 114 | 115 | https://github.com/r7kamura/draft-js-markless-plugin 116 | 117 | ## LICENSE 118 | 119 | draft-js-markless-plugin is MIT licensed. 120 | `; 121 | 122 | ReactDOM.render( 123 | , 124 | document.getElementById("root") 125 | ); 126 | -------------------------------------------------------------------------------- /demo/application.scss: -------------------------------------------------------------------------------- 1 | @import "./prism"; 2 | 3 | body { 4 | background-color: #F7F7F7; 5 | } 6 | 7 | .container { 8 | max-width: 980px; 9 | } 10 | 11 | .header { 12 | background-color: #222; 13 | padding: 10px 20px 100px; 14 | 15 | &-description { 16 | color: rgba(255, 255, 255, .54); 17 | } 18 | } 19 | 20 | .markdown-body { 21 | font-size: 15px; 22 | 23 | h1 { 24 | border-bottom: solid 1px #DDD; 25 | padding-bottom: 5px; 26 | font-size: 22px; 27 | } 28 | 29 | h2 { 30 | font-size: 20px; 31 | } 32 | 33 | h3 { 34 | font-size: 18px; 35 | } 36 | 37 | h4 { 38 | font-size: 16px; 39 | } 40 | 41 | code { 42 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; 43 | font-size: 12px; 44 | } 45 | 46 | pre { 47 | background-color: #f7f7f7; 48 | border-radius: 3px; 49 | font-size: 85%; 50 | font: 12px Consolas, "Liberation Mono", Menlo, Courier, monospace; 51 | line-height: 1.45; 52 | margin-bottom: 0; 53 | margin-top: 0; 54 | overflow: auto; 55 | padding: 16px; 56 | word-wrap: normal; 57 | } 58 | 59 | ul { 60 | margin-left: 2.7em; 61 | 62 | li { 63 | list-style-type: disc; 64 | } 65 | } 66 | 67 | img { 68 | max-width: 100%; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /demo/html-editor.jsx: -------------------------------------------------------------------------------- 1 | import { Entity } from "draft-js"; 2 | import createAutoListPlugin from "draft-js-autolist-plugin"; 3 | import createBlockBreakoutPlugin from "draft-js-block-breakout-plugin"; 4 | import createLinkifyPlugin from "draft-js-linkify-plugin"; 5 | import createMarklessPlugin from "../src/index.js"; 6 | import Editor from "draft-js-plugins-editor"; 7 | import Prism from "prismjs"; 8 | import React from "react"; 9 | 10 | const autoListPlugin = createAutoListPlugin(); 11 | const blockBreakoutPlugin = createBlockBreakoutPlugin({ 12 | breakoutBlocks: [ 13 | "blockquote", 14 | "header-five", 15 | "header-four", 16 | "header-one", 17 | "header-six", 18 | "header-three", 19 | "header-two", 20 | ] 21 | }); 22 | const linkifyPlugin = createLinkifyPlugin(); 23 | const marklessPlugin = createMarklessPlugin(); 24 | const plugins = [ 25 | autoListPlugin, 26 | blockBreakoutPlugin, 27 | linkifyPlugin, 28 | marklessPlugin, 29 | ]; 30 | 31 | const ImageComponent = (props) => { 32 | const { alt, src } = Entity.get(props.entityKey).getData(); 33 | return {alt}; 34 | }; 35 | 36 | const LinkComponent = (props) => { 37 | const { url } = Entity.get(props.entityKey).getData(); 38 | return( 39 | 40 | {props.children} 41 | 42 | ); 43 | }; 44 | 45 | const TokenComponent = (props) => { 46 | const sections = props.offsetKey.split("-"); 47 | const blockKey = sections[0]; 48 | const offset = sections[1]; 49 | const tokenType = tokensCache[blockKey][offset]; 50 | const block = props.getEditorState().getCurrentContent().getBlockForKey(blockKey); 51 | return( 52 | 53 | {props.children} 54 | 55 | ); 56 | }; 57 | 58 | const tokensCache = {}; 59 | 60 | const decorators = [ 61 | { 62 | strategy: function (contentBlock, callback) { 63 | contentBlock.findEntityRanges( 64 | (character) => { 65 | const entityKey = character.getEntity(); 66 | return entityKey !== null && Entity.get(entityKey).getType() === "LINK"; 67 | }, 68 | callback, 69 | ); 70 | }, 71 | component: LinkComponent, 72 | }, 73 | { 74 | strategy: function (contentBlock, callback) { 75 | contentBlock.findEntityRanges( 76 | (character) => { 77 | const entityKey = character.getEntity(); 78 | return entityKey !== null && Entity.get(entityKey).getType() === "IMAGE"; 79 | }, 80 | callback 81 | ); 82 | }, 83 | component: ImageComponent, 84 | }, 85 | { 86 | strategy: function (contentBlock, callback) { 87 | if (contentBlock.getType() === "code-block") { 88 | const languageName = contentBlock.getData().get("languageName"); 89 | const language = Prism.languages[languageName]; 90 | if (language) { 91 | const tokens = Prism.tokenize(contentBlock.getText(), language); 92 | const blockKey = contentBlock.getKey(); 93 | tokensCache[blockKey] = tokensCache[blockKey] || {}; 94 | let offset = 0; 95 | tokens.forEach((token) => { 96 | if (typeof token === "string") { 97 | offset += token.length; 98 | } else { 99 | tokensCache[blockKey][offset.toString()] = token.type; 100 | callback(offset, offset + token.content.length); 101 | offset += token.content.length; 102 | } 103 | }); 104 | } 105 | } 106 | }, 107 | component: TokenComponent, 108 | }, 109 | ]; 110 | 111 | export default class HtmlEditor extends React.Component { 112 | render() { 113 | return( 114 |
115 | 121 |
122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | draft-js-markless-plugin 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/markdown-editor.jsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "draft-js"; 2 | import React from "react"; 3 | 4 | export default class MarkdownEditor extends React.Component { 5 | render() { 6 | return( 7 | 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /demo/state-to-gfm.js: -------------------------------------------------------------------------------- 1 | import { 2 | BLOCK_TYPE, 3 | ENTITY_TYPE, 4 | getEntityRanges, 5 | INLINE_STYLE, 6 | } from "draft-js-utils"; 7 | import { Entity } from "draft-js"; 8 | import { 9 | ContentBlock, 10 | ContentState, 11 | } from "draft-js"; 12 | 13 | const { 14 | BOLD, 15 | CODE, 16 | ITALIC, 17 | STRIKETHROUGH, 18 | UNDERLINE, 19 | } = INLINE_STYLE; 20 | 21 | class MarkdownGeneration { 22 | /** 23 | * @param {ContentState} contentState 24 | */ 25 | constructor(contentState) { 26 | this.contentState = contentState; 27 | this.blocks = this.contentState.getBlockMap().toArray(); 28 | this.currentBlockIndex = 0; 29 | this.listItemCounts = {}; 30 | this.output = []; 31 | this.totalBlocks = this.blocks.length; 32 | } 33 | 34 | /** 35 | * @returns {String} 36 | */ 37 | generate() { 38 | this.blocks.map((block, index) => { 39 | this.currentBlockIndex = index; 40 | this.consumeBlock(block); 41 | }); 42 | return this.output.join(""); 43 | } 44 | 45 | onBlockHeader(block, level) { 46 | this.pushLineBreak(); 47 | this.output.push(`${"#".repeat(level)} ${this.renderBlockContent(block)}\n`); 48 | } 49 | 50 | onBlockListItemUnordered(block) { 51 | const blockDepth = block.getDepth(); 52 | const blockType = block.getType(); 53 | const lastBlock = this.getLastBlock(); 54 | const lastBlockType = lastBlock ? lastBlock.getType() : null; 55 | const lastBlockDepth = lastBlock && checkNestableBlockType(lastBlockType) ? lastBlock.getDepth() : null; 56 | if (lastBlockType !== blockType && lastBlockDepth !== blockDepth - 1) { 57 | this.pushLineBreak(); 58 | if (lastBlockType === BLOCK_TYPE.ORDERED_LIST_ITEM) { 59 | this.pushLineBreak(); 60 | } 61 | } 62 | const listMarker = "-"; 63 | const indent = " ".repeat(block.depth * 2); 64 | this.output.push(`${indent}${listMarker} ${this.renderBlockContent(block)}\n`); 65 | } 66 | 67 | onBlockListItemOrdered(block) { 68 | const blockDepth = block.getDepth(); 69 | const blockType = block.getType(); 70 | const lastBlock = this.getLastBlock(); 71 | const lastBlockType = lastBlock ? lastBlock.getType() : null; 72 | const lastBlockDepth = lastBlock && checkNestableBlockType(lastBlockType) ? lastBlock.getDepth() : null; 73 | if (lastBlockType !== blockType && lastBlockDepth !== blockDepth - 1) { 74 | this.pushLineBreak(); 75 | if (lastBlockType === BLOCK_TYPE.UNORDERED_LIST_ITEM) { 76 | this.pushLineBreak(); 77 | } 78 | } 79 | const listMarker = `${this.getListItemCount(block) % 1000000000}.`; 80 | const indent = " ".repeat(block.depth * 2); 81 | this.output.push(`${indent}${listMarker} ${this.renderBlockContent(block)}\n`); 82 | } 83 | 84 | onBlockQuote(block) { 85 | this.pushLineBreak(); 86 | this.output.push(`> ${this.renderBlockContent(block)}\n`); 87 | } 88 | 89 | onBlockCode(block) { 90 | this.pushLineBreak(); 91 | const languageName = block.getData().get("languageName") || ""; 92 | this.output.push("```" + languageName + "\n"); 93 | this.output.push(`${this.renderBlockContent(block)}\n`); 94 | this.output.push("```\n"); 95 | } 96 | 97 | onBlockUnknown(block) { 98 | this.pushLineBreak(); 99 | this.output.push(`${this.renderBlockContent(block)}\n`); 100 | } 101 | 102 | /** 103 | * @param {ContentBlock} block 104 | */ 105 | consumeBlock(block) { 106 | switch (block.getType()) { 107 | case "header-one": { 108 | this.onBlockHeader(block, 1); 109 | break; 110 | } 111 | case "header-two": { 112 | this.onBlockHeader(block, 2); 113 | break; 114 | } 115 | case "header-three": { 116 | this.onBlockHeader(block, 3); 117 | break; 118 | } 119 | case "header-four": { 120 | this.onBlockHeader(block, 4); 121 | break; 122 | } 123 | case "header-five": { 124 | this.onBlockHeader(block, 5); 125 | break; 126 | } 127 | case "header-six": { 128 | this.onBlockHeader(block, 6); 129 | break; 130 | } 131 | case "ordered-list-item": { 132 | this.onBlockListItemOrdered(block); 133 | break; 134 | } 135 | case "unordered-list-item": { 136 | this.onBlockListItemUnordered(block); 137 | break; 138 | } 139 | case "blockquote": { 140 | this.onBlockQuote(block); 141 | break; 142 | } 143 | case "code-block": { 144 | this.onBlockCode(block); 145 | break; 146 | } 147 | default: { 148 | this.onBlockUnknown(block); 149 | break; 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * @returns {ContentBlock} 156 | */ 157 | getLastBlock() { 158 | return this.blocks[this.currentBlockIndex - 1]; 159 | } 160 | 161 | /** 162 | * @returns {ContentBlock} 163 | */ 164 | getNextBlock() { 165 | return this.blocks[this.currentBlockIndex + 1]; 166 | } 167 | 168 | /** 169 | * @param {ContentBlock} block 170 | * @returns {Number} 171 | */ 172 | getListItemCount(block) { 173 | let blockType = block.getType(); 174 | let blockDepth = block.getDepth(); 175 | // To decide if we need to start over we need to backtrack (skipping list 176 | // items that are of greater depth) 177 | let index = this.currentBlockIndex - 1; 178 | let prevBlock = this.blocks[index]; 179 | while ( 180 | prevBlock && 181 | checkNestableBlockType(prevBlock.getType()) && 182 | prevBlock.getDepth() > blockDepth 183 | ) { 184 | index -= 1; 185 | prevBlock = this.blocks[index]; 186 | } 187 | if ( 188 | !prevBlock || 189 | prevBlock.getType() !== blockType || 190 | prevBlock.getDepth() !== blockDepth 191 | ) { 192 | this.listItemCounts[blockDepth] = 0; 193 | } 194 | return ( 195 | this.listItemCounts[blockDepth] = this.listItemCounts[blockDepth] + 1 196 | ); 197 | } 198 | 199 | pushLineBreak() { 200 | if (this.currentBlockIndex > 0) { 201 | this.output.push("\n"); 202 | } 203 | } 204 | 205 | /** 206 | * @param {ContentBlock} block 207 | * @returns {String} 208 | */ 209 | renderBlockContent(block) { 210 | const text = block.getText(); 211 | const zeroWidthSpace = "\u200B"; 212 | if (text === "") { 213 | return zeroWidthSpace; 214 | } 215 | let charMetaList = block.getCharacterList(); 216 | let entityPieces = getEntityRanges(text, charMetaList); 217 | return entityPieces.map(([entityKey, stylePieces]) => { 218 | let content = stylePieces.map(([text, style]) => { 219 | const encodedText = encodeContent(text || ""); 220 | if (encodedText === "") { 221 | return ""; 222 | } else if (style.has(BOLD)) { 223 | return `**${encodedText}**`; 224 | } else if (style.has(UNDERLINE)) { 225 | return `++${encodedText}++`; // TODO: encode `+`? 226 | } else if (style.has(ITALIC)) { 227 | return `_${encodedText}_`; 228 | } else if (style.has(STRIKETHROUGH)) { 229 | return `~~${encodedText}~~`; // TODO: encode `~`? 230 | } else if (style.has(CODE)) { 231 | if (block.getType() === "code-block") { 232 | return encodedText; 233 | } else { 234 | return "`" + encodedText + "`"; 235 | } 236 | } else { 237 | return encodedText; 238 | } 239 | }).join(""); 240 | let entity = entityKey ? Entity.get(entityKey) : null; 241 | if (entity !== null && entity.getType() === ENTITY_TYPE.LINK) { 242 | let data = entity.getData(); 243 | let url = data.url || ''; 244 | let title = data.title ? ` "${escapeTitle(data.title)}"` : ''; 245 | return `[${content}](${encodeURL(url)}${title})`; 246 | } else if (entity != null && entity.getType() === ENTITY_TYPE.IMAGE) { 247 | let data = entity.getData(); 248 | return `![${escapeTitle(data.alt || "")}](${encodeURL(data.src || "")})`; 249 | } else { 250 | return content; 251 | } 252 | }).join(""); 253 | } 254 | } 255 | 256 | /** 257 | * @param {String} blockType 258 | * @returns {Boolean} 259 | */ 260 | function checkNestableBlockType(blockType) { 261 | return ["ordered-list-item", "unordered-list-item"].indexOf(blockType) === 0; 262 | } 263 | 264 | /** 265 | * @param {String} text (e.g. "1 * 1") 266 | * @returns {String} (e.g. "1 \\* 1") 267 | */ 268 | function encodeContent(text) { 269 | return text.replace(/[*_`]/g, '\\$&'); 270 | } 271 | 272 | /** 273 | * @param {String} url (e.g. "https://example.com/\\") 274 | * @returns {String} (e.g. "https://example.com/%29") 275 | */ 276 | function encodeURL(url) { 277 | return url.replace(/\)/g, '%29'); 278 | } 279 | 280 | /** 281 | * @param {String} text (e.g. "\"foo\"") 282 | * @returns {String} (e.g. "\\\"foo\\\"") 283 | */ 284 | function escapeTitle(text) { 285 | return text.replace(/"/g, '\\"'); 286 | } 287 | 288 | /** 289 | * @param {ContentState} contentState 290 | * @returns {String} 291 | */ 292 | export default function stateToGfm(contentState) { 293 | return new MarkdownGeneration(contentState).generate(); 294 | } 295 | -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/draft-js-markless-plugin/a8162987b4378abbb6f7065cf9036cef3e3b7551/images/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draft-js-markless-plugin", 3 | "description": "A plugin for draft-js that allows you to create a markdown-like keybinding WYSIWYG editor.", 4 | "version": "0.0.1", 5 | "author": "Ryo Nakamura (https://github.com/r7kamura)", 6 | "bugs": "https://github.com/r7kamura/draft-js-markless-plugin/issues", 7 | "devDependencies": { 8 | "@r7kamura/draft-js-import-markdown": "^0.1.6", 9 | "babel": "^6.5.2", 10 | "babel-preset-es2015": "^6.14.0", 11 | "babel-preset-react": "^6.11.1", 12 | "babelify": "^7.3.0", 13 | "browserify": "^13.1.0", 14 | "draft-js-autolist-plugin": "0.0.3", 15 | "draft-js-block-breakout-plugin": "0.0.2", 16 | "draft-js-emoji-plugin": "^1.2.3", 17 | "draft-js-linkify-plugin": "^1.0.1", 18 | "draft-js-plugins-editor": "^1.1.0", 19 | "draft-js-utils": "^0.1.5", 20 | "eslint": "^3.4.0", 21 | "eslint-plugin-react": "^6.2.0", 22 | "fixpack": "^2.3.1", 23 | "gh-pages": "^0.11.0", 24 | "node-sass": "^3.8.0", 25 | "prismjs": "^1.5.1", 26 | "react": "15.2.1", 27 | "react-dom": "15.2.1", 28 | "watchify": "^3.7.0" 29 | }, 30 | "engines": { 31 | "node": ">= 6.0.0" 32 | }, 33 | "homepage": "https://github.com/r7kamura/draft-js-markless-plugin", 34 | "keywords": [ 35 | "draft-js", 36 | "draft-js-plugins", 37 | "editor", 38 | "markdown", 39 | "wysiwyg" 40 | ], 41 | "license": "MIT", 42 | "main": "lib/index.js", 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/r7kamura/draft-js-markless-plugin.git" 46 | }, 47 | "scripts": { 48 | "build": "npm run build:rm && npm run build:mkdir && npm run build:js && npm run build:demo", 49 | "build:demo": "rm -rf docs && mkdir docs && cp demo/index.html docs && browserify demo/application.jsx -t babelify -o docs/application.js && npm run build:sass", 50 | "build:js": "browserify src/index.js -t babelify -o lib/index.js", 51 | "build:mkdir": "mkdir lib", 52 | "build:rm": "rm -rf lib", 53 | "build:sass": "node-sass demo/application.scss docs/application.css", 54 | "lint": "npm run lint:fixpack && npm run lint:eslint", 55 | "lint:eslint": "eslint src/**/*.{js,jsx}", 56 | "lint:fixpack": "fixpack", 57 | "publish": "gh-pages -d docs", 58 | "test": "npm run lint && npm run build", 59 | "watch": "npm run build && npm run watch:js & npm run watch:demo", 60 | "watch:demo": "watchify demo/application.jsx -t babelify -o docs/application.js -v", 61 | "watch:js": "watchify src/index.js -t babelify -o lib/index.js -v" 62 | }, 63 | "dependencies": { 64 | "draft-js": "^0.8.1", 65 | "immutable": "^3.8.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/create-markless-plugin.js: -------------------------------------------------------------------------------- 1 | import { genKey, ContentBlock, EditorState, RichUtils } from "draft-js"; 2 | import { List } from "immutable"; 3 | 4 | /** 5 | * @param {EditorState} editorState 6 | * @param {String} type 7 | * @returns {EditorState} 8 | */ 9 | const changeCurrentBlockType = (editorState, type, blockMetadata = {}) => { 10 | const currentContent = editorState.getCurrentContent(); 11 | const selection = editorState.getSelection(); 12 | const key = selection.getStartKey(); 13 | const blockMap = currentContent.getBlockMap(); 14 | const block = blockMap.get(key); 15 | const newBlock = block.merge({ 16 | type, 17 | data: block.getData().merge(blockMetadata), 18 | text: "", 19 | }); 20 | const newSelection = selection.merge({ 21 | anchorOffset: 0, 22 | focusOffset: 0, 23 | }); 24 | const newContentState = currentContent.merge({ 25 | blockMap: blockMap.set(key, newBlock), 26 | selectionAfter: newSelection, 27 | }); 28 | return EditorState.push( 29 | editorState, 30 | newContentState, 31 | "change-block-type" 32 | ); 33 | }; 34 | 35 | export default function createMarklessPlugin () { 36 | return { 37 | handleBeforeInput(character, { getEditorState, setEditorState }) { 38 | const editorState = getEditorState(); 39 | const key = editorState.getSelection().getStartKey(); 40 | const text = editorState.getCurrentContent().getBlockForKey(key).getText(); 41 | switch (`${text}${character}`) { 42 | case "# ": 43 | setEditorState(changeCurrentBlockType(editorState, "header-one")); 44 | return true; 45 | case "## ": 46 | setEditorState(changeCurrentBlockType(editorState, "header-two")); 47 | return true; 48 | case "### ": 49 | setEditorState(changeCurrentBlockType(editorState, "header-three")); 50 | return true; 51 | case "#### ": 52 | setEditorState(changeCurrentBlockType(editorState, "header-four")); 53 | return true; 54 | case "##### ": 55 | setEditorState(changeCurrentBlockType(editorState, "header-five")); 56 | return true; 57 | case "###### ": 58 | setEditorState(changeCurrentBlockType(editorState, "header-six")); 59 | return true; 60 | case "> ": 61 | setEditorState(changeCurrentBlockType(editorState, "blockquote")); 62 | return true; 63 | default: 64 | return false; 65 | } 66 | }, 67 | 68 | handleReturn(event, { getEditorState, setEditorState }) { 69 | const editorState = getEditorState(); 70 | const contentState = editorState.getCurrentContent(); 71 | const selection = editorState.getSelection(); 72 | const key = selection.getStartKey(); 73 | const currentBlock = contentState.getBlockForKey(key); 74 | const targetString = "```"; 75 | const matchData = /^```([\w-]+)?$/.exec(currentBlock.getText()); 76 | if (matchData && selection.getEndOffset() === currentBlock.getText().length) { 77 | setEditorState(changeCurrentBlockType(editorState, "code-block", { languageName: matchData[1] })); 78 | return true; 79 | } 80 | const currentBlockType = RichUtils.getCurrentBlockType(editorState); 81 | if (["blockquote", "code-block"].indexOf(currentBlockType) !== -1) { 82 | if (event.ctrlKey) { 83 | const emptyBlockKey = genKey(); 84 | const emptyBlock = new ContentBlock({ 85 | characterList: List(), 86 | depth: 0, 87 | key: emptyBlockKey, 88 | text: "", 89 | type: "unstyled", 90 | }) 91 | const blockMap = contentState.getBlockMap(); 92 | const blocksBefore = blockMap.toSeq().takeUntil((value) => value === currentBlock); 93 | const blocksAfter = blockMap.toSeq().skipUntil((value) => value === currentBlock).rest(); 94 | const augmentedBlocks = [ 95 | [ 96 | currentBlock.getKey(), 97 | currentBlock, 98 | ], 99 | [ 100 | emptyBlockKey, 101 | emptyBlock, 102 | ], 103 | ]; 104 | const newBlocks = blocksBefore.concat(augmentedBlocks, blocksAfter).toOrderedMap(); 105 | const focusKey = emptyBlockKey; 106 | const newContentState = contentState.merge({ 107 | blockMap: newBlocks, 108 | selectionBefore: selection, 109 | selectionAfter: selection.merge({ 110 | anchorKey: focusKey, 111 | anchorOffset: 0, 112 | focusKey: focusKey, 113 | focusOffset: 0, 114 | isBackward: false, 115 | }), 116 | }); 117 | setEditorState( 118 | EditorState.push( 119 | editorState, 120 | newContentState, 121 | "split-block" 122 | ) 123 | ); 124 | return true; 125 | } else { 126 | setEditorState(RichUtils.insertSoftNewline(editorState)); 127 | return true; 128 | } 129 | } 130 | } 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createMarklessPlugin from "./create-markless-plugin"; 2 | 3 | export default createMarklessPlugin; 4 | --------------------------------------------------------------------------------