I have a contenteditable div which contains typical wysiwyg editor html (bold, anchors, lists).
I need to determine if the current cursor is, onKeyDown, at the start
This is how I ended up solving this. My proposed solution above worked sometimes but there were way to many edge cases, so I ended up considering how much text was before or after the cursor, and if that was 0 characters, then I was at the start or end:
handle_keydown = function(e) {
// Get the current cusor position
range = window.getSelection().getRangeAt(0)
// Create a new range to deal with text before the cursor
pre_range = document.createRange();
// Have this range select the entire contents of the editable div
pre_range.selectNodeContents(this);
// Set the end point of this range to the start point of the cursor
pre_range.setEnd(range.startContainer, range.startOffset);
// Fetch the contents of this range (text before the cursor)
this_text = pre_range.cloneContents();
// If the text's length is 0, we're at the start of the div.
at_start = this_text.textContent.length === 0;
// Rinse and repeat for text after the cursor to determine if we're at the end.
post_range = document.createRange();
post_range.selectNodeContents(this);
post_range.setStart(range.endContainer, range.endOffset);
next_text = post_range.cloneContents();
at_end = next_text.textContent.length === 0;
}
Still not entirely sure there are any other edge cases, as I'm not entirely sure how to unit test this, since it requires mouse interaction - there's probably a library to deal with this somewhere.
Simple solution for checking if cursor/caret is at the end of an input:
this.$('input').addEventListener('keydown', (e) => {
if (e.target.selectionEnd == e.target.value.length) {
// DO SOMETHING
}
})
I would use a similar approach to yours except using the toString()
method of Range
objects rather than cloneContents()
to avoid unnecessary cloning. Also, in IE < 9 (which doesn't support ranges), you can use a similar approach with the text
property of TextRange
.
Note that this will have issues when there are leading and/or trailing line breaks in the content because the toString()
method of a range works just like the textContent
property of a node and only considers text nodes, therefore not taking into account line breaks implied by <br>
or block elements. Also CSS is not taken into account: for example, text inside elements that are hidden via display: none
is included.
Here's an example:
Live demo: http://jsfiddle.net/YA3Pu/1/
Code:
function getSelectionTextInfo(el) {
var atStart = false, atEnd = false;
var selRange, testRange;
if (window.getSelection) {
var sel = window.getSelection();
if (sel.rangeCount) {
selRange = sel.getRangeAt(0);
testRange = selRange.cloneRange();
testRange.selectNodeContents(el);
testRange.setEnd(selRange.startContainer, selRange.startOffset);
atStart = (testRange.toString() == "");
testRange.selectNodeContents(el);
testRange.setStart(selRange.endContainer, selRange.endOffset);
atEnd = (testRange.toString() == "");
}
} else if (document.selection && document.selection.type != "Control") {
selRange = document.selection.createRange();
testRange = selRange.duplicate();
testRange.moveToElementText(el);
testRange.setEndPoint("EndToStart", selRange);
atStart = (testRange.text == "");
testRange.moveToElementText(el);
testRange.setEndPoint("StartToEnd", selRange);
atEnd = (testRange.text == "");
}
return { atStart: atStart, atEnd: atEnd };
}
I figured out this pretty consistent and short method:
function isAtTextEnd() {
var sel = window.getSelection(),
offset = sel.focusOffset;
sel.modify ("move","forward","character");
if (offset == sel.focusOffset) return true;
else {
sel.modify ("move","backward","character");
return false;
}
}
The key: try to force move it one character forward - if it actually moved:
not at the end (move it one character back), if it didn't - it's at the end (no need to move back, it didn't move).
Implementing for start of text is the opposite, and is "left as an exercise for the reader"...
Cavities:
MDN marks modify as "Non-standard", though the compatibility
table shows a pretty wide support (tested to work on the latest Chrome and Firefox, according to the table - not supported in Edge).
I tried using the more supported extend()
for it - however, it seems that, weirdly, the extend does work even when at the end of text.
If you check if after a user initiates a move of the caret (e.g. in a keyboard or mouse event handler) - you should handle cases where the check forces the caret to move in an unexpected way.
I've had the same issue today with no clean solution, so I developed the following approach. It uses just Selection
- no Range
or vendor-specific features. It also takes newlines at the start and end of the content into account.
It works in current Chrome, Firefox, Safari and Opera.
Microsoft Edge again is the outlier since text selection itself is partially broken in contenteditable
div
s when there are newlines at the beginning or end of the content. Unfortunately I haven't found a workaround for that issue yet.
It's also worth noting that the logic is different not just between browsers but also between white-space
modes (normal
vs. pre*
) because the browser will generate different nodes for each while typing.
document.addEventListener("selectionchange", function() {
updateCaretInfo(document.getElementById('input-normal'))
updateCaretInfo(document.getElementById('input-pre'))
});
function updateCaretInfo(input) {
function isAcceptableNode(node, side) {
if (node === input) { return true }
const childProperty = side === 'start' ? 'firstChild' : 'lastChild'
while (node && node.parentNode && node.parentNode[childProperty] === node) {
if (node.parentNode === input) {
return true
}
node = node.parentNode
}
return false
}
function isAcceptableOffset(offset, node, side) {
if (side === 'start') {
return offset === 0
}
if (node.nodeType === Node.TEXT_NODE) {
return offset >= node.textContent.replace(/\n$/, '').length
}
else {
return offset >= node.childNodes.length - 1
}
}
function isAcceptableSelection(selection, side) {
return selection &&
selection.isCollapsed &&
isAcceptableNode(selection.anchorNode, side) &&
isAcceptableOffset(selection.anchorOffset, selection.anchorNode, side)
}
const selection = document.getSelection()
const isAtStart = isAcceptableSelection(selection, 'start')
const isAtEnd = isAcceptableSelection(selection, 'end')
document.getElementById('start-' + input.id).innerText = isAtStart ? 'YES' : 'no'
document.getElementById('end-' + input.id).innerText = isAtEnd ? 'YES' : 'no'
}
body {
padding: 10px;
}
[id^="input-"] {
border: 1px solid black;
display: inline-block;
margin-bottom: 10px;
padding: 5px;
}
<div contenteditable id="input-normal">Move the caret inside here!</div>
(<code>white-space: normal</code>)
<p>
Caret at start: <span id="start-input-normal">no</span><br>
Caret at end: <span id="end-input-normal">no</span>
</p>
<hr>
<div contenteditable id="input-pre" style="white-space: pre-wrap">Move the caret inside here!</div>
(<code>white-space: pre-wrap</code>)
<p>
Caret at start: <span id="start-input-pre">no</span><br>
Caret at end: <span id="end-input-pre">no</span>
</p>