Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 48 additions & 0 deletions lib/api/DraftUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
135 changes: 135 additions & 0 deletions lib/api/DraftUtils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,141 @@ describe("DraftUtils", () => {
});
});

describe("#applyMarkdownStyle", () => {
it("works", () => {
let editorState = EditorState.createWithContent(
ContentState.createFromBlockArray(
convertFromHTML(`<p>This is a _Test_</p>`),
),
);
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(`<p>This is a _<strong>Test</strong>_</p>`),
),
);
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(`<p>A !!!test!!!</p>`),
),
);
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(
Expand Down
31 changes: 31 additions & 0 deletions lib/api/behavior.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
KEYBOARD_SHORTCUTS,
CUSTOM_STYLE_MAP,
INPUT_BLOCK_MAP,
INPUT_STYLE_MAP,
INPUT_ENTITY_MAP,
INLINE_STYLE,
} from "./constants";
Expand Down Expand Up @@ -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?: {} }>,
) {
Expand Down
45 changes: 45 additions & 0 deletions lib/api/behavior.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions lib/api/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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] = "---";
Expand Down
Loading