39

For some reason I need to use contenteditable div instead of normal text input for inputting text. (for some javascript library) It works fine until I found that when I set the contenteditable div using display: inline-block, it gives focus to the div even if I click outside the div!

I need it to be giving focus to the div only when the user clicks right onto the div but not around it. Currently, I found that when the user clicks elsewhere and then click at position that is the same row as the div, it gives focus to it.

A simple example to show the problem:

HTML:

<div class="outside">
    <div class="text-input" contenteditable="true">
        Input 1
    </div>
    <div class="text-input" contenteditable="true">
        Input 2
    </div>
    <div class="unrelated">This is some unrelated content<br>
      This is some more unrelated content
      This is just some space to shows that clicking here doesn't mess with the contenteditable div
      but clicking the side mess with it.
    </div>
</div>

CSS:

div.outside {
  margin: 30px;
}
div.text-input {
  display:inline-block;
  background-color: black;
  color: white;
  width: 300px;
}

The JSFiddle for displaying the problem

Is there a way (CSS or javascript are both acceptable) to make the browser only give focus to div when it is clicked instead of clicking the same row?

P.S. I noticed that there are similar problem (link to other related post), but the situation is a bit different and the solution provided is not working for me.

Community
  • 1
  • 1
cytsunny
  • 4,838
  • 15
  • 62
  • 129

5 Answers5

39

Explanation (if you don't care, skip to the Workarounds below)

When you click in an editable element, the browser places a cursor (a.k.a. insertion point) in the nearest text node that is within the clicked element, on the same line as your click. The text node may be either directly within the clicked element, or in one of its child elements. You can verify this by running the code snippet below and clicking around in the large blue box.

.container {width: auto; padding: 20px; background: cornflowerblue;}
.container * {margin: 4px; padding: 4px;}
div {width: 50%; background: gold;}
span {background: orange;}
span > span {background: gold;}
span > span > span {background: yellow;}
<div class="container" contenteditable>
  text in an editable element
  <div>
    text in a nested div
  </div>
  <span><span><span>text in a deeply nested span</span></span></span></div>
Notice that you can get an insertion point by clicking above the first line or below the last. This is because the "hitbox" of these lines extends to the top and bottom of the container, respectively. Some of the other answers don't account for this!

The blue box is a <div> with the contenteditable attribute, and the inner orange/yellow boxes are nested child elements. Notice that if you click near (but not in) one of the child elements, the cursor ends up inside it, even though you clicked outside. This is not a bug. Since the element you clicked on (the blue box) is editable and the child element is part of its content, it makes sense to place the cursor in the child element if that's where the nearest text node happens to be.

The problem is that Webkit browsers (Chrome, Safari, Opera) exhibit this same behavior when contenteditable is set on the child instead of the parent. The browser shouldn't even bother looking for the nearest text node in this case since the element you actually clicked on isn't editable. But Webkit does, and if that text node happens to be in the editable child, you get a blinking cursor. I'd consider that a bug; Webkit browsers are doing this:

on click:
  find nearest text node within clicked element;
  if text node is editable:
    add insertion point;

...when they should be doing this:

on click:
  if clicked element is editable:
    find nearest text node within clicked element;
    add insertion point;

Block elements (such as divs) don't seem to be affected by the bug, which makes me think @GOTO 0's answer is correct in implicating text selection-- at least insofar as it seems to be governed by the same logic that controls insertion point placement. Multi-clicking outside an inline element highlights the text within it, but not so for block elements. It's probably no coincidence that you also don't get an insertion point when you click outside a block. The first workaround below makes use of this exception.


Workaround 1 (nested div)

Since blocks aren't affected by the bug, I think the best solution is to nest a div in the inline-block and make it editable instead. Inline-blocks already behave like blocks internally, so the div should have no effect on its behavior.

div.outside {
  margin: 30px;
}
div.text-input {
  display:inline-block;
  background-color: black;
  color: white;
  width: 300px;
}
<div class="outside">
    <div class="text-input">
      <div contenteditable>
        Input 1
      </div>
    </div>
    <div class="text-input">
      <div contenteditable>
        Input 2
      </div>
    </div>
    <div class="unrelated">This is some unrelated content<br>
      This is some more unrelated content
      This is just some space to shows that clicking here doesn't mess with the contenteditable div
      but clicking the side mess with it.
    </div>
</div>

Workaround 2 (invisible characters)

If you must put the contenteditable attribute on the inline-blocks, this solution will allow it. It works by surrounding the inline-blocks with invisible characters (specifically, zero-width spaces) which shield them from external clicks. (GOTO 0's answer uses the same principle, but it still had some problems last I checked).

div.outside {
  margin: 30px;
}
div.text-input {
  display:inline-block;
  background-color: black;
  color: white;
  width: 300px;
  white-space: normal;
}
.input-container {white-space: nowrap;}
<div class="outside">
  <span class="input-container">&#8203;<div class="text-input" contenteditable>
    Input 1
  </div>&#8203;</span>
  <span class="input-container">&#8203;<div class="text-input" contenteditable>
    Input 2
  </div>&#8203;</span>
  <div class="unrelated">This is some unrelated content<br>
      This is some more unrelated content
      This is just some space to shows that clicking here doesn't mess with the contenteditable div
      but clicking the side mess with it.
  </div>
</div>

Workaround 3 (javascript)

If you absolutely can't change your markup, then this JavaScript-based solution could work as a last resort (inspired by this answer). It sets contentEditable to true when the inline-blocks are clicked, and false when they lose focus.

(function() {
  var inputs = document.querySelectorAll('.text-input');
  for(var i = inputs.length; i--;) {
    inputs[i].addEventListener('click', function(e) {
      e.target.contentEditable = true;
      e.target.focus();
    });
    inputs[i].addEventListener('blur', function(e) {
      e.target.contentEditable = false;
    });
  }
})();
div.outside {
  margin: 30px;
}
div.text-input {
  display:inline-block;
  background-color: black;
  color: white;
  width: 300px;
}
<div class="outside">
    <div class="text-input">
      Input 1
    </div>
    <div class="text-input">
      Input 2
    </div>
    <div class="unrelated">This is some unrelated content<br>
      This is some more unrelated content
      This is just some space to shows that clicking here doesn't mess with the contenteditable div
      but clicking the side mess with it.
    </div>
</div>
DoctorDestructo
  • 4,166
  • 25
  • 43
  • Great answer. That solves the problem. Just curious about the explanation: what is "insertion point location"? – cytsunny Dec 28 '15 at 03:17
  • @user1273587 "insertion point location" is the specific place on a web page that the browser designates as the correct location at which to add an [insertion point](http://techterms.com/definition/insertion_point) (i.e. the blinking cursor that allows you to enter text). This location will be either directly within the element you clicked on, or within one of its child elements. Browsers shouldn't actually place an insertion point at this location (and probably shouldn't even calculate it) unless the element you clicked on is editable, but unfortunately, Webkit doesn't always follow this rule. – DoctorDestructo Dec 29 '15 at 18:13
  • The zero-width spaces did the trick! No extra JS to hook up, and they even work when trying to override Draft.js to be inline (which you can do by setting `*:first-child {display:inline;}`). What an annoying bug! – btown Mar 29 '17 at 02:24
10

I was able to reproduce this behavior only in Chrome and Safari, suggesting that this may be a Webkit related issue.

It's hard to tell what's going on without inspecting the code but we can at least suspect that the problem lies in some faulty mechanism that triggers text selection in the browser. For analogy, if the divs were not contenteditable, clicking in the same line of text after the last character would trigger a text selection starting at the end of the line.

The workaround is to wrap the contenteditable divs into a container element and style the container with -webkit-user-select: none to make it unselectable.

As Alex Char points out in a comment, this will not prevent a mouse click outside the container to trigger a selection at the start of the text inside it, since there is no static text between the first contenteditable div and the (selectable) ancestor container around it. There are likely more elegant solutions, but a way to overcome this problem is to insert an invisible, nonempty span of text of zero width just before the first contenteditable div to capture the unwanted text selection.

  • Why non empty?: Because empty elements are ignored upon text selection.
  • Why zero width?: Because we don't want to see it...
  • Why invisible?: Because we don't want the content to be copied to the clipboard with, say Ctrl+A, Ctrl+C.

div.outside {
  margin: 30px;
}
div.text-input {
  display:inline-block;
  background-color: black;
  color: white;
  width: 300px;
}
div.text-input-container {
  -webkit-user-select: none;
}
.invisible {
  visibility: hidden;
}
<div class="outside">
    <div class="text-input-container">
        <span class="invisible">&#8203;</span><div class="text-input" contenteditable="true">
            Input 1
        </div>
        <div class="text-input" contenteditable="true">
            Input 2
        </div>
    </div>
    <div class="unrelated">This is some unrelated content<br>
      This is some more unrelated content
      This is just some space to shows that clicking here doesn't mess with the contenteditable div
      but clicking the side mess with it.
    </div>
</div>

Even in normal circumstances it is generally a good idea to keep adjacent inline-block elements in a separate container rather than next to a block element (like the unrelated div) to prevent unexpected layout effects in case the order of the sibling elements changes.

GOTO 0
  • 42,323
  • 22
  • 125
  • 158
  • 3
    Still doesn't work on Chrome. Check when you click above of those div's(on margin area). – Alex Char Dec 22 '15 at 10:12
  • @AlexChar Thanks for seeing that. Updated my answer. – GOTO 0 Dec 23 '15 at 18:09
  • Still has the same problem Alex pointed out. What's really strange is that if you click left-of-center, the first div gets selected, but right-of-center the second div gets selected, even though in both cases you're clicking above the first div. – DoctorDestructo Dec 24 '15 at 00:12
  • @DoctorDestructo Hmm... can't seem to reproduce on my machine. Tested in Chrome and Safari. – GOTO 0 Dec 24 '15 at 00:30
  • @GOTO0 Didn't work for me in Chrome 47.0.2526.106 on Windows 7. Does work in Safari for Windows, though, for whatever that's worth :). I'm guessing you're on a Mac, right? What Chrome version? – DoctorDestructo Dec 24 '15 at 00:37
  • @DoctorDestructo I'm in Chrome 47.0.2526.106 too on El Capitan :O – GOTO 0 Dec 24 '15 at 00:46
  • Another bit of interesting behavior I noticed (again, only in Chrome for Windows): if I select some of the text below the inline-blocks, and then click and drag next to them, it puts the insertion point in. It should just deselect the text. If I just click beside them, without having any text selected and without dragging, then your solution works. Very strange. – DoctorDestructo Dec 24 '15 at 00:51
  • 1
    The reason this appears to work in Safari for Windows is that it incorrectly makes the inline-blocks totally uneditable when the `-webkit-user-select: none` property is present. Guessing it wouldn't work otherwise. Anyhoo, just had a thought: if you used a `:before` pseudo-element to insert the `‌` maybe you could get rid of the anonymous span. – DoctorDestructo Dec 24 '15 at 01:23
  • @DoctorDestructo Safari for Windows is so dead :) Anyway, I like the idea of using a `::before` pseudoelement in place of a hidden span. – GOTO 0 Dec 24 '15 at 01:32
  • 1
    The pseudo-element adds an unwanted space unless you put all your code on one line :/. So I guess it's not perfect. In any case, I just have one more thought and then I'll leave you alone: zero-width space (`​` or `\200B`) might be a better choice than zero-width non-joiner, since the latter is meant to be used for controlling [ligatures](https://en.wikipedia.org/wiki/Typographic_ligature). Just a technicality, but couldn't hurt. – DoctorDestructo Dec 24 '15 at 02:14
1

If it's not needed to use display: inline-block, I would recommend using float. Here is the example.

Based on your example, the new CSS would be:

div.text-input {
  display: block;
  background-color: black;
  color: white;
  width: 300px;
  float: left;
  margin-right: 10px;
}
div.unrelated {
  clear: both;
}
Inacio Schweller
  • 1,986
  • 12
  • 22
  • Why do you even need to include the float: left? When I switch the display from inline-block to block it worked. http://jsfiddle.net/dr6ocnfu/1/ – jjbskir Dec 22 '15 at 15:30
  • 2
    I included `float: left` to reproduce the same CSS styling he wanted with `display: inline-block`. That is the whole point of the question. – Inacio Schweller Dec 22 '15 at 17:42
0

Disable text selection in container... should fix that.

For example:

* {
   -ms-user-select: none; /* IE 10+ */
   -moz-user-select: -moz-none;
   -khtml-user-select: none;
   -webkit-user-select: none;
   user-select: none;
}
Flash Thunder
  • 11,672
  • 8
  • 47
  • 91
0

How about a little jQuery?

$(".outside").click(function(e){
    $(e.target).siblings(".text-input").blur();
    window.getSelection().removeAllRanges();
});

And if IRL you need to account for clicks on contenteditable=true siblings' children:

$(".outside").click(function(e){
    if ($(e.target).siblings(".text-input").length != 0){
        $(e.target).siblings(".text-input").blur();
        window.getSelection().removeAllRanges();
    } 
    else {
        $(e.target).parentsUntil(".outside").last().siblings(".text-input").blur();
        window.getSelection().removeAllRanges();
    }
});

window.getSelection().removeAllRanges();"The trick is to remove all ranges after calling blur"

Community
  • 1
  • 1
JBG
  • 25
  • 1
  • 5
  • This might not be the case for everyone, but what I just did was `$(body).on('blur', [contenteditable="true"], function() { window.getSelection().removeAllRanges(); });` (with jQuery), and voilà! The problem is gone. – maswerdna Aug 18 '19 at 13:18