5

I want to position a pop-up element directly below the text caret/blinking text cursor in a textarea or content editable element.

I've found answers that give me the text index within an element where the caret exists, but as far as I can tell this is useless to me. (like this)

I need something similar to the event.pageX and pageY properties available with mouse-events, but I want that same thing for the position of my text editor caret. Anyone know how to do this?

Ethan
  • 787
  • 1
  • 8
  • 28

2 Answers2

2

You can't get the exact coordinates of the text caret effortlessly, but this snippet will give you a rect object with the approximate coordinates of the window's current selection. You may have to also add/subtract use additional offsets of containing elements depending on your styling.

const selection = window.getSelection();
const range = selection.getRangeAt(0);
rect = range.getBoundingClientRect();

Note: This won't work in input or textarea in the browsers I'm familiar with

Slbox
  • 10,957
  • 15
  • 54
  • 106
  • 1
    Wow, actually this looks like it's exactly what I was after. I am now embarrassed about the direction I was going trying to get this same type of output. I think there might be some stuff I have to adjust/account for as you mention but this is great! – Ethan Sep 06 '20 at 23:56
  • 2
    This doesn't work anymore all the values in the rect return 0 for me. – Tenpi Apr 28 '22 at 14:35
  • No, it works still - we still use this code in Chrome 98. It may not work in your particular environment though. – Slbox Apr 28 '22 at 18:24
  • 1
    I also find that this gives all 0 now. – Gunther Schadow Dec 21 '22 at 10:19
  • @GuntherSchadow what browser/version? – Slbox Dec 21 '22 at 20:50
  • 2
    Regardless of browser version (latest Chrome, FF) it works when selecting any DOM text, but precisely in input and textarea values it does not work. – Gunther Schadow Dec 22 '22 at 04:42
  • Oh yes, you're right this won't work in an input. I'll add a note about that. – Slbox Dec 22 '22 at 21:25
2

The accepted answer is wrong. Whether or not that has worked 2 years ago I can't tell. It works for text content, yes, but not for input and textarea values, which was what the question specifically was about.

Just try it out here: select the page text and it works, select input box text and it doesn't.

document.addEventListener('mouseup', function() { console.log(window.getSelection().getRangeAt(0).getBoundingClientRect()) })
<p>Lorem ipsum ... <input value="bla bla"/></p>
<textarea style="width: 100px; height: 50px;">    Lorem ipsum bla bla bla
</textarea>
<p>Input <b>box</b><p>
<input value="Lorem"/>

So what does work? I have found somewhere during frantic searching an approach which seems utterly crazy, but possibly is the only thing that works.

Make a hidden (but not un-rendered) div on which you apply all computed style properties from your input box.

How do you know what the selected input box is? The getSelection() range is meaningless if your have select text in an input box. You must find the input box by document.activeElement (because it has the focus). Now you have that input box with selectionStart and selectionEnd properties. Copy that input's computed style properties onto your hidden div.

Now and put the value text from the input box inside, up until the start of your selected text, e.g., input.value.substring(0, input.selectionStart).

Now append a little after that text, and then measure the position of that span, relative to the div.

Finally add that relative position to the position of your real input box. And voilà you have the position of the cursor!

Crazy, I know. And I will do it now because I know of no other solution.

Here it is:

function showPopUp() {
  let popUpPos = getSelection()
                .getRangeAt(0)
                .getBoundingClientRect();
  if(popUpPos.x == 0 && popUpPos.y == 0) {
    const input = document.activeElement;
    const div = document.createElement('div');
    for(style of input.computedStyleMap())
      div.style[style[0]] = style[1].toString();
    div.textContent = input.value.substring(0, input.selectionStart);
    const span = document.createElement('span');
    div.insertBefore(span, null);
    document.body.insertBefore(div, null);
    const [divPos, spanPos, inputPos] = 
          [div, span, input].map(e => e.getBoundingClientRect());
    popUpPos = { 
      x: inputPos.x + (spanPos.x - divPos.x), 
      y: inputPos.y + (spanPos.y - divPos.y) 
    };
  }
  const popUp = document.body.insertAdjacentHTML('beforeend',
    `<div style="position: absolute; 
                 top: ${popUpPos.y}px; 
                 left: ${popUpPos.x}px; 
                 border: 2px solid green;'">Look!</div>`);
}
document.addEventListener('mouseup', showPopUp);
<p>Lorem ipsum ... <input value="bla bla"/></p>
<textarea style="width: 100px; height: 50px;">    Lorem ipsum bla bla bla
</textarea>
<p>Input <b>box</b><p>
<input value="Lorem"/>

Now as I have done this to meet the needs of a real app, I found a whole slur of little issues that I had to overcome. Here is an (incomplete) list:

  • a default textarea has no width or height style, to copy that, need to actually read the getBoundingClientRect() to size the hidden div
  • scrollbar! need to scroll the hidden div to the same location
  • word wrapping, right before the span, put an unbreakable zero-width space and put the text after the selection in after the span, the span then wraps with the following word as needed.
  • vertical positioning of the pop up under the current line requires you use the bottom of the span, not the y position. (Not sure if left and x are different in some right-to-left writing schemes perhaps?)
Gunther Schadow
  • 1,490
  • 13
  • 22
  • 1
    The accepted answer isn't "wrong." It works in a `contentEditable` as OP requested. – Slbox Dec 22 '22 at 21:47
  • 2
    Yep, this worked 100% fine for what I was using it for back in the day. However, it's great to have another answer that'll expand the usefulness of this question for future folks with the question! Also, I actually ended up switching away from this pretty quickly, since I'm using slate. Slate has an `editor.selection` property which actually worked better for me in the end. Thanks for the great work on another solution!! It's always amazing to see people contributing to things like this when they find an answer doesn't solve their specific problem, even if they don't NEED to contribute. – Ethan Dec 23 '22 at 04:29
  • 1
    @Slbox, no the question was "in a *textarea* *or* content editable element." The answer is wrong as in incomplete. I suspect it has never worked in textarea, and the whole confusion with people commenting even a long time ago is that this issue is not made explicit. I have made it explicit. – Gunther Schadow Dec 23 '22 at 05:57