Dealing with the caret jump in contenteditable blocks in Vue

The background

When it comes to contenteditable, it's a real pain –– even more so if you try to create a text editor with it (unpleasant memories unveiling...), such as handling line breaks, empty divs and poor browser compatibiliy of contenteditable with value.

The list is growing. Recently I stumbled upon an issue while rebuilding the editable card with contenteditable for Saiku in Vue. I tried to use v-model to two-way bind a contenteditable block's text content, with the following pseudo code:

https://gist.github.com/jessuni/8a848bff21ab3137b00c43b1e82bb340

content is limited to plain text in my scenario. I excluded the part where I sanitized user input in the snippet as it's not relevant to the topic, so for now let's just assume what we're dealing with here only involves plain text.

Normally we should've listened to the change event and updated content accordingly, but contenteditable blocks don't even fire this event in the first place.

The code works as expected in Chrome. But in Safari and Firefox, the caret position always jumps to the beginning while typing in the editable block.

Behavior in Chrome

Behavior in Safari

The problem

After some research I found the root cause:

because content is reactive, Vue re-renders the editable div every time the input event is fired;

in Safari and Firefox, the caret position will default to the very start when the node is re-rendered.

Now we know what's under the hood, we could try to fix it.

The fix

Since altering the browser behavior is out of the question, I could think of two ways: either mimicking the change event, or de-couple the re-rendering process from reactive value change.

Method 1: what's the equivalent of onchange?

According to W3C's definition1, the condition to fire a change event requires:

Using the keyboard or an assistive technology that emulates the keyboard, move focus to the input control and alter the input control's value.

Using the keyboard or an assistive technology that emulates the keyboard, move focus off the input control to trigger the onChange event.

So if we know there's any text content change happening between the focus shift, it qualifies as a change event. To implement this, we need to monitor the blur event and check if anything has changed.

https://gist.github.com/jessuni/f6dbbacb6e3f42352475979d951ed6fc

Not sure if this fix covers every edge case and it needs further testing.

Method 2: Decoupling

We only want the node to re-render when the input stops. Though I'm not terribly familiar with React, I know there's a shouldComponentUpdate that determines if any re-rendering is necessary. Vue doesn't have such a hook in its lifecycle, but it does provide v-once directive to apply on the node that you want to skip subsequent re-renders. Additionally, we need to manually mutate the editable block's content (in this case, text) when a user stops typing.

https://gist.github.com/jessuni/33bfd7eb5433e2979af288c5514fea8c

The fix in fiddle:

https://jsfiddle.net/jessuni/o0rej3ks

“ONCHANGE Attribute for Input Elements.” HTML Test Suite for UAAG 1.0 (Draft), https://www.w3.org/WAI/UA/TS/html401/cp0102/0102-ONCHANGE-INPUT.html.↩