How to build a WYSIWYG Editor with Draft.js

Mar 14, 2021


What is WYSIWYG?

In computing, WYSIWYG (/ˈwɪziwɪɡ/ WIZ-ee-wig), an acronym for What You See Is What You Get, is a system in which editing software allows content to be edited in a form that resembles its appearance when printed or displayed as a finished product, such as a printed document, web page, or slide presentation.

The Result#

result

draft-js-plugins-editor#

Draft.js is a highly customizable but complex library to use. draft-js-plugins-editor makes it easier to working with Draft.js, but it's not actively maintained right now.

Install#

terminal
1yarn add draft-js
2yarn add -D @types/draft-js

Draft.js is written in Flow, not TypeScript, so I installed @types/draft-js as well.

tsx
1/** TextEditor.tsx */
2
3import React from "react";
4import { Editor, EditorState } from "draft-js";
5import "draft-js/dist/Draft.css";
6
7const TextEditor = () => {
8 const [editorState, setEditorState] = React.useState<EditorState>(
9 EditorState.createEmpty()
10 );
11 return <Editor editorState={editorState} onChange={setEditorState} />;
12};
13
14export default TextEditor;

I called EditorState.createEmpty() when initializing useState hook, but you can also use EditorState.createWithContent.

This will render a default contenteditable component. You can pass readOnly prop to the Editor to make it uneditable.

Bold, Italic, H1/H2/H3#

RichUtils, the build in utilities of Draft.js provide basic commands .

Using Key Command#

tsx
1/** TextEditor.tsx */
2
3...
4
5import { Editor, EditorState, RichUtils, DraftEditorCommand } from "draft-js";
6
7...
8
9const TextEditor = () => {
10
11 ...
12
13 const handleKeyCommand = (command: DraftEditorCommand) => {
14 const newState = RichUtils.handleKeyCommand(editorState, command);
15 if (newState) {
16 setEditorState(newState);
17 return "handled";
18 }
19 return "not-handled";
20 };
21
22 return (
23 <Editor
24 editorState={editorState}
25 onChange={setEditorState}
26 handleKeyCommand={handleKeyCommand}
27 />
28 );
29};

You can also make your custom commands using handleKeyCommand and keyBindingFn (For example, saving contents with Command + S).

Toolbar#

tsx
1/** TextEditor.tsx */
2
3...
4
5const TextEditor = () => {
6
7 ...
8
9 const handleTogggleClick = (e: React.MouseEvent, inlineStyle: string) => {
10 e.preventDefault();
11 setEditorState(RichUtils.toggleInlineStyle(editorState, inlineStyle));
12 };
13
14 const handleBlockClick = (e: React.MouseEvent, blockType: string) => {
15 e.preventDefault();
16 setEditorState(RichUtils.toggleBlockType(editorState, blockType));
17 };
18
19 return (
20 <div>
21 <button onMouseDown={(e) => handleBlockClick(e, "header-one")}>H1</button>
22 <button onMouseDown={(e) => handleBlockClick(e, "header-two")}>H2</button>
23 <button onMouseDown={(e) => handleBlockClick(e, "header-three")}>H3</button>
24 <button onMouseDown={(e) => handleBlockClick(e, "unstyled")}>Normal</button>
25 <button onMouseDown={(e) => handleTogggleClick(e, "BOLD")}>bold</button>
26 <button onMouseDown={(e) => handleTogggleClick(e, "ITALIC")}>italic</button>
27 <button onMouseDown={(e) => handleTogggleClick(e, "STRIKETHROUGH")}>strikthrough</button>
28 <button onMouseDown={(e) => handleBlockClick(e, "ordered-list-item")}>Ordered List</button>
29 <button onMouseDown={(e) => handleBlockClick(e, "unordered-list-item")}>Unordered List</button>
30 <Editor editorState={editorState} onChange={setEditorState} handleKeyCommand={handleKeyCommand} />
31 </div>
32 );
33};
34
35export default TextEditor;

Redo / Undo#

tsx
1/** TextEditor.tsx */
2
3...
4 <button
5 disabled={editorState.getUndoStack().size <= 0}
6 onMouseDown={() => setEditorState(EditorState.undo(editorState))}>
7 undo
8 </button>
9 <button
10 disabled={editorState.getRedoStack().size <= 0}
11 onMouseDown={() => setEditorState(EditorState.redo(editorState))}>
12 redo
13 </button>
14...

To render links, we need to provide decorator.

tsx
1/** Link.tsx */
2
3import { CompositeDecorator, DraftDecoratorComponentProps } from "draft-js";
4
5export const Link = (props: DraftDecoratorComponentProps) => {
6 const { url } = props.contentState.getEntity(props.entityKey).getData();
7 return (
8 <a rel="noopener noreferrer" target="_blank" href={url}>
9 {props.children}
10 </a>
11 );
12};
13
14export const linkDecorator = new CompositeDecorator([
15 {
16 strategy: (contentBlock, callback, contentState) => {
17 contentBlock.findEntityRanges((character) => {
18 const entityKey = character.getEntity();
19 return entityKey !== null && contentState.getEntity(entityKey).getType() === "LINK";
20 }, callback);
21 },
22 component: Link
23 }
24]);

We should filter links with strategy method and render them with component prop by passing them into CompositeDecorator. DraftDecoratorComponentProps is not included to @types/draft-js currently, so I extended it based on Flow type defined in Draft.js library. (See here)

ts
1/** module.d.ts */
2
3import 'draft-js';
4
5declare module 'draft-js' {
6 export interface DraftDecoratorComponentProps {
7 blockKey: any;
8 children?: ReactNode;
9 contentState: ContentState;
10 decoratedText: string;
11 dir?: any;
12 end: number;
13 entityKey?: string;
14 offsetKey: string;
15 start: number;
16 }
17}

Then, we need to build a method to add a link entity to the editor.

tsx
1/** TextEditor.tsx */
2
3...
4const handleAddLink = () => {
5 const selection = editorState.getSelection();
6 const link = prompt("Please enter the URL of your link");
7 if (!link) {
8 setEditorState(RichUtils.toggleLink(editorState, selection, null));
9 return;
10 }
11 const content = editorState.getCurrentContent();
12 const contentWithEntity = content.createEntity("LINK", "MUTABLE", {
13 url: link
14 });
15 const newEditorState = EditorState.push(editorState, contentWithEntity, "apply-entity");
16 const entityKey = contentWithEntity.getLastCreatedEntityKey();
17 setEditorState(RichUtils.toggleLink(newEditorState, selection, entityKey));
18 };
19...
tsx
1/** TextEditor.tsx */
2
3import React from "react";
4import { Editor, EditorState, RichUtils, DraftEditorCommand } from "draft-js";
5import "draft-js/dist/Draft.css";
6import { linkDecorator } from "./Link";
7
8const TextEditor = () => {
9 const [editorState, setEditorState] = React.useState<EditorState>(EditorState.createEmpty(linkDecorator));
10
11 ...
12
13 return (
14 <div>
15 ...
16 <button
17 {/* If nothing is selected, disable link button */}
18 disabled={editorState.getSelection().isCollapsed()}
19 onMouseDown={(e) => {
20 e.preventDefault();
21 handleAddLink();
22 }}>
23 link
24 </button>
25 ...
26 );
27};

Insert Images#

We can think of several ways to insert images, like copy and paste / drag and drop / type URL.

We will type URL to insert images here, but if you want to implement copy and paste / drag and drop as well, you can use handlePastedFiles or handleDroppedFiles props of the Editor.

tsx
1/** TextEditor.tsx */
2
3...
4 const handleInsertImage = () => {
5 const src = prompt("Please enter the URL of your picture");
6 if (!src) {
7 return;
8 }
9 const contentState = editorState.getCurrentContent();
10 const contentStateWithEntity = contentState.createEntity("image", "IMMUTABLE", { src });
11 const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
12 const newEditorState = EditorState.set(editorState, {
13 currentContent: contentStateWithEntity
14 });
15 return setEditorState(AtomicBlockUtils.insertAtomicBlock(newEditorState, entityKey, " "));
16 };
17...

Since images are custom Block , we need to use blockRendererFn prop of the Editor .

tsx
1/** Media.tsx */
2
3import React from 'react';
4import { ContentBlock, ContentState } from 'draft-js';
5
6interface BlockComponentProps {
7 contentState: ContentState;
8 block: ContentBlock;
9}
10
11export const Image = (props: BlockComponentProps) => {
12 const { block, contentState } = props;
13 const { src } = contentState.getEntity(block.getEntityAt(0)).getData();
14 return <img src={src} alt={src} role="presentation" />;
15};
16
17const Media = (props: BlockComponentProps) => {
18 const entity = props.contentState.getEntity(props.block.getEntityAt(0));
19 const type = entity.getType();
20
21 let media = null;
22 if (type === 'image') {
23 media = <Image {...props} />;
24 }
25
26 return media;
27};
28
29export const mediaBlockRenderer = (block: ContentBlock) => {
30 if (block.getType() === 'atomic') {
31 return {
32 component: Media,
33 editable: false,
34 };
35 }
36 return null;
37};
tsx
1/** TextEditor.tsx */
2
3...
4 <Editor
5 editorState={editorState}
6 onChange={setEditorState}
7 handleKeyCommand={handleKeyCommand}
8 blockRendererFn={mediaBlockRenderer}
9 />
10...

Save & Reload#

To save the contents, we need to convert them into JSON (We will use localStorage here). You can also convert them into HTML by using libraries like draft-js-export-html.

tsx
1/** TextEditor.tsx */
2
3import React from "react";
4import {
5 ...
6 convertToRaw,
7 convertFromRaw
8} from "draft-js";
9
10...
11
12const TEXT_EDITOR_ITEM = "draft-js-example-item";
13
14const TextEditor = () => {
15 const data = localStorage.getItem(TEXT_EDITOR_ITEM);
16
17 const initialState = data
18 ? EditorState.createWithContent(convertFromRaw(JSON.parse(data)), linkDecorator)
19 : EditorState.createEmpty(linkDecorator);
20
21 const [editorState, setEditorState] = React.useState<EditorState>(initialState);
22
23 const handleSave = () => {
24 const data = JSON.stringify(convertToRaw(editorState.getCurrentContent()));
25 localStorage.setItem(TEXT_EDITOR_ITEM, data);
26 };
27
28 ...
29
30 return (
31 <div>
32 ...
33 <Editor
34 editorState={editorState}
35 onChange={setEditorState}
36 handleKeyCommand={handleKeyCommand}
37 blockRendererFn={mediaBlockRenderer}
38 />
39 <button
40 onClick={(e) => {
41 e.preventDefault();
42 handleSave();
43 }}>
44 save
45 </button>
46 </div>
47 );
48};

Sample Code