diff --git a/CHANGELOG.md b/CHANGELOG.md index 605d443d..1e76671e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This new API makes it possible to build much more advanced extensions to the edi - Add data reset parameter to `DraftUtils.resetBlockWithType()`. - Add ability to disable or customise the editor toolbar with [`topToolbar`](https://www.draftail.org/docs/customising-toolbars). - Add ability to add a toolbar below the editor with [`bottomToolbar`](https://www.draftail.org/docs/customising-toolbars). +- Add support for Markdown shortcuts for inline styles, e.g. `**` for bold, `_` for italic, etc ([#134](https://github.com/springload/draftail/issues/134), [#187](https://github.com/springload/draftail/pull/187)). View the full list of [keyboard shortcuts](https://www.draftail.org/docs/keyboard-shortcuts). ### Changed diff --git a/README.md b/README.md index d6ea71e1..1329fc4c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Here are important features worth highlighting: - Support for [keyboard shortcuts](https://www.draftail.org/docs/keyboard-shortcuts). Lots of them! - Paste from Word. Or any other editor. It just works. - Autolists – start a line with `-` , `*` , `1.` to create a list item. -- Shortcuts for heading levels `##`, code blocks ` ``` `, and more. +- Shortcuts for heading levels `##`, code blocks ` ``` `, text formats `**`, and more. - Undo / redo – until the end of times. - Common text types: headings, paragraphs, quotes, lists. - Common text styles: Bold, italic, and many more. diff --git a/lib/api/DraftUtils.js b/lib/api/DraftUtils.js index d1f3e3a7..e2f171f6 100644 --- a/lib/api/DraftUtils.js +++ b/lib/api/DraftUtils.js @@ -188,6 +188,54 @@ export default { ); }, + /** + * Applies an inline style on a given range, based on a Markdown shortcut, + * removing the Markdown markers. + * Supports adding styles on existing styles, and entities. + */ + applyMarkdownStyle( + editorState: EditorState, + range: {| + pattern: string, + start: number, + end: number, + type: string, + |}, + char: string, + ) { + const selection = editorState.getSelection(); + let content = editorState.getCurrentContent(); + + const marked = selection.merge({ + anchorOffset: range.start, + focusOffset: range.end, + }); + const endMarker = selection.merge({ + anchorOffset: range.end - range.pattern.length, + focusOffset: range.end, + }); + const startMarker = selection.merge({ + anchorOffset: range.start, + focusOffset: range.start + range.pattern.length, + }); + + // Remove the markers separately to preserve existing styles and entities on the marked text. + content = Modifier.applyInlineStyle(content, marked, range.type); + content = Modifier.removeRange(content, endMarker, "forward"); + content = Modifier.removeRange(content, startMarker, "forward"); + + const offset = selection.getFocusOffset() - range.pattern.length * 2; + const endSelection = selection.merge({ + anchorOffset: offset, + focusOffset: offset, + }); + content = content.merge({ selectionAfter: endSelection }); + + content = Modifier.insertText(content, endSelection, char); + + return EditorState.push(editorState, content, "change-inline-style"); + }, + /** * Removes the block at the given key. */ diff --git a/lib/api/DraftUtils.test.js b/lib/api/DraftUtils.test.js index 8ac3b261..ef1c1fcd 100644 --- a/lib/api/DraftUtils.test.js +++ b/lib/api/DraftUtils.test.js @@ -364,6 +364,141 @@ describe("DraftUtils", () => { }); }); + describe("#applyMarkdownStyle", () => { + it("works", () => { + let editorState = EditorState.createWithContent( + ContentState.createFromBlockArray( + convertFromHTML(`
This is a _Test_
`), + ), + ); + editorState = EditorState.moveFocusToEnd(editorState); + editorState = DraftUtils.applyMarkdownStyle( + editorState, + { + start: 10, + end: 16, + pattern: "_", + type: "ITALIC", + }, + "!", + ); + + expect( + convertToRaw(editorState.getCurrentContent()).blocks[0], + ).toMatchObject({ + text: "This is a Test!", + inlineStyleRanges: [{ length: 4, offset: 10, style: "ITALIC" }], + }); + expect(editorState.getSelection().toJS()).toMatchObject({ + anchorOffset: 15, + focusOffset: 15, + }); + }); + + it("stacks styles", () => { + let editorState = EditorState.createWithContent( + ContentState.createFromBlockArray( + convertFromHTML(`This is a _Test_
`), + ), + ); + editorState = EditorState.moveFocusToEnd(editorState); + editorState = DraftUtils.applyMarkdownStyle( + editorState, + { + start: 10, + end: 16, + pattern: "_", + type: "ITALIC", + }, + "!", + ); + + expect( + convertToRaw(editorState.getCurrentContent()).blocks[0], + ).toMatchObject({ + text: "This is a Test!", + inlineStyleRanges: [ + { length: 4, offset: 10, style: "BOLD" }, + { length: 4, offset: 10, style: "ITALIC" }, + ], + }); + }); + + it("respects entities", () => { + let editorState = EditorState.createWithContent( + convertFromRaw({ + entityMap: { + "1": { + type: "LINK", + mutability: "MUTABLE", + data: { + url: "/test", + }, + }, + }, + blocks: [ + { + key: "a", + text: "A _test_", + entityRanges: [ + { + key: "1", + offset: 3, + length: 4, + }, + ], + }, + ], + }), + ); + editorState = EditorState.moveFocusToEnd(editorState); + editorState = DraftUtils.applyMarkdownStyle( + editorState, + { + start: 2, + end: 8, + pattern: "_", + type: "ITALIC", + }, + "!", + ); + + expect( + convertToRaw(editorState.getCurrentContent()).blocks[0], + ).toMatchObject({ + text: "A test!", + inlineStyleRanges: [{ length: 4, offset: 2, style: "ITALIC" }], + entityRanges: [{ length: 4, offset: 2 }], + }); + }); + + it("supports arbitrary markers", () => { + let editorState = EditorState.createWithContent( + ContentState.createFromBlockArray( + convertFromHTML(`A !!!test!!!
`), + ), + ); + editorState = EditorState.moveFocusToEnd(editorState); + editorState = DraftUtils.applyMarkdownStyle( + editorState, + { + start: 2, + end: 12, + pattern: "!!!", + type: "CUSTOM", + }, + "$", + ); + + expect( + convertToRaw(editorState.getCurrentContent()).blocks[0], + ).toMatchObject({ + text: "A test$", + inlineStyleRanges: [{ length: 4, offset: 2, style: "CUSTOM" }], + }); + }); + }); + describe("#removeBlock", () => { it("works", () => { const editorState = DraftUtils.removeBlock( diff --git a/lib/api/behavior.js b/lib/api/behavior.js index b121e042..3b2e5514 100644 --- a/lib/api/behavior.js +++ b/lib/api/behavior.js @@ -16,6 +16,7 @@ import { KEYBOARD_SHORTCUTS, CUSTOM_STYLE_MAP, INPUT_BLOCK_MAP, + INPUT_STYLE_MAP, INPUT_ENTITY_MAP, INLINE_STYLE, } from "./constants"; @@ -188,6 +189,36 @@ export default { ); }, + /** + * Checks whether a given input string contains style shortcuts. + * If so, returns the range onto which the shortcut is applied. + */ + handleBeforeInputInlineStyle( + input: string, + inlineStyles: $ReadOnlyArray<{ type: string }>, + ) { + const activeShortcuts = INPUT_STYLE_MAP.filter(({ type }) => + inlineStyles.some((s) => s.type === type), + ); + let range; + + const match = activeShortcuts.find(({ regex }) => { + // Re-create a RegExp object every time because RegExp is stateful. + range = new RegExp(regex, "g").exec(input); + + return range; + }); + + return range && match + ? { + pattern: match.pattern, + start: range.index === 0 ? 0 : range.index + 1, + end: range.index + range[0].length, + type: match.type, + } + : false; + }, + getCustomStyleMap( inlineStyles: $ReadOnlyArray<{ type: string, style?: {} }>, ) { diff --git a/lib/api/behavior.test.js b/lib/api/behavior.test.js index 616b3781..d8fa054c 100644 --- a/lib/api/behavior.test.js +++ b/lib/api/behavior.test.js @@ -494,6 +494,51 @@ describe("behavior", () => { }); }); + describe("#handleBeforeInputInlineStyle", () => { + it.each` + label | beforeInput | styles | expected + ${"no marker"} | ${"test"} | ${["ITALIC"]} | ${false} + ${"open only"} | ${"a _test"} | ${["ITALIC"]} | ${false} + ${"close only"} | ${"a test_"} | ${["ITALIC"]} | ${false} + ${"open - close markers"} | ${"a _test_"} | ${["ITALIC"]} | ${{}} + ${"open - close markers in the middle"} | ${"a _test_ a"} | ${["ITALIC"]} | ${false} + ${"open - close markers whole text"} | ${"_a test_"} | ${["ITALIC"]} | ${{}} + ${"open - close markers single char"} | ${"_a_"} | ${["ITALIC"]} | ${{}} + ${"no marked text"} | ${"a _test"} | ${["ITALIC"]} | ${false} + ${"open only, or no marked text"} | ${"a _test"} | ${["ITALIC", "BOLD"]} | ${false} + ${"open - close markers, or no marked text"} | ${"a _test_"} | ${["ITALIC", "BOLD"]} | ${{ pattern: "_" }} + ${"different open & close"} | ${"a _test*"} | ${["ITALIC", "BOLD"]} | ${false} + ${"different open & close but similar"} | ${"a __test_"} | ${["ITALIC", "BOLD"]} | ${false} + ${"different open & close, after input"} | ${"a _test__"} | ${["ITALIC", "BOLD"]} | ${false} + ${"two marker sets"} | ${"a __test__ two _test_"} | ${["ITALIC", "BOLD"]} | ${{ pattern: "_" }} + ${"no whitespace before open"} | ${"a_test_"} | ${["ITALIC"]} | ${false} + ${"whitespace after open"} | ${"a _ test_"} | ${["ITALIC"]} | ${false} + ${"whitespace before close"} | ${"a _test _"} | ${["ITALIC"]} | ${false} + `("$label", ({ beforeInput, styles, expected }) => { + const inlineStyles = styles.map((type) => ({ type })); + const result = behavior.handleBeforeInputInlineStyle( + beforeInput, + inlineStyles, + ); + if (expected) { + expect(result).toMatchObject(expected); + } else { + expect(result).toEqual(expected); + } + }); + + it("open - close marker, handling", () => { + expect( + behavior.handleBeforeInputInlineStyle("a **test**", [{ type: "BOLD" }]), + ).toEqual({ + pattern: "**", + start: 2, + end: 10, + type: "BOLD", + }); + }); + }); + describe("#getCustomStyleMap", () => { it("existing styles, default styling", () => { expect( diff --git a/lib/api/constants.js b/lib/api/constants.js index 39328086..966fceb2 100644 --- a/lib/api/constants.js +++ b/lib/api/constants.js @@ -154,6 +154,38 @@ export const INPUT_BLOCK_MAP = { "```": BLOCK_TYPE.CODE, }; +export const INPUT_STYLE_MAP = [ + // Order matters, as shorter patterns are contained in the longer ones. + { pattern: "**", type: INLINE_STYLE.BOLD }, + { pattern: "__", type: INLINE_STYLE.BOLD }, + { pattern: "*", type: INLINE_STYLE.ITALIC }, + { pattern: "_", type: INLINE_STYLE.ITALIC }, + { pattern: "~~", type: INLINE_STYLE.STRIKETHROUGH }, + { pattern: "~", type: INLINE_STYLE.STRIKETHROUGH }, + { pattern: "`", type: INLINE_STYLE.CODE }, +].map<{| + pattern: string, + type: string, + regex: string, +|}>(({ pattern, type }) => { + const pat = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const char = pattern[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // https://regexper.com/#%28%5Cs%7C%5E%29__%28%5B%5E%5Cs_%5D%7B1%2C2%7D%7C%5B%5E%5Cs_%5D.%2B%5B%5E%5Cs_%5D%29__%24 + // This is stored as an escaped string instead of a RegExp object because they are stateful. + // This regex encapsulates a few rules: + // - The pattern must be preceded by whitespace, or be at the start of the input. + // - The pattern must end the input. + // - In-between the start and end patterns, there can't be only whitespace or characters from the pattern. + // - There has to be at least 1 char that's not whitespace or the pattern’s char. + const regex = `(\\s|^)${pat}([^\\s${char}]{1,2}|[^\\s${char}].+[^\\s${char}])${pat}$`; + + return { + pattern, + type, + regex, + }; +}); + export const INPUT_ENTITY_MAP = {}; INPUT_ENTITY_MAP[ENTITY_TYPE.HORIZONTAL_RULE] = "---"; diff --git a/lib/components/DraftailEditor.js b/lib/components/DraftailEditor.js index 2ddb629c..08822ab3 100644 --- a/lib/components/DraftailEditor.js +++ b/lib/components/DraftailEditor.js @@ -1,7 +1,7 @@ // @flow import React, { Component } from "react"; import type { ComponentType } from "react"; -import { EditorState, RichUtils, ContentBlock } from "draft-js"; +import { EditorState, RichUtils, ContentBlock, Modifier } from "draft-js"; import type { EntityInstance } from "draft-js"; import type { RawDraftContentState } from "draft-js/lib/RawDraftContentState"; import type { DraftEditorCommand } from "draft-js/lib/DraftEditorCommand"; @@ -490,9 +490,8 @@ class DraftailEditor extends Component