Repository: withspectrum/draft-js-markdown-plugin
Pull request #73 (2018-05-10 09:10:55)

Trigger markdown on any character and change markdown implementation
**DEMO:  https://public-myglckweth.now.sh**

This PR makes it so that markdown is created when pressing the closing character of the markdown instance, not when pressing space which is a much nicer experience for the users.

Due to that, I had to change the markdown implementation to only allow a single way of doing things that doesn't conflict with each other:

- **bold** will be triggered by `*bold*`
- _italic_ will be triggered by `_italic_`
- ~~strikethrough~~ will be triggered by `~strikethrough~`

Nothing else should have changed.


Patch:
diff --git a/README.md b/README.md
index f95e8ff..ce6f6fc 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,64 @@ A [DraftJS] plugin for supporting Markdown syntax shortcuts in DraftJS. This plu
 npm i --save draft-js-markdown-plugin
 ```
 
+## Usage
+
+```js
+import React, { Component } from 'react';
+import Editor from 'draft-js-plugins-editor';
+import createMarkdownPlugin from 'draft-js-markdown-plugin';
+import { EditorState } from 'draft-js';
+
+export default class DemoEditor extends Component {
+
+  state = {
+    editorState: EditorState.createEmpty(),
+    plugins: [createMarkdownPlugin()]
+  };
+
+  onChange = (editorState) => {
+    this.setState({
+      editorState,
+    });
+  };
+
+  render() {
+    return (
+      <Editor
+        editorState={this.state.editorState}
+        onChange={this.onChange}
+        plugins={this.state.plugins}
+      />
+    );
+  }
+}
+```
+
+### Add code block syntax highlighting
+
+Using the [`draft-js-prism-plugin`](https://github.com/withspectrum/draft-js-prism-plugin) you can easily add syntax highlighting support to your code blocks!
+
+```JS
+// Install prismjs and draft-js-prism-plugin
+import Prism from 'prismjs';
+import createPrismPlugin from 'draft-js-prism-plugin';
+
+class Editor extends Component {
+  state = {
+    plugins: [
+      // Add the Prism plugin to the plugins array 
+      createPrismPlugin({
+        prism: Prism
+      }),
+      createMarkdownPlugin()
+    ]
+  }
+}
+```
+
 ## Options
-The `draft-js-markdown-plugin` is configurable. Just pass a config object. Here are the available options:
 
+The `draft-js-markdown-plugin` is configurable. Just pass a config object. Here are the available options:
 
 ### `renderLanguageSelect`
 
@@ -152,61 +207,6 @@ const markdownPlugin = createMarkdownPlugin({ entityType });
 const editorPlugins = [focusPlugin, imagePlugin, markdownPlugin];
 ```
 
-## Usage
-
-```js
-import React, { Component } from 'react';
-import Editor from 'draft-js-plugins-editor';
-import createMarkdownPlugin from 'draft-js-markdown-plugin';
-import { EditorState } from 'draft-js';
-
-export default class DemoEditor extends Component {
-
-  state = {
-    editorState: EditorState.createEmpty(),
-    plugins: [createMarkdownPlugin()]
-  };
-
-  onChange = (editorState) => {
-    this.setState({
-      editorState,
-    });
-  };
-
-  render() {
-    return (
-      <Editor
-        editorState={this.state.editorState}
-        onChange={this.onChange}
-        plugins={this.state.plugins}
-      />
-    );
-  }
-}
-```
-
-### Add code block syntax highlighting
-
-Using the [`draft-js-prism-plugin`](https://github.com/withspectrum/draft-js-prism-plugin) you can easily add syntax highlighting support to your code blocks!
-
-```JS
-// Install prismjs and draft-js-prism-plugin
-import Prism from 'prismjs';
-import createPrismPlugin from 'draft-js-prism-plugin';
-
-class Editor extends Component {
-  state = {
-    plugins: [
-      // Add the Prism plugin to the plugins array 
-      createPrismPlugin({
-        prism: Prism
-      }),
-      createMarkdownPlugin()
-    ]
-  }
-}
-```
-
 ## Why fork the `markdown-shortcuts-plugin`?
 
 Writing is a core part of our app, and while the `markdown-shortcuts-plugin` is awesome and battle-tested there are a few opinionated things we wanted to do differently. Rather than bother [@ngs](https://github.com/ngs) with tons of PRs, we figured it'd be better to own that core part of our experience fully. 
diff --git a/src/constants.js b/src/constants.js
index 5c82a97..707da1e 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -3,10 +3,10 @@ import { CHECKABLE_LIST_ITEM } from "draft-js-checkable-list-item";
 export const CODE_BLOCK_REGEX = /^```([\w-]+)?\s*$/;
 
 export const inlineMatchers = {
-  BOLD: [/\*\*(.+)\*\*$/g, /__(.+)__$/g],
-  ITALIC: [/\*(.+)\*$/g, /_(.+)_$/g],
-  CODE: [/`(.+)`$/g],
-  STRIKETHROUGH: [/~~(.+)~~$/g],
+  BOLD: [/\*(.+)\*$/g],
+  ITALIC: [/_(.+)_$/g],
+  CODE: [/`([^`]+)`$/g],
+  STRIKETHROUGH: [/~(.+)~$/g],
 };
 
 export const CODE_BLOCK_TYPE = "code-block";
diff --git a/src/index.js b/src/index.js
index bf54751..8ff3ca4 100755
--- a/src/index.js
+++ b/src/index.js
@@ -29,7 +29,7 @@ import insertText from "./modifiers/insertText";
 import changeCurrentBlockType from "./modifiers/changeCurrentBlockType";
 import createLinkDecorator from "./decorators/link";
 import createImageDecorator from "./decorators/image";
-import { replaceText } from "./utils";
+import { replaceText, getCurrentLine } from "./utils";
 import {
   CODE_BLOCK_REGEX,
   CODE_BLOCK_TYPE,
@@ -57,7 +57,7 @@ const defaultLanguages = {
   swift: "Swift",
 };
 
-const INLINE_STYLE_CHARACTERS = [" ", "*", "_"];
+const INLINE_STYLE_CHARACTERS = ["*", "_", "`", "~"];
 
 const defaultRenderSelect = ({ options, onChange, selectedValue }) => (
   <select value={selectedValue} onChange={onChange}>
@@ -341,16 +341,16 @@ const createMarkdownPlugin = (_config = {}) => {
       return "not-handled";
     },
     handleBeforeInput(character, editorState, { setEditorState }) {
-      if (character !== " ") {
-        return "not-handled";
-      }
-
       // If we're in a code block - don't transform markdown
       if (inCodeBlock(editorState)) return "not-handled";
 
       // If we're in a link - don't transform markdown
       if (inLink(editorState)) return "not-handled";
 
+      // Don't let users type two spaces after another
+      if (character === " " && getCurrentLine(editorState).slice(-1) === " ")
+        return "handled";
+
       const newEditorState = checkCharacterForState(
         config,
         editorState,
diff --git a/src/modifiers/handleInlineStyle.js b/src/modifiers/handleInlineStyle.js
index 5705f8f..73ae3e3 100644
--- a/src/modifiers/handleInlineStyle.js
+++ b/src/modifiers/handleInlineStyle.js
@@ -1,6 +1,8 @@
 import changeCurrentInlineStyle from "./changeCurrentInlineStyle";
 import { EditorState, Modifier } from "draft-js";
 import { inlineMatchers } from "../constants";
+import insertText from "./insertText";
+import { getCurrentLine as getLine } from "../utils";
 
 const handleChange = (editorState, line, whitelist) => {
   let newEditorState = editorState;
@@ -26,26 +28,21 @@ const handleChange = (editorState, line, whitelist) => {
   return newEditorState;
 };
 
-const getLine = (editorState, anchorOffset) => {
-  const selection = editorState.getSelection().merge({ anchorOffset });
-  const key = editorState.getSelection().getStartKey();
-
-  return editorState
-    .getCurrentContent()
-    .getBlockForKey(key)
-    .getText()
-    .slice(0, selection.getFocusOffset());
-};
-
-const handleInlineStyle = (whitelist, editorState, character) => {
+const handleInlineStyle = (
+  whitelist,
+  editorStateWithoutCharacter,
+  character
+) => {
+  const editorState = insertText(editorStateWithoutCharacter, character);
   let selection = editorState.getSelection();
-  let line = getLine(editorState, selection.getAnchorOffset());
+  let line = getLine(editorState);
   let newEditorState = handleChange(editorState, line, whitelist);
   let lastEditorState = editorState;
 
+  // Recursively resolve markdown, e.g. _*text*_ should turn into both italic and bold
   while (newEditorState !== lastEditorState) {
     lastEditorState = newEditorState;
-    line = getLine(newEditorState, selection.getAnchorOffset());
+    line = getLine(newEditorState);
     newEditorState = handleChange(newEditorState, line, whitelist);
   }
 
@@ -56,11 +53,7 @@ const handleInlineStyle = (whitelist, editorState, character) => {
     if (character === "\n") {
       newContentState = Modifier.splitBlock(newContentState, selection);
     } else {
-      newContentState = Modifier.insertText(
-        newContentState,
-        selection,
-        character
-      );
+      newContentState = Modifier.insertText(newContentState, selection, " ");
     }
 
     newEditorState = EditorState.push(
@@ -68,9 +61,11 @@ const handleInlineStyle = (whitelist, editorState, character) => {
       newContentState,
       "change-inline-style"
     );
+
+    return newEditorState;
   }
 
-  return newEditorState;
+  return editorStateWithoutCharacter;
 };
 
 export default handleInlineStyle;
diff --git a/src/modifiers/insertText.js b/src/modifiers/insertText.js
index faf4959..d09ba1c 100644
--- a/src/modifiers/insertText.js
+++ b/src/modifiers/insertText.js
@@ -3,6 +3,7 @@ import { EditorState, Modifier } from "draft-js";
 const insertText = (editorState, text) => {
   const selection = editorState.getSelection();
   const content = editorState.getCurrentContent();
+  if (!selection.isCollapsed()) return editorState;
   const newContentState = Modifier.insertText(
     content,
     selection,
diff --git a/src/utils.js b/src/utils.js
index 6b8b13e..aa11975 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,5 +1,17 @@
 import { Modifier, EditorState } from "draft-js";
 
+export const getCurrentLine = editorState => {
+  const { anchorOffset } = editorState.getSelection();
+  const selection = editorState.getSelection().merge({ anchorOffset });
+  const key = editorState.getSelection().getStartKey();
+
+  return editorState
+    .getCurrentContent()
+    .getBlockForKey(key)
+    .getText()
+    .slice(0, selection.getFocusOffset());
+};
+
 export function addText(editorState, bufferText) {
   const contentState = Modifier.insertText(
     editorState.getCurrentContent(),


Test patch:
diff --git a/src/__test__/plugin.test.js b/src/__test__/plugin.test.js
index a8a8055..7b805d0 100755
--- a/src/__test__/plugin.test.js
+++ b/src/__test__/plugin.test.js
@@ -228,37 +228,6 @@ describe("draft-js-markdown-plugin", () => {
           expect(store.setEditorState).toHaveBeenCalledWith(newEditorState);
         });
 
-        it("adds list item and transforms markdown", () => {
-          // createMarkdownPlugin.__Rewire__("leaveList", modifierSpy); // eslint-disable-line no-underscore-dangle
-          currentRawContentState = {
-            entityMap: {},
-            blocks: [
-              {
-                key: "item1",
-                text: "**some bold text**",
-                type: "unordered-list-item",
-                depth: 0,
-                inlineStyleRanges: [],
-                entityRanges: [],
-                data: {},
-              },
-            ],
-          };
-          currentSelectionState = currentSelectionState.merge({
-            focusOffset: 18,
-            anchorOffset: 18,
-          });
-          expect(subject()).toBe("handled");
-          // expect(modifierSpy).toHaveBeenCalledTimes(1);
-          expect(store.setEditorState).toHaveBeenCalledTimes(1);
-          newEditorState = store.setEditorState.mock.calls[0][0];
-          const newRawContentState = Draft.convertToRaw(
-            newEditorState.getCurrentContent()
-          );
-          expect(newRawContentState.blocks.length).toBe(2);
-          expect(newEditorState.getCurrentInlineStyle().size).toBe(0);
-        });
-
         const emptyBlockTypes = [
           "blockquote",
           "header-one",
diff --git a/src/modifiers/__test__/handleInlineStyle-test.js b/src/modifiers/__test__/handleInlineStyle-test.js
index ed26390..9aaba55 100644
--- a/src/modifiers/__test__/handleInlineStyle-test.js
+++ b/src/modifiers/__test__/handleInlineStyle-test.js
@@ -48,12 +48,13 @@ describe("handleInlineStyle", () => {
 
   const testCases = {
     "converts a mix of code, bold and italic and strikethrough in one go": {
+      character: "`",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "`h~~el**lo *inline~~***` style",
+            text: "`h~el*lo _inline~_* style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [],
@@ -99,21 +100,22 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 24,
+        anchorOffset: 19,
         focusKey: "item1",
-        focusOffset: 24,
+        focusOffset: 19,
         isBackward: false,
         hasFocus: true,
       }),
     },
 
     "converts to bold with astarisks": {
+      character: "*",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello **inline** style",
+            text: "hello *inline style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [],
@@ -144,20 +146,21 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 16,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 16,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
     },
     "converts semicolons to bold with astarisks": {
+      character: "*",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello **TL;DR:** style",
+            text: "hello *TL;DR: style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [],
@@ -188,108 +191,21 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 16,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 16,
-        isBackward: false,
-        hasFocus: true,
-      }),
-    },
-    "converts to bold with underscores": {
-      before: {
-        entityMap: {},
-        blocks: [
-          {
-            key: "item1",
-            text: "hello __inline__ style",
-            type: "unstyled",
-            depth: 0,
-            inlineStyleRanges: [],
-            entityRanges: [],
-            data: {},
-          },
-        ],
-      },
-      after: {
-        entityMap: {},
-        blocks: [
-          {
-            key: "item1",
-            text: "hello inline  style",
-            type: "unstyled",
-            depth: 0,
-            inlineStyleRanges: [
-              {
-                length: 6,
-                offset: 6,
-                style: "BOLD",
-              },
-            ],
-            entityRanges: [],
-            data: {},
-          },
-        ],
-      },
-      selection: new SelectionState({
-        anchorKey: "item1",
-        anchorOffset: 16,
-        focusKey: "item1",
-        focusOffset: 16,
-        isBackward: false,
-        hasFocus: true,
-      }),
-    },
-    "converts to italic with astarisk": {
-      before: {
-        entityMap: {},
-        blocks: [
-          {
-            key: "item1",
-            text: "hello *inline* style",
-            type: "unstyled",
-            depth: 0,
-            inlineStyleRanges: [],
-            entityRanges: [],
-            data: {},
-          },
-        ],
-      },
-      after: {
-        entityMap: {},
-        blocks: [
-          {
-            key: "item1",
-            text: "hello inline  style",
-            type: "unstyled",
-            depth: 0,
-            inlineStyleRanges: [
-              {
-                length: 6,
-                offset: 6,
-                style: "ITALIC",
-              },
-            ],
-            entityRanges: [],
-            data: {},
-          },
-        ],
-      },
-      selection: new SelectionState({
-        anchorKey: "item1",
-        anchorOffset: 14,
-        focusKey: "item1",
-        focusOffset: 14,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
     },
     "converts to italic with underscore": {
+      character: "_",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello _inline_ style",
+            text: "hello _inline style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [],
@@ -320,20 +236,21 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 14,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 14,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
     },
     "combines to italic and bold with astarisks": {
+      character: "*",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello **inline** style",
+            text: "hello *inline style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [
@@ -375,20 +292,21 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 16,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 16,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
     },
     "converts to code with backquote": {
+      character: "`",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello `inline` style",
+            text: "hello `inline style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [],
@@ -419,20 +337,21 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 14,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 14,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
     },
     "converts to strikethrough with tildes": {
+      character: "~",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello ~~inline~~ style",
+            text: "hello ~inline style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [],
@@ -463,9 +382,9 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 16,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 16,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
@@ -473,69 +392,14 @@ describe("handleInlineStyle", () => {
 
     // combine tests
 
-    "combines to italic and bold with underscores": {
-      before: {
-        entityMap: {},
-        blocks: [
-          {
-            key: "item1",
-            text: "hello __inline__ style",
-            type: "unstyled",
-            depth: 0,
-            inlineStyleRanges: [
-              {
-                length: 4,
-                offset: 5,
-                style: "ITALIC",
-              },
-            ],
-            entityRanges: [],
-            data: {},
-          },
-        ],
-      },
-      after: {
-        entityMap: {},
-        blocks: [
-          {
-            key: "item1",
-            text: "hello inline  style",
-            type: "unstyled",
-            depth: 0,
-            inlineStyleRanges: [
-              {
-                length: 2,
-                offset: 5,
-                style: "ITALIC",
-              },
-              {
-                length: 6,
-                offset: 6,
-                style: "BOLD",
-              },
-            ],
-            entityRanges: [],
-            data: {},
-          },
-        ],
-      },
-      selection: new SelectionState({
-        anchorKey: "item1",
-        anchorOffset: 16,
-        focusKey: "item1",
-        focusOffset: 16,
-        isBackward: false,
-        hasFocus: true,
-      }),
-    },
-
     "combines to bold and italic with underscores": {
+      character: "_",
       before: {
         entityMap: {},
         blocks: [
           {
             key: "item1",
-            text: "hello _inline_ style",
+            text: "hello _inline style",
             type: "unstyled",
             depth: 0,
             inlineStyleRanges: [
@@ -577,9 +441,9 @@ describe("handleInlineStyle", () => {
       },
       selection: new SelectionState({
         anchorKey: "item1",
-        anchorOffset: 14,
+        anchorOffset: 13,
         focusKey: "item1",
-        focusOffset: 14,
+        focusOffset: 13,
         isBackward: false,
         hasFocus: true,
       }),
@@ -588,7 +452,11 @@ describe("handleInlineStyle", () => {
   Object.keys(testCases).forEach(k => {
     describe(k, () => {
       const testCase = testCases[k];
-      const { before, after, selection, character = " " } = testCase;
+      const { before, after, selection, character } = testCase;
+      if (!character)
+        throw new Error(
+          "Invalid test case, needs to provide character option."
+        );
       const contentState = Draft.convertFromRaw(before);
       const editorState = EditorState.forceSelection(
         EditorState.createWithContent(contentState),
@@ -610,10 +478,10 @@ describe("handleInlineStyle", () => {
           sameEditorState,
           character
         );
-        expect(newEditorState).toEqual(sameEditorState);
         expect(Draft.convertToRaw(newEditorState.getCurrentContent())).toEqual(
           before
         );
+        expect(newEditorState).toEqual(sameEditorState);
       });
 
       it("converts markdown to style or block type", () => {
@@ -622,10 +490,10 @@ describe("handleInlineStyle", () => {
           editorState,
           character
         );
-        expect(newEditorState).not.toEqual(editorState);
         expect(Draft.convertToRaw(newEditorState.getCurrentContent())).toEqual(
           after
         );
+        expect(newEditorState).not.toEqual(editorState);
       });
     });
   });