Retain Cursor Position in Contenteditable

Maintain cursor position in contenteditable div

If the text the user sees remains the same (which your question seems to imply is the case), you could use a character offset-based solution. I've posted some code for that elsewhere on Stack Overflow:

https://stackoverflow.com/a/13950376/96100

I also answered a related question quite recently:

jQuery: Convert text URL to link as typing

HTML contenteditable: Keep Caret Position When Inner HTML Changes

You need to get position of the cursor first then process and set the content. Then restore the cursor position.

Restoring cursor position is a tricky part when there are nested elements. Also you are creating new <strong> and <em> elements every time, old ones are being discarded.

const editor = document.querySelector(".editor");
editor.innerHTML = parse(
"For **bold** two stars.\nFor *italic* one star. Some more **bold**."
);

editor.addEventListener("input", () => {
//get current cursor position
const sel = window.getSelection();
const node = sel.focusNode;
const offset = sel.focusOffset;
const pos = getCursorPosition(editor, node, offset, { pos: 0, done: false });
if (offset === 0) pos.pos += 0.5;

editor.innerHTML = parse(editor.innerText);

// restore the position
sel.removeAllRanges();
const range = setCursorPosition(editor, document.createRange(), {
pos: pos.pos,
done: false,
});
range.collapse(true);
sel.addRange(range);
});

function parse(text) {
//use (.*?) lazy quantifiers to match content inside
return (
text
.replace(/\*{2}(.*?)\*{2}/gm, "**<strong>$1</strong>**") // bold
.replace(/(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)/gm, "*<em>$1</em>*") // italic
// handle special characters
.replace(/\n/gm, "<br>")
.replace(/\t/gm, " ")
);
}

// get the cursor position from .editor start
function getCursorPosition(parent, node, offset, stat) {
if (stat.done) return stat;

let currentNode = null;
if (parent.childNodes.length == 0) {
stat.pos += parent.textContent.length;
} else {
for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
currentNode = parent.childNodes[i];
if (currentNode === node) {
stat.pos += offset;
stat.done = true;
return stat;
} else getCursorPosition(currentNode, node, offset, stat);
}
}
return stat;
}

//find the child node and relative position and set it on range
function setCursorPosition(parent, range, stat) {
if (stat.done) return range;

if (parent.childNodes.length == 0) {
if (parent.textContent.length >= stat.pos) {
range.setStart(parent, stat.pos);
stat.done = true;
} else {
stat.pos = stat.pos - parent.textContent.length;
}
} else {
for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
currentNode = parent.childNodes[i];
setCursorPosition(currentNode, range, stat);
}
}
return range;
}
.editor {
height: 100px;
width: 400px;
border: 1px solid #888;
padding: 0.5rem;
white-space: pre;
}

em, strong{
font-size: 1.3rem;
}
<div class="editor" contenteditable ></div>

Restore cursor position after changing contenteditable

I found the solution.

Here is a complete code:

<div class="container" style="margin-top: 10px">

<div class="thumbnail value" contenteditable="true">

</div>

</div>

<script>
$(document).ready(function () {
function getCaretCharacterOffsetWithin(element) {
var caretOffset = 0;
var doc = element.ownerDocument || element.document;
var win = doc.defaultView || doc.parentWindow;
var sel;
if (typeof win.getSelection != "undefined") {
sel = win.getSelection();
if (sel.rangeCount > 0) {
var range = win.getSelection().getRangeAt(0);
var preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
caretOffset = preCaretRange.toString().length;
}
} else if ((sel = doc.selection) && sel.type != "Control") {
var textRange = sel.createRange();
var preCaretTextRange = doc.body.createTextRange();
preCaretTextRange.moveToElementText(element);
preCaretTextRange.setEndPoint("EndToEnd", textRange);
caretOffset = preCaretTextRange.text.length;
}
return caretOffset;
}

function setCaretPosition(element, offset) {
var range = document.createRange();
var sel = window.getSelection();

//select appropriate node
var currentNode = null;
var previousNode = null;

for (var i = 0; i < element.childNodes.length; i++) {
//save previous node
previousNode = currentNode;

//get current node
currentNode = element.childNodes[i];
//if we get span or something else then we should get child node
while(currentNode.childNodes.length > 0){
currentNode = currentNode.childNodes[0];
}

//calc offset in current node
if (previousNode != null) {
offset -= previousNode.length;
}
//check whether current node has enough length
if (offset <= currentNode.length) {
break;
}
}
//move caret to specified offset
if (currentNode != null) {
range.setStart(currentNode, offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}

function onInput(event) {
var position = getCaretCharacterOffsetWithin(input.get(0));
var text = input.text();
text = text.replace(new RegExp('\\btest\\b', 'ig'), '<span style="background-color: yellow">test</span>');
input.html($.parseHTML(text));
setCaretPosition(input.get(0), position);
}

var input = $('.value').on('input',onInput);

//content should be updated manually to prevent aditional spaces
input.html('simple input test example');
//trigger event
onInput();
});
</script>

How to set the caret (cursor) position in a contenteditable element (div)?

In most browsers, you need the Range and Selection objects. You specify each of the selection boundaries as a node and an offset within that node. For example, to set the caret to the fifth character of the second line of text, you'd do the following:

function setCaret() {
var el = document.getElementById("editable")
var range = document.createRange()
var sel = window.getSelection()

range.setStart(el.childNodes[2], 5)
range.collapse(true)

sel.removeAllRanges()
sel.addRange(range)
}
<div id="editable" contenteditable="true">
text text text<br>text text text<br>text text text<br>
</div>

<button id="button" onclick="setCaret()">focus</button>

React: How to maintain caret position when editing contentEditable div?

I was able to get this working following solution in https://stackoverflow.com/a/13950376/1730260

Key changes:

  1. Add new component EditCaretPositioning.js with 2 functions: (1) saveSelection to save caret position, and (2) restoreSelection to restore caret position.
  2. Save the caret position in the state of Input component
  3. Call saveSelection() after every Change event
  4. restoreSelection() as a callback after setting the state
  5. Added id to <div> so can reference in restoreSelection() function

EditCaretPositioning.js

const EditCaretPositioning = {}

export default EditCaretPositioning;

if (window.getSelection && document.createRange) {
//saves caret position(s)
EditCaretPositioning.saveSelection = function(containerEl) {
var range = window.getSelection().getRangeAt(0);
var preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(containerEl);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
var start = preSelectionRange.toString().length;

return {
start: start,
end: start + range.toString().length
}
};
//restores caret position(s)
EditCaretPositioning.restoreSelection = function(containerEl, savedSel) {
var charIndex = 0, range = document.createRange();
range.setStart(containerEl, 0);
range.collapse(true);
var nodeStack = [containerEl], node, foundStart = false, stop = false;

while (!stop && (node = nodeStack.pop())) {
if (node.nodeType === 3) {
var nextCharIndex = charIndex + node.length;
if (!foundStart && savedSel.start >= charIndex && savedSel.start <= nextCharIndex) {
range.setStart(node, savedSel.start - charIndex);
foundStart = true;
}
if (foundStart && savedSel.end >= charIndex && savedSel.end <= nextCharIndex) {
range.setEnd(node, savedSel.end - charIndex);
stop = true;
}
charIndex = nextCharIndex;
} else {
var i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}

var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}

} else if (document.selection && document.body.createTextRange) {
//saves caret position(s)
EditCaretPositioning.saveSelection = function(containerEl) {
var selectedTextRange = document.selection.createRange();
var preSelectionTextRange = document.body.createTextRange();
preSelectionTextRange.moveToElementText(containerEl);
preSelectionTextRange.setEndPoint("EndToStart", selectedTextRange);
var start = preSelectionTextRange.text.length;

return {
start: start,
end: start + selectedTextRange.text.length
}
};
//restores caret position(s)
EditCaretPositioning.restoreSelection = function(containerEl, savedSel) {
var textRange = document.body.createTextRange();
textRange.moveToElementText(containerEl);
textRange.collapse(true);
textRange.moveEnd("character", savedSel.end);
textRange.moveStart("character", savedSel.start);
textRange.select();
};

}

Updated contentEditable div component:

import CaretPositioning from 'EditCaretPositioning'

class Input extends Component {

constructor(props) {
super(props);
this.state = {
//newValue input by user
newValue : undefined,
//stores positions(s) of caret to handle reload after onChange end
caretPosition : {
start : 0,
end : 0
}
}
}

//handler during key press / input
onChangeHandler = event => {
let targetValue = event.currentTarget.textContent;
//save caret position(s), so can restore when component reloads
let savedCaretPosition = CaretPositioning.saveSelection(event.currentTarget);
this.setState({
"newValue": targetValue,
"caretPosition" : savedCaretPosition
}, () => {
//restore caret position(s)
CaretPositioning.restoreSelection(document.getElementById("editable"), this.state.caretPosition);
})
}

//handler when user opens input form
onBlurHandler = event => {
//some code that sends the "newValue" to be saved, and resets state
}

render() {
//determine which value to show in the div
let showValue;
//if there is a new value being input by user, show this value
if (this.state.newValue !== undefined) {
showValue = this.state.newValue;
} else {
//if prop has no value e.g. null or undefined, use "" placeholder
if (this.props.value) {
showValue = this.props.value;
} else {
showValue = "";
}
}

return (
<table>
<tbody>
<td>
<div
id="editable"
contentEditable="true"
suppressContentEditableWarning="true"
onInput={this.onChangeHandler.bind(this)}
onBlur={this.onBlurHandler}
>{showValue}
</div>
</td>
</tbody>
</table>
)
}
}

export default Input;


Related Topics



Leave a reply



Submit