Paul BecotteAdmin

An editor in Draftjs with Prism code blocks

And Image Uploads!

(There is a demo of this editor under the About me page)

A key part of a CMS is the editor- how do you enter blog posts? Even more important for MY blog, how do I have effective code blocks. I just finished rewriting the frontend of this site in React, and was looking for a nice editor solution. The first thing I saw was "draft-wysiwyg". This worked in that it provided a working image upload button, but I ran into problems. I also wanted to add in Prismjs for code blocks and draft-js-code. Not being a React pro (which was the primary reason for this project), I am not sure why, but I was not able to get these working together. It seemed like the wysiwg Editor component wasn't exposing enough functionality for me. I decided to just see how hard it would be to build the whole thing myself, and this is what we got.

Draftjs basically provides a data model that takes into account things like the current mouse selection and all the styling elements to build up a blog post. React has a functional core- so each update to that state is done by taking the old state, making a mutated copy, and replacing the previous state with that one. (Draft actually stores a list of "content states" as part of the "editor state" to handle undo/redo. Its a neat design). Because of this, it gives you hooks to handle specific key presses, and we can override the default behavior.

The first one we handle is `toggle style` - for the currently selected block, turn on/off a specific style element (like italic or bold). This is straightforward-


    const toggleInlineStyle = (inlineStyle) => {
        onChange(Draft.RichUtils.toggleInlineStyle(editorState, inlineStyle));
    };
        

Here `onChange` is the update function from our state hook, and `Draft.RichUtils` is the function set provided by Draftjs for style modifications. There isn't anything original here- the only reason to do this is so that we can make a button to toggle the styles on or off-

const BLOCK_TYPES = [
    {label: 'H1', style: 'header-one'},
    {label: 'H2', style: 'header-two'},
    {label: 'H3', style: 'header-three'},
    {label: 'H4', style: 'header-four'},
    {label: 'H5', style: 'header-five'},
    {label: 'H6', style: 'header-six'},
    {label: 'Blockquote', style: 'blockquote'},
    {label: 'UL', style: 'unordered-list-item'},
    {label: 'OL', style: 'ordered-list-item'},
    {label: 'Code Block', style: 'code-block'},
];

export const BlockStyleControls = (props) => {
    const {editorState, setEditorState, uploadImage} = props;
    const selection = editorState.getSelection();
    const blockType = editorState
        .getCurrentContent()
        .getBlockForKey(selection.getStartKey())
        .getType();

    const onToggle = (e, type) => {
        e.preventDefault();
        props.onToggle(type.style);
    };
    return (
        <ButtonGroup size={"sm"} className="mb-3">
            {BLOCK_TYPES.map((type) =>
                <Button onMouseDown={(e) => onToggle(e, type)} active={type.style === blockType} key={type.label}>
                    {type.label}
                </Button>
            )}
            <AddImage editorState={editorState} setEditorState={setEditorState} uploadImage={uploadImage}/>
        </ButtonGroup>
    );
};

const INLINE_STYLES = [
    {label: 'Bold', style: 'BOLD'},
    {label: 'Italic', style: 'ITALIC'},
    {label: 'Underline', style: 'UNDERLINE'},
    {label: 'Monospace', style: 'CODE'},
];

export const InlineStyleControls = (props) => {
    const currentStyle = props.editorState.getCurrentInlineStyle();
    const onToggle = (e, type) => {
        e.preventDefault();
        props.onToggle(type.style);
    };
    return (
        <ButtonGroup size={"sm"} className="mb-3">
            {INLINE_STYLES.map((type) =>
                <Button onMouseDown={(e) => onToggle(e, type)} active={currentStyle.has(type.style)} key={type.label}>
                    {type.label}
                </Button>
            )}
            <SyntaxBlock editorState={props.editorState} updateEditorState={props.updateEditorState}/>
        </ButtonGroup>
    );
};

(Note- the code up till now is basically taken directly from the draftjs `examples` folder. The docs/api spec can be a little tough to figure out how things are expected to be used, but they have a bunch of examples that made this possible).

If you're following along closely, note the `SyntaxBlock` component in the InlinestyleControls. This is the first custom piece I added- given a `code-block` styled block, lets pick the programming language for syntax highlighting.

const getSyntax = (block) => {
    if (block.getData) {
        return block.getData().get('syntax');
    }
    return null;
};

const SyntaxBlock = (props) => {
    const {editorState, updateEditorState} = props;
    if (!CodeUtils.hasSelectionInBlock(editorState)) return "";
    const selection = editorState.getSelection();
    const contentState = editorState.getCurrentContent();
    const startKey = selection.getStartKey();
    const currentBlock = contentState.getBlockForKey(startKey);
    const currentSyntax = getSyntax(currentBlock);
    const onChange = (e) => {
        const newContent = Modifier.mergeBlockData(contentState, selection, {"syntax": e.target.value});
        updateEditorState(EditorState.push(editorState, newContent, 'change-block-data'));
    };
    if (!currentSyntax) {
        onChange({target: {value: 'javascript'}});
    }
    return (
        <Form.Control as={"select"} onChange={onChange} value={currentSyntax}>
            <option value="css">CSS</option>
            <option value="javascript">Javascript</option>
            <option value="python">Python</option>
        </Form.Control>
    );
};

This piece gave me a lot of trouble. The trick is to understand that each `block` element in the draftjs EditorState can have a set of metadata- and the Prismjs plugin we are going to use depends on that metadata to determine code highlighting. In order to change that we need to

  1. Figure out if the current selection is in a code block
  2. If so, which one
  3. Get the current syntax
  4. On change, replace the editor state with a new one with the syntax data changed.

`if (!CodeUtils.hasSelectionInBlock(editorState)) return "";` This is a utility function from draft-js-code that basically determines if the cursor is currently in a code block or not. Once we realize we are in a block, we get the current selection and the current content. Then we can use the selection info to figure out the `block` that we are in, and pull the syntax name out of the metadata. Finally, if there is a change, we update that block and build a new EditorState with our newly updated block. `Modifier.mergeBlockData()` returns a new `ContentState` with the updated block, and `EditorState.push()` provides a new EditorState with an updated ContentState.

At this point you may be wondering- how exactly is this syntax happening, or even better, how are we handling the editorState at all?

import Prism from "../prism";
import "../prism.css";
import PrismDecorator from "draft-js-prism";


const decorator = new PrismDecorator({
    prism: Prism,
    defaultSyntax: "javascript",
});

const StyledContainer = styled(Container)`
`;

const emptyEntry = () => {
    return {
        title: "",
        slug: "",
        tagline: "",
        content: EditorState.createEmpty(decorator),
        published: false,
        timestamp: new Date(),
        header_image: {
            url: "",
        },
        author: {
            alias: ""
        },
    }
};

export const PostContainer = (props) => {
    let core;
    const slug = props.slug || "";
    const api = useContext(ApiContext);
    const [entry, setEntry] = useState(emptyEntry);

    useEffect(() => {
        if (slug === "") {
            setEntry(emptyEntry);
        } else {
            api.get(`/blog/post/${slug}`)
                .then((response) => {
                    const baseContentState = convertFromRaw(JSON.parse(response.data.post.content));
                    const editorState = EditorState.createWithContent(baseContentState, decorator);
                    setEntry({...response.data.post, ...{content: editorState}});
                });
        }
    }, [slug, api]);
    
    ...

This is the state handling in my outer Post container. This is where we keep the base EditorState. Basically, we pull the content from the backend and use it to build an EditorState which is pushed into our React state. If we are looking at an empty post, we can do `EditorState.createEmpty(decorator)`. As a note, the decorator is basically a Draftjs plugin- decorators are invoked during the rendering phase by Draftjs. In this case, it does code highlighting. Its important to remember- any time you update your state you need to re-apply the deocrator, it doesn't get carried along when you do stuff like `EditorState.push()`. You can see this in the state hook for the Editor itself-

    const [editorState, setEditorState] = props.content;
    const editor = React.useRef(null);
    const [readOnly, setReadOnly] = useState(false);
    const onChange = (newState) => {
        const decorator = new PrismDecorator({
            prism: Prism,
            defaultSyntax: "javascript",  // always set a default, it doesn't handle it gracefully if not
        });
        setEditorState(EditorState.set(newState, {decorator}));
    };
    

It is the `ContentState` that gets stored in our database- so for a save we extract the content state, and on load we build a new EditorState using it. Now, we come to the tricky parts!

First problem- when you hit `enter` in a code block, we want to keep the curent indentation level and NOT end the block. The default behavior is to split the block every time you hit the enter button, but that would leave us with hundreds of one line code snippets. We also want the tab key to work like in a code editor and move four spaces vs switching the focus of the browser window. draft-js-code provides some utils, and we can take over key mappings -

    const mapKeyToEditorCommand = (e) => {
        if (e.keyCode === 9 /* TAB */) {
            onChange(CodeUtils.onTab(e, editorState));
            return;
        }
        if (e.keyCode === 13 /* RETURN */) {
            if (CodeUtils.hasSelectionInBlock(editorState)) {
                // This prevents splitting the block inside a code block
                onChange(CodeUtils.handleReturn(e, editorState));
                return;
            }
        }
        if (CodeUtils.hasSelectionInBlock(editorState)) {
            const command = CodeUtils.getKeyBinding(e);
            if (command) return command;
        }
        return Draft.getDefaultKeyBinding(e);
    };
    

If you return nothing from the mapping, the editor will assume you have already dealt with things. Otherwise it will pass the returned command to the `handleKeyCommand` callback.

    const handleKeyCommand = (command, editorState) => {
        let newState;
        if (CodeUtils.hasSelectionInBlock(editorState)) {
            newState = CodeUtils.handleKeyCommand(editorState, command);
        }
        if (!newState) {
            newState = Draft.RichUtils.handleKeyCommand(editorState, command);
        }
        if (newState) {
            onChange(newState);
            return "handled";
        }
        return "not-handled";
    };
    

In this case, returning "not-handled" will tell the editor to do the default update, will returning "handled" means that you dealt with it. The previous tab and return could be handled here except that we need to prevent the default of the event (submit or change focus) and the event itself doesn't get passed along to this callback. Again, draft-js-code provides a mapping function that we use here.

This gets us most of the way there. However, there is one pretty bad bug- pasted text gives the same behavior as the default "return" key, splitting the block on every line. We have to override it-

    const handlePastedText = (pastedText) => {
        if (CodeUtils.hasSelectionInBlock(editorState)) {
            const newContent = Modifier.insertText(
                editorState.getCurrentContent(),
                editorState.getSelection(),
                pastedText,
            );
            onChange(EditorState.push(
                editorState,
                newContent,
                'insert-characters'
            ));
            return true;
        }
        return false;
    };
    

We just use `insertText` here and it does what we wanted- I am not sure why this is not the default, but here we are :). Finally, we want to handle inserting images in the text. In addition, I wanted to control the size of the image, and even allow the user to update them later. For this, we needed a custom `Entity`. In Draftjs, an entity is a chunk of the UI, like a code-block. We can pass in custom react components to give special handling, and use the block metadata to control that custom component (like we did with the Prism syntax).

import React, {Fragment, useRef, useState} from "react";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
import {AtomicBlockUtils, EditorState} from "draft-js";
import Form from "react-bootstrap/Form";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import styled from "styled-components";

const SizedImage = styled.img`
  max-width: ${props => props.size}px;
`;

const Image = (props) => {
    const [show, setShow] = useState(false);
    const entity = props.contentState.getEntity(
        props.block.getEntityAt(0)
    );
    const key = props.block.getKey();
    const currentImage = entity.getData();
    const updateImage = props.blockProps && props.blockProps.updateImage;
    const uploadImage = props.blockProps && props.blockProps.uploadImage;
    const onClick = () => {
        if (updateImage) {
            props.blockProps.setReadOnly(true);
            setShow(true);
        }
    };
    const dismiss = () => {
        setShow(false);
        props.blockProps && props.blockProps.setReadOnly(false);
    };
    return (
        <Fragment>
            {show ? <ImageModal insertImage={(imageData) => updateImage(key, imageData)} dismiss={dismiss}
                                uploadImage={uploadImage} currentImage={{...currentImage}}/> : ""}
            <SizedImage onClick={onClick} size={currentImage.size} src={currentImage.url} alt=""/>
        </Fragment>
    );
};

export const mediaBlockRenderer = (updateImage, uploadImage, setReadOnly) => (block) => {
    if (block.getType() === 'atomic') {
        return {
            component: Image,
            editable: false,
            props: {updateImage: updateImage, uploadImage: uploadImage, setReadOnly: setReadOnly},
        };
    }
    return null;
};

const ImageModal = (props) => {
    const {uploadImage, insertImage, dismiss, currentImage} = props;
    const [imageData, setImageData] = useState(currentImage);
    const handleClose = () => dismiss();
    const handleSave = () => {
        dismiss();
        setTimeout(() => insertImage(imageData));
    };
    const inputFile = useRef(null);
    return (
        <Modal show={true} onHide={handleClose}>
            <Modal.Header closeButton>
                <Modal.Title>Upload an Image</Modal.Title>
            </Modal.Header>
            <Modal.Body>
                <Form>
                    <Form.Group as={Row}>
                        <input
                            id="myInput"
                            type="file"
                            ref={inputFile}
                            style={{display: 'none'}}
                            onChange={(event) => {
                                uploadImage(event.target.files[0])
                                    .then((image) => setImageData({...imageData, ...{url: image.url}}))
                            }}
                        />
                        <Col sm={10}>
                            <Button variant="primary" type="button" block onClick={() => inputFile.current.click()}>
                                Select An Image
                            </Button>
                        </Col>
                    </Form.Group>
                    <Form.Group>
                        <Form.Label column sm={2}>Size</Form.Label>
                        <Col sm={10}>
                            <Form.Control type="text" defaultValue={imageData.size}
                                          onChange={(event) => setImageData({...imageData, ...{size: event.target.value}})}/>
                        </Col>
                    </Form.Group>
                    <Form.Group>
                        <Form.Label column sm={2}>Url</Form.Label>
                        <Col sm={10}>
                            <Form.Control type="text" defaultValue={imageData.url}
                                          onChange={(event) => setImageData({...imageData, ...{url: event.target.value}})}/>
                        </Col>
                    </Form.Group>
                    <SizedImage size={imageData.size} src={imageData.url} alt=""/>
                </Form>
            </Modal.Body>
            <Modal.Footer>
                <Button variant="secondary" onClick={handleClose}>
                    Close
                </Button>
                <Button variant="primary" onClick={handleSave}>
                    Save Changes
                </Button>
            </Modal.Footer>
        </Modal>
    )
};


export const AddImage = (props) => {
    const {editorState, setEditorState, uploadImage} = props;
    const [show, setShow] = useState(false);
    const handleShow = () => setShow(true);
    const insertImage = (imageData) => {
        const contentState = editorState.getCurrentContent();
        const contentStateWithEntity = contentState.createEntity(
            "uploaded-image",
            'IMMUTABLE',
            {url: imageData.url, size: imageData.size},
        );
        const entityKey = contentStateWithEntity.getLastCreatedEntityKey();
        const newEditorState = EditorState.set(editorState, {currentContent: contentStateWithEntity});
        setEditorState(AtomicBlockUtils.insertAtomicBlock(
            newEditorState,
            entityKey,
            ' '
        ));
    };
    return (
        <Fragment>
            <Button variant="primary" onClick={handleShow}>
                Image
            </Button>
            {show ? <ImageModal
                insertImage={insertImage}
                uploadImage={uploadImage}
                dismiss={() => setShow(false)}
                currentImage={{url: "", size: 200}}/> : ""}
        </Fragment>
    )
};

This provides three basic things- a modal to insert/update an image entity, the entity renderer itself, and a button to launch the modal. The modal wound up being the hardest thing to get right. It turns out that you need to render the entity read-only. Otherwise if you launch a modal clicking inside the Editor, the Editor will keep the cursor focus and prevent you from typing in the input fields! We build the modal with a button to upload an image, which returns the url for the uploaded image. We use that and a `size` parameter for the entity block. That state is stored in the Block metadata that is a part of the ContentState. We have to pass in callbacks to update our global state- this is handled by the renderer function. As you'll see, the "display" iteration of the editor just doesn't pass these callbacks- in that event, we just don't enable the onClick behavior. Otherwise the button and the entity itself both have the ability to launch the image modal. The modal component knows how to take the EditorState and update/insert a new block with the correct metadata to display our entity. The updateImage callback looks like this-

    const updateImage = (key, imageData) => {
        const contentState = editorState.getCurrentContent();
        const block = contentState.getBlockForKey(key);
        const newContent = contentState.mergeEntityData(block.getEntityAt(0), {...imageData});
        const updatedState = EditorState.push(editorState, newContent, 'change-block-data');
        onChange(updatedState);
    };
    

Given all of that, here is the actual Editor component call-

    return (
        <EditorRoot className="RichEditor-root">
            <EditorStyles/>
            <Row>
                <Col sm={12}>
                    <BlockStyleControls
                        editorState={editorState}
                        setEditorState={setEditorState}
                        uploadImage={props.uploadImage}
                        onToggle={toggleBlockType}
                    />
                </Col>
            </Row>
            <Row>
                <Col sm={12}>
                    <InlineStyleControls
                        editorState={editorState}
                        updateEditorState={onChange}
                        onToggle={toggleInlineStyle}
                    />
                </Col>
            </Row>
            <Row>
                <Col sm={12} className="RichEditor-editor">
                    <EditorDiv
                        ref={editor}
                        editorState={editorState}
                        onChange={onChange}
                        blockRendererFn={mediaBlockRenderer(updateImage, props.uploadImage, setReadOnly)}
                        handleKeyCommand={handleKeyCommand}
                        keyBindingFn={mapKeyToEditorCommand}
                        blockStyleFn={getBlockStyle}
                        spellCheck={true}
                        handlePastedText={handlePastedText}
                        readOnly={readOnly}
                    />
                </Col>
            </Row>
        </EditorRoot>
        

Hopefully having a fully put together example instead of a more limited toy will help some people shorten the development cycle when they decide to learn Draftjs the first time!

(The complete code, vs the snippets above, can be seen at https://gitlab.com/devblog/devblogreact)