如何在Firefox中区分换行文本的第一个和最后一个位置

8
我正在使用一个`contentEditable span`,希望将一个`position: absolute`的元素放在光标所在的同一行。问题出现在文本换行时,因为它无法适应 - 换行的第一个和最后一个位置有奇怪的行为。
对于它们两个来说,当我在第二行的第一个位置时,`getBoundingClientRect()`的`y`偏移量等于第一行的偏移量,然而如果我在第二行再移动一个位置,`y`偏移量就正确地匹配第二行了。
在下面的代码片段中,这种行为在Firefox中显示出来。对于Chrome来说,它似乎工作得很好,尽管在我的完整实现中它也有不精确的行为,但我已经能够解决了Chrome的问题。然而对于Firefox来说,第一行的最后一个位置的`offset`等于第一行,第二行的第一个位置的`offset`等于第一行,之后它就正常工作了。
在这个例子中,跳到第一行的最后一个位置,并注意控制台中的CURRENT_TOP值显示为16。如果你向右移动一个位置,光标已经在下一行,它仍然显示16。如果你再向右移动一个位置,它将显示36

const textEl = document.getElementById("myText")

textEl.addEventListener("keyup", (event) => {
  const domSelection = window.getSelection();
  if (domSelection && domSelection.isCollapsed && domSelection.anchorNode) {
    let offsetNewLine = 0;

    const domRange = domSelection.getRangeAt(0);
    const rect = domRange.getBoundingClientRect();
    const rects = domRange.getClientRects();
    const newRange = document.createRange();
    const newRangeNextOffset = domSelection.anchorNode.textContent.length < domSelection.anchorOffset + 1 ? domSelection.anchorOffset : domSelection.anchorOffset + 1

    newRange.setStart(domSelection.anchorNode, newRangeNextOffset);
    newRange.setEnd(domSelection.anchorNode, newRangeNextOffset);
    const nextCharacterRect = newRange.getBoundingClientRect();

    console.log(`CURRENT_TOP: ${rect.y}, NEXT_CHAR_TOP: ${nextCharacterRect.y}`);
  }
})
.text-container {
  width: 500px;
  display: inline-block;
  border: 1px solid black;
  line-height: 20px;
  padding: 5px;
}
<span id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row</span>


请注意,您在您的fiddle中的console.log()行使用了错误的语法。我已经为您纠正了这个问题,并将代码移入了一个内联片段中,这样您更有可能得到一些答案,因为人们更容易看到您所谈论的代码。 - undefined
1
@ChrisBarr,问题具体在于,如果你在第二行的开头位置,它会显示与第一行相同的CURRENT_TOP值。如果你在这些位置上,这个功能对你有效吗? - undefined
啊,抱歉,我没有仔细阅读具体的说明。在那个位置上并不是像你所描述的那样报告。 - undefined
虽然无法提供一个完全可行的解决方案,但是:当我记录下domSelection.anchorNode.textContent[newRangeNextOffset]的值以查看它认为下一个字母是什么时,它比我预期的位置提前了一个位置。通过将设置newRangeNextOffset值的三元语句中的+1移动,我成功修复了这个问题。为什么不直接使用domRange.endOffset来获取光标位置呢? - undefined
DomSelectiondomRange是折叠的,但是domSelection.anchorNode.textContent[domRange.endOffset]将显示光标右侧的字符。当你在第一行的末尾时,下一个字符是" ",所以我的想法是获取下一个字符的位置,并检查它是否已经在第二行,但这还不够,因为第二行的第一个位置仍然等于第一行。我可以使用currentOffset + 2,但这在Chrome上不起作用,因为它会认为我在第二行提前一个字符。 - undefined
显示剩余5条评论
2个回答

4
首先进行诊断,然后进行治疗。
诊断
这种奇怪的行为是由于Chrome和Firefox似乎以不同的方式处理换行符。在Chrome和Firefox中执行以下代码片段。唯一的区别是我添加了
anchorOffset: ${domSelection.anchorOffset}

将结果输出到控制台。我们将在下面讨论结果。

const textEl = document.getElementById("myText")

textEl.addEventListener("keyup", (event) => {
  const domSelection = window.getSelection();
  if (domSelection && domSelection.isCollapsed && domSelection.anchorNode) {
    let offsetNewLine = 0;

    let domRange = domSelection.getRangeAt(0);
    let rect = domRange.getBoundingClientRect();
    const rects = domRange.getClientRects();
    const newRange = document.createRange();
    const newRangeNextOffset = domSelection.anchorNode.textContent.length < domSelection.anchorOffset + 1 ? domSelection.anchorOffset : domSelection.anchorOffset + 1

    newRange.setStart(domSelection.anchorNode, newRangeNextOffset);
    newRange.setEnd(domSelection.anchorNode, newRangeNextOffset);
    let nextCharacterRect = newRange.getBoundingClientRect();

    console.log(`anchorOffset: ${domSelection.anchorOffset}, CURRENT_TOP: ${rect.y}, NEXT_CHAR_TOP: ${nextCharacterRect.y}`);
  }
})
.text-container {
  width: 500px;
  display: inline-block;
  border: 1px solid black;
  line-height: 20px;
  padding: 5px;
}
<span id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row</span>

浏览器在这里的换行位置不同,但这不是重点。首先看一下Chrome的输出。请注意,caret直接跳到下一行,实际存在的空格已经被转换为换行符(NL),看起来是经典的回车加换行符(CR+LF)形式。因此,在NL之后,Chrome看到光标,就像人眼一样,已经在第二行上。
第一行最后一个非空白字符 换行符 第二行第一个非空白字符
偏移量61处的't' 偏移量62处的NL 偏移量63处的'p'

chrome

现在是Firefox。插入符号会跟随空格,然后跳到下一行。空格(SP)被保留了。然而,插入的换行符没有被计算在偏移量中。此外,它仍然被视为第一行的一部分,也就是说,人眼看到光标在第二行,但Firefox却在第一行。不知为何。
因此,Firefox在第一行的末尾迭代两次(先是SP,然后是NL),但偏移量只增加一次(计算SP和NL的总和),而且实际上还没有真正移动到第二行。所有这些使得这里的情况变得混乱不堪。
在第一行的最后一个非空白字符是偏移量为73的'n',换行符是SP和NL,它们的偏移量都是74,而第二行的第一个非空白字符是偏移量为75的't'。

firefox

治疗

目前我能想到的唯一方法是检测浏览器并引入一个针对Firefox的解决方案,所以可以通过以下方式检查Firefox:

const isFirefox = typeof InstallTrigger !== 'undefined';

经过测试,仍然可以在Firefox 111上运行。
所以,我们可以通过标记是否处于Firefox换行来解决这个问题。让我们首先添加一些全局变量:
// whether we're in a (Firefox-)NL
let isNewline = false;
// whether we're in Firefox
const isFirefox = typeof InstallTrigger !== 'undefined';

请注意,如果需要的话,也可以在其他浏览器中使用isNewline。接下来,我们将Firefox特定的换行功能添加到keyup处理程序中。
/*
* Check whether we're in Firefox and on the edge of a line.
* At need easily extendable for other browsers.
*/
if(isFirefox && rect.y < nextCharacterRect.y)
{
    // caret is after the SP, i.e. we're in the NL-sequence
    if(isNewline)
    {
        /*
        * Hop straight to the next line by
        * de facto enforcing a LF+CR.
        */
        domRange = newRange;
        domSelection.getRangeAt(0);
        rect = domRange.getBoundingClientRect();

        // end of Firefox' NL-sequence
        isNewline = false;
    }
    // begin of Firefox' NL-sequence, i.e. we hit the SP
    else
        isNewline = true;
}

这可以通过选择方向检测进行精调来延长。让我们将所有内容放在以下片段中。请注意,domRangerect改为使用let而不是const

// our denotation values, see above
let isNewline = false;
const isFirefox = typeof InstallTrigger !== 'undefined';

const textEl = document.getElementById("myText")

textEl.addEventListener("keyup", (event) => {
  const domSelection = window.getSelection();
  if (domSelection && domSelection.isCollapsed && domSelection.anchorNode) {
    let offsetNewLine = 0;

    let domRange = domSelection.getRangeAt(0);
    let rect = domRange.getBoundingClientRect();
    const rects = domRange.getClientRects();
    const newRange = document.createRange();
    const newRangeNextOffset = domSelection.anchorNode.textContent.length < domSelection.anchorOffset + 1 ? domSelection.anchorOffset : domSelection.anchorOffset + 1

    newRange.setStart(domSelection.anchorNode, newRangeNextOffset);
    newRange.setEnd(domSelection.anchorNode, newRangeNextOffset);
    const nextCharacterRect = newRange.getBoundingClientRect();

    // the line-hopping, see above
    if(isFirefox && rect.y < nextCharacterRect.y)
    {
        if(isNewline)
        {
            domRange = newRange;
            domSelection.getRangeAt(0);
            rect = domRange.getBoundingClientRect();
            isNewline = false;
        }
        else
            isNewline = true;
    }

    console.log(`anchorOffset: ${domSelection.anchorOffset}, CURRENT_TOP: ${rect.y}, NEXT_CHAR_TOP: ${nextCharacterRect.y}`);
  }
})
.text-container {
  width: 500px;
  display: inline-block;
  border: 1px solid black;
  line-height: 20px;
  padding: 5px;
}
<span id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row</span>

结论

可能有更优雅和复杂的解决方案,但目前这个方法已经能够胜任工作。基本上,我们通过强制实施类似Chrome的LF+CR换行方式来修改Firefox的换行行为。唯一的区别是在实际换行之前,即在Firefox中我们仍然需要按两次键才能进入下一行,而不像Chrome只需按一次。但这在这里并不重要。除此之外,两个浏览器的行为现在是等效的。此外,如果需要,这个解决方法也可以轻松地适用于其他浏览器。


致谢
使用换行符表示变量的最后灵感来自post,该帖子由@herrstrietzel发布,其中还讨论了选择方向和鼠标交互的方法。

2
根据@Krokomot的解释,Firefox对待换行有一种古怪的方式。
实际上,前一行的结尾和当前行的开头(显示光标/插入符号的位置)将返回相同的字符索引/位置(或anchorOffset值)。
一个解决方法可能是将最后一个字符索引以及最后一个顶部y值保存在全局变量中。
如果当前字符位置和前一个顶部y值等于之前的值
- 我们从下一个字符(anchorOffset + 1)计算出的y位置 - 在这种情况下是换行之后。

const textEl = document.getElementById("myText");
let bbText = textEl.getBoundingClientRect();
let textElTop = bbText.top;
let textElRight = bbText.right;

let lastCharPos = 0;
let lastTop = 0;

myText.addEventListener("click", (e) => {
  updateSelection(e);
});

document.addEventListener("keyup", (e) => {
  updateSelection(e);
});

function updateSelection(e) {
  let selection = window.getSelection();
  let caret = selection.getRangeAt(0);
  let range = document.createRange();
  let {
    anchorNode,
    anchorOffset
  } = selection;
  range.setStart(anchorNode, anchorOffset);

  // get y pos of next character
  let anchorOffset2 =
    anchorOffset < anchorNode.textContent.length - 1 ?
    anchorOffset + 1 :
    anchorOffset;

  let rangeN = document.createRange();
  rangeN.setStart(anchorNode, anchorOffset2);

  let bb = caret.getBoundingClientRect();
  let bb2 = rangeN.getBoundingClientRect();

  let height = bb.height;
  let top = bb.top - textElTop;
  let top2 = bb2.top - textElTop;



  // check mouse position on click
  let mouseX = e.pageX ? e.pageX : 0;
  let distX = mouseX ? Math.abs(bb.left - mouseX) : 0;
  let distX2 = mouseX ? Math.abs(bb2.left - mouseX) : 0;



  if (
    ((lastTop && lastTop == top && lastCharPos == anchorOffset) ||
      (lastTop && lastTop != top && lastCharPos < anchorOffset)
    ) ||
    (distX > distX2)
  ) {
    top = top2;
  }

  if (distX < distX2) {
    top = bb.top - textElTop;
  }

  // update
  lastCharPos = anchorOffset;
  lastTop = top;
  mouseX = 0;

  // shift line indicator
  selectionLine.setAttribute("style", `top:${top}px; height:${height}px;`);
  cursor.setAttribute("style", `top:${bb.top}px; left:${bb.left}px;`);


}
body {
  font-size: 2em;
  margin: 0em;
  padding: 11px;
}

* {
  box-sizing: border-box;
}

.wrap {
  position: relative;
  width: 300px;
}

.text-container {
  display: block;
  border: 1px solid black;
  line-height: 1.5em;
  padding: 1em;
  position: relative;
}

.text-container:focus+.selectionLine {
  border-left: 10px solid green;
  display: block;
  position: absolute;
  width: 0;
  height: 1em;
  top: 0;
  right: 0;
}

#cursor {
  position: absolute;
  width: 0.2em;
  height: 0.2em;
  top: 0;
  right: 0;
  background: red;
  border-radius: 50%;
}
<div id="info" style="position:absolute; right:0; top:0;"></div>
<div class="wrap">
  <div id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row.
  </div>
  <div id="selectionLine" class="selectionLine"></div>
</div>

<div id="cursor"></div>

上述示例还根据鼠标输入检查新的插入符位置。
然而,当使用上/下箭头键时,这种方法仍然失败。
红点表示无法修复的本机插入符位置,绿色条表示修复的y偏移量。

包括选择方向(向前或向后)

我们还检查键盘输入,如"ArrowLeft""ArrowUp",以调整插入符位置。
虽然有些笨拙,但这样我们就能得到对箭头键导航的良好支持。
此示例还包括@Krokomot建议的Firefox用户检测。

const textEl = document.getElementById("myText");
let bbText = textEl.getBoundingClientRect();
let textElTop = bbText.top;
let textElRight = bbText.right;

let lastCharPos = 0;
let lastTop = 0;
let forwards = true;

// simple firefox agent detection
const isFirefox = typeof InstallTrigger !== "undefined";

myText.addEventListener("click", (e) => {
  updateSelection(e);
});

document.addEventListener("keyup", (e) => {
  updateSelection(e);
});

function updateSelection(e) {
  let selection = window.getSelection();
  let caret = selection.getRangeAt(0);
  let range = document.createRange();
  let { anchorNode, anchorOffset } = selection;
  range.setStart(anchorNode, anchorOffset);

  let bb = caret.getBoundingClientRect();
  let height = bb.height;
  let top = bb.top - textElTop;

  if (isFirefox) {
    // get y pos of next character
    let anchorOffset2 =
      anchorOffset < anchorNode.textContent.length - 1
        ? anchorOffset + 1
        : anchorOffset;

    let anchorOffset3 = anchorOffset > 0 ? anchorOffset - 1 : anchorOffset;

    let rangeN = document.createRange();
    rangeN.setStart(anchorNode, anchorOffset2);

    let rangeP = document.createRange();
    rangeP.setStart(anchorNode, anchorOffset3);

    let bb2 = rangeN.getBoundingClientRect();
    let bb0 = rangeP.getBoundingClientRect();

    let top2 = bb2.top - textElTop;
    let top0 = bb0.top - textElTop;

    // check mouse position on click
    let mouseX = e.pageX ? e.pageX : 0;
    let mouseY = e.pageY ? e.pageY : 0;

    // check keybord inputs
    let key = e.key ? e.key : "";

    let distX = mouseX ? Math.abs(bb.left - mouseX) : 0;
    let distX2 = mouseX ? Math.abs(bb2.left - mouseX) : 0;

    let distY = mouseY ? Math.abs(bb.top - mouseY) : 0;
    let distY2 = mouseY ? Math.abs(bb2.top - mouseY) : 0;

    // direction: forward or backward
    if (
      lastCharPos > anchorOffset ||
      key === "ArrowLeft" ||
      key === "ArrowUp" ||
      (distY && distY < distY2)
    ) {
      forwards = false;
    } else if (
      lastCharPos < anchorOffset ||
      key === "ArrowRight" ||
      key === "ArrowDown" ||
      (distY && distY > distY2)
    ) {
      forwards = true;
    }

    // forwards
    if (
      forwards &&
      (lastCharPos == anchorOffset || distX > distX2 || key === "ArrowDown")
    ) {
      top = top2;
    }
    
    // backwards
    else {
      //console.log("back", lastCharPos, anchorOffset);
      if (lastCharPos > anchorOffset) {
        top = top2;
      }
    }

    // update
    lastCharPos = anchorOffset;
    lastTop = top;
  }

  // shift line indicator
  selectionLine.setAttribute("style", `top:${top}px; height:${height}px;`);
  cursor.setAttribute("style", `top:${bb.top}px; left:${bb.left}px;`);
}
body{
  font-size: 2em;
  margin:0em;
  padding:11px;
}

*{
  box-sizing:border-box;
}

.wrap{
  position: relative;
  width: 300px;

}

.text-container {
  display: block;
  border: 1px solid black;
  line-height: 1.5em;
  padding: 1em;
  position: relative;
}




.text-container:focus+
.selectionLine {
  border-left: 10px solid green;
  display:block;
  position: absolute;
  width:0;
  height:1em;
  top:0;
  right:0;

}

#cursor{
    position: absolute;
  width:0.2em;
  height:0.2em;
  top:0;
  right:0;
  background: red;
    border-radius: 50%;
}
<div id="info" style="position:absolute; right:0; top:0;"></div>
<div class="wrap">
<div id="myText" class="text-container" contentEditable="true">Go on the last position in the first row and come it to first position in the second row. 
</div>
<div id="selectionLine" class="selectionLine"></div>
  </div>

<div id="cursor"></div>


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接