I'm trying to make a dropdown menu follow the cursor in a Rich Text Editor for the web. Using the following I'm able to get the cursor's coordinates no problem:
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const position = sel.getRangeAt(0).getBoundingClientRect());
However, if I try to use this after a \n character it returns the position of the cursor after the newline char rather than the beginning of the new line (where the cursor actually appears in the window):

Is there a way to avoid this?
Edit: Based on the comments below here's a more in depth version of what I'm trying to achieve.
I'm currently building a text editor with React and Slate.js (https://github.com/ianstormtaylor/slate). It's a more robust version of a contentEditable component at its heart but allows you to drop in an editable text field into a page. Because of the node structure I'm using, I want there to be soft breaks between paragraphs rather than new <div /> elements. Because this is non-standard behavior for contentEditable, it is very difficult to make a small example without recreating the whole app.
Edit (further responses to comments): The raw HTML of the text element looks like this:
<span data-slate-string="true">working until newline
see?
</span>
you can see that slate literally translates the break to a \n character which is what I think is causing the problem.
Even when using the default contenteditable of the browser there is indeed a weird behavior when the cursor is set to a new line: the Range's getClientRects() will be empty and thus getBoundingClientRect() will return a full 0 DOMRect.
Here is a simple demo demonstrating the issue:
const target = document.getElementById('target');
document.onselectionchange = (e) => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
const range = sel.getRangeAt(0);
const position = range.getBoundingClientRect();
floater.style.top = position.bottom + 'px';
floater.style.left = position.right + 'px';
}
#floater {
position: absolute;
width: 20px;
height: 30px;
background: #DDAADDCC;
pointer-events: none;
bottom: 0;
}
<div id="target" contenteditable>Type here and enter new lines</div>
<div id="floater"></div>
For this, there is a simple workaround which consists in selecting the contents of the current Range's container:
// check if we have client rects
const rects = range.getClientRects();
if(!rects.length) {
// probably new line buggy behavior
if(range.startContainer && range.collapsed) {
// explicitely select the contents
range.selectNodeContents(range.startContainer);
}
}
const target = document.getElementById('target');
document.onselectionchange = (e) => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
const range = sel.getRangeAt(0);
// check if we have client rects
const rects = range.getClientRects();
if(!rects.length) {
// probably new line buggy behavior
if(range.startContainer && range.collapsed) {
// explicitely select the contents
range.selectNodeContents(range.startContainer);
}
}
const position = range.getBoundingClientRect();
floater.style.top = position.bottom + 'px';
floater.style.left = position.right + 'px';
}
#floater {
position: absolute;
width: 20px;
height: 30px;
background: #DDAADDCC;
pointer-events: none;
bottom: 0;
}
<div id="target" contenteditable>Type here and enter new lines</div>
<div id="floater"></div>
Now OP seems to be in a different issue, since they do deal with soft-breaks \n and a white-space: pre.
However I was able to reproduce it only from my Firefox., Chrome behaving "as expected" in this case...
So in my Firefox, the DOMRect will not be all 0, but it will be the one before the line break.
To demonstrate this case, click on the empty line:
const target = document.getElementById('target');
document.onselectionchange = (e) => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
const range = sel.getRangeAt(0);
const position = range.getBoundingClientRect();
floater.style.top = position.bottom + 'px';
floater.style.left = position.right + 'px';
}
#target {
white-space: pre;
}
#floater {
position: absolute;
width: 20px;
height: 30px;
background: #DDAADDCC;
pointer-events: none;
bottom: 0;
}
<div id="target" contenteditable>Click on the below empty line
Click on the above empty line</div>
<div id="floater"></div>
And to workaround this case, it's a bit more complex...
We need to check what is the character before our Range, if it's a new line, then we need to update our range by selecting the next character. But doing so, we'd also move the cursor, so we actually need to do it from a cloned Range. But since Chrome doesn't behave like this, we need to also check if the previous character was on a different line, which becomes a problem when there is no such previous character...
const target = document.getElementById('target');
document.onselectionchange = (e) => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) {
return;
}
const range = sel.getRangeAt(0);
// we can still workaround the default behavior too
const rects = range.getClientRects();
if(!rects.length) {
if(range.startContainer && range.collapsed) {
range.selectNodeContents(range.startContainer);
}
}
let position = range.getBoundingClientRect();
const char_before = range.startContainer.textContent[range.startOffset - 1];
// if we are on a \n
if(range.collapsed && char_before === "\n") {
// create a clone of our Range so we don't mess with the visible one
const clone = range.cloneRange();
// check if we are experiencing a bug
clone.setStart(range.startContainer, range.startOffset-1);
if(clone.getBoundingClientRect().top === position.top) {
// make it select the next character
clone.setStart(range.startContainer, range.startOffset + 1 );
position = clone.getBoundingClientRect();
}
}
floater.style.top = position.bottom + 'px';
floater.style.left = position.right + 'px';
}
#target {
white-space: pre;
}
#floater {
position: absolute;
width: 20px;
height: 30px;
background: #DDAADDCC;
pointer-events: none;
bottom: 0;
}
<div id="target" contenteditable>Click on the below empty line
Click on the above empty line</div>
<div id="floater"></div>
This works:
Insert a "zero width space" in the range and again call getBoundingClientRect.
Then remove the the space.
function rangeRect(r){
let rect = r.getBoundingClientRect();
if (r.collapsed && rect.top===0 && rect.left===0) {
let tmpNode = document.createTextNode('\ufeff');
r.insertNode(tmpNode);
rect = r.getBoundingClientRect();
tmpNode.remove();
}
return rect;
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With