CodeMirror Markdown editor toolbar

Updated Dani 1 Tallied Votes 32 Views Share

The Markdown editor that we use here at DaniWeb is called CodeMirror and we've been using it for forever. The other day, I noticed some bugs in the editor toolbar that we use, which is based on the CodeMirror API. (The one that allows the buttons for bold, italic, etc.)

Over the past few days, I've gone ahead and refactored the code for the editor toolbar and fixed some bugs as well. I decided to publish my work since it's pretty heavily commented.

import { Compartment } from "@codemirror/state";
import { Decoration, EditorView, KeyBinding } from "@codemirror/view";
import { redo, undo } from "@codemirror/commands";
import { addMarks, filterMarks } from "./daniweb";

export const compartment = new Compartment();

declare var window: any;

const prependSelectedLines = ({
      cm,
      marker,
      prefixNumber = false,
      blankLinesBefore = 1,
      blankLinesAfter = 1
}: {
    cm: EditorView;
    marker: string;
    prefixNumber?: boolean;
    blankLinesBefore?: number;
    blankLinesAfter?: number;
}): boolean => {

    if (!cm) {
        return false;
    }

    cm.focus();

    // Get the first line and last line that have selected text
    let firstLine = cm.state.doc.lineAt(cm.state.selection.main.from);
    let lastLine = cm.state.doc.lineAt(cm.state.selection.main.to);

    let maxLines = Math.min(
        cm.state.doc.lines,
        lastLine.number
    );

    let currentLine = firstLine;
    let currentText = currentLine.text;
    let numberBullet = 1;

    // Loop through each line
    for (let lineNumber = firstLine.number; lineNumber <= maxLines; lineNumber++) {

        // Get the current Line
        currentLine = cm.state.doc.line(lineNumber);
        currentText = currentLine.text;

        let textToInsert = `${prefixNumber ? numberBullet++ : ""}${marker}`;
        let raw = currentText.replace(textToInsert, "");

        if (currentText.includes(textToInsert)) {

            // Select the entire line
            cm.dispatch({
                selection: {
                    anchor: currentLine.from,
                    head: currentLine.to,
                }
            });

            // Replace the entire line
            cm.dispatch({
                ...cm.state.replaceSelection(raw)
            });

        }
        else {

            // Add the marker to the beginning of each line
            cm.dispatch({
                changes: {
                    from: currentLine.from,
                    to: currentLine.from,
                    insert: `${textToInsert}`
                },
            });
        }
    }

    // We don't know what modifications were made to the first line, so let's refetch it
    firstLine = cm.state.doc.line(firstLine.number);

    // We don't know what modifications were made to the last line, so let's refetch it
    lastLine = cm.state.doc.line(lastLine.number);

    // Expand the selection from the beginning of the first line to the end of the last line
    cm.dispatch({
        selection: {
            anchor: firstLine.from,
            head: lastLine.to,
        },
        scrollIntoView: true
    });

    if (blankLinesBefore)
    {
        let insertLinesBefore = true;

        // If it's not the first line in the document
        if (firstLine.number > 1)
        {
            // Get the previous line
            let prevLine = cm.state.doc.line(firstLine.number - 1);
            if (prevLine.text == '') {
                insertLinesBefore = false;
            }
        }

        if (insertLinesBefore) {
            cm.dispatch({
                changes: {
                    from: firstLine.from,
                    to: firstLine.from,
                    insert: Array.from({length: blankLinesBefore}, () => `\n`).join(""),
                },
            });
        }
    }

    if (blankLinesAfter)
    {
        let insertLinesAfter = true;

        // If it's not the last line in the document
        if (lastLine.number < cm.state.doc.lines)
        {
            // Get the next line
            let nextLine = cm.state.doc.line(lastLine.number + 1);
            if (nextLine.text == '') {
                insertLinesAfter = false;
            }
        }

        if (insertLinesAfter) {
            cm.dispatch({
                changes: {
                    from: lastLine.to,
                    to: lastLine.to,
                    insert: Array.from({length: blankLinesAfter}, () => `\n`).join(""),
                },
            });
        }
    }

    return true;

};

const handleMarkdownHotkey = ({
  cm,
  surroundingText,
  surroundingTextRight,
  placeholderText,
}: {
    cm: EditorView;
    surroundingText: string;
    surroundingTextRight?: string;
    placeholderText: string;
}): boolean => {

    if (!cm) {
        return false;
    }

    cm.focus();

    // If no special text for the right side was given,
    // then it should be the same as the left side
    surroundingTextRight = surroundingTextRight || surroundingText;

    if (cm.state.selection.ranges.some((r) => !r.empty)) {

        // If something is selected

        // We start surroundingText characters before the selected text
        // We end surroundingText characters after the selected text
        let start = cm.state.selection.main.from - surroundingText.length;
        let end = cm.state.selection.main.to + surroundingTextRight.length;

        // String from start to end
        // This will either start/end in surroundingText or irrelevant surrounding chars
        let string = cm.state.sliceDoc(start, end);

        // String of the raw characters that were selected
        let raw = cm.state.sliceDoc(
            cm.state.selection.main.from,
            cm.state.selection.main.to
        );

        let output = `${surroundingText}${raw}${surroundingTextRight}`;

        if (string === output) {

            // We get here if the characters to the left/right of the selected text
            // are the correct surrounding chars

            // This means that we likely want to undo existing formatting

            // Expand the selection to include the surrounding chars
            cm.dispatch({
                selection: {
                    anchor: start,
                    head: end
                },
            });

            cm.dispatch({

                // Replace the newly expanded selection with the raw text
                ...cm.state.replaceSelection(raw),

                // Shrink the end of the selection by the number of surrounding chars
                selection: {
                    anchor: start,
                    head: end - (surroundingText.length + surroundingTextRight.length),
                },
                scrollIntoView: true
            });

        } else {

            // We want to add formatting

            start = cm.state.selection.main.from + surroundingText.length;
            end = cm.state.selection.main.to + surroundingTextRight.length;

            cm.dispatch({
                ...cm.state.replaceSelection(output),
                selection: {anchor: start, head: end},
                scrollIntoView: true
            });
        }
    } else {

        // Nothing is selected, so from and to is simply where the cursor is
        // We will want to add placeholder text with surrounding text

        // Both from and to are at the beginning of the selection
        let from = cm.state.selection.main.from;
        let to = from;

        cm.dispatch({

            // Insert the placeholder text
            changes: {from, to, insert: `${placeholderText}`},

            selection: {
                // Selection starts after left surrounding characters
                anchor: from + surroundingText.length,
                // Selection ends before right surrounding characters
                head: to + placeholderText.length - surroundingTextRight.length,
            },
            scrollIntoView: true
        });
    }

    return true;
};

function editor_bold(cm: EditorView): boolean {
    return handleMarkdownHotkey({
        cm,
        surroundingText: "**",
        placeholderText: "**Bold Text Here**",
    });
}

function editor_italic(cm: EditorView): boolean {
    return handleMarkdownHotkey({
        cm,
        surroundingText: "*",
        placeholderText: "*Emphasized Text Here*",
    });
}

function editor_link(cm: EditorView): boolean {

    if (!cm) {
        return false;
    }

    let url = prompt(
        "Please enter the URL",
        "https://www."
    );

    // User might click on 'cancel'
    if (url != null) {

        cm.focus();

        let from = cm.state.selection.main.from;
        let to = cm.state.selection.main.to;
        let text = "Click Here";

        // Something is selected, so use that as the link's text.
        if (from !== to) {
            text = cm.state.sliceDoc(from, to);
        }

        cm.dispatch({
            changes: {from, to, insert: `[${text}](${url})`},
            selection: {
                anchor: from + 1,
                head: from + 1 + text.length,
            },
            scrollIntoView: true
        });
    }

    return true;
}

function editor_heading(cm: EditorView): boolean {

    if (cm.state.selection.ranges.some((r) => !r.empty)) {

        // If we have selected some stuff

        return prependSelectedLines({
            cm,
            marker: "# ",
            prefixNumber: false,
            blankLinesBefore: 0,
            blankLinesAfter: 0
        });

    } else {

        return handleMarkdownHotkey({
            cm,
            surroundingText: "\n# ",
            surroundingTextRight: "\n",
            placeholderText: "\n# Heading Here\n",
        });
    }

}

function editor_subheading(cm: EditorView): boolean {

    if (cm.state.selection.ranges.some((r) => !r.empty)) {

        // If we have selected some stuff

        return prependSelectedLines({
            cm,
            marker: "## ",
            prefixNumber: false,
            blankLinesBefore: 0,
            blankLinesAfter: 0
        });

    } else {

        return handleMarkdownHotkey({
            cm,
            surroundingText: "\n## ",
            surroundingTextRight: "\n",
            placeholderText: "\n## Sub-Heading Here\n",
        });
    }
}

function editor_inlinecode(cm: EditorView): boolean {
    return handleMarkdownHotkey({
        cm,
        surroundingText: "`",
        placeholderText: "`Inline Code Example Here`",
    });
}

function editor_quote(cm: EditorView, message?: string): boolean {

    if (cm.state.selection.ranges.some((r) => !r.empty)) {

        // If we have selected some stuff

        return prependSelectedLines({
            cm,
            marker: "> ",
            prefixNumber: false,
            blankLinesBefore: 0,
            blankLinesAfter: 2
        });

    } else if (message.length) {

        if (!cm) {
            return false;
        }

        cm.focus();

        cm.dispatch({
            ...cm.state.replaceSelection(message),
            scrollIntoView: true
        });

    } else {

        return handleMarkdownHotkey({
            cm,
            surroundingText: "\n> ",
            surroundingTextRight: "\n\n",
            placeholderText: "\n> Insert Quote Here\n\n",
        });
    }
}

function editor_listol(cm: EditorView): boolean {

    if (cm.state.selection.ranges.some((r) => !r.empty)) {

        // If we have selected some stuff

        return prependSelectedLines({
            cm,
            marker: ". ",
            prefixNumber: true,
            blankLinesBefore: 0,
            blankLinesAfter: 2
        });

    } else {

        return handleMarkdownHotkey({
            cm,
            surroundingText: "\n",
            surroundingTextRight: "\n\n",
            placeholderText: "\n1. Item One\n2. Item Two\n3. Item Three\n\n",
        });
    }

}

function editor_listul(cm: EditorView): boolean {

    if (cm.state.selection.ranges.some((r) => !r.empty)) {

        // If we have selected some stuff

        return prependSelectedLines({
            cm,
            marker: "* ",
            prefixNumber: false,
            blankLinesBefore: 0,
            blankLinesAfter: 2
        });

    } else {

        return handleMarkdownHotkey({
            cm,
            surroundingText: "\n",
            surroundingTextRight: "\n\n",
            placeholderText: "\n* Item One\n* Item Two\n* Item Three\n\n",
        });
    }

}

function editor_undo(cm: EditorView): void {
    undo(cm);
}

function editor_redo(cm: EditorView): void {
    redo(cm);
}

window.editor_image = function (
    cm: EditorView,
    anchor: string,
    uri: string
): boolean {

    if (!cm) {
        return false;
    }

    if (uri == null) {
        uri = prompt(
            "Please enter the Image URI",
            "https://static.daniweb.com/attachments/"
        );
    }

    if (anchor == null) {
        anchor = "Image";
    }

    if (uri != null) {

        cm.focus();

        cm.dispatch({
            changes: {
                from: cm.state.selection.main.from,
                to: cm.state.selection.main.from,
                insert: "\n![" + anchor + "](" + uri + ")\n",
            },
        });
    }

    return true;
};

window.insert_code = (cm: EditorView, code: string) => {

    if (!cm) {
        return false;
    }

    cm.dispatch({
        changes: {
            from: cm.state.selection.main.from,
            to: cm.state.selection.main.from,
            insert: code,
        },
    });
};

window.editor_flag_line = (cm: EditorView, line: number): boolean => {
    if (!cm) {
        return false;
    } else if (line < 0) {

        // Remove all line flags
        cm.dispatch({
            effects: filterMarks.of((from: number, to: number) => {
                return true;
            }),
        });

        return;
    }

    // If the line is valid and non-empty
    if (
        Number.isFinite(line) &&
        line > 0 &&
        line <= cm.state.doc.lines &&
        cm.state.doc.line(line).from !== cm.state.doc.line(line).to
    ) {

        // CSS to flag the line
        const strikeMark = Decoration.mark({
            attributes: {
                style: "text-decoration: line-through",
                class: "bg-danger text-light",
            },
        });

        // Add the CSS as a line effect
        cm.dispatch({
            effects: addMarks.of([
                strikeMark.range(
                    cm.state.doc.line(line).from,
                    cm.state.doc.line(line).to
                ),
            ]),
        });
    }

    return true;
};

window.editor_bold = editor_bold;
window.editor_italic = editor_italic;
window.editor_link = editor_link;
window.editor_heading = editor_heading;
window.editor_subheading = editor_subheading;
window.editor_inlinecode = editor_inlinecode;
window.editor_quote = editor_quote;
window.editor_listol = editor_listol;
window.editor_listul = editor_listul;
window.editor_undo = editor_undo;
window.editor_redo = editor_redo;

export const daniwebKeymap: KeyBinding[] = [
    {key: "Mod-s", run: () => true},
    {key: "Mod-b", run: editor_bold},
    {key: "Mod-i", run: editor_italic},
    {key: "Mod-l", run: editor_link},
    {key: "Mod-h", run: editor_heading},
    {key: "Mod-k", run: editor_inlinecode},
    {key: "Mod-q", run: editor_quote},
    {key: "Ctrl-o", mac: "Cmd-o", run: editor_listol},
    {key: "Ctrl-u", mac: "Cmd-u", run: editor_listul},
];
Be a part of the DaniWeb community

We're a friendly, industry-focused community of developers, IT pros, digital marketers, and technology enthusiasts meeting, networking, learning, and sharing knowledge.