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.
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
In discussions on Code Project and in writing this, I've come to realize that there are different uses of CSS:
- Defining multiple attributes of an element that can be considered a reusable element, particularly in that it often has child elements
- Defining a specific style that customizes just that element
- Defining a tag as a class or id that is used for manipulating the element
- Visual (as opposed to layout) styles such as color and font size
- Animations
- 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.
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.
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!
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
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.
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.
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.
Horizontal Layout
Vertical Layout
So let's look under the hood.
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.
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.
<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.
<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:
The markup for the above paragraph looks like this:
<p id='myParagraph' name='fizbin'>This is a demo paragraph.</p>
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.
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.
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>
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!
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;
var sectionContentVisible = [];
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();
}
The comments really says it all:
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 string
s) 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 string
s.
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
.
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});
sectionContentVisible['.' + contentName] = true;
});
$("#__sections").html(sectionContent.join(''));
wireUpSectionEvents(sectionNameClasses);
wireUpSectionStyleEvents(sections);
}
We do a few things here:
- 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. - A
div
is created for the section content -- the style name and "input
" control. - All the sections are marked as initially visible.
- 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 =>
{
$(document).on('click', sni.section, () => showOrHideSectionContent(sni.content));
})
}
function wireUpSectionStyleEvents(sections) {
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:
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();
html = html.replaceAll('{hv}', hv);
$('.__propertiesContainer' + hv).html(html);
}
Note that the token {hv}
replacement is done here.
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());
$('.__propertyName').keypress((event) => propertyNameKeyPress(event));
$('.__propertyId').keypress((event) => propertyIdKeyPress(event));
$('.__propertyName').blur((event) => {
updateElementName(event);
updateSource();
});
$('.__propertyId').blur((event) => {
updateElementId(event);
updateSource();
});
}
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> ', '</p>'], eoi: true},
{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!
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);
}
The real brains of the editor is in the event handlers, so let's take a look at them.
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) {
if (element.classList.contains('__preview')) {
hoverElement = undefined;
} else {
hoverElement = element;
}
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');
if (sectionContentVisible[contentName]) {
$(contentName).removeClass('__hidden');
} else {
$(contentName).addClass('__hidden');
}
} else {
$(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();
}
}
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) {
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();
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);
}
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);
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();
}
}
} 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 {
editor[0].selectionStart = newIdx;
editor[0].selectionEnd = newIdx;
}
preview.html(newText);
}
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) ) {
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);
editor[0].selectionStart = start - t1.length;
editor[0].selectionEnd = end - t1.length;
} else {
newText = s1 + t1 + between + t2 + s2;
$(editor).val(newText);
editor[0].selectionStart = start + t1.length;
editor[0].selectionEnd = end + t1.length;
}
preview.html(newText);
}
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:
Next, you change the background color of DIV
to green
:
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:
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:
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.)
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) {
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 <, >, and &
. 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');
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:
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;
}
I also wanted TOC generation built into the editor, so here it is:
function generateTOC() {
let toc = '<ul>';
let text = $(currentEditor).val().toLowerCase();
let originalText = $(currentEditor).val();
let level = 1;
let idx = text.indexOf('<h');
let n = 0;
let q = 0;
while (idx != -1) {
let cnum = text[idx + 2];
let num = undefined;
if (!isNaN(num = parseInt(cnum, 10))) {
while (num > level) {
toc = toc + '<ul>';
++level;
}
while (num < level) {
toc = toc + '</ul>';
--level;
}
idx += 4;
q += idx;
if (originalText.substring(q, q + '<a name="'.length) == '<a name="') {
originalText = removeAName(originalText, q);
text = removeAName(text, idx);
}
let hdr = text.substring(idx);
hdr = originalText.substring(q, q + hdr.indexOf('</'));
toc = toc + '<li><a href="#' + n.toString() + '">' + hdr + '</a></li>';
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;
let hdrLen = hdr.length + '</hn>'.length;
idx += hdrLen;
} else {
idx += 2;
q += 2;
}
text = text.substring(idx);
idx = text.indexOf('<h');
}
while (level > 0) {
toc = toc + '</ul>';
--level;
}
$(currentPreview).html(originalText);
$("#toc").html(toc);
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;
}
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!
- 15th September, 2019: Initial version