Using contenteditable in Ember

Last reviewed on November 14, 2022

This post covers how I used the contenteditable attribute in Ember and worked around a jumping cursor issue.

My First Implementation with a Jumping Cursor

I started with an implementation that adhered to Data Down, Actions Up (DDAU):

app/templates/application.hbs
<div contenteditable="true" {{on "input" this.setContent}}>
  {{this.content}}
</div>
app/controllers/application.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ApplicationController extends Controller {
  @tracked content = 'foo';

  @action
  setContent(event) {
    this.content = event.target.textContent;
  }
}

It worked, but when I typed into the contenteditable div, the cursor jumped to the beginning of the element after each keystroke, which is a terrible user experience.

Jumping cursor in contenteditable div

The Solution

To fix this, I created a modifier called contenteditable:

ember install ember-modifier
ember g modifier contenteditable

Here is how the contenteditable modifier can be used:

app/templates/application.hbs
<div {{contenteditable textContent=this.content onInput=this.setContent}}></div>

{{!-- Rendered this.content just to show that it is updated via onInput --}}
{{this.content}}

And here is the modifier's implementation:

app/modifiers/contenteditable.js
import { modifier } from 'ember-modifier';

export default modifier(function contenteditable(element, positional, named) {
  element.contentEditable = true;

  if (named.textContent !== element.textContent) {
    element.textContent = named.textContent;
  }

  function handleInput(event) {
    named.onInput(event.target.textContent);
  }

  element.addEventListener('input', handleInput);

  return () => {
    element.removeEventListener('input', handleInput);
  };
});

The implementation is pretty straightforward. It does the following:

  • Adds the contenteditable attribute to the element that the modifier was invoked on
  • Adds an event listener for the input event and invokes the onInput argument with the element's textContent
  • Removes the input event listener in the modifier's teardown.

Per the ember-modifier documentation regarding modifiers:

Any tracked values that it accesses will be tracked, including the arguments it receives, and if any of them changes, the function will run again.

So whenever this.content changes, the modifier will run again. When that happens, the cursor jumps to the start of the contenteditable div because the textContent of the div is reassigned.

To get around this, I added the following conditional:

if (named.textContent !== element.textContent) {
  element.textContent = named.textContent;
}

As a user types into the contenteditable div, named.textContent and element.textContent will be the same value, so the element's textContent won't get reassigned which caused the jumping cursor issue. The only time these two values might not be the same is on initial render where I specified an initial value of "foo" and the element's initial textContent is an empty string. Even if there isn't an initial value, and hence the values will be the same (an empty string), there won't be a problem as the cursor will just start at the beginning of the element.

Now it works as expected:

No jumping cursor in contenteditable div