换行符后 getBoundingClientRect 的定位不正确
Incorrect positioning of getBoundingClientRect after newline character
我正在尝试在 web 富文本编辑器中制作一个跟随光标的下拉菜单。使用以下我可以毫无问题地获得光标的坐标:
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const position = sel.getRangeAt(0).getBoundingClientRect());
但是,如果我尝试在 \n
字符之后使用它,它 returns 光标在换行符之后的位置而不是新行的开头(光标实际出现的位置)在 window 中):
有没有办法避免这种情况?
编辑:根据下面的评论,这是我正在努力实现的更深入的版本。
我目前正在使用 React 和 Slate.js (https://github.com/ianstormtaylor/slate) 构建一个文本编辑器。它的核心是 contentEditable 组件的更强大版本,但允许您将可编辑的文本字段放入页面中。由于我使用的节点结构,我希望段落之间有软中断而不是新的 <div />
元素。因为这是 contentEditable 的非标准行为,所以很难在不重新创建整个应用程序的情况下制作一个小示例。
编辑(对评论的进一步回复):
文本元素的原始 HTML 如下所示:
<span data-slate-string="true">working until newline
see?
</span>
您可以看到 slate 从字面上将中断转换为 \n 字符,我认为这是导致问题的原因。
即使使用浏览器的默认 contenteditable,当光标设置为新行时确实会出现奇怪的行为:Range 的 getClientRects()
将为空,因此 getBoundingClientRect()
将 return 一个完整的 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>
为此,有一个简单的解决方法,即选择当前范围容器的内容:
// 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 似乎遇到了不同的问题,因为他们确实处理软中断 \n
和 white-space: pre
.
但是我只能从我的 Firefox 中复制它。,Chrome 在这种情况下表现 "as expected"...
所以在我的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>
要解决这种情况,它有点复杂...
我们需要检查范围之前的字符是什么,如果是新行,那么我们需要通过选择下一个字符来更新我们的范围。但是这样做,我们也会移动光标,因此我们实际上需要从克隆的范围中进行操作。但是由于 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>
这个有效:
在范围内插入一个 "zero width space" 并再次调用 getBoundingClientRect.
然后删除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;
}
我正在尝试在 web 富文本编辑器中制作一个跟随光标的下拉菜单。使用以下我可以毫无问题地获得光标的坐标:
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const position = sel.getRangeAt(0).getBoundingClientRect());
但是,如果我尝试在 \n
字符之后使用它,它 returns 光标在换行符之后的位置而不是新行的开头(光标实际出现的位置)在 window 中):
有没有办法避免这种情况?
编辑:根据下面的评论,这是我正在努力实现的更深入的版本。
我目前正在使用 React 和 Slate.js (https://github.com/ianstormtaylor/slate) 构建一个文本编辑器。它的核心是 contentEditable 组件的更强大版本,但允许您将可编辑的文本字段放入页面中。由于我使用的节点结构,我希望段落之间有软中断而不是新的 <div />
元素。因为这是 contentEditable 的非标准行为,所以很难在不重新创建整个应用程序的情况下制作一个小示例。
编辑(对评论的进一步回复): 文本元素的原始 HTML 如下所示:
<span data-slate-string="true">working until newline
see?
</span>
您可以看到 slate 从字面上将中断转换为 \n 字符,我认为这是导致问题的原因。
即使使用浏览器的默认 contenteditable,当光标设置为新行时确实会出现奇怪的行为:Range 的 getClientRects()
将为空,因此 getBoundingClientRect()
将 return 一个完整的 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>
为此,有一个简单的解决方法,即选择当前范围容器的内容:
// 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 似乎遇到了不同的问题,因为他们确实处理软中断 \n
和 white-space: pre
.
但是我只能从我的 Firefox 中复制它。,Chrome 在这种情况下表现 "as expected"...
所以在我的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>
要解决这种情况,它有点复杂...
我们需要检查范围之前的字符是什么,如果是新行,那么我们需要通过选择下一个字符来更新我们的范围。但是这样做,我们也会移动光标,因此我们实际上需要从克隆的范围中进行操作。但是由于 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>
这个有效:
在范围内插入一个 "zero width space" 并再次调用 getBoundingClientRect.
然后删除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;
}