Firefox 中的 ContentEditable 创建 2 个换行符而不是 1 个

ContentEditable in Firefox creates 2 newlines instead of 1

我刚刚注意到 div contenteditable 在 Firefox 中报告 1 个换行符为 2 个换行符。

这是一个错误还是我遗漏了什么?

在下面的示例中,只需键入:

Hello

World

contenteditable.

使用 innerText 获取值报告为:

Hello



World

const textarea = document.querySelector('#textarea')

textarea.addEventListener('keydown', e => {
  console.log(textarea.innerText)
})
#textarea {
  width: 200px;
  height: 75px;
  outline: 1px solid black;
}
  <div id="textarea" contenteditable="true"></div>

这似乎是 Firefox 的 innerText 实现的错误。

contenteditable 中的新行在历史上对精确实施缺乏共识。 Gecko 插入了 <br>s,IE 和 Opera 的 Presto 将行包装在 <p> 标签中,而 Webkit 和 Blink 将行包装在 <div> 标签中。 Firefox 的 innerText 的旧实现使用 <br> 标签,工作得很好,正是您的问题所希望的。

然而如今的现代浏览器为了一致性已经达成了普遍共识。专门针对 Firefox,MDN 声明:

As of Firefox 60, Firefox will be updated to wrap the separate lines in <div> elements, matching the behavior of Chrome, modern Opera, Edge, and Safari.

当现代浏览器插入一个空的新行时,它们通过将 <br> 包装在 <div> 标记中来实现。似乎 Firefox 的 innerText 实现将其解释为两个新行。


解决方法非常简单。 execCommand 方法(非官方 W3 草案,但受 Firefox 支持,这正是我们所需要的)允许您使用定义的命令操作 contenteditable 的内容。一个这样的命令是 defaultParagraphSeparatorwhich allows 你指定是否使用 <div><p> 作为你的 contenteditable.

行的容器

Firefox - 并且 Firefox - 还通过 defaultParagraphSeparator 实现了对 <br>s 的支持。使用此命令,contenteditable 的行为类似于 FF60 之前的行为,即插入换行符而不是在容器中换行。

因此,您只需输入:

document.execCommand("defaultParagraphSeparator", false, "br");

在你的 JavaScript 中。所有版本的 Firefox 将使用 <br>s 而不是 contenteditable 中新行的容器,从而使 innerText 正确解释新行。除了 Firefox 之外的所有浏览器都会忽略它。

2020-08-27更新

认为这个问题的答案可能不是使用innerText,而是textContent,结合CSS指令white-space: pre-wrap;

参见


正如我在评论中所说,截至今天,2020 年 7 月 26 日,这个 execCommand 解决方案似乎没有效果。

查看问题我发现如果我在可编辑的 DIV:

中有这个
asdfkadsfaklshdfkajsa

sdfaalkj 

Pinkerton detective agency

sdfaldskfhalks

... 我在第一行的末尾添加了一个换行符,在 Firefox 中我得到以下 innerHTML:

<div>asdfkadsfaklshdfkajsa</div><div><br></div><br>sdfaalkj <br><br>Pinkerton detective agency<br><br>sdfaldskfhalks<br>

... 并计算 innerText 上的换行符 (\n) 据说有 9 个换行符。在 Chrome 中(没有发生此问题)我得到以下 innerHTML:

asdfkadsfaklshdfkajsa<div><br><br>sdfaalkj <br><br>Pinkerton detective agency<br><br>sdfaldskfhalks<br></div>

据说innerText中有8个换行符。我们可以看到 DIV 标签的使用是完全不同的,确实是荒谬的不同。 Firefox 公开宣称的目标就是让这种处理与其他浏览器保持一致!

可能的解决方案
解决方案是干扰生成的 HTML,当然不适用于您在文本中故意包含 DIV 元素的情况。但是对于处理最简单的纯旧文本,其中 DIV 元素确实无处可去,这个解决方案似乎有效(当然,此代码仅适用于 Firefox):

if( comp.innerHTML.toLowerCase().includes( '<div>' )){
        // spurious double new BR happens under certain circs
        if( comp.innerHTML.includes( '<div><br><\/div><div><br><\/div>' )){
            comp.innerHTML = comp.innerHTML.replace(/<div><br><\/div><div><br><\/div>/i, '<br>' );
            // see below: must be decremented 
            startOffset -= 1;
        }
        // simple new BR
        comp.innerHTML = comp.innerHTML.replace(/<div><br><\/div>/i, '<br>' );
        // console.log( comp.innerHTML )
        comp.innerHTML = comp.innerHTML.replace(/<\/div>/gi, '<br>' );
        // console.log( comp.innerHTML )
        comp.innerHTML = comp.innerHTML.replace(/<div>/gi, '' );
        console.log( comp.innerHTML )
}

我在第一次尝试时就犯了上述错误。我每次都包含 console.log 的原因是您可以看到发生了什么。事实上,在删除 <DIV> 之前,您必须用 <BR> 替换 </DIV> :如果删除 <DIV>,这还会自动删除结束 </DIV> 标签。但是,如果您只删除结束标签,则开始标签仍然存在!

...在那之后,在 FF 中,您有一个带有 8 个换行符的 innerText,并且事情有点像您 hope/expect.

不幸的是,事实并非如此简单:重复按 Enter 键 有时 会导致两个 <DIV><BR></DIV> 被插入,而应该只插入一个是。但是,如果您实际单击其他位置,然后 return 插入符号到 DIV,则下次您单击 Enter 时它会正常工作。

浏览器检测,见here

您还有另一个问题:设置 innerHTML 会使光标回到内容的开头。这不是微不足道的:请记住,作为一个 DOM 结构,您现在拥有的是几个散布在 <BR> 节点中的文本节点。请参阅下面提出的解决方案,似乎 有效。

另请注意,如果您有一个突变观察器来侦听文档中的更改,像这样插入一个新行会触发大量异步突变事件。事实上,事件的数量似乎与 DIV 中的 BR 节点数量有关。即使突变观察者作为对第一个此类事件的响应而断开连接,您仍然会得到这些。在我的例子中,上面的代码每次都被触发,但它是无害的,因为一旦你按照上面的方式处理,后续调用将不会再改变它(即所有 DIVs 已经从内部 HTML).

插入符号的适当解决方案get/set
正如我所说,设置 innerHTML 会将插入符号设置为文本的开头。不好。我们还知道(或假设)此文本包含新行(在 HTML 中使用 <BR>)但它没有以其他方式标记,我们可以利用的东西:[= 的子节点101=] 应该只是 #text 节点和 BR 节点。

第一个实验似乎表明我们可以使用这些功能。首先,在我们应用更改之前获取插入符位置,将每个 BR 计为 1 个字符:

function calculateTotalOffset(node, offset) {
    let total = offset;
    let currNode = node;
    while (currNode !== comp ) {
        if( currNode.tagName === 'BR'){
            total += 1;
        }
        if(currNode.previousSibling) {
            total += currNode.previousSibling.textContent.length;
            currNode = currNode.previousSibling;
        } else {
            currNode = currNode.parentElement;
        }
    }
    return total;
}
const sel = window.getSelection();
const range = sel.getRangeAt( 0 );
let startOffset = calculateTotalOffset(range.startContainer, range.startOffset);

... 更改 innerHTML:

后设置插入符号
function applyTotalOffset( headNode, offset ){
    let remainingOffset = offset;
    for( let node of headNode.childNodes ){
        if( node.nodeName === '#text' ){
            if( node.length > remainingOffset ){
                range.setStart( node, remainingOffset );
                return;
            }
            else {
                remainingOffset -= node.length;
            }
        }
        else if( node.nodeName === 'BR' ){
            if( remainingOffset === 0 ){
                range.setStart( node, 0 );
                return;
            }
            else {
                remainingOffset -= 1;
            }
        }
        else {
            throw new Error( 'not plain text: ' + headNode.innerHTML );
        }
    }
}
// add 1 to returned offset because the cursor should advance by 1, after adding new line
applyTotalOffset( comp, startOffset + 1 );