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 { /* :: handleReturn: (e: SyntheticKeyboardEvent<>) => 'not-handled' | 'handled'; */ handleReturn(e: SyntheticKeyboardEvent<>) { - const { enableLineBreak } = this.props; + const { enableLineBreak, inlineStyles } = this.props; const { editorState } = this.state; - const contentState = editorState.getCurrentContent(); let ret = NOT_HANDLED; // alt + enter opens links and other entities with a `url` property. @@ -504,7 +503,8 @@ class DraftailEditor extends Component { const entityKey = DraftUtils.getSelectionEntity(editorState); if (entityKey) { - const entityData = contentState.getEntity(entityKey).getData(); + const content = editorState.getCurrentContent(); + const entityData = content.getEntity(entityKey).getData(); if (entityData.url) { window.open(entityData.url); @@ -516,9 +516,37 @@ class DraftailEditor extends Component { e.which = 0; } - const newState = DraftUtils.handleNewLine(editorState, e); + let newState = editorState; + let newStyle = false; - if (newState) { + const selection = newState.getSelection(); + // Check whether we should apply a Markdown styles shortcut. + if (selection.isCollapsed()) { + const block = DraftUtils.getSelectedBlock(editorState); + newStyle = behavior.handleBeforeInputInlineStyle( + block.getText(), + inlineStyles, + ); + + if (newStyle) { + newState = DraftUtils.applyMarkdownStyle(newState, newStyle, ""); + } + } + + const newLineState = DraftUtils.handleNewLine(newState, e); + + // Manually handle the return if there is a style to apply. + if (!newLineState && newStyle) { + const content = newState.getCurrentContent(); + const newContent = Modifier.splitBlock(content, selection); + newState = EditorState.push(newState, newContent, "split-block"); + // Do not propagate the style from the last block. + newState = RichUtils.toggleInlineStyle(newState, newStyle.type); + } else { + newState = newLineState; + } + + if (newState && newState !== editorState) { ret = HANDLED; this.onChange(newState); } @@ -567,7 +595,7 @@ class DraftailEditor extends Component { /* :: handleBeforeInput: (char: string) => 'handled' | 'not-handled'; */ handleBeforeInput(char: string) { - const { blockTypes, enableHorizontalRule } = this.props; + const { blockTypes, inlineStyles, enableHorizontalRule } = this.props; const { editorState } = this.state; const selection = editorState.getSelection(); @@ -575,8 +603,8 @@ class DraftailEditor extends Component { const block = DraftUtils.getSelectedBlock(editorState); const startOffset = selection.getStartOffset(); const text = block.getText(); - const beforeBeforeInput = text.slice(0, startOffset); - const mark = `${beforeBeforeInput}${char}`; + const beforeInput = text.slice(0, startOffset); + const mark = `${beforeInput}${char}`; let newEditorState = editorState; const newBlockType = behavior.handleBeforeInputBlockType( @@ -588,7 +616,7 @@ class DraftailEditor extends Component { newEditorState = DraftUtils.resetBlockWithType( newEditorState, newBlockType, - text.replace(beforeBeforeInput, ""), + text.replace(beforeInput, ""), ); } @@ -599,6 +627,19 @@ class DraftailEditor extends Component { ); } + const newStyle = behavior.handleBeforeInputInlineStyle( + beforeInput, + inlineStyles, + ); + + if (newStyle) { + newEditorState = DraftUtils.applyMarkdownStyle( + newEditorState, + newStyle, + char, + ); + } + if (newEditorState !== editorState) { this.onChange(newEditorState); return HANDLED; diff --git a/lib/components/DraftailEditor.test.js b/lib/components/DraftailEditor.test.js index d486d8d9..472957fd 100644 --- a/lib/components/DraftailEditor.test.js +++ b/lib/components/DraftailEditor.test.js @@ -15,7 +15,7 @@ import DraftUtils from "../api/DraftUtils"; import DividerBlock from "../blocks/DividerBlock"; import DraftailEditor from "./DraftailEditor"; import Toolbar from "./Toolbar"; -import { ENTITY_TYPE } from "../api/constants"; +import { ENTITY_TYPE, INLINE_STYLE } from "../api/constants"; jest.mock("draft-js/lib/generateRandomKey", () => () => "a"); @@ -401,7 +401,7 @@ describe("DraftailEditor", () => { it("default", () => { jest .spyOn(DraftUtils, "handleNewLine") - .mockImplementation((editorState) => editorState); + .mockImplementation(() => EditorState.createEmpty()); const wrapper = shallowNoLifecycle(); expect( @@ -412,6 +412,7 @@ describe("DraftailEditor", () => { DraftUtils.handleNewLine.mockRestore(); }); + it("enabled br", () => { const wrapper = shallowNoLifecycle(); @@ -421,6 +422,7 @@ describe("DraftailEditor", () => { }), ).toBe("not-handled"); }); + it("alt + enter on text", () => { const wrapper = shallowNoLifecycle(); @@ -430,6 +432,7 @@ describe("DraftailEditor", () => { }), ).toBe("handled"); }); + it("alt + enter on entity without url", () => { const wrapper = shallowNoLifecycle( { entityMap: { "1": { type: "LINK", - mutability: "IMMUTABLE", data: { url: "test", }, }, "2": { type: "LINK", - mutability: "IMMUTABLE", - data: {}, }, }, blocks: [ { - key: "b3kdk", text: "test", - type: "unstyled", - depth: 0, - inlineStyleRanges: [], entityRanges: [ { offset: 0, @@ -462,7 +458,6 @@ describe("DraftailEditor", () => { key: 2, }, ], - data: {}, }, ], }} @@ -484,7 +479,6 @@ describe("DraftailEditor", () => { entityMap: { "1": { type: "LINK", - mutability: "IMMUTABLE", data: { url: "test", }, @@ -492,11 +486,7 @@ describe("DraftailEditor", () => { }, blocks: [ { - key: "b3kdk", text: "test", - type: "unstyled", - depth: 0, - inlineStyleRanges: [], entityRanges: [ { offset: 0, @@ -504,7 +494,6 @@ describe("DraftailEditor", () => { key: 1, }, ], - data: {}, }, ], }} @@ -520,6 +509,54 @@ describe("DraftailEditor", () => { window.open.mockRestore(); }); + + it("style shortcut", () => { + jest.spyOn(DraftUtils, "applyMarkdownStyle"); + + const wrapper = shallowNoLifecycle( + , + ); + + expect(wrapper.instance().handleReturn({})).toBe("handled"); + expect(DraftUtils.applyMarkdownStyle).toHaveBeenCalled(); + + DraftUtils.applyMarkdownStyle.mockRestore(); + }); + + it("style shortcut but selection is not collapsed", () => { + jest.spyOn(DraftUtils, "applyMarkdownStyle"); + + const wrapper = shallowNoLifecycle( + , + ); + + // Monkey-patching the one method. A bit dirty. + const selection = new SelectionState().set("anchorKey", "aaaa2"); + selection.isCollapsed = () => false; + wrapper.setState({ + editorState: Object.assign(wrapper.state("editorState"), { + getSelection: () => selection, + getCurrentInlineStyle: () => new OrderedSet(), + }), + }); + + expect(wrapper.instance().handleReturn({})).toBe("not-handled"); + expect(DraftUtils.applyMarkdownStyle).not.toHaveBeenCalled(); + + DraftUtils.applyMarkdownStyle.mockRestore(); + }); }); it("onFocus, onBlur", () => { @@ -680,8 +717,10 @@ describe("DraftailEditor", () => { .spyOn(DraftUtils, "getSelectedBlock") .mockImplementation(() => new ContentBlock()); jest.spyOn(DraftUtils, "addHorizontalRuleRemovingSelection"); + jest.spyOn(DraftUtils, "applyMarkdownStyle"); jest.spyOn(behavior, "handleBeforeInputBlockType"); jest.spyOn(behavior, "handleBeforeInputHR"); + jest.spyOn(behavior, "handleBeforeInputInlineStyle"); jest.spyOn(wrapper.instance(), "onChange"); }); @@ -730,6 +769,19 @@ describe("DraftailEditor", () => { expect(DraftUtils.addHorizontalRuleRemovingSelection).toHaveBeenCalled(); DraftUtils.shouldHidePlaceholder.mockRestore(); }); + + it("change style", () => { + wrapper.instance().render = () => {}; + behavior.handleBeforeInputInlineStyle = jest.fn(() => ({ + pattern: "**", + type: "BOLD", + start: 0, + end: 0, + })); + expect(wrapper.instance().handleBeforeInput("!")).toBe("handled"); + expect(wrapper.instance().onChange).toHaveBeenCalled(); + expect(DraftUtils.applyMarkdownStyle).toHaveBeenCalled(); + }); }); describe("handlePastedText", () => {