获取范围相对于其父容器的起始和结束偏移量

101

假设我有这个HTML元素:

<div id="parent">
 Hello everyone! <a>This is my home page</a>
 <p>Bye!</p>
</div>

用户使用鼠标选择了“主页”。

我希望能够确定他的选择从#parent的哪个字符开始(以及从#parent结尾处的哪些字符结束)。这应该即使他选择HTML标记也有效。(并且我需要它在所有浏览器中都能工作)

range.startOffset看起来很有前途,但它只是相对于范围的立即容器的偏移量,并且如果容器是文本节点,则仅是字符偏移量。


这应该可以正常工作,即使他选择了一个HTML标签。你是什么意思?有人如何选择HTML标签?请解释一下。 - Satyajit
如果用户在#parent中选择了所有内容,他的选择将包括一些HTML标签(<a>和<p>)。 - Tom Lehman
https://stackoverflow.com/questions/64618729/how-do-you-get-and-set-the-caret-position-in-a-contenteditable/64823701#64823701 - user875234
4个回答

245

更新

如评论所指出,我的原始答案(下面)只返回选择的结尾或插入符位置。很容易调整代码以返回开始和结束偏移量;以下是一个示例:

function getSelectionCharacterOffsetWithin(element) {
    var start = 0;
    var end = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.startContainer, range.startOffset);
            start = preCaretRange.toString().length;
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            end = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToStart", textRange);
        start = preCaretTextRange.text.length;
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        end = preCaretTextRange.text.length;
    }
    return { start: start, end: end };
}

function reportSelection() {
  var selOffsets = getSelectionCharacterOffsetWithin( document.getElementById("editor") );
  document.getElementById("selectionLog").innerHTML = "Selection offsets: " + selOffsets.start + ", " + selOffsets.end;
}

window.onload = function() {
  document.addEventListener("selectionchange", reportSelection, false);
  document.addEventListener("mouseup", reportSelection, false);
  document.addEventListener("mousedown", reportSelection, false);
  document.addEventListener("keyup", reportSelection, false);
};
#editor {
  padding: 5px;
  border: solid green 1px;
}
Select something in the content below:

<div id="editor" contenteditable="true">A <i>wombat</i> is a marsupial native to <b>Australia</b></div>
<div id="selectionLog"></div>

这是一个获取指定元素光标字符偏移量的函数;然而,这是一种朴素实现,几乎肯定会在换行时出现不一致,并且不尝试处理通过CSS隐藏的文本(我怀疑IE将正确忽略此类文本,而其他浏览器则不会)。妥善处理所有这些东西将是棘手的。现在我已经为我的Rangy尝试了这个。

在线示例:http://jsfiddle.net/TjXEG/900/

function getCaretCharacterOffsetWithin(element) {
    var caretOffset = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            caretOffset = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        caretOffset = preCaretTextRange.text.length;
    }
    return caretOffset;
}

3
@TimDown: 太好了,谢谢!针对我的特定示例(使用TinyMCE),我实际上发现了一种更简单的方法:tinyMCE.execCommand('mceInsertContent', false, newContent);,我在这里找到了这个方法链接。但是还是感谢你的帮助! - Travesty3
4
是的,还有HTML或CSS所暗示的任何其他换行符。这并不是一个理想的解决方案。 - Tim Down
3
有人知道如何处理换行符吗? - k102
2
@KimchiMan:你不需要了。element.document是针对IE 5和5.5的。 - Tim Down
1
您的rangy库中是否有一个示例,可以在考虑换行符的情况下提供插入符位置? - AshD
显示剩余24条评论

28

我知道这篇文章已经一年了,但是这篇文章在寻找光标位置的许多问题上是一个顶级搜索结果,我发现它很有用。

我试图使用Tim上面优秀的脚本,在可编辑div中将一个元素从一个位置拖放到另一个位置后找到新的光标位置。在Firefox和IE中它完美地工作,但是在Chrome中,拖动操作会突出显示拖动开始和结束之间的所有内容,这导致返回的caretOffset太大或太小(超过所选区域的长度)。

我添加了一些代码到第一个if语句中,以检查是否选择了文本并相应地调整结果。新语句如下。如果在此处添加此内容不当,请原谅我,因为这不是OP要做的事情,但正如我所说,与Caret位置相关的几个搜索将我带到了这篇文章上,所以它(希望)可能会帮助其他人。

带有添加行(*)的Tim的第一个if语句:

if (typeof window.getSelection != "undefined") {
  var range = window.getSelection().getRangeAt(0);
  var selected = range.toString().length; // *
  var preCaretRange = range.cloneRange();
  preCaretRange.selectNodeContents(element);
  preCaretRange.setEnd(range.endContainer, range.endOffset);

  caretOffset = preCaretRange.toString().length - selected; // *
}

1
有人知道如何高亮一个单词吗?我已经从服务器端JSON响应中获取了偏移值,并希望根据此来突出显示该单词。我在rangy库中找不到任何东西,可以简单地插入这两个值(start字符偏移和stop字符偏移)并突出显示该单词。请给予建议。 - John
我知道这是4年前的帖子,但在我有可选择范围的情况下,我们能否突出显示一个单词? - Varun
你检查 selected 变量后再减去它是否有特殊原因?如果 selected 是 0,那么减去 0 不会改变 caretOffset,是吗? - Donnie D'Amato
1
@DonnieD'Amato 说得好。我进行了一些测试,当没有选择时,始终会得到selected == 0(从未出现undefined或null或任何其他意外情况),因此您可以放心跳过该检查并始终减去selected。 - Cody Crumrine
1
@CodyCrumrine 顺便说一下,用 null 减去某个值等同于使用 0(但不确定在所有 JavaScript 引擎中是否都是这样)。 :) - aleclarson
很遗憾,window.getSelection()在Safari上不起作用。我看到很多人都有这个问题,但目前还没有Safari的解决方案。 - KYin

27

经过几天的实验,我找到了一种看起来很有前途的方法。由于selectNodeContents()不能正确处理<br>标签,我编写了一个自定义算法来确定contenteditable内每个node的文本长度。例如,要计算选择的开始位置,我将所有前置节点的文本长度相加。这样,我可以处理(多个)换行符:

var editor = null;
var output = null;

const getTextSelection = function (editor) {
    const selection = window.getSelection();

    if (selection != null && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);

        return {
            start: getTextLength(editor, range.startContainer, range.startOffset),
            end: getTextLength(editor, range.endContainer, range.endOffset)
        };
    } else
        return null;
}

const getTextLength = function (parent, node, offset) {
    var textLength = 0;

    if (node.nodeName == '#text')
        textLength += offset;
    else for (var i = 0; i < offset; i++)
        textLength += getNodeTextLength(node.childNodes[i]);

    if (node != parent)
        textLength += getTextLength(parent, node.parentNode, getNodeOffset(node));

    return textLength;
}

const getNodeTextLength = function (node) {
    var textLength = 0;

    if (node.nodeName == 'BR')
        textLength = 1;
    else if (node.nodeName == '#text')
        textLength = node.nodeValue.length;
    else if (node.childNodes != null)
        for (var i = 0; i < node.childNodes.length; i++)
            textLength += getNodeTextLength(node.childNodes[i]);

    return textLength;
}

const getNodeOffset = function (node) {
    return node == null ? -1 : 1 + getNodeOffset(node.previousSibling);
}

window.onload = function () {
    editor = document.querySelector('.editor');
    output = document.querySelector('#output');

    document.addEventListener('selectionchange', handleSelectionChange);
}

const handleSelectionChange = function () {
    if (isEditor(document.activeElement)) {
        const textSelection = getTextSelection(document.activeElement);

        if (textSelection != null) {
            const text = document.activeElement.innerText;
            const selection = text.slice(textSelection.start, textSelection.end);
            print(`Selection: [${selection}] (Start: ${textSelection.start}, End: ${textSelection.end})`);
        } else
            print('Selection is null!');
    } else
        print('Select some text above');
}

const isEditor = function (element) {
    return element != null && element.classList.contains('editor');
}

const print = function (message) {
    if (output != null)
        output.innerText = message;
    else
        console.log('output is null!');
}
* {
    font-family: 'Georgia', sans-serif;
    padding: 0;
    margin: 0;
}

body {
    margin: 16px;
}

.p {
    font-size: 16px;
    line-height: 24px;
    padding: 0 2px;
}

.editor {
    border: 1px solid #0000001e;
    border-radius: 2px;
    white-space: pre-wrap;
}

#output {
    margin-top: 16px;
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="./script.js" async></script>
    <link href="./stylesheet.css" rel="stylesheet">
    <title>Caret Position</title>
</head>
<body>
    <p class="editor" contenteditable="true"><em>Write<br></em><br>some <br>awesome <b><em>text </em></b>here...</p>
    <p id="output">Select some text above</p>
</body>
</html>


我不得不在getTextLength和getNodeTextLength中加入一个if条件检查,检查“if(node!= null)”。此外,在“if(node!= parent)”之后,我添加了一个检查null的“node.parentNode”。 - RugerSR9
非常感谢,运行得非常完美。由于某种原因,document.activeElement无法正常工作,所以我使用了我正在处理的单个div。 - nreh
感谢您的出色工作。这是我找到的唯一一个正确处理BR标签的方法。 - Chrysotribax

3
这个解决方案通过计算回溯到父容器之前的文本内容的长度来实现。它可能无法覆盖所有边缘情况,但可以处理任意深度的嵌套标签,如果您有类似的需求,这是一个很好、简单的起点。
  calculateTotalOffset(node, offset) {
    let total = offset
    let curNode = node

    while (curNode.id != 'parent') {
      if(curNode.previousSibling) {
        total += curNode.previousSibling.textContent.length

        curNode = curNode.previousSibling
      } else {
        curNode = curNode.parentElement
      }
    }

   return total
 }

 // after selection

let start = calculateTotalOffset(range.startContainer, range.startOffset)
let end = calculateTotalOffset(range.endContainer, range.endOffset)

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