Using JavaScript & contenteditable
Over the past few months I’ve been making more time to teach myself JavaScript without the guardrails of jQuery.
It’s been fun. A little confusing at times. But, I’m actually surprised at how much JavaScript I “knew” just from meddling around with other people’s code, and from my firm grasp on Sass’s Control Directives. The Control Directives in particular really made some of the parallel concepts of JavaScript click for me.
I see what you did there… but what about this?
In a lot of the tutorial websites and books that I’ve been reading, there isn’t a whole lot of time spent on actual node manipulation (at least, I haven’t gotten to them yet?). As someone who learns best by seeing the results of what he codes, a lot of the JS that I was learning were more behind the scenes, math functions and variable checks. These are obviously important concepts to master. But returning all values via console.log was doing nothing to help me bridge the gap between what I was learning, and how I would want to implement it.
So, instead of being discouraged from not getting what I needed to really understand what I was learning vs what I wanted to do, I left the tutorials and moved over to Stack Overflow to search for the missing pieces.
And what is it I wanted to do? Well, I wanted to muck around with the idea of creating editable content outside of standard form controls.
Making an Editable HTML Element
Making a editable element in HTML isn’t all that difficult. You can add the contenteditable="true"
HTML attribute to the element (a <div>
for example) that you want to be editable.
On the topic of editable elements...
If you're anticipating a user to only update a word or two within a paragraph, then you could make a <p>
itself editable. However, if you're providing an area for a user to add long-form content, a div
may be more appropriate, as browsers handle character returns differently.
For instance, Safari injects div
s for each line break, where as Firefox injects br
elements. If editing a p
, div
s are invalid child elements and could lead to some unexpected and undesired rendering quirks.
Beyond just editing content, I wanted to be able to temporarily save changes and revert state (or undo) the modified content.
Here’s where I wanted to apply the JavaScript I had been learning. So, I started with what I knew well, the HTML and CSS…
The HTML and CSS
To start, here’s the basic HTML I wanted to work with for this exercise:
<div id="container" class="container">
<p>
Edit the following content as you see fit...
</p>
<div contenteditable="true" id="myContent">
This is the editable content.
</div>
<button class="btn" id="undo" disabled>Undo Changes</button>
<button class="btn" id="save" disabled>Save Content</button>
</div>
The content I want to edit is contained within the div
with contenteditable="true"
. Without any JavaScript, browsers that support this attribute will allow you to modify the content as is. Pretty neat!
Next I have two button
s which are initially disabled. The reason for this is that I want people to be aware that these controls will exist, but due to there being no changes, they should not be currently actionable or accessible.
Next, here’s some quick and dirty CSS to give it the UI a semblance of styling:
html, body {
font-family: arial;
font-size: 110%;
margin: 0;
padding: 0;
}
.container {
margin: auto;
padding: 20px;
position: relative;
width: 50%;
}
[contenteditable] {
font-size: 26px;
padding: .25em 0em;
margin: 1em 0;
transition: padding .3s ease-in-out;
}
[contenteditable]:hover,
[contenteditable]:focus {
padding: .25em;
}
[contenteditable]:hover {
background: #fafafa;
outline: 2px solid #eee;
}
[contenteditable]:focus {
background: #efefef;
outline: 2px solid blue;
}
.btn {
background: #fff;
color: darkgreen;
border: 1px solid darkgreen;
box-shadow: inset 0 0 0 1px;
font-size: 1em;
padding: .75em;
margin-right: .5em;
}
.btn[disabled] {
color: #666;
border-color: #ddd;
opacity: .5;
}
.btn:not([disabled]):hover,
.btn:not([disabled]):focus {
outline: 3px solid;
outline-offset: 1px;
}
.btn:not([disabled]):active {
background: darkgreen;
color: #fff;
}
Pretty straight forward. I use attribute selectors to help me style the UI, instead of creating additional classes that would, in some cases, need to be toggled by JavaScript depending on the element’s state.
I also make sure to provide :hover
, :focus
, and :active
styles to ensure that users get the appropriate visual feedback as they interact with the UI.
But now that I have the markup and presentational layer out of the way, it’s time to dive into the JavaScript…
(function ( doc ) {
'use strict';
// Use a more terse method for getting by id
function getById ( id_string ) {
return doc.getElementById(id_string);
}
function insertAfter( newEl, refEl ) {
refEl.parentNode.insertBefore(newEl, refEl.nextSibling);
}
var editElement = getById('myContent');
var undoBtn = getById('undo');
var saveBtn = getById('save');
var originalContent = editElement.innerHTML;
var updatedContent = "";
// if a user has refreshed the page, these declarations
// will make sure everything is back to square one.
undoBtn.disabled = true;
saveBtn.disabled = true;
// create a redo button
var redoBtn = doc.createElement('button');
var redoLabel = doc.createTextNode('Redo');
redoBtn.id = 'redo';
redoBtn.className = 'btn';
redoBtn.hidden = true;
redoBtn.appendChild(redoLabel);
insertAfter( redoBtn, undo );
// if the content has been changed, enable the save button
editElement.addEventListener('keypress', function () {
if ( editElement.innerHTML !== originalContent ) {
saveBtn.disabled = false;
}
});
// on button click, save the updated content
// to the updatedContent var
saveBtn.addEventListener('click', function () {
// updates the myContent block to 'save'
// the new content to updatedContent var
updatedContent = getById('myContent').innerHTML;
if ( updatedContent !== originalContent ) {
// Show the undo button in the case that you
// didn't like what you wrote and you want to
// go back to square one
undoBtn.disabled = false;
}
});
// If you click the undo button,
// revert the innerHTML of the contenteditable area to
// the original statement that was there.
//
// Then add in a 'redo' button, to bring back the edited content
undoBtn.addEventListener('click', function() {
editElement.innerHTML = originalContent;
undoBtn.disabled = true;
redoBtn.hidden = false;
});
redoBtn.addEventListener('click', function() {
editElement.innerHTML = updatedContent;
this.hidden = true;
undoBtn.disabled = false;
undoBtn.focus();
});
})( document );
Breaking down the JavaScript
Now, let’s look at each piece of the script and talk about what’s going on.
The first thing of note is that I’ve wrapped my code in a Immediately Invoked Function Expression (IIFE).
<script>
(function( doc ){
// Pass in 'document' as 'doc' to trim down on instances of having
// to write the word "document" over and over again...
...
})( document );
</script>
In JavaScript, all variables are scoped at the function level. Without scoping variables to a particular function (wrapping them within an IIFE) the variables instead get scoped to the global level.
This is not necessarily a problem if your goal is to create global variables. However, depending on the scale of your project, you may end up with many different functions that could potentially have variables with the same name.
Reusing variable names is fine. It happens a lot.
The problem though is that without scoping variables to a particular function, they will be overwritten if you use that same variable name within a different function.
For Example:
<button id="bananafication">Click me</button>
<script>
// Here's a global variable of 'b'.
// Globally, I do not want b to be a banana
var b = "this is not a banana";
console.log(b);
// if you look at the console, you'll see that b is set to
// "this is not a banana"
// but in this instance, I am setting var 'b' to be a banana
// my motivation for this does not need to be explained here
// perhaps it'd be best to read the product spec?
document.getElementById('bananafication').addEventListener('click', function() {
var b = "banana!"
console.log(b);
});
// Now the global variable b has been overwritten to be 'banana!'
// This may not be what we wanted to have happen?
</script>
Enough about that…
Helper functions
To reduce the need for writing redundant functions where everything is the same except for a value or two, small helper functions can be created.
The function getByID
accepts the id
of an element as a string, and the function locates and returns the DOM element. So instead of having to write document.getElementByID('my_id')
, it’s just getById('my_id')
.
Next, the function insertAfter()
will be used to insert the redo button into the DOM. The insertAfter()
function accepts a string for the new element that needs to be inserted, and the refEl
(reference element) that the new element will be inserted after.
Helper functions like these are great to reduce the redundancy of common tasks, but for someone that is still learning JavaScript, it might be better to just continue to write things out the long way. You know, so it sticks and you aren’t abstracting away what it is you’re trying to learn.
Declaring variables
Using the getById
function, I can find elements in the DOM to manipulate. However, instead of using processing power to re-find elements over and over again, I can use variables to store those elements for repeated reference, along with other information that I want to keep track of.
var editElement = getById('myContent');
var undoBtn = getById('undo');
var saveBtn = getById('save');
var originalContent = editElement.innerHTML;
var updatedContent = "";
And here is what each variable is for:
editElement
is the element that is editable.originalContent
stores the original content of the editable region.redoBtn
andredoLabel
are used in creating the redo button after undo is pressed.saveBtn
the button to save the most recent changes.undoBtn
keeps track of the Undobutton
.updatedContent
is a placeholder variable that will contain updated content on save.
Disabling the buttons
In the unchanged state, there is nothing to save or undo, so I use the following lines to add the disabled
attribute to the save and undo buttons.
undoBtn.disabled = true;
saveBtn.disabled = true;
The disabled buttons will indicate that these controls are currently not functional. Ideally, there would be other text on the page to indicate to people that they must perform a specific action to enable these controls, as some people might find it confusing to have elements on the page that they can’t interact with.
Creating an element
While I could have manually added the redo button to the DOM, I had wanted to take a stab at generating elements with JavaScript. The following lines create my redo button and its text label.
var redoBtn = doc.createElement('button');
var redoLabel = doc.createTextNode('Redo');
Next, I add the necessary attributes to the button. Giving it an id
, class
, and setting it to be hidden
by default.
redoBtn.id = 'redo';
redoBtn.className = 'btn';
redoBtn.hidden = true;
Finally, I add the redoLabel
as a child of redoBtn
and use my insertAfter
helper function to place it after the undo button in the DOM.
redoBtn.appendChild(redoLabel);
insertAfter( redoBtn, undoBtn );
Adding Event Listeners and ifs
Now that all the elements have been setup, it’s time to make these buttons do things.
First, these buttons will continue to be useless if they remain disabled, so the script listens for if someone performs a keypress
while focused within the contenteditable
element.
editElement.addEventListener('keypress', function () {
if ( editElement.innerHTML !== originalContent ) {
saveBtn.disabled = false;
}
});
Using an if
statement, the script looks to see if the innerHTML
of the editable element is still the same the originalContent
. If so, nothing happens, but if the values no longer match, the disabled
attribute is removed from the save button.
In the event there’s something to save, the save button needs to function as well!
saveBtn.addEventListener('click', function () {
// updates the myContent block to 'save'
// the new content to updatedContent var
updatedContent = editElement.innerHTML;
if ( updatedContent !== originalContent ) {
// Enable the undo button in the case that you
// didn't like what you wrote and you want to
// go back to square one
undoBtn.disabled = false;
}
});
An event listener is being added to the button
with the id of save. When that button
is clicked, or is activated by use of the Space or Enter keys, for keyboard users, the anonymous function is run which saves the inner HTML of myContent (referenced as the editElment
variable) to the placeholder variable updatedContent
.
The second part of the code, checks if
the updated content is actually different than the original. If not, there’s nothing to undo so the undo button
is kept disabled. But if the content has changed, then it looks for the element with the ID of undo
and removes the disabled
attribute.
Finally, in the event the undo button was pressed but then the changed content was needed again, the following removes the hidden
attribute from the redo button, and disables the undo button again…cause you can’t undo things twice.
undoBtn.addEventListener('click', function() {
editElement.innerHTML = originalContent;
undoBtn.disabled = true;
redoBtn.hidden = false;
});
Activating the undoBtn
changes the innerHTML
of the editable element to the content that was stored in originalContent
. Since it can’t be undone again, the undo button is reset to disabled
, and the redo button is made visible by removing the hidden
attribute.
redoBtn.addEventListener('click', function() {
editElement.innerHTML = updatedContent;
this.hidden = true;
undoBtn.disabled = false;
undoBtn.focus();
});
If the redo button is activated, the editable element is reset to the innerHTML
that was stored in updatedContent
. The redo button gets the hidden
attribute again and the undo button is re-enabled. Since the redo button is hidden to all users (both visually hidden, and hidden to the accessibility API), the line undoBtn.focus()
moves keyboard focus to the undo button, ensuring that a user’s keyboard focus isn’t “lost” when the redo button no longer appears in the DOM.
So that’s it!
Check out a working demo here.
Now obviously there is no real “saving” here beyond the single browser session. That would require going into more detail than I am presently ready to talk about. Maybe a topic for another day.
Thanks and updates
A quick shout out to @spmurrayzzz, @wwnjp and @kevincennis who all helped me out while learning / writing about this exercise. Thank you.
This post was originally written back in 2014, but I’ve learned quite a bit since then, about both JavaScript and accessibility. I’ve revised quite a bit of this post to provide some more accurate descriptions of what I was doing here, as well as to modify the code a bit to make it a tad more accessible of a end-user experience. I have more thoughts on the accessibility of contenteditable
elements, but that will also have to be a topic for another day…