Friday, 27 January, 2017 UTC


Summary

Today, the easiest way to create a rich text editor on the web is to add the contenteditable attribute to an element. This allows users to insert, delete and style web content and works great for many uses of editing on the web. However, some web-based rich text editors, such as iCloud Pages or Google Docs, employ JavaScript-based implementations of rich text editing by capturing key events using a hidden contenteditable element and then using the information in these captured events to update the DOM. This gives more control over the editing experience across browsers and platforms.
However, such an approach comes with a weakness — capturing key events only covers a subset of text editing actions. Examples of this include the bold/italic/underline buttons on iOS, the context menu on macOS, and the editing controls shown in the Touch Bar in Safari. While some of these editing actions dispatch input events, these input events do not convey any notion of what the user is trying to accomplish — they only indicate that some editable content has been altered, which is not enough information for a JavaScript-based editor to respond appropriately.
Furthermore, you may need not only to know when a user has performed some editing action, but also to replace the default behavior resulting from this editing action with custom behavior. For instance, you could imagine such functionality being useful for an editable area that only inserts pasted or dropped content as plaintext rather than HTML. Existing input events do not suffice for this purpose, since they are dispatched after the editing action has been carried out, and are therefore non-preventable. Let’s see how input events can address these issues.
Revisiting Input Events
The latest Input Events specification introduces beforeinput events, which are dispatched before any change resulting from the editing action has taken place. These events are cancelable by calling preventDefault() on the event, which also prevents the subsequent input event from being dispatched. Additionally, each input and beforeinput event now contains information relevant to the editing action being performed. Here is an overview of the attributes added to input events:
  • InputEvent.inputType describes the type of editing action being performed. A full list of input types are enumerated in the official spec, linked above. The names of input type are also share prefixes — for instance, all input types that cause text to be inserted begin with the string "insert". Some examples of input types are insertReplacementText, deleteByCut, and formatBold.
  • InputEvent.data contains plaintext data to be inserted in the case of insert* input types, and style information in the case of format* input types. However, if the content being inserted contains rich text, this attribute will be null, and the dataTransfer attribute will be used instead.
  • InputEvent.dataTransfer contains both rich and plain text data to be inserted in a contenteditable area. The rich text data is retrieved as an HTML string using dataTransfer.getData("text/html"), while the plain text representation is retrieved using dataTransfer.getData("text/plain").
  • InputEvent.getTargetRanges is a method that returns a list of ranges that will be affected by editing. For example, when spellchecking or autocorrect replaces typed text with replacement text, the target ranges of the beforeinput event indicate the existing ranges of text that are about to be replaced. It is important to note that each range in this list is a type of StaticRange, as opposed to a normal Range; while a StaticRange is similar to a normal Range in that it has start and end containers and start and end offsets, it does not automatically update as the DOM is modified.
Let’s see how this all comes together in a simple example.
Formatting-only Regions Example
Suppose we’re creating a simple editable area where a user can compose a response to an email or comment. Let’s say we want to restrict editing within certain parts of the message that represent quotes from an earlier response — while we allow the user to change the style of text within a quote, we will not allow the user to edit the text content of the quote. Consider the HTML below:
HTML
<body onload="setup()">
    <div id="editor" contenteditable>
        <p>This is some regular content.</p>
        <p>This text is fully editable.</p>
        <div class="quote" style="background-color: #EFFEFE;">
            <p>This is some quoted content.</p>
            <p>You can only change the format of this text.</p>
        </div>
        <p>This is some more regular content.</p>
        <p>This text is also fully editable.</p>
    </div>
</body>
This gives us the basic ability to edit the contents of our message, which contains a quoted region highlighted in blue. Our goal is to prevent the user from performing editing actions that modify the text content of this quoted region. To accomplish this, we first attach a beforeinput event handler to our editable element. In this handler, we call event.preventDefault() if the input event is not a formatting change (i.e. its inputType does not begin with 'format') and it might modify the contents of the quoted region, which we can tell by inspecting the target ranges of the event. If any of the affected ranges starts or ends within the quoted region, we immediately prevent editing and bail from the handler.
JavaScript
function setup() {
    editor.addEventListener("beforeinput", event => {
        if (event.inputType.match(/^format/))
            return;

        for (let staticRange of event.getTargetRanges()) {
            if (nodeIsInsideQuote(staticRange.startContainer)
                || nodeIsInsideQuote(staticRange.endContainer)) {
                event.preventDefault();
                return;
            }
        }
    });

    function nodeIsInsideQuote(node) {
        let currentElement = node.nodeType == Node.ELEMENT_NODE ? node : node.parentElement;
        while (currentElement) {
            if (currentElement.classList.contains("quote"))
                return true;
            currentElement = currentElement.parentElement;
        }
        return false;
    }
}
After adding the script, attempts to insert or delete text from the quoted region no longer result in any changes, but the format of the text can still be changed. For instance, users can bold text by right clicking selected text in the quote and then choosing Font ▸ Bold, or by tapping the Bold button in the Touch Bar in Safari. You can check out the final result in an Input Events demo.
Additional Work
Input events are crucial if you want to build a great text editor, but they don’t yet solve every problem. We believe they could be enhanced to give web developers control over more native editing behaviors on macOS and iOS. For instance, it would be useful for an editable element to specify the set of input types that it supports, so that (1) input events of an unsupported input type are not dispatched on the element, and (2) the browser will not show enabled editing UI that would dispatch only unsupported input types.
Another capability is for web pages to provide a custom handler that WebKit can use to determine the style of the current selection. This is particularly useful in the context of the bold/italic/underline controls on both the iOS keyboard and the Touch Bar — these buttons are highlighted if the current selection is already bold, italic or underlined, indicating to the user that interacting with these controls will undo bold, italic or underlined style. If a web page prevents default behavior and renders these text styles via custom means, it would need to inform WebKit of current text style to ensure that platform controls remain in sync with the content.
Input events are enabled by default as of Safari Technology Preview 18, and available in Safari 10.1 in the recent beta releases of macOS 10.12.4 and iOS 10.3. Please give our example a try and experiment with the feature! If you have any questions or comments, please contact me at [email protected], or Jonathan Davis, Apple’s Web Technologies Evangelist, at @jonathandavis or [email protected]