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:
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.
After some research I found the root cause:
contentis reactive, Vue re-renders the editable
divevery time the
inputevent 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.
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
According to W3C's definition[^change], 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.
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.
The fix in fiddle:
[^change]: “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.