首页 > 解决方案 > 换行符后 getBoundingClientRect 的定位不正确

问题描述

我正在尝试在 Web 的富文本编辑器中使下拉菜单跟随光标。使用以下我可以得到光标的坐标没有问题:

const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const position = sel.getRangeAt(0).getBoundingClientRect());

但是,如果我尝试在一个\n字符之后使用它,它会在换行符之后返回光标的位置,而不是新行的开头(光标实际出现在窗口中的位置): 在此处输入图像描述

有没有办法避免这种情况?

编辑:根据下面的评论,这是我想要实现的更深入的版本。

我目前正在使用 React 和 Slate.js ( https://github.com/ianstormtaylor/slate ) 构建一个文本编辑器。它的核心是更强大的 contentEditable 组件版本,但允许您将可编辑的文本字段放入页面中。由于我使用的节点结构,我希望段落之间有软中断而不是新<div />元素。因为这是 contentEditable 的非标准行为,所以很难在不重新创建整个应用程序的情况下制作一个小示例。

编辑(对评论的进一步回应):文本元素的原始 HTML 如下所示:

<span data-slate-string="true">working until newline
see?
</span>

您可以看到 slate 将中断字面翻译为 \n 字符,这是我认为导致问题的原因。

标签: javascriptcssreactjsslatejs

解决方案


即使在使用浏览器的默认 contenteditable 时,当光标设置为新行时确实会出现奇怪的行为:RangegetClientRects()将为空,因此getBoundingClientRect()将返回一个完整的 0 DOMRect。

这是一个演示该问题的简单演示:

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>

为此,有一个简单的解决方法,包括选择当前 Range 容器的内容:

// 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>

现在 OP 似乎遇到了不同的问题,因为它们确实处理软中断\nwhite-space: pre.
但是我只能从我的 Firefox 中复制它。,Chrome 在这种情况下表现“如预期”......

所以在我的 Firefox 中,DOMRect 不会全为 0,而是换行之前的那个。

为了演示这个案例,点击空行:

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>

为了解决这种情况,它有点复杂......
我们需要检查我们的 Range 之前的字符是什么,如果它是一个新行,那么我们需要通过选择下一个字符来更新我们的 range。但是这样做,我们也会移动光标,所以我们实际上需要从克隆的 Range 中进行。但是由于 Chrome 的行为不是这样,我们还需要检查前一个字符是否在不同的行上,当没有这样的前一个字符时,这会成为一个问题......

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>


推荐阅读