Click here to Skip to main content
14,332,414 members

HTML Editor with Live Preview and Style Playground

Rate this:
4.83 (8 votes)
Please Sign up or sign in to vote.
4.83 (8 votes)
15 Sep 2019CPOL
Edit markup, styles, and Javascript with live preview as well as a style "property grid."

Image 1

Table of Contents

Introduction

I've been interested in writing an HTML editor with some specific features, namely a preview window as well as a property pane to view/manipulate attributes and properties of the various elements. After writing the article, Fun Exploring Div and Table Layout, it seemed like a natural progression to put that article to use doing something real, so here we are.

The main talking points and interesting tricks employed here are:

  • Creating an editor with both horizontal and vertical layout of the editor, preview, and properties sections.
  • Updating the preview as changes are made in the editor.
  • Synchronize the property values between the two editor "views."
  • Provide both "hover over element" and "select and lock element" into the property pane.
  • When the user changes the layout (horizontal/vertical), track the currently selected element.
  • Shortcuts for some commonly used markup, as this entire article is written using this very HTML editor!
  • Configurable attributes/properties defined in metadata.
  • Dynamic HTML for generating the property sections.
  • Resetting the CSSOM so styles in the preview reflect changes in the <style> section in editor.

Fun Things on the TODO List

These are things I'd like to continue to explore but sort of break the "bare bones" editor concept presented here as things like token colorization will require a real editor, not just the textarea element used here.

  • A real menu bar
  • Auto-completion of element tags
  • Colorization
  • Adjustable widths/heights of the three panes -- editor, preview, properties
  • More advanced keyboard shortcuts. Mousetrap looks like a good library to use for this.
  • Save and the missing load suck. I don't like that save puts the file into the downloads area.
  • Views as objects (see section on "HTML is Not Objects" below)
  • Search and replace

A Word About CSS

In discussions on Code Project and in writing this, I've come to realize that there are different uses of CSS:

  1. Defining multiple attributes of an element that can be considered a reusable element, particularly in that it often has child elements
  2. Defining a specific style that customizes just that element
  3. Defining a tag as a class or id that is used for manipulating the element
  4. Visual (as opposed to layout) styles such as color and font size
  5. Animations
  6. Other?

Of particular importance is the idea that you never, ever, want to use a tag (typically a class) for both layout styling and selection. Even if this seems like a perfectly reasonable thing to do when you're starting to develop the page, it can become a nightmare when you need to customize the layout and suddenly you need two different class tags but the behavior for manipulating the element is tied to class tag that describes the layout.

Cascading Styles

OK, I despise cascading styles for the simple reason that if you change the structure of the markup, it invalidates the cascading structure of the CSS. I much prefer flat CSS in which (typically) the class tag describes what is applied for that element in the HTML structure.

Keyboard Shortcuts

In this editor, I'm mapping a several keyboard shortcuts using the ALT key. Certain extensions in Chrome (and I suppose other browsers) may trigger instead. At least in Chrome, you can go to chrome://extensions/shortcuts and change the keyboard mappings (or disable them) if you find they interfere with the shortcuts in this editor. Even using event.preventDefault() doesn't stop an extension with a shortcut from taking over!

Bugs

I've noticed a couple of odd things on occasion:

  • The "wrap selected text with markup" sometimes doesn't pick the entire selected text. No idea why.
  • The preview editor doesn't automatically scroll to where you are editing text in the editor. This is a particularly annoying problem to figure out how to track where the user is in the editor and position the vertical scrollbar so the user can see the preview as well. The only way that I can think of to fix this is to figure out the current element that wraps where the user is typing and use scrollIntoView(). This seems complicated as a custom tag will have to be created to identify the element and then removed. I already employ this trick when switching between horizontal and vertical layouts when an element is selected.
  • I use a double space when starting a new sentence, something I learned in school ages ago. The editor needs to replace additional spaces with &nbsp; otherwise it renders as a single space.
  • The preview doesn't update after you paste something into the editor. You have to press a key, like one of the cursor keys, to see what you pasted. Just haven't gotten around to fixing that.

HTML is Not "Objects"

If I were writing this as a WinForm app, I could easily assign the TextBox to the "other" layout panel as a child control, and all the text, selections, events, etc., would just move over. HTML is not like that. In the layout of this editor, you'll see that I've duplicated the editor, preview, and property "controls" but this means that they require separate event handlers and proper updating when the user switches between v/h layouts. It's annoying and could be worked around with a mechanism that would treat the HTML sections as true objects. This was too complicated to implement at this point.

Noteworthy

I wrote this editor entirely in Visual Studio Code using the Quick HTML Previewer, which handles the HTML, CSS, and even JavaScript, even importing jQuery. Debugging I did in Chrome, but it's impressive what Quick HTML Previewer will do!

Also of note is that, yes, I'm using jQuery. It's just so much easier to use, especially when manipulating multiple elements of the same class tag.

Screenshots

Horizontal Layout

Image 2

Vertical Layout

Image 3

Code Dive - The Basic Structure

So let's look under the hood.

HTML Layout

First thing to take a look at is the layout of the HTML. The horizontal (side-by-side) structure looks like this:

<div class='__fullscreen __hidden' id='__horizontalView'>
    <div class='__parent __toolbar'>
        <div class="__child-middle-left __buttonBar"></div>
    </div>
    <table class='__noborders __fixed __fill'>
        <tr>
            <td class='__editorhw'>
                <div class='__h100p __border1'>
                    <textarea class='__taeditorh' id='__editorh'></textarea>
                </div>
            </td>
            <td class='__mw1px'>
                <div class='__preview __h100p __border1' id='__previewh'></div>
            </td>
            <td class='__properties'>
                <div class='__h100p  __border1 __vscroll'>
                    <div class='__bothPropertiesContainer __propertiesContainerh'></div>
                </div>
            </td>
        </tr>
    </table>
</div>

Contrast this with the vertical layout in which the editor and preview are top-bottom and the properties section is on the right:

<div class='__fullscreen' id='__verticalView'>
    <table class='__noborders __fill'>
        <tr style='height:1px;'>
            <div class='__parent __toolbar'>
                <div class="__child-middle-left __buttonBar"></div>
            </div>
        </tr>
        <tr>
            <td>
                <table class='__noborders __fill'>
                    <tr>
                        <td>
                            <div class='__h100p __border1'>
                                <textarea class='__taeditorv' id='__editorv'></textarea>
                            </div>
                        </td>
                    </tr>
                    <tr>
                        <td class='__mw1px'>
                            <div class='__preview __border1 __h100p' id='__previewv'></div>
                        </td>
                    </tr>
                </table>
            </td>
            <td class='__properties'>
                <div class='__h100p __border1 __vscroll'>
                    <div class='__bothPropertiesContainer __propertiesContainerv'></div>
            </td>
        </tr>
    </table>
</div>  

Not much different, is it? Some things of note:

  • All class and id tags are prefixed with __. Why? Because I don't want the tags used in the editor to conflict with the tags that the user might create for their own markup. Technically, I should also prefix the functions with __ as well, but at some point, I plan on wrapping them in class containers, so the point of doing that becomes somewhat moot.
  • Note that we have two editors, previews, and property containers, distinguished by the suffix h and v. The naming convention here is very intentional as there are functions that rely on these specific suffixes.
  • Note that some of the class tags are layout while other class tags are for selecting the element.

Templates

There are various templates used to populate the views with dynamic data as well as to duplicate common features, that being the toolbar at the top.

The Toolbar Template

<div class='__hidden' id='__sourceButtonBar'>
    <button class='__showHorizontal'>Horizontal</button>
    <button class='__showVertical'>Vertical</button>
    <button class='__clearEditors'>Clear</button>
    <button class='__save'>Save</button>
</div>

This template is simply copied into the horizontal and vertical views.

The Element Template

<div class='__hidden' id = '__sourcePropertiesContainer'>
    <div class='__propertyItem'>Element:
         <span class='__text-bold __elementTagName'></span></div>
    <div class='__propertyItem'>
         <span class='__propertyLabel'>Name:</span>
         <input class='__inputPropertyField __propertyName' id='__pname{hv}'></div>
    <div class='__propertyItem'><span class='__propertyLabel'>ID:</span>
         <input class='__inputPropertyField __propertyId' id='__pid{hv}'></div>
    <div id='__sections'></div>
</div>

This template is used in the properties container at the very top and is responsible for rendering the selected element and its ID and name. For example, given this markup (coded directly into the editor):

This is a demo paragraph.

We see in the properties container:

Image 4

The markup for the above paragraph looks like this:

<p id='myParagraph' name='fizbin'>This is a demo paragraph.</p>

The Property Section Template

Properties are organized into sections with a header. These are dynamically created from the metadata using this template:

<div class='__hidden' id = '__sectionTemplate'>
    <div class='__propertItem __section __sectionBar __sectionName{sectionName}'>
     {sectionName}</div>
</div>

Notice the use of the field {sectionName} which gets replaced with the actual section name when the properties container is generated.

Input Style Template

Most style properties are simply an input box using this template as the basis for generating the label and input element:

<div class='__hidden' id = '__inputStyleTemplate'>
    <div class='__propertyItem'><span class='__propertyLabel'>{name}:</span>
    <input class='__inputPropertyField'

    id='__inputProperty{name}{hv}' cssName='{name}'></div>
</div>

Notice the use of the fields {name} and {hv} which are used to identify and ID the specific property style name and whether it belongs in the horizontal or vertical view.

Checkbox Style Template

Some style properties technically don't have values -- their existence determines the state of the element. For these, there is a checkbox template:

<div class='__hidden' id = '__checkboxStyleTemplate'>
    <div class='__propertyItem'><span class='__propertyLabel'>{name}:</span>
    <input class='__checkboxPropertyField' id='__inputProperty{name}{hv}'

     type='checkbox' cssName='{name}'></div>
</div>

Metadata

The metadata determines the property sections and the specific style properties that go in each section. The metadata also determines what sections should be displayed for a specific element. Please note that these definitions are not comprehensive. Add to them as per your requirements. First, the sections and their styles:

var sections = [
        {name:'Dimensions', styles: ['width', 'height', 'max-width', 'max-height']},
        {name:'Margins', styles: ['margin-top', 'margin-right',
         'margin-bottom', 'margin-left']},
        {name:'Padding', styles: ['padding-top', 'padding-right',
         'padding-bottom', 'padding-left']},
        {name:'Font', styles: ['font-family', 'font-size', 'font-weight', 'font-style']},
        {name:'Border', styles: ['border-style', 'border-width',
         'border-color', 'border-radius']},
        {name:'Flags', styles: [{style:'readonly', control:'checkbox'}]}
    ];

Note that the Flags section has a slightly different format that specifies the control used to render the style option. By default, the control is assumed to be an input element.

The element tags are then map to the desired sections for that element:

var tagSections = [
    {tag: 'input', sections: ['Margins', 'Padding', 'Flags']},
    {tag: 'p', sections: ['Dimensions', 'Margins', 'Padding', 'Font', 'Border']},
    {tag: 'div', sections: ['Dimensions', 'Margins', 'Padding', 'Border']},
    {tag: 'ul', sections: ['Margins', 'Padding', 'Border']},
    {tag: 'li', sections: ['Margins', 'Padding', 'Border']},
];

Again, this is obviously not a comprehensive list! Add to the list yourself!

Global Variables

Ugh. Global variables. These are used throughout the editor code and ought to be put into a static class. I'm also going to review them at some point to see if they are all necessary. At the moment, they are used to maintain the state of the editor.

var altKeyDown = false;
var ctrlKeyDown = false;
var hoverElement = undefined;
var selectedElement = undefined;
var currentEditor = undefined;
var currentPreview = undefined;

// For preserving section content visibility when we click on tags.
var sectionContentVisible = [];

Code Dive - Initialization

Now that we've covered the structural basics, let's look at the initialization process to understand how the templates and metadata are used to populate the horizontal and vertical layouts. The core initialization routine is:

$(document).ready(() => initialize());

function initialize() {
    initializeSections();
    setupButtonBar();
    setupSourcePropertiesContainer();
    setupPropertiesContainer('h');
    setupPropertiesContainer('v');
    wireUpEvents();
    showHorizontalLayout();
    demo();
}

Section Initialization

The comments really says it all:

// The purpose of this is to take the simpler (more human friendly) version of this:
// {name:'Dimensions', styles: ['width', 'height', 'max-width', 'max-height']},
// and convert the styles to an array of objects that looks like this:
// styles: [{style:'width', control:'input'}]
// That way we can get rid off all the horrid if-else checks
// when dealing with the style formatting.
function initializeSections()
{
    sections.forEach(s => {
        let i = 0;
        for (i = 0; i < s.styles.length; i++) {
            if (typeof(s.styles[i]) == 'string') {
                s.styles[i] = {style: s.styles[i], control: 'input'};
            }
        }
    });
}

I had originally written the code with a bunch of if-else statements based on whether the a control key was defined in the style list. In my opinion, this was hideous, so I decided to "map" the styles into a common format based on whether the style itself was a string or an object, so this:

styles: ['width', 'height', 'max-width', 'max-height']

and this:

styles: [{style:'readonly', control:'checkbox'}]

get unified into the latter form, where the default control (the array of strings) is mapped into an object:

{style: [stylename], control: 'input'}

This unifies all the handling of the styles in each section but makes it easier for the programmer to define the styles when the list of styles is simply an array of strings.

Setup the Button Bar

Very simple -- just copy the button bar template into the horizontal and vertical views:

function setupButtonBar() {
    let html = $('#__sourceButtonBar').html();
    $('.__buttonBar').html(html);
}

The joys if jQuery -- the template HTML is copied into each instance of the element with the class name __buttonBar.

Setup the Properties Container

The properties container is actually a template which is populated with the sections and section style "input" controls, which is then copied into the horizontal and vertical views.

function setupSourcePropertiesContainer() {
    let sectionNames = sections.map(s => s.name);

    let sectionTemplate = $('#__sectionTemplate').html();
    let sectionContent = [];
    let sectionNameClasses = [];

    sectionNames.forEach(n =>
    {
        let sectionName = '__sectionName' + n;
        let contentName = '__content' + n;
        let st = sectionTemplate.replaceAll('{sectionName}', n);
        st = st + "<div class='" + contentName + "'>";
        st = createSectionStyleTemplate(sections.filter(s => s.name == n)[0].styles, st);
        st = st + "</div>";
        sectionContent.push(st);
        sectionNameClasses.push({section: '.' + sectionName, content: '.' + contentName});

        // All section content initially visible
        sectionContentVisible['.' + contentName] = true;
    });

    $("#__sections").html(sectionContent.join(''));

    wireUpSectionEvents(sectionNameClasses);
    wireUpSectionStyleEvents(sections);
}

We do a few things here:

  1. We replace token {sectionName} simply with the section index, the full name __sectionName[n] which is used later on to wire up the event for clicking on the section name, which collapses or reveals the section contents.
  2. A div is created for the section content -- the style name and "input" control.
  3. All the sections are marked as initially visible.
  4. The sections, along with their content, are then joined and this replaces the stub in the div with the ID __sections.

Creating the section template continues to parse the metadata that defines the styles in each section:

function createSectionStyleTemplate(styles, template) {
    styles.forEach(s =>
    {
        let overrideTemplate = $('#__' + s.control + 'StyleTemplate').html();
        template = template + overrideTemplate.replaceAll('{name}', s.style);
    });

    return template;
}

Here, the specific input template, being at this point either input or checkbox is acquired and the token {name} is replaced with the style name.

Lastly, the events that handle events when the user tabs off a section or presses the ENTER key is wired up. Because the sections are dynamically generated, we have to use the on event of the document:

function wireUpSectionEvents(sectionNameClasses) {

    sectionNameClasses.forEach(sni =>
    {
        // When clicking on the section div, show or hide the content.
        // This doesn't work:
        // $(sni.section).on('click', () => showOrHideContent(sni.content));
        // We have to wire this up at the document level and pass in the selector!
        // See: https://stackoverflow.com/a/29674985/2276361
        $(document).on('click', sni.section, () => showOrHideSectionContent(sni.content));
    })
}

function wireUpSectionStyleEvents(sections) {
    // Also wire up future property style input boxes and checkbox events.
    sections.forEach(section =>
    {
        section.styles.filter(s=>s.control=='input').forEach(sectStyle =>
        {
            let inputElement = '#__inputProperty' + sectStyle.style;

            $(document).on('keydown', inputElement + 'h', onInputKeyPress);
            $(document).on('blur', inputElement + 'h', onUpdateElementStyle);

            $(document).on('keydown', inputElement + 'v', onInputKeyPress);
            $(document).on('blur', inputElement + 'v', onUpdateElementStyle);
        });

        section.styles.filter(s=>s.control=='checkbox').forEach(sectStyle =>
        {
            let inputElement = '#__inputProperty' + sectStyle.style;
            $(document).on('click', inputElement + 'h', onCheckbox);
            $(document).on('click', inputElement + 'v', onCheckbox);
        });
    });
}

At the end of the day, we do things like collapse the sections:

Image 5

as well as tab (blur) off an input or press the ENTER key to accept the change.

Once the properties section template has been initialized, it is copied into the specific views. Recall:

setupPropertiesContainer('h');
setupPropertiesContainer('v');

Which is a simple function:

function setupPropertiesContainer(hv) {
    let html = $('#__sourcePropertiesContainer').html();
    // Resolve the final dynamic ID with h or v to distinguish which property
    // is being changed.
    html = html.replaceAll('{hv}', hv);
    $('.__propertiesContainer' + hv).html(html);
}

Note that the token {hv} replacement is done here.

Document Events

There are document-wide events that are also handled that do some fancy things which I'll describe later. These behaviors are wired up like this:

function wireUpEvents() {
    $('#__editorh').keyup(event => editorKeyPress(event, '#__editorh', '#__previewh'));
    $('#__editorv').keyup(event => editorKeyPress(event, '#__editorv', '#__previewv'));
    $('#__editorh').keydown(event => editorKeyDown(event, '#__editorh', '#__previewh'));
    $('#__editorv').keydown(event => editorKeyDown(event, '#__editorv', '#__previewv'));
    $('#__editorh').on('paste', event => onPaste(event, '#__editorh', '#__previewh'));
    $('#__editorv').on('paste', event => onPaste(event, '#__editorv', '#__previewv'));
    $('.__preview').mouseover(event => previewMouseOver(event.target));
    $('.__preview').mouseleave(event => clearProperties());
    $('.__preview').click(event => previewClick(event));
    $('.__showHorizontal').click(() => showHorizontalLayout());
    $('.__showVertical').click(() => showVerticalLayout());
    $('.__clearEditors').click(() => clearEditors());
    $('.__save').click(() => save());

    // Handle CR
    $('.__propertyName').keypress((event) => propertyNameKeyPress(event));
    $('.__propertyId').keypress((event) => propertyIdKeyPress(event));

    // Handle lose focus
    $('.__propertyName').blur((event) => {
        updateElementName(event);
        updateSource();
    });

    $('.__propertyId').blur((event) => {
        updateElementId(event);
        updateSource();
    });
}

More Metadata - Custom Keys

The ability to specify custom key behaviors is also defined in metadata:

keymap = [
    {special: 'alt', key: 'C', insert: ['<code>', '</code>'] },
    {special: 'alt', key: 'P', insert: ['<p>', '</p>'] },
    {special: 'alt', key: 'R',
     insert: ['<p>&nbsp;', '</p>'], eoi: true}, // cursor at end of insert
    {special: 'alt', key: 'O', insert: ['<ol>', '</ol>'] },
    {special: 'alt', key: 'U', insert: ['<ul>', '</ul>'] },
    {special: 'alt', key: 'L', insert: ['<li>', '</li>'] },
    {special: 'alt', key: 'B', insert: ['<b>', '</b>'], toggle: true },
    {special: 'alt', key: 'I', insert: ['<i>', '</i>'], toggle: true },
    {special: 'alt', key: '1', insert: ["<pre lang='cs'>", '</pre>'] },
    {special: 'alt', key: '2', insert: ["<pre lang='jscript'>", '</pre>'] },
    {special: 'alt', key: '3', insert: ["<pre lang='html'>", '</pre>'] },
    {special: 'alt', key: '4', insert: ["<pre lang='css'>", '</pre>'] },
];

Note here that these are all wired up with alt key combinations to avoid browser behaviors that might be associated with ctrl or other key combinations. When writing this article, I've found that these shortcut keys really streamline the process of writing -- I'm no longer hand-typing the code and pre tags, and having the alt 1-4 keys mapped to specific lang options is really nice!

Demo Initialization

Lastly, recall in the initialize() function the last two lines:

showHorizontalLayout();
demo();

These set up the initial editor layout and put something in the editor so you can start playing with it right away. For completeness, I'll show the three functions that control which layout you are using and also the function to clear the layout:

function showVerticalLayout() {
    copy('h', 'v');
    $('#__verticalView').removeClass('__hidden');
    $('#__horizontalView').addClass('__hidden');
    currentEditor = '#__editorv';
    currentPreview = '#__previewv';
}

function showHorizontalLayout() {
    copy('v', 'h');
    $('#__verticalView').addClass('__hidden');
    $('#__horizontalView').removeClass('__hidden');
    currentEditor = '#__editorh';
    currentPreview = '#__previewh';
}

function clearEditors() {
    $('#__editorh').val('');
    $('#__editorv').val('');
    $('#__previewh').html('');
    $('#__previewv').html('');
}

And lastly, initializing the editor:

function demo() {
    let demoText="<style>\n
        p {\n
           margin: 0px 0px 0px 5px;\n
          }\n
        .mydiv {\n
           background-color: red;\n
           margin: 5px;\n
        </style>\n
        <p id="hi">Hello World!</p>
        <div class="mydiv" id="div">DIV Content</div>
        <input value="An Input">";
    $('#__editorv').val(demoText);
    $('#__previewv').html(demoText);
    $('#__editorh').val(demoText);
    $('#__previewh').html(demoText);
}

Code Dive - Event Handlers

The real brains of the editor is in the event handlers, so let's take a look at them.

Hovering Over and Selecting an Element

Selecting an element is the first step in editing its style. However, there is a "preview" model which tracks the mouse position and displays the style attribute-values over any element in the preview pane. This "hover preview" is active only when an element has not been selected and is implemented by these three functions:

function previewMouseOver(element) {
    // Setup for mouse click elsewhere.
    if (element.classList.contains('__preview')) {
        hoverElement = undefined;
    } else {
        hoverElement = element;
    }

    // Ignore the __preview box itself and, if we have a selected element,
    // don't update the property list during a hover.
    if (!element.classList.contains('__preview') && !selectedElement) {
        showProperties(element);
    }
    else {
        clearProperties();
    }
}

function showProperties(element) {
    let tagName = element.tagName;
    $('.__elementTagName').text(tagName);
    $('.__bothPropertiesContainer').removeClass('__hidden');
    $('.__propertyName').val($(element).attr('name'));
    $('.__propertyId').val($(element).attr('id'));
    showAllowableSections(tagName);
    populateStyleValues(element, tagName);
}

function clearProperties() {
    if (!selectedElement) {
        $('.__elementTagName').text('');
        $('.__bothPropertiesContainer').addClass('__hidden');
        hoverElement = undefined;
    }
}

The supporting functions showAllowableSections and populateStyleValues are implemented as:

function showAllowableSections(tagName) {
    let lcTagName = tagName.toLowerCase();
    let sectionNames = sections.map(s => s.name);
    let section = tagSections.filter(s => s.tag == lcTagName)[0];

    if (section) {
        let allowableSections = section.sections;

        sectionNames.forEach(sn => {
            let sectionName = '.__sectionName' + sn;
            let contentName = '.__content' + sn;
            if (allowableSections.includes(sn)) {
                $(sectionName).removeClass('__hidden');

                // Restore state when section can be displayed.
                if (sectionContentVisible[contentName]) {
                    $(contentName).removeClass('__hidden');
                } else {
                    $(contentName).addClass('__hidden');
                }
            } else {
                // always hide if excluded.
                $(sectionName).addClass('__hidden');
                $(contentName).addClass('__hidden');
            }
        });
    } else {
        clearProperties();
    }
}

function populateStyleValues(el, tagName) {
    let lcTagName = tagName.toLowerCase();
    let sectionNames = sections.map(s => s.name);
    let section = tagSections.filter(s => s.tag == lcTagName)[0];

    if (section) {
        let allowableSections = section.sections;

        allowableSections.forEach(s =>
        {
            if (sections.filter(section => section.name == s).length > 0) {
                let sectionStyles = sections.filter(section => section.name == s)[0].styles;

                sectionStyles.forEach(sectStyle =>
                {
                    let inputElement = '#__inputProperty' + sectStyle.style;

                    if (sectStyle.control == 'checkbox') {
                        let attr = sectStyle.style;
                        let checked = $(el).prop(attr);
                        $(inputElement + 'h').prop('checked', checked);
                        $(inputElement + 'v').prop('checked', checked);
                    } else {
                        let styleValue = $(el).css(sectStyle.style);
                        $(inputElement + 'h').val(styleValue);
                        $(inputElement + 'v').val(styleValue);
                    }
                });
            } else {
                console.log('Section ' + s + ' is not defined.');
            }
        });
    }
}

Note that populateStyleValues has the only case where the control type is checked because we have to handle the setting of the checkbox state differently than that of the input element value.

When the user clicks on a element, which is handled by the parent level div:

$('.__preview').click(event => previewClick(event));

The currently hovering-over element is selected, or if there is no element selected, the properties are cleared and the preview behavior returns to "hover preview":

function previewClick(event) {
    selectedElement = hoverElement;

    if (selectedElement) {
        showProperties(selectedElement);
    } else {
        clearProperties();
    }
}

Style Editor Events

Two events are wired up for the input box elements of the style section, keydown and blur, for both horizontal and vertical views:

$(document).on('keydown', inputElement + 'h', onInputKeyPress);
$(document).on('blur', inputElement + 'h', onUpdateElementStyle);

$(document).on('keydown', inputElement + 'v', onInputKeyPress);
$(document).on('blur', inputElement + 'v', onUpdateElementStyle);

And one for each view for checkbox controls:

$(document).on('click', inputElement + 'h', onCheckbox);
$(document).on('click', inputElement + 'v', onCheckbox);

The keydown event and blur events share common code but the reason they call into the method updateElementStyle is simply to separate the event function from the logic that performs the style update. This way, you can add functionality specific to the event handler without affecting the underlying behavior.

function onInputKeyPress(event) {
    if (event.keyCode == 13) {
        updateElementStyle(event);
    }
}

function onUpdateElementStyle(event) {
    updateElementStyle(event);
}

function updateElementStyle(event) {
    let elName = '#' + event.target.id;
    let el = $(elName);
    let attr = $(el).attr('cssName');
    let val = el.val();
    $(selectedElement).css(attr, val);
    updateSource();
    updateOtherPropertyGrid(elName, val);
}

Note that the "other" view's property pane is also updated:

function updateOtherPropertyGrid(elName, val) {
    // We also need to update the complimentary input in the other properties section
    // Remove the trailing h or v.
    let hv = elName[elName.length - 1];
    let althv = hv == 'h' ? 'v' : 'h';
    let elx = elName.slice(0, -1) + althv;
    $(elx).val(val);
}

The checkbox event is similar:

function onCheckbox(event) {
    let elName = '#' + event.currentTarget.id;
    let el = $(elName);
    let attr = $(el).attr('cssName');
    let checked = el.is(':checked');
    $(selectedElement).prop(attr, checked);
    updateSource();

    // like updateOtherPropertyGrid
    let hv = elName[elName.length - 1];
    let althv = hv == 'h' ? 'v' : 'h';
    let elx = elName.slice(0, -1) + althv;
    $(elx).prop('checked', checked);
}

Note that in all cases, the function updateSource is called, which updates the text in the editor:

function updateSource() {
    let editor = $(currentEditor);
    let preview = $(currentPreview);
    let text = preview.html();
    editor.val(text);
}

Editor Keyboard Events

There are some, what I think are snazzy, features when editing the HTML. The keypress is checked for a shortcut key which either inserts or toggles (adds or removes) markup (or whatever is defined in the keymap metadata:

function editorKeyPress(event, sourceEditor, targetPreview) {
    let key = String.fromCharCode(event.which);
    let editor = $(sourceEditor);
    let preview = $(targetPreview);
    let editorText = editor.val();

    if (ctrlKeyDown || altKeyDown) {
        let start = editor[0].selectionStart;
        let end = editor[0].selectionEnd;
        let s1 = editorText.substring(0, start);
        let s2 = editorText.substring(end);
        let between = editorText.substring(start, end);

        // TODO: Check that other metakeys aren't down
        // TODO: Handle combination of meta keys.
        // TODO: Maybe handle key combinations.
        if (altKeyDown) {
            map = keymap.filter(k => k.special == 'alt' && k.key == key);

            if (map.length == 1) {
                map = map[0];

                if (map.toggle) {
                    toggleTags(editor, preview, map.insert[0], map.insert[1],
                               editorText, s1, between, s2, start, end);
                } else {
                    insertTags(editor, preview, map.insert[0], map.insert[1],
                               s1, between, s2, end, map.eoi);
                }

                event.preventDefault();
            }
        }

        /*
        if (ctrlKeyDown) {
            switch(key) {
            }
        }
        */
    } else {
        clearSpuriousStyle();
        preview.html(editorText);
    }
}

The insert and toggle functionality:

function insertTags(editor, preview, t1, t2, s1, between, s2, end, eoiFlag) {
    let newText = s1 + t1 + between + t2 + s2;
    let newIdx = end + t1.length;
    $(editor).val(newText);

    if (eoiFlag) {
        editor[0].selectionStart = newIdx + t2.length;
        editor[0].selectionEnd = newIdx + t2.length;
    } else {
        // Set cursor between the tags and at the end of the selected (if any) text.
        editor[0].selectionStart = newIdx;
        editor[0].selectionEnd = newIdx;
    }

    preview.html(newText);
}

// Not the smartest toggling, as it doesn't search for the outermost tags but only
// if they are immediately adjacent to the selected text.
function toggleTags(editor, preview, t1, t2, editorText, s1, between, s2, start, end) {
    var newText = editorText;

    if (start - t1.length >= 0 &&
        (editorText.substring(start-t1.length, start) == t1) &&
        end + t1.length < editorText.length &&
        (editorText.substring(end, end + t2.length) == t2) ) {
        // Remove tags
        s1 = editorText.substring(0, start - t1.length);
        between = editorText.substring(start, end);
        s2 = editorText.substring(end + t2.length);
        newText = s1 + between + s2;
        $(editor).val(newText);

        // Preserve selected text.
        editor[0].selectionStart = start - t1.length;
        editor[0].selectionEnd = end - t1.length;
    } else {
    // Add tags.
        newText = s1 + t1 + between + t2 + s2;
        $(editor).val(newText);

        // Preserve selected text.
        editor[0].selectionStart = start + t1.length;
        editor[0].selectionEnd = end + t1.length;
    }

    preview.html(newText);
}

Trick: Clearing the Style Rule

So what is this call to clearSpuriousStyle when a key is processed normally? First, let me describe the problem. Let's say you start off with the demo markup in the default horizontal view:

Image 6

Next, you change the background color of DIV to green:

Image 7

Why is it still red???

The reason for this is that the document for some reason has a two identical versions of the editor's stylesheet, index 0 being the CSS rules for the editor itself:

Image 8

This behavior occurs only on the horizontal view because the CSS is at index 1 which is ignored. The vertical view is using the CSS rules at index 2, so the problem doesn't occur. Because both views share the same CSS rules, so we need to delete the rules at index 2 so that when you edit the markup in the editor, the CSS rules at index 1 are updated:

// FM: We have to clear the CSS rules of the last style sheet so that the preview
// is responsive to changes in the<style type="text/css">section of the editor.</style>
function clearSpuriousStyle() {
    let i = document.styleSheets.length;
    let ss = document.styleSheets[i - 1];

    if (i > 1) {
        while (ss.rules.length > 0) {
            ss.deleteRule(0);
        }
    }
}

This is a real kludge -- it gets executed for every keystroke and is probably buggy.

and that's what clearSpuriousStyle does and is why this is called "FM" (figure it out.)

Trick: Tab and Encoding Keys

Tabs are easy to handle in the keydown event:

function editorKeyDown(event, editor, preview) {
    altKeyDown = event.altKey;
    ctrlKeyDown = event.ctrlKey;

    if (event.keyCode == 9 && !event.shiftKey) {
        insertTab(editor, preview);
        event.preventDefault();
    } else if (event.keyCode == 9 && event.shiftKey) {
        removeTab(editor, preview);
        event.preventDefault();
    }

    insideCodeBlockHandling(event, editor, preview);
}

However, I've added a feature that automatically converts characters that would otherwise be treated as markup to their encoded HTML equivalents:

function insideCodeBlockHandling(event, editor, preview) {
    // Map to encoded HTML if inside a code or pre block.
    let start = $(editor)[0].selectionStart;
    let editorText = $(editor).val();
    let s1 = editorText.substring(0, start);

    if (inTag(s1, 'pre') || inTag(s1, 'code')) {
        let char = mapKeyPressToActualCharacter(event.originalEvent.shiftKey, event.which);
        let echar = $(editor).html(char).html();

        if (char != echar) {
            let end = $(editor)[0].selectionEnd;
            let s2 = editorText.substring(end);
            let newText = s1 + echar + s2;
            $(editor).val(newText);
            let curPos = s1.length + echar.length;
            $(editor)[0].selectionStart = curPos;
            $(editor)[0].selectionEnd = curPos;
            $(preview).html(newText);
            event.preventDefault();
        }
    }
}

The function mapKeyPressToActualCharacter comes from this SO post: https://stackoverflow.com/a/22975675/2276361.

With this code, if I'm typing outside of a pre or code block, characters like <, > and & appear as those actual characters, whereas when I'm typing inside a code or pre block, they are encoded into &lt;, &gt;, and &amp;. This adds some (but not perfect) smarts to the editor so I don't have to think about the encoding as I type. Normally, as a pure code editor, those characters would always be HTML encoded, but since the editor supports actual HTML elements, I implemented this as a way to handle both the scenarios of using the editor to write articles and using the editor for playing around with HTML layouts.

This same "trick" is used when pasting which is particularly useful when pasting code fragments:

function onPaste(event, editor, preview) {
    let ret = true;
    let clippy = event.originalEvent.clipboardData;
    let textItems = Object.filter(clippy.items,
                 item => item.kind == 'string' && item.type == 'text/plain');

    if (Object.getOwnPropertyNames(textItems).length == 1) {
        let start = $(editor)[0].selectionStart;
        let end = $(editor)[0].selectionEnd;
        let editorText = $(editor).val();
        let s1 = editorText.substring(0, start);
        let s2 = editorText.substring(end);
        let text = clippy.getData('text/plain');

        // Now we have to check if we're inside a <code> or <pre> block, as these
        // need to be encoded so that < becomes < etc.  Otherwise we assume the user
        // is pasting a real HTML element.
        // Note that we don't check for closing </code> or </pre> tags to the right
        // of the current position, as the user may not have created these yet.

        if (inTag(s1, 'pre') || inTag(s1, 'code')) {
            text = $(editor).html(text).html();
        }

        let newText = s1 + text + s2;
        $(editor).val(newText);
        let curPos = s1.length + text.length;
        $(editor)[0].selectionStart = curPos;
        $(editor)[0].selectionEnd = curPos;
        $(preview).html(newText);
        ret = false;
    }

    return ret;
}

where inTag is defined as:

// Returns true if the text contains a final open tag but not the closing tag.
// Open tag can be of the form "<tag>" or "<tag " (note space)
function inTag(s, tag) {
    let tagOpen = '<' + tag + '>';
    let tagAltOpen = '<' + tag + ' ';
    let tagClose = '</' + tag + '>';
    let idxOpen = s.lastIndexOf(tagOpen);
    let idxAltOpen = s.lastIndexOf(tagAltOpen);
    let idxClose = s.lastIndexOf(tagClose);

    return idxOpen > idxClose || idxAltOpen > idxClose;
}

Table of Contents Generation

I also wanted TOC generation built into the editor, so here it is:

// Will be fooled by putting <h> tags into quoted strings.
function generateTOC() {
    let toc = '<ul>';
    let text = $(currentEditor).val().toLowerCase();
    let originalText = $(currentEditor).val();
    let level = 1;
    // Next opening <h[n]> tag in the text.
    let idx = text.indexOf('<h');
    let n = 0;      // href tag.
    // index into original text, for getting the header without toLowerCase
    // and to insert the <a name> and </a> tags.
    let q = 0;

    while (idx != -1) {
        let cnum = text[idx + 2];
        let num = undefined;

        // If it's really of the form <h[n]...
        if (!isNaN(num = parseInt(cnum, 10))) {
            // indent or outdent to the new level.
            while (num > level) {
                toc = toc + '<ul>';
                ++level;
            }

            while (num < level) {
                toc = toc + '</ul>';
                --level;
            }

            idx += 4;
            q += idx;

            // Remove the <a name> and closing </a> if it already exists.
            if (originalText.substring(q, q + '<a name="'.length) == '<a name="') {
                // remove the <a name> and </a> from the original text...
                originalText = removeAName(originalText, q);
                // ... and the working text.
                text = removeAName(text, idx);
            }

            // Get the body of the header.
            let hdr = text.substring(idx);
            hdr = originalText.substring(q, q + hdr.indexOf('</'));
            toc = toc + '<li><a href="#' + n.toString() + '">' + hdr + '</a></li>';

            // Insert the <a name> and closing </a> around the header text.
            let s1 = originalText.substring(0, q);
            let s2 = originalText.substring(q + hdr.length);
            let aname = '<a name="' + n.toString() + '">' + hdr + '</a>';
            originalText = s1 + aname + s2;
            q += aname.length + '</hn>'.length;
            ++n;

            // Update the index to just past </h[n]>
            let hdrLen = hdr.length + '</hn>'.length;
            idx += hdrLen;
        } else {
            // Skip whatever the <h thing is we found.
            idx += 2;
            q += 2;
        }

        text = text.substring(idx);
        idx = text.indexOf('<h');
    }

    // Close off the ul's.
    while (level > 0) {
        toc = toc + '</ul>';
        --level;
    }

    // Update the preview with the <a name> tags.
    $(currentPreview).html(originalText);
    // Then set the #toc div.
    $("#toc").html(toc);
    // Then update the editor text.
    updateSource();
}

And the helper function to remove existing <a name> and </a> tags that are part of the headers:

function removeAName(text, idx) {
    s1 = text.substring(0, idx);
    anameIdx = idx + text.substring(idx).indexOf('">') + 2;
    s2 = text.substring(anameIdx);
    hdr = s2.substring(0, s2.indexOf('</a>'));
    s3 = s2.substring(hdr.length + '</a>'.length);
    text = s1 + hdr + s3;

    return text;
}

Conclusion

This editor is still very much in its prototype stages, however I did write the entire article using this editor, so it is actually quite useful. And as mentioned in the introduction, you can run the code in Visual Studio code using Quick HTML Previewer!

History

  • 15th September, 2019: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marc Clifton
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
QuestionThanks a lot, Marc! Pin
Carsten V2.019-Sep-19 0:42
memberCarsten V2.019-Sep-19 0:42 
PraiseLooks Great Pin
Woody D17-Sep-19 5:34
memberWoody D17-Sep-19 5:34 
GeneralRe: Looks Great Pin
Marc Clifton23-Sep-19 2:14
protectorMarc Clifton23-Sep-19 2:14 
Questionnot found Pin
RmcbainTheThird16-Sep-19 9:25
professionalRmcbainTheThird16-Sep-19 9:25 
AnswerRe: not found Pin
Marc Clifton16-Sep-19 11:37
protectorMarc Clifton16-Sep-19 11:37 
GeneralMy vote of 5 Pin
Jay Bardeleben16-Sep-19 8:11
professionalJay Bardeleben16-Sep-19 8:11 
GeneralRe: My vote of 5 Pin
Marc Clifton16-Sep-19 9:00
protectorMarc Clifton16-Sep-19 9:00 
QuestionNot Found Pin
johnta116-Sep-19 6:59
memberjohnta116-Sep-19 6:59 
AnswerRe: Not Found Pin
Marc Clifton16-Sep-19 9:00
protectorMarc Clifton16-Sep-19 9:00 
Questioneditor.zip not found on server Pin
Member 792845816-Sep-19 6:38
memberMember 792845816-Sep-19 6:38 
AnswerRe: editor.zip not found on server Pin
Marc Clifton16-Sep-19 9:00
protectorMarc Clifton16-Sep-19 9:00 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Article
Posted 15 Sep 2019

Tagged as

Stats

4.1K views
159 downloads
9 bookmarked