Markdown Page Editor

By Hawkee on Oct 20, 2014

One of our more advanced features here at Hawkee is our markdown page editor. What sets it apart is fluid integration with CodeMirror to quickly and easily add code to a page. There's no fumbling with tabs and no need to paste between fences. This article gives a general overview of how it works.

The editor features three different ways to view a page: Hybrid Mode, Raw Markdown, and Preview Mode. I'll begin by explaining Hybrid Mode.

Hybrid Mode


Hybrid mode gets its name because it uses a combination of textareas and CodeMirror instances. This allows for easy code entry without the constraints of a basic textarea. Since a page is stored in raw markdown some manipulation needs to happen in order to break the markdown apart into textareas and CodeMirror instances. The code looks something like this:

// Takes the contents of a textarea, scans for ```code blocks and breaks them out
// into Codemirror instances 

function break_out_code(element) {

    // If we're starting from scratch put everything into a single text box and break that down with the raw as the source.

    if(element == undefined) {
        var raw = $("#raw_markdown").val();
        $("#raw_markdown").hide();
        element = append_text(raw);
    }   

    var contents = $(element).val();
    var lines = contents.split("\n");
    var num_lines = lines.length;

    var md_string, md_string2, code_string, plat_type;
    var match = false;
    var remainder = 0;

    for(var i = 0; i < num_lines; i++) {
        var matches;
        if(matches = lines[i].match(/^```\s*(.*)$/)) {

            // Be sure we're getting an actual plat type and not whitespace following the closing fence
            var match_lang = matches[1].replace(/[\0\s]*/g, '');
            if(match_lang.length > 1) language = match_lang;

            if(match == true) {
                match = false;
                $(element).val(md_string);
                $(element).trigger('change');
                append_code(code_string, language);
                plat_type = undefined;

                // Record where we left off and put the rest in a new textrea.
                remainder = i + 1;
                i = num_lines;
            }   
            else match = true;
        }
        else {
            lines[i] += "\n";
            if(match == false) md_string += lines[i];
            else code_string += lines[i];
        }
    }

    // Now if there is some text after the code append it below the code box and 
    // call this function again to break it apart as well.

    if(remainder > 0) {
        for(var i = remainder; i < num_lines; i++) {
            if(i == remainder && lines[i].length == 0) continue;
            md_string2 += lines[i]+"\n";
        }

        var new_textarea = append_text(md_string2);
        break_out_code(new_textarea);
    }

    window.collapsed = false;
}

This is a recursive function that looks for code fences ```, scans for the closing fence and calls our append_code function below. There is also a function called append_text which appends the remaining content in the form of a textarea. This textarea will be passed back to the function to look for more code until every piece of code has been placed neatly into a CodeMirror instance.

function append_code(text, language) {

    // Remove extra line breaks at the end.
    if(text != undefined) text = text.replace(/[\r\n]{1,}$/g,'');

    pos++;
    var textArea = $('#code_template_edit').clone();

    // Only autofocus if this was a newly added code box.  Otherwise let it be.
    var autofocus;
    if(text) {
        textArea.find('textarea').val(text);
        autofocus = false;
    }
    else autofocus = true;

    textArea.show();
    textArea.find('textarea').attr('name', 'code['+pos+']');
    $('#page').append(textArea);
    window.cm[pos] = CodeMirror.fromTextArea(textArea.find('textarea')[0], {
        lineNumbers: true,
        matchBrackets: true,
        mode: "none",
        theme: 'mdn-like',
        autofocus: autofocus
    });
}

You might notice a reference to #code_template_edit above. This is a div containing a textarea with name="code[]". It gets cloned, the code taken from the raw markdown is inserted and it's converted to a CodeMirror box. The CodeMirror object is placed in the global window so we can retrieve it later in the collapse_code function below.

Raw Markdown


Raw markdown is pretty straightforward. This is simply a textarea containing raw markdown. But when switching from Hybrid mode we need to break it down from textareas and CodeMirror instances into plain text.

// Merges all of the code boxes and text into a single raw md textarea

function collapse_code() {

    var raw, last_md_line, last_md_line;

    // If everything has already been collapsed don't do it again or we'll
    // lose all of our data.

    if(window.collapsed == true) return;

    $("#page").find('textarea').each(function() {
        if($(this).parent().attr("id") == 'code_template_edit') {

            // Find the CodeMirror object based on the name of the textarea.
            // Use the object to get the code to be translated to raw format.

            var name = $(this).attr('name');
            var matches = name.match(/code\[(\d+)\]/);
            var key = matches[1];
            var cm_obj = window.cm[key];
            var code = cm_obj.getValue();

            // Ignore if it's an empty code block.

            if(code.length < 1) return true;

            // If the last line from the markdown is not a newline then prepend the code ticks with one
            if(last_md_line.length > 0) raw += "\n";

            raw += "```\n";
            raw += code;

            // If the last line of code is not a newline we need one before the ```

            var code_lines = code.split("\n");
            var last_line = code_lines;
            if(last_line.length > 0) raw += "\n```\n";
            else raw += "```\n";
        }
        else if($(this).attr('id') == 'description') {
            var md = $(this).val();
            raw += md;
            var md_lines = md.split("\n");
            last_md_line = md_lines[md_lines.length-1];
        }
    });

    // Populate the raw textarea and trigger a change to set the height (Uses auto-expanding textarea).

    $("#raw_snippet").val(raw).trigger('change');
    $("#raw_snippet").show();

    window.collapsed = true;
}

So what this function does is it loops through each textarea in the #page area checking whether or not it's a #description or a CodeMirror instance. There's a little fun with newlines here to make sure the code fences stand alone on a single lines.

Preview Mode


Preview mode can use a JavaScript Markdown library or in this case we're using the PHP Parsedown parser. Depending on the current mode we either pass the raw markdown to the server or we collapse it first before sending it to the server. In order to facilitate a clean pass I convert the markdown into Base64 using Vassilis Petroulias's B64 Library. I've found this one to work quite well with PHP's own built-in Base64 decoder. The preview function looks like this:

// Converts everything to raw markdown, base64 encodes it, passed it to the server-side parsedown parser and outputs the result.

function preview_page() {

    if(window.collapsed == false) collapse_code(false);

    var textarea = $("#raw_markdown");
    var md = textarea.val();
    var encoded = B64.encode(md);
    var params = { mode:'convert', md:encoded };

    $.ajax({
        url: "/parsedown_parser.php",
        cache: false,
        data: params,
        type: 'POST',
        success: function(html){
            $("#code_preview").html(html).show(0, function() {
                $("#page").hide();
                $("#raw_markdown").hide();
                code_boxes();
            });
        }
    });
}

This is fairly straightforward, but what is code_boxes and what does that do? Well, now that we've got our freshly parsed Markdown, the code will only appear in <pre><code> tags and not our fancy CodeMirror boxes. So here's what that does:

function code_boxes(div) {

    // Only refresh snippets in given div.

    if(div !== undefined) div = div+' pre code';
    else div = 'pre code';

    $(div).each(function() {    

        // Decodes the HTML entities within the code tags.
        var contents = $("<div/>").html($(this).html()).text();
        // Remove extra lines from end of code block.
        if(contents != undefined) contents = contents.replace(/[\r\n]{1,}$/g,'');

        // Maintain a unique ID for each snippet block and refresh them rather than
        // recreate more CodeMirror instances.

        var snippet_dom_id = $(this).data('snippet_dom_id');
        if(snippet_dom_id === undefined) {
            snippet_tot++;
            $(this).data('snippet_dom_id', snippet_tot);
            snippet_dom_id = snippet_tot;
        }

        if(!snippets[snippet_dom_id]) {

            var textArea = $('#code_template').clone();

            // Change the id so the blank template is cloned every time.
            textArea.attr('id', 'code_block');
            textArea.find('textarea').val(contents);
            textArea.show();
            $(this).html(textArea);

            snippets[snippet_dom_id] = CodeMirror.fromTextArea($(this).find('textarea')[0], {
                lineNumbers: true,
                matchBrackets: true,
                theme: 'mdn-like',
                autofocus: false
            });
        }
        else {
            snippets[snippet_dom_id].refresh();
        }   
    });

    // Remove the pre class from around the code so it's not styled incorrectly.
    $('pre > code').unwrap();
}

This is a more generalized function that also handles code on our threads and comments, so the "snippet" terminology is somewhat new to this article. It's really just a code block. This uses a global snippets variable to contain every CodeMirror object referenced a data tag we call snippet_dom_id.

This article very generally explains how the system works. I've left out some details such as the programming language dropdowns as that requires a server-side database to populate it. If you can wrap your head around all of this, I'm sure you can figure out how to add it back. I hope this has been informative and please don't hesitate to leave some feedback below.

For a live demo of this code in action visit our Homepage or if you're logged in already Create a Page

Comments

Sign in to comment.
Are you sure you want to unfollow this person?
Are you sure you want to delete this?
Click "Unsubscribe" to stop receiving notices pertaining to this post.
Click "Subscribe" to resume notices pertaining to this post.