获取字符串中子字符串的起始位置,其中包含   和 HTML 标记。

huangapple go评论81阅读模式
英文:

Get start position of substring in string with   and HTML tags

问题

我在处理混合内容(HTML 和文本)的字符串时遇到了一个准确的起始位置的挑战。这些内容是由用户输入到一个可编辑的 div 中的。我不能使用文本区域,因为内容中可以添加 HTML 标记,而文本区域只接受文本。

在应用程序中,用户输入文本,然后有机会选择要突出显示的文本(应用程序将 <span class="hilite"></span> 标记添加到所选文本)和“标记”(<span>{{selected text}}<sup>{{tagname}}</sup></span> 标记),以便字符串看起来像这样:

a very  <span class="hilite">unhappy</span>   person <span>made life very difficult<sup>problem</sup></span> for another person

  项似乎是由浏览器添加的(可编辑的 div 的独特功能?),每当有多个连续的空格时都会出现,并且可以出现为 “   ”(常规空格在前)或 “  ”(常规空格在后)或一系列   空格,并在开头或结尾有一个常规空格。

因为这是用户驱动的输入,永远不知道字符串中会有多少 <span> 标记或 &amp;nbsp; 空格。无论如何,目标是计算在选定文本的 literal HTML 字符串中的开始位置。

例如,如果用户想要“标记”第二个“person”实例,用户会突出显示该单词,然后右键单击将弹出一个包含所选文本的模态框。在右键单击时,我需要在整个字符串的上下文中准确获取所选文本的位置,以其当前存在的形式。当然,我不会知道会有多少个 <span><sup>&amp;nbsp; 元素,或者会有多少个“person”实例。

以下是我用于右键单击的基本事件处理程序(使用 jQuery):

$('.editableDiv').on('contextmenu', function(e) {
    e.preventDefault();
    let fullString = $(this).html();
    let selectedText = window.getSelection();
    let textStart = {{这里的问题是如何设置}}
    $.ajax(
         {{向服务器发送参数执行操作返回修改后的字符串将$(this)的内容替换为返回的字符串}}
    );
});

具体问题是如何准确设置 textStart 的值。

JavaScript 的 string.indexOf(person) 不会起作用,因为它只会找到第一个 person 的实例,而我永远不会知道会有多少个实例,因为用户正在输入文本并选择要操作的文本。

我还尝试过像这样遍历 DOM(使用上面的 selectedText 作为 selection 调用):

function findTheStartPosition(selection) {
    let range = selection.getRangeAt(0);
    let startContainer = range.startContainer;
    let start = 0;
    for (let node of Array.from(startContainer.parentElement.childNodes)) {
        if (node === startContainer) {
            break;
        }
        if (node.nodeType === Node.TEXT_NODE) {
            start += node.textContent.length;
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            let tempDiv = document.createElement('div');
            tempDiv.appendChild(node.cloneNode(true));
            start += tempDiv.innerHTML.length;
        }
    }
    start += range.startOffset;

    return start;
}

这个方法在没有 &amp;nbsp; 元素的情况下工作得很好,因为 range.startOffset 计算所有 &amp;nbsp; 字符为单个空格,从而位置不准确。

我正在尝试避免剥离主字符串中的所有标记和空格,因为这将需要跟踪剥离的内容以及每个剥离项的长度(包括每个 &amp;nbsp; 的 6 个字符的计数),然后需要重建字符串以保留标记(但不保留多余的空格)... 这很复杂。

我只需要一种简单可靠的方法来获取用户选定文本在字符串中的起始位置。

更新 1

为了清晰起见,让我们将原始字符串(如上所示)称为“脏字符串”,将用户选择的子字符串称为“selectedText”。

可接受的解决方案可以是:

A) 一个函数,正确返回选定文本在脏字符串中的确切起始点;或者

B) 一个函数,返回一个“干净字符串”(将 &amp;nbsp; 转换为单个空格),以及选定文本在“干净字符串”中的确切起始点。

请注意:

  • 脏字符串中可能会有重复的文本。简单的“搜索短语”的选项将不起作用。
  • 字符串中可能会有任意数量的 &amp;nbsp;,它们需要被计算为“脏字符串”中的 6 个字符。
  • 任何将 &amp;nbsp; 转换为单个空格的操作都必须考虑到选定文本之前的每次转换的 5 个字符的丢失。
英文:

I'm having a challenge getting an accurate starting position in a string with mixed content (HTML and text). The content is entered by users into a contenteditable div. I can't use textareas because HTML tags can be added to the content and textareas don't accept anything but text.

In the app, users enter text and then they are given the opportunity to select text for highlighting (the app adds &lt;span class=&quot;hilite&quot;&gt;&lt;/span&gt; tags to the selected text) and "tags" (&lt;span&gt;{{selected text}}&lt;sup&gt;{{tagname}}&lt;/sup&gt;&lt;/span&gt; tags) so that a string can look like this:

a very&amp;nbsp; &lt;span class=&quot;hilite&quot;&gt;unhappy&lt;/span&gt;&amp;nbsp;&amp;nbsp; person &lt;span&gt;made life very difficult&lt;sup&gt;problem&lt;/sup&gt;&lt;/span&gt; for another person 

The &amp;nbsp; items appear to be added by the browser (a unique feature of contenteditable divs?) any time there is more than one space sequentially and this can appear as " &amp;nbsp;" (regular space first) or "&amp;nbsp; " (regular space last) or a series of &amp;nbsp; spaces with a regular space at the beginning or the end.

Because this is user driven input, it's never known how many <span> tags or &amp;nbsp; spaces will be found in a string. Either way, the goal is to count every character in the literal html string up to the beginning of the selected text.

For example, if the user wants to "tag" the second instance of "person", the user highlights the word, then a right click will pop up a modal with the selected text. At the moment of the right click, I need to get an accurate position of the selected text in the context of the whole string as it exists at the time. And, of course, I won't know how many spans, sups or &amp;nbsp; elements there are or how many instances of "person" there might be.

Here's my basic event handler for the right click (using jQuery):

$(&#39;.editableDiv&#39;).on(&#39;contextmenu&#39;, function(e) {
    e.preventDefault();
    let fullString = $(this).html();
    let selectedText = window.getSelection();
    let textStart = {{what goes here is the problem}}
    $.ajax(
         {{send arguments to server, 
             do stuff, 
             return the amended string 
             replace the contents of the $(this) with the returned string}}
    );
});

The specific problem is how do I set the value of textStart accurately.

JavaScript's string.indexOf(person) won't work because it only finds the first instance of person and I'll never know how many instances there will be since the user is entering the text and selecting the text to manipulate.

I've also tried traversing the DOM like this (called with selectedText from above as selection):

function findTheStartPosition(selection) {
    let range = selection.getRangeAt(0);
    let startContainer = range.startContainer;
    let start = 0;
    for (let node of Array.from(startContainer.parentElement.childNodes)) {
        if (node === startContainer) {
            break;
        }
        if (node.nodeType === Node.TEXT_NODE) {
            start += node.textContent.length;
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            let tempDiv = document.createElement(&#39;div&#39;);
            tempDiv.appendChild(node.cloneNode(true));
            start += tempDiv.innerHTML.length;
        }
    }
    start += range.startOffset;

    return start;
}

And that works well, except when there are &amp;nbsp; elements because range.startOffset counts all &amp;nbsp; characters as single space and thus the position is inaccurate.

I'm trying to avoid stripping the main string of all tags and spaces because it will require keeping track of what was stripped and the length of each item stripped (including a count of 6 characters for every &amp;nbsp;) and then having to rebuild the string to preserve the tags (but not the superfluous spaces)....nightmare.

All I need is a simple, reliable way to get the start position of the user selected text in a string.

Update 1

For clarity, lets call the original string (as shown above) the "dirty string" and the user-selected substring the "selectedText".
The acceptable solution could be either:

A) A function that properly returns the exact starting point of the selectedText in the dirty string; OR

B) A function that returns a "clean string" (&amp;nbsp;s converted into single spaces) and the exact starting point of the selectedText in the "clean string".

Bear in mind that:

  • there can be duplicate text in the dirty string. Simple "search-for-phrase" options will not work
  • there can be any number of &amp;nbsp;s present in the string and they need to be counted as 6 characters in the "dirty string"
  • any conversion of &amp;nbsp;s to
    single spaces has to account for the loss of 5 characters per
    conversion prior to the start of the selectedText

答案1

得分: 2

你可以使用更复杂的遍历方法。

document.getElementById("log").addEventListener("click", () => {
  const selection = window.getSelection();
  console.log("Selected " + selection.toString() + " at position " + getStartOf(selection));
});

function nodeTillStartContainer(node, start) {
  const div = document.createElement("div");
  div.appendChild(node.cloneNode(true));
  let result = div.innerHTML.substring(0, div.innerHTML.indexOf(">") + 1);

  for (let child of Array.from(node.childNodes)) {
    if (child === start) break;
    if (child.contains(start)) {
      result += nodeTillStartContainer(child, start);
      break;
    }

    const temp = document.createElement("div");
    temp.appendChild(child.cloneNode(true));
    result += temp.innerHTML;
  }

  return result;
}

function handleNbspsBefore(selection) {
  const range = selection.getRangeAt(0);
  const start = range.startContainer;
  let result = "";

  let initialContainer = start.parentElement;
  while (!initialContainer.hasAttribute("contentEditable")) {
    initialContainer = initialContainer.parentElement;
  }

  for (let node of Array.from(initialContainer.childNodes)) {
    if (node === start) break;
    if (node.contains(start)) {
      result += nodeTillStartContainer(node, start).replaceAll("&nbsp;", " ");
      break;
    }

    if (node.nodeType === Node.TEXT_NODE) {
      result += node.textContent.replaceAll("&nbsp;", " ");
    } else if (node.nodeType === Node.ELEMENT_NODE) {
      const temp = document.createElement("div");
      temp.appendChild(node.cloneNode(true));
      result += temp.innerHTML.replaceAll("&nbsp;", " ");
    }
  }

  result += start.textContent.substring(0, range.startOffset).replaceAll("&nbsp;", " ");

  return result;
}

function getStartOf(selection) {
  return handleNbspsBefore(selection).length;
}
.highlight {
  color: red;
}
<div contentEditable>a very&nbsp; <span class="highlight">unhappy</span>&nbsp;&nbsp; person <span>made life very difficult<sup>problem</sup></span> for another person</div>
<br/>
<button id="log">Log</button>

你不再需要之前问题中的算法,因为遍历是在 handleNbspsBefore 函数中执行的。

更新: 如果你希望重新组装整个字符串,我会提供一个示例以确保你正确理解我。

const selection = window.getSelection();
const cleanString = dirtyString.replaceAll("&nbsp;", " ");
const reassembledCleanString = handleNbspsBefore(selection)
  + wrapInNewTags(selection.toString())
  + cleanString.substring(getStartOf(selection) + selection.toString().length);

请注意,上述代码片段中的 dirtyStringwrapInNewTags 部分没有提供,你可能需要自行实现它们。

英文:

You can use a more complex traversing.

<!-- begin snippet: js hide: false console: true babel: false -->

<!-- language: lang-js -->

document.getElementById(&quot;log&quot;).addEventListener(&quot;click&quot;, () =&gt; {
const selection = window.getSelection();
console.log(&quot;Selected &quot; + selection.toString() + &quot; at position &quot; + getStartOf(selection));
});
function nodeTillStartContainer(node, start) {
const div = document.createElement(&quot;div&quot;);
div.appendChild(node.cloneNode(true));
let result = div.innerHTML.substring(0, div.innerHTML.indexOf(&quot;&gt;&quot;) + 1);
for (let child of Array.from(node.childNodes)) {
if (child === start) break;
if (child.contains(start)) {
result += nodeTillStartContainer(child, start);
break;
}
const temp = document.createElement(&quot;div&quot;);
temp.appendChild(child.cloneNode(true));
result += temp.innerHTML;
}
return result;
}
function handleNbspsBefore(selection) {
const range = selection.getRangeAt(0);
const start = range.startContainer;
let result = &quot;&quot;;
let initialContainer = start.parentElement;
while (!initialContainer.hasAttribute(&quot;contentEditable&quot;)) {
initialContainer = initialContainer.parentElement;
}
for (let node of Array.from(initialContainer.childNodes)) 
{
if (node === start) break;
if (node.contains(start)) {
result += nodeTillStartContainer(node, start).replaceAll(&quot;&amp;nbsp;&quot;, &quot; &quot;);
break;
}
if (node.nodeType === Node.TEXT_NODE) {
result += node.textContent.replaceAll(&quot;&amp;nbsp;&quot;, &quot; &quot;);
} else if (node.nodeType === Node.ELEMENT_NODE) {
const temp = document.createElement(&quot;div&quot;);
temp.appendChild(node.cloneNode(true));
result += temp.innerHTML.replaceAll(&quot;&amp;nbsp;&quot;, &quot; &quot;);
}
}
result += start.textContent.substring(0, range.startOffset).replaceAll(&quot;&amp;nbsp;&quot;, &quot; &quot;);
return result;
}
function getStartOf(selection) {
return handleNbspsBefore(selection).length;
}

<!-- language: lang-css -->

.highlight {
color: red;
}

<!-- language: lang-html -->

&lt;div contentEditable&gt;a very&amp;nbsp; &lt;span class=&quot;highlight&quot;&gt;unhappy&lt;/span&gt;&amp;nbsp;&amp;nbsp; person &lt;span&gt;made life very difficult&lt;sup&gt;problem&lt;/sup&gt;&lt;/span&gt; for another person&lt;/div&gt;
&lt;br/&gt;
&lt;button id=&quot;log&quot;&gt;Log&lt;/button&gt;

<!-- end snippet -->

You no longer need that algorithm from your question, since the traversal is performed in function handleNbspsBefore.

UPDATE: you want to reassemble the entire string after all. I'll provide an example to make sure you understood me correctly.

const selection = window.getSelection();
const cleanString = dirtyString.replaceAll(&quot;&amp;nbsp;&quot;, &quot; &quot;);
const reassembledCleanString = handleNbspsBefore(selection)
  + wrapInNewTags(selection.toString())
  + cleanString.substring(getStartOf(selection) + selection.toString().length);

答案2

得分: 1

surroundContents方法在这里非常有用:它有助于定位可编辑 div 中选定的部分。此外,如果失败,选定的内容本身可能无法被突出显示,例如:

Here is &lt;i&gt;some&lt;/i&gt; &amp;nbsp;text
      \_________selected____/
&gt; s &lt;i&gt;some&lt;/i&gt; &amp;nbsp;tex (偏移 6)

Here is &lt;i&gt;some&lt;/i&gt; &amp;nbsp;text
             \__selected____/
&gt; 失败
function extract(div) {
  var contents = div.innerHTML;
  var r = getSelection().getRangeAt(0);
  r.surroundContents(document.createElement("_"));
  var start = div.innerHTML.indexOf("<_>");
  var end = div.innerHTML.indexOf("</_>") - 3;
  div.innerHTML = contents;
  console.log(div.innerHTML.substring(start, end),
    "(偏移 " + start + ")");
}
<div contenteditable onmouseup="extract(this)">Here is &lt;i&gt;some&lt;/i&gt; &amp;nbsp;text</div>
英文:

The method surroundContents comes in handy here: It helps locate the selected part of the editable div. Moreover, if it fails, the selection is something that could not be highlighted anyway, for example

Here is &lt;i&gt;some&lt;/i&gt; &amp;nbsp;text
\_________selected____/
&gt; s &lt;i&gt;some&lt;/i&gt; &amp;nbsp;tex (offset 6)
Here is &lt;i&gt;some&lt;/i&gt; &amp;nbsp;text
\__selected____/
&gt; fails

<!-- begin snippet: js hide: false console: true babel: false -->

<!-- language: lang-js -->

function extract(div) {
var contents = div.innerHTML;
var r = getSelection().getRangeAt(0);
r.surroundContents(document.createElement(&quot;_&quot;));
var start = div.innerHTML.indexOf(&quot;&lt;_&gt;&quot;);
var end = div.innerHTML.indexOf(&quot;&lt;/_&gt;&quot;) - 3;
div.innerHTML = contents;
console.log(div.innerHTML.substring(start, end),
&quot;(offset &quot; + start + &quot;)&quot;);
}

<!-- language: lang-html -->

&lt;div contenteditable onmouseup=&quot;extract(this)&quot;&gt;Here is &lt;i&gt;some&lt;/i&gt; &amp;nbsp;text&lt;/div&gt;

<!-- end snippet -->

答案3

得分: 0

这是一个非常有趣的小挑战!不知道我是否为派对太晚了,但这是我的代码,它有效(著名的最后一句话)。

理论上,这段代码将找到任何div中所选文本的正确位置,不仅仅是contenteditable,并且将忽略任何格式,只要它是一级深度的。因此,主文本容器可以有多个span、sub等,但每个span不能包含额外嵌套的span。它可以轻松扩展,只是没有时间添加,你也可以添加一些乐趣!

解释

由于contenteditable通过应用HTML标记创建各种格式,它会将您的文本分成无数个HTML元素块。

当您调用getSelection()时,它将返回DOM范围中所选文本的“元素”,在这里,您无法应用本能的逻辑,认为它都在一个div中。您需要在DOM范围内工作。

所以,关键点是:

  1. 从设置一个空的“计数器”变量开始,该变量将计算不是所选目标的子元素的长度

遍历文本容器(contenteditable)div的所有子元素,直到达到document.getSelection().anchorNode

在这个过程中,您可能还需要遍历任何容器子元素的子元素。
对于不是目标选定节点的任何子元素,您将检查其文本长度,并将该长度添加到计数器中。

现在,关键在于在需要时使用“innerText”,这实际上会平坦化该元素,并将所有的“&nbsp”垃圾转换为正确的空格。

  1. 如果选择跨越多个contenteditable span、sub等,则通过检查“getSelection().extentNode”属性来获取它,这基本上是选择结束的最后部分。找出那个元素是什么,然后在主容器树中重复搜索该元素(再次查找子元素等)。

  2. 最后,您应该得到基于整个文本容器div的正确偏移量起始/结束innerText

我做了一个粗略的示例,它只适用于一个子元素的嵌套级别,但您可以使用递归循环轻松扩展它。这更多地展示了这种方法的工作方式。

//HTML
//我们使用文本div上的onmouseup来触发选择的搜索,您可以将其更改为您想要的任何触发器

<div id="test" contenteditable="true" onmouseup="GetSelectedText()" style="border: thin solid #f00;">a very&nbsp; <span class="hilite">unhappy</span>&nbsp;&nbsp; person <span>made life very difficult<sup>problem</sup></span> for another person </div>

///
function GetSelectedText () {

   let text_container = document.getElementById("test")
   let all_text = text_container.innerText //<<<这会展平text_container文本并删除所有格式

   var selection = document.getSelection()
   let nodes = Array.from(text_container.childNodes)

    let target_node = selection.anchorNode
    let parent_node = selection.anchorNode.parentNode
    if(parent_node != text_container){ //这不仅仅是一个文本元素,而是像span、sub这样的东西...
        target_node = parent_node
    }

    let sel_start = 0
    let sel_end = 0

    let res = findNodeOffsets(nodes,target_node,selection.anchorOffset,selection.focusOffset)
    sel_start = res[0]
    sel_end = res[1]

    //有点疯狂,但基本上我们需要弄清楚是否有extentNode,如果有的话,那是什么,一个基本的文本节点,其父节点是主文本容器,还是像span、sub这样的东西...
    if(selection.extentNode.parentNode != target_node){
        let extent_target = selection.extentNode
        let extent_parent_node = selection.extentNode.parentNode
        if(extent_parent_node != text_container){ 
            extent_target = selection.extentNode.parentNode
        }

        let res = findNodeOffsets(nodes,extent_target, 0,selection.extentOffset)
        
        let final_end = res[1]
        sel_end = final_end
    }


    function findNodeOffsets(nodes,target, startOffest,endOffset){
        let chars_to_target = 0 //我们将计算每个节点中的文本长度,直到达到包含选择的节点

        let start = 0
        let end = 0

        root_loop:
            for(node of nodes){
               
                if(node == target){ //我们找到它了
                        start = chars_to_target + startOffest
                        end = start + (endOffset - startOffest)

                        break root_loop
    
                }else if(node.innerText != undefined){ //这是一个父DOM元素节点,例如span、sub...
                    //快速遍历一下,看看我们的目标是否在这里
                    let sub_children = Array.from(node.childNodes)
                    
                    for(let subchild of sub_children){
                       
                        if(subchild == target){ //我们找到它了
                            start = chars_to_target + startOffest
                            end = start + (endOffset - startOffest)
                            
                            break root_loop
                        }else{
                            if(subchild.innerText){ //如果这是一个像<span这样的元素
                                chars_to_target += subchild.innerText.length
                            }else{
                              
                                chars_to_target += subchild.length
                            }
                        }
                    }
    
                }else{
                    if(node.innerText){ //如果这是一个像<span这样的元素
                        chars_to_target += node.innerText.length
                     }else{
                        chars_to_target += node.length
                     }
                }
            }

        return [start,end]
    }

    console.log("selection start: " + sel_start + " selection end: " + sel_end)

    // //test if it's correct using the all_text variable which is text_container.innerText()
    console.log("selected text: " + all_text.substring(sel_start,sel_end))
}
英文:

Hey this was a really fun little challenge! I don't know if I'm too late for the party but here's my code that works (famous last words:-)

In theory this code will find a correct position of selected text in any div not just contenteditable and will ignore any formatting as long as it's one level deep. So a main text container can have as many spans, subs as you want but each span can't have extra nested spans inside. It can be easily expanded just didn't have the time to add that, you can have some fun too!

Explanation

As the contenteditable creates various formatting by applying html tags it breaks your text in gazillion html element chunks.

When you call getSelection() it will return the selected text "element" in the DOM scope and that's where you can't really apply the instinctive logic that it's all in one div. You need to work kinda in the DOM scope.

So, the key points are:

  1. Start by setting an empty "counter" var, that will count the length of any child elements that are not the selected target

Traverse all the children of your text container (contenteditable) div until you reach the document.getSelection().anchorNode

During that journey you may have to also traverse any sub-children of any container child.
Any child that is not your target selected node you will check for it's text length and add that length to the counter.

Now, the key here is to use the "innerText" when required, that effectively flattens that element and converts all the "&nbsp" crap to proper spaces.

  1. If the selection spans across multiple contenteditable spans, subs etc. you will get that by checking the "getSelection().extentNode" property, that's basically where the last part of the selection end. Figure out what element that is and rinse and repeat the search for that element in the main container tree (again look for sub-children etc.)

  2. finally, you should end up with the correct offset start/end based on your entire text container div innerText

I've done a rough example, it only works for one nesting level of sub-children but you can easily expand that with a recursive loop. This is more to show how this approach works.

//HTML
// we use the onmouseup on the text div to trigger the search for the selection, you can change that to whatever trigger you want
&lt;div id=&quot;test&quot; contenteditable=&quot;true&quot; onmouseup=&quot;GetSelectedText()&quot; style=&quot;border:thin solid #f00;&quot;&gt;a very&amp;nbsp; &lt;span class=&quot;hilite&quot;&gt;unhappy&lt;/span&gt;&amp;nbsp;&amp;nbsp; person &lt;span&gt;made life very difficult&lt;sup&gt;problem&lt;/sup&gt;&lt;/span&gt; for another person &lt;/div&gt;
///
function GetSelectedText () {
let text_container = document.getElementById(&quot;test&quot;)
let all_text = text_container.innerText //&lt;&lt;&lt; this flattens the text_container text and removes all formatting
var selection = document.getSelection()
let nodes = Array.from(text_container.childNodes)
let target_node = selection.anchorNode
let parent_node = selection.anchorNode.parentNode
if(parent_node != text_container){ //this is not just a text element but something like span, sub...
target_node = parent_node
}
let sel_start = 0
let sel_end = 0
let res = findNodeOffsets(nodes,target_node,selection.anchorOffset,selection.focusOffset)
sel_start = res[0]
sel_end = res[1]
//bit of a crazyness here but basically we need to figure out if there is an extentNode what is it, a basic text node whose parent is the main text_container or something like span,sub...
if(selection.extentNode.parentNode != target_node){
let extent_target = selection.extentNode
let extent_parent_node = selection.extentNode.parentNode
if(extent_parent_node != text_container){ 
extent_target = selection.extentNode.parentNode
}
let res = findNodeOffsets(nodes,extent_target, 0,selection.extentOffset)
let final_end = res[1]
sel_end = final_end
}
function findNodeOffsets(nodes,target, startOffest,endOffset){
let chars_to_target = 0 //we will count the length of text in each node until we reach the node that contains the selection
let start = 0
let end = 0
root_loop:
for(node of nodes){
if(node == target){ //we found it
start = chars_to_target + startOffest
end = start + (endOffset - startOffest)
break root_loop
}else if(node.innerText != undefined){ //this is a parent dom element node i.e. span, sub...
//quickly traverse through this one to see if our target is here
let sub_children = Array.from(node.childNodes)
for(let subchild of sub_children){
if(subchild == target){ //we found it
start = chars_to_target + startOffest
end = start + (endOffset - startOffest)
break root_loop
}else{
if(subchild.innerText){ //if this is an element like &lt;span
chars_to_target += subchild.innerText.length
}else{
chars_to_target += subchild.length
}
}
}
}else{
if(node.innerText){ //if this is an element like &lt;span
chars_to_target += node.innerText.length
}else{
chars_to_target += node.length
}
}
}
return [start,end]
}
console.log(&quot;selection start: &quot; + sel_start + &quot; selection end: &quot; + sel_end)
// //test if it&#39;s correct using the all_text variable which is text_container.innerText()
console.log(&quot;selected text: &quot; + all_text.substring(sel_start,sel_end))

}

答案4

得分: 0

这是我之前发布的代码的完全重做。尽管它使用了相同的内部方法,但它明显不同,所以我想将它发布为一个单独的答案。

我希望这段代码能够按照提问者的要求执行(如果我理解要求正确的话)。现在它使用了完全的递归来深入遍历文本容器的树,并根据选择检索带有所有HTML标记的起始/结束偏移量。

我只在一个小样本上测试过它,即使在2-3级子嵌套的情况下,它也能产生正确的结果。我还没有时间处理&lt;br&gt;标签,但应该不难包含在内。此外,目前仅处理以下字符子集:&quot;&amp;amp;&quot;,&quot;&amp;lt;&quot;,&quot;&amp;gt;&quot;,&quot;&amp;sect;&quot;,&quot;&amp;copy;&quot;,&quot;&amp;quot;&quot;,&quot;&amp;#39;&quot;,&quot;&amp;#45;&quot;,&quot;&amp;nbsp;&quot;

很好奇这是否有效,这是一项相当有趣的思维挑战 获取字符串中子字符串的起始位置,其中包含 &nbsp; 和 HTML 标记。

// HTML - 我们使用文本div上的onmouseup来触发选择的搜索,您可以将其更改为任何触发器

a very  unhappy   person person made life very difficultproblemsmaller problem another problem hamster size plums problem for another person thathas noplum problems

// 结束HTML

function GetSelectedText () {

let text_container = document.getElementById("test")
let all_text = text_container.innerHTML
var selection = document.getSelection()

let main_nodes = Array.from(text_container.childNodes)
let startOffset = selection.anchorOffset
let endOffset = selection.focusOffset

let target_node = selection.anchorNode
let node_path = []
let break_loop = 0
findNode(0,main_nodes,target_node,startOffset,node_path)
let sel_start = 0
let sel_end = 0
for(let el of node_path){
sel_start += (el.start + el.end)
}

let extent_path = []
if(selection.extentNode != target_node){
break_loop = 0
findNode(0,main_nodes,selection.extentNode,endOffset,extent_path)

for(let el of extent_path){
sel_end += (el.start + el.end)
}

}else{
let real_offset = countTagsToTargetIndex(target_node,startOffset,endOffset)
sel_end = sel_start + (real_offset[1]- real_offset[0])
}

console.log("offset start: " + sel_start + " offset end: " + sel_end)

console.log("selected text: " + all_text.substring(sel_start,sel_end))
}

function findNode(i, nodes, target,offset,node_collection){
if(break_loop == 1){
return
}
if(i < nodes.length){

if(nodes[i] != target){

if(nodes[i].childNodes.length > 0){

let sub_nodes = Array.from(nodes[i].childNodes)

let found = findNode(0,sub_nodes, target,offset,node_collection)
let tags = getOuterTags(nodes[i])

node_collection.push({
node: nodes[i],
start: tags[0],
end: 0
})

if(found){
break_loop = 1
return 1
}else{
node_collection.push({
node: nodes[i],
start: 0,
end: tags[1]
})

}
}else{
let real_offset = countTagsToTargetIndex(nodes[i],0,nodes[i].length)
node_collection.push({
node: nodes[i],
start: real_offset[0],
end: real_offset[1]
})

let found = findNode(i + 1, nodes, target,offset,node_collection)
if(found){
break_loop = 1
return 1
}

}else{
let real_offset = countTagsToTargetIndex(nodes[i],0,offset)
node_collection.push({
node: nodes[i],
start: real_offset[0],
end: real_offset[1]
})
return 1

}

}
}

function getOuterTags(node){
let outer = node.outerHTML
let inner = node.innerHTML
let start_tag = outer.indexOf(inner)
let end_tag = outer.length - start_tag - inner.length
return [start_tag,end_tag]
}

function countTagsToTargetIndex(node,start,end){

let encs = ["&","<",">","§","©",""","'","-"," "]
const newDiv = document.createElement("div");
newDiv.appendChild(node.cloneNode())
let html = newDiv.innerHTML

let indexMap = {}
let cnt = 0

for(var i = 0; i <= html.length; i++){
let flag = 0
tags:
for(let enc of encs){
const l = enc.length
if(html.substring(i,i + l) == enc){
indexMap[cnt] = l
i = i + l - 1
flag = 1
break tags
}

}
if(flag == 0){
indexMap[cnt] = 1
}
cnt++
}

let realStart = 0
for(let i = 0;i < start; i++ ){
realStart += indexMap[i]
}

let realEnd = 0
for(let i = 0;i < end; i++ ){
realEnd += indexMap[i]
}

return [realStart,realEnd]

}

英文:

This is a complete rework of the code I have posted earlier. Although it uses the same internal approach it is distinctly different so I thought to post it as a separate answer.

I hope that this code should do what the OP has asked for (if I understood the brief correctly). It's now using a full recursiveness to drill down through the tree of the text container and based on the selection retrieve the start/end offsets with all html markup included.

I've only tested it on a small sample and it produces correct results even with 2-3 levels of sub nesting. One thing I haven't had the time to look at handling is the &lt;br&gt; tags but shouldn't be to difficult to include. Also, the handling of various chars is currently this subset:&quot;&amp;amp;&quot;,&quot;&amp;lt;&quot;,&quot;&amp;gt;&quot;,&quot;&amp;sect;&quot;,&quot;&amp;copy;&quot;,&quot;&amp;quot;&quot;,&quot;&amp;#39;&quot;,&quot;&amp;#45;&quot;,&quot;&amp;nbsp;&quot;

Intrigued to see if this works, it's been quite an interesting mental challenge 获取字符串中子字符串的起始位置,其中包含 &nbsp; 和 HTML 标记。

//HTML - we use the onmouseup on the text div to trigger the search for the selection, you can change that to whatever trigger you want
&lt;div id=&quot;test&quot; contenteditable=&quot;true&quot; onmouseup=&quot;GetSelectedText()&quot;style=&quot;border:thin solid #f00;&quot;&gt;a very&amp;nbsp; &lt;span class=&quot;hilite&quot;&gt;unhappy&lt;/span&gt;&amp;nbsp;&amp;nbsp; person person&lt;span&gt; made life very difficult&lt;sup&gt;problem&lt;/sup&gt;smaller problem &lt;sup&gt; another problem &lt;sup&gt;hamster size plums problem&lt;/sup&gt;&lt;/sup&gt;&lt;/span&gt; for another person &lt;span&gt;that&lt;sup&gt;has no&lt;/sup&gt;plum problems&lt;/span&gt;&lt;/div&gt;
////END HTML
function GetSelectedText () {
let text_container = document.getElementById(&quot;test&quot;)
let all_text = text_container.innerHTML 
var selection = document.getSelection()
let main_nodes = Array.from(text_container.childNodes)
let startOffset = selection.anchorOffset
let endOffset = selection.focusOffset
let target_node = selection.anchorNode
let node_path = []
let break_loop = 0
findNode(0,main_nodes,target_node,startOffset,node_path)
let sel_start = 0
let sel_end = 0
for(let el of node_path){
sel_start += (el.start + el.end)
}
let extent_path = []
if(selection.extentNode != target_node){
break_loop = 0
findNode(0,main_nodes,selection.extentNode,endOffset,extent_path)
for(let el of extent_path){
sel_end += (el.start + el.end)
}
}else{
let real_offset = countTagsToTargetIndex(target_node,startOffset,endOffset)
sel_end = sel_start + (real_offset[1]- real_offset[0])
}
console.log(&quot;offset start: &quot; + sel_start + &quot; offset end: &quot; + sel_end)
console.log(&quot;selected text: &quot; + all_text.substring(sel_start,sel_end))
function findNode(i, nodes, target,offset,node_collection){
if(break_loop == 1){
return
}
if(i &lt; nodes.length){
if(nodes[i] != target){
if(nodes[i].childNodes.length &gt; 0){
let sub_nodes = Array.from(nodes[i].childNodes)
let found = findNode(0,sub_nodes, target,offset,node_collection)
let tags = getOuterTags(nodes[i])
node_collection.push({
node: nodes[i],
start: tags[0],
end: 0
})
if(found){
break_loop = 1
return 1
}else{
node_collection.push({
node: nodes[i],
start: 0,
end: tags[1]
})
}
}else{
let real_offset = countTagsToTargetIndex(nodes[i],0,nodes[i].length)
node_collection.push({
node: nodes[i],
start: real_offset[0],
end: real_offset[1]
})
}
let found = findNode(i + 1, nodes, target,offset,node_collection)
if(found){
break_loop = 1
return 1
}
}else{
let real_offset = countTagsToTargetIndex(nodes[i],0,offset)
node_collection.push({
node: nodes[i],
start: real_offset[0],
end: real_offset[1]
})
return 1
}
}
}
function getOuterTags(node){
let outer = node.outerHTML
let inner = node.innerHTML
let start_tag = outer.indexOf(inner)
let end_tag = outer.length - start_tag - inner.length
return [start_tag,end_tag]
}
function countTagsToTargetIndex(node,start,end){
let encs = [&quot;&amp;amp;&quot;,&quot;&amp;lt;&quot;,&quot;&amp;gt;&quot;,&quot;&amp;sect;&quot;,&quot;&amp;copy;&quot;,&quot;&amp;quot;&quot;,&quot;&amp;#39;&quot;,&quot;&amp;#45;&quot;,&quot;&amp;nbsp;&quot;]
const newDiv = document.createElement(&quot;div&quot;);
newDiv.appendChild(node.cloneNode())
let html = newDiv.innerHTML
let indexMap = {}
let cnt = 0
for(var i = 0; i &lt;= html.length; i++){
let flag = 0
tags:
for(let enc of encs){
const l = enc.length
if(html.substring(i,i + l) == enc){
indexMap[cnt] = l 
i = i + l - 1 
flag = 1
break tags
}
}
if(flag == 0){
indexMap[cnt] = 1 
}
cnt++
}
let realStart = 0
for(let i = 0;i &lt; start; i++ ){
realStart += indexMap[i]
}
let realEnd = 0
for(let i = 0;i &lt; end; i++ ){
realEnd += indexMap[i]
}
return [realStart,realEnd]
}
}

答案5

得分: 0

以下是翻译好的内容:

这是我对@Heiko解决方案的个人看法:

function extract(div) {
    let contents = div[0].innerHTML;
    let r = getSelection().getRangeAt(0);
    let newParent = document.createElement("_");
    r.surroundContents(newParent);
    let start = div[0].innerHTML.indexOf("<_>");
    let end = div[0].innerHTML.indexOf("</_>") - 3;
    let beginString = div[0].innerHTML.substring(0, start).replace(/(&amp;nbsp;|&nbsp;)/g, '');
    let selectedText = div[0].innerHTML.substring((start + 3), (end + 3));
    let newText = wrapInNewTags(selectedText);
    let endString = div[0].innerHTML.substring(end + 7).replace(/(&amp;nbsp;|&nbsp;)/g, '');
    let reassembledString = beginString + newText + endString;
    console.log('reassembledString', reassembledString);
}

// 用于将解决方案包装在新标签中的简单函数
function wrapInNewTags(selection) {
    return '<span class="tagged">' + selection + '</span>'.replace(/(&amp;nbsp;|&nbsp;)/g, '');
}

然后在PHP中,如果需要,可以使用htmlspecialchars_decodereassembledString转换为可读的标记字符串。

这与Heiko的解决方案之间的小差异包括:

  1. 使用div[0].innerHTML而不是div.innerHTML
  2. 从innerHTML内容创建所需字符串时清除&amp;nbsp;&nbsp;实体字符
  3. 提取beginStringendString的值

在这个过程中,我了解到,如果在contents中有一个&amp;nbsp;或包含<span></span>的HTML标签,从div[0].innerHTML输出的字符串会将它们转换为&amp;amp;nbsp;&amp;lt;span&amp;gt;&amp;lt;/span&amp;gt;,分别。当存在多个标签时,这看起来很丑陋,但它可以正常工作。如果在wrapInNewTags函数中不按照此格式处理,那么您将获得一些包含可读标记的文本以及一些使用&amp;的实体字符。

最终,这个解决方案干净而直观。

英文:

Here's my riff on @Heiko's solution:

function extract(div) {
let contents = div[0].innerHTML;
let r = getSelection().getRangeAt(0);
let newParent = document.createElement(&quot;_&quot;);
r.surroundContents(newParent);
let start = div[0].innerHTML.indexOf(&quot;&lt;_&gt;&quot;);
let end = div[0].innerHTML.indexOf(&quot;&lt;/_&gt;&quot;) - 3;
let beginString = div[0].innerHTML.substring(0,start).replace(/(&amp;amp;nbsp;|&amp;nbsp;)/g, &#39;&#39;);
let selectedText =  div[0].innerHTML.substring((start + 3), (end+3));
let newText = wrapInNewTags(selectedText);
let endString = div[0].innerHTML.substring(end+7).replace(/(&amp;amp;nbsp;|&amp;nbsp;)/g, &#39;&#39;);
let reassembledString = beginString + newText + endString;
console.log(&#39;reassembledString&#39;, reassembledString);
}

And a simple function to wrap the solution in new tags:

function wrapInNewTags(selection) {
return &#39;&amp;lt;span class=&quot;tagged&quot;&amp;gt;&#39;+selection+&#39;&amp;lt;/span&amp;gt;&#39;.replace(/(&amp;amp;nbsp;|&amp;nbsp;)/g, &#39;&#39;);
}

Then in PHP, if needed, the reassembledString is converted neatly into readable, tagged strings with htmlspecialchars_decode;

The minor differences between this and Heiko's solution are:

  1. the use of div[0].innerHTML vs div.innerHTML
  2. the cleaning of both &amp;amp;nbsp; and &amp;nbsp; entities from the string when the desired string is being created from innerHTML content
  3. The extraction of the beginString and endString values as well.

Along the way, I learned that if there is a single &amp;nbsp; or html tag with a &lt;span&gt;&lt;/span&gt; in the contents, the string that is output from div[0].innerHTML converts these to &amp;amp;nbsp; or &amp;lt;span&amp;gt;&amp;lt;/span&amp;gt;, respectively. It's quite ugly when there are multiple tags, but it works. If you don't follow this format in the wrapInNewTags function, you then get some text with readable tags and some items that are htmlentities (using the &amp;).

In the end the solution is clean and straightforward.

答案6

得分: 0

目标

当用户在 [contenteditable][1] 元素中选择文本,并右键单击该元素时,应复制(或移动)所选文本及其包含的 HTML,以保留其中的[空白字符][2](例如 &amp;nbsp;、制表符、换行符)。然后,通过 AJAX 对文本进行某些处理,然后将其返回到 contenteditable 元素的原始位置。文本应保留任何在修改过程中未更改的原始空白和/或文本字符。

需要记住的事情

每个浏览器使用不同的 HTML 在 contenteditable 元素中获取相同的外观(多多少少会有一些错误,特别是在 Chrome 中)。在下面的实时演示中,有一些措施来标准化浏览器的默认行为。

关注 [节点][3],而不是字符串中的索引位置

在 OP 中描述的情况下,基本上有两种 [nodeType][4] 需要关注:元素(nodeType 1)和文本(nodeType 3)。为了访问所选内容,请使用 [Selection 对象][5]。为了操作 Selection 对象 中的节点,请创建 [Range 对象][6]。

详细信息在示例中有注释。

/**
 * 带有†的注释与<dialog>相关:
 * 用于演示目的,因为无法证明通过OP中提到的AJAX如何保留所选文本。
 */
// 引用contenteditable元素以获取文本。
const A = document.getElementById("editorA");
// † 引用将复制文本的元素。
const B = document.getElementById("editorB");
// † 引用<dialog>
const modal = document.getElementById("modal");

/**
 * - 声明Selection和Range对象的变量。
 * - † 如果用户取消了任何更改,请使用关键字“let”(也推荐使用“var”)声明一个变量(而不是定义它),
 *   在函数之外声明允许对其值进行赋值和/或重新赋值,
 *   可以让其他函数访问它,并在函数运行后保持不变。
 */
let select, range, original;

// 在#editorA上注册“contextmunu”事件
A.oncontextmenu = getText;
// † 在<dialog>上注册“close”事件
modal.onclose = setText;

// 事件处理程序默认传递(e)vent对象
function getText(e) {
  // 阻止默认的上下文菜单打开
  e.preventDefault();
  // 请参阅下面。
  if (navigator.userAgent.match(/firefox|fxios/i)) {
    lineBreak(this);
  }
  // 定义Selection对象
  select = window.getSelection();
  // 定义Range对象
  range = select.getRangeAt(0);
  // 声明复制的文本和<span>
  let text, node;
  // † 打开<dialog>作为模态。
  modal.showModal();
  /* 从#editorA中删除所选文本并将其保留在
  documentFragment Node中。 */
  text = range.extractContents();
  // 保留文本的副本。
  original = text.cloneNode(true);
  // 将文本添加到#editorB。
  B.append(text);
}

function setText(e) {
  // † “this”是dialog#modal。
  // 请参阅下面。
  if (navigator.userAgent.match(/firefox|fxios/i)) {
    lineBreak(B);
  }
  // 创建一个<span>。
  const node = document.createElement("SPAN");
  // 分配<span>的类。
  node.className = "edit";
  // † 如果用户单击了.value = “Cancel”的按钮...
  if (this.returnValue === "Cancel") {
    // † ...将原始文本添加到span.edit...
    node.append(original);
  } else {
    // † ...否则获取#editorB的内容。
    content = B.innerHTML;
    // † ...并将其添加到span.edit。
    node.insertAdjacentHTML("beforeend", content);
  }
  /* 在Selection对象的Range对象的开始位置插入span.edit
  #editorA中。 */
  range.insertNode(node);
  // † 清除#editorB的内容。
  B.replaceChildren();
  /* 可选 - 如果不想要大量
  <span class="edit">取消注释下一行 -
  请参阅下面 */
  //unWrap(A.querySelector(".edit"));
}

/**
 * 此函数移除给定元素但保留其内容。
 */
function unWrap(selector) {
  let target = typeof selector === "string" ? document.querySelector(selector) : selector;
  const ancestor = target.parentElement;
  while (target.firstChild) {
    ancestor.insertBefore(target.firstChild, target);
  }
  target.remove();
}

/**
 * 此函数将<div>替换为<br>,这是
 * 优于Firefox默认的<div>内嵌的<br>。
 */
function lineBreak(selector) {
  let target = typeof selector === "string" ? document.querySelector(selector) : selector;
  const divs = target.querySelectorAll("div");
  divs.forEach(div => {
    if (!div.querySelector("br")) {
      div.append(document.createElement("BR"));
    }
    unWrap(div);
  });
}
:root {
  font: 5vmin/1.15 "Segoe UI";
}

fieldset {
  border-radius: 4px;
  /** 
   * 可选 - 保留所有空白字符并不会折叠连续的空白字符。
   */
  /* white-space: pre-wrap; */
  box-shadow: inset 0.2rem 0.2rem 0.75rem rgba(255, 255, 255, .2), inset -0.2rem -0.25rem 0.25rem rgba(0, 0, 0, .2);
}

dialog {
  width: 20rem;
  border-radius: 5px;
  box-shadow: 0 10px 6px -6px #777;
}

dialog::backdrop {
  background: rgba(50, 50, 50, 0.3);
}

input {
  float: right;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  font: inherit;
  font-variant: small-caps

<details>
<summary>英文:</summary>

Objective
=

When the user selects text within a [`contenteditable`][1] element, and right clicks the element, the selected text and it&#39;s containing HTML should be copied (or moved) so that it&#39;s [whitespace characters][2] (ex. `&amp;nbsp;`, tabs, line breaks) are preserved. The text is then modified by some process via AJAX and then returned back to the `contenteditable` element in it&#39;s original location. The text should have any original whitespace and/or text characters that wasn&#39;t changed during it&#39;s modification. 

Things to Keep in Mind
-
Each browser uses different HTML in `contenteditable` elements to obtain the same appearance (more or less since there are bugs (especially in Chrome)). In the live demo below, there are some measures to standardize browser defaults. 


Focus on [Nodes][3], not Index Positions in Strings 
-
As far as the situation described in OP there are basically two [`nodeType`][4]s to be concerned with: Element (`nodeType` 1) and Text (`nodeType` 3). In order to access the selected content, use [Selection Object][5]. In order to manipulate the nodes within the Selection Object , create a [Range Object][6].

***Details are commented in example.***

&lt;!-- begin snippet: js hide: false console: true babel: false --&gt;

&lt;!-- language: lang-js --&gt;

    /**
     * Any comment with a dagger: † pertains to the &lt;dialog&gt;
     * which is used for demonstration purposes since there&#39;s
     * no way to prove how the selected text is preserved by
     * the using AJAX mentioned in OP.
     */
    // Reference the contenteditable element to get the text from.
    const A = document.getElementById(&quot;editorA&quot;);
    //  Reference the element the text will be copied to.
    const B = document.getElementById(&quot;editorB&quot;);
    //  Reference the &lt;dialog&gt;
    const modal = document.getElementById(&quot;modal&quot;);

    /**
     * - Declare variables for Selection and Range Objects.
     * - † Declare a variable for the original copied text if 
     *   the user cancels any changes.
     * - Declaring (not defining) a variable with the keyword
     *   &quot;let&quot; (&quot;var&quot; as well but not recommended) outside of
     *   the functions allows it&#39;s value to be assigned and/or 
     *   reassigned later, be accessible to other functions, and 
     *   persist after a function&#39;s runtime.
     */
    let select, range, original;

    // Register the &quot;contextmunu&quot; event to #editorA
    A.oncontextmenu = getText;
    //  Register the &quot;close&quot; event to &lt;dialog&gt;
    modal.onclose = setText;

    // Event handler passes (e)vent Object by default
    function getText(e) {
      // Stop the default context menu from opening
      e.preventDefault();
      // See below.
      if (navigator.userAgent.match(/firefox|fxios/i)) {
        lineBreak(this);
      }
      // Define Selection Object
      select = window.getSelection();
      // Define Range Object
      range = select.getRangeAt(0);
      // Declare variables for copied text and &lt;span&gt;
      let text, node;
      //  Open &lt;dialog&gt; as a modal.
      modal.showModal();
      /* Remove selected text from #editorA and keep it in a
      documentFragment Node. */
      text = range.extractContents();
      // Keep a copy of text.
      original = text.cloneNode(true);
      // Add text to #editorB.
      B.append(text);
    }

    function setText(e) {
      //  &quot;this&quot; is dialog#modal.
      // See below. 
      if (navigator.userAgent.match(/firefox|fxios/i)) {
        lineBreak(B);
      }
      // Create a &lt;span&gt;.
      const node = document.createElement(&quot;SPAN&quot;);
      // Assign &lt;span&gt; a class.
      node.className = &quot;edit&quot;;
      //  If the user clicked a button with .value = &quot;Cancel&quot;...
      if (this.returnValue === &quot;Cancel&quot;) {
        //  ...add the original text to span.edit...
        node.append(original);
      } else {
        //  ...otherwise get the content of #editorB.
        content = B.innerHTML;
        //  ...and add it to span.edit. 
        node.insertAdjacentHTML(&quot;beforeend&quot;, content);
      }
      /* Insert span.edit at the start of the Range Object 
      within the Selection Object of #editorA. */
      range.insertNode(node);
      //  Clear the contents of #editorB.
      B.replaceChildren();
      /* OPTIONAL - if you don&#39;t want a ton of 
      &lt;span class=&quot;edit&quot;&gt; uncomment the next line - 
      See below */
      //unWrap(A.querySelector(&quot;.edit&quot;));
    }

    /**
     * This function removes a given element but keeps
     * it&#39;s contents.
     */
    function unWrap(selector) {
      let target = typeof selector === &quot;string&quot; ? document.querySelector(selector) : selector;
      const ancestor = target.parentElement;
      while (target.firstChild) {
        ancestor.insertBefore(target.firstChild, target);
      }
      target.remove();
    }

    /**
     * This function replaces &lt;div&gt;s with &lt;br&gt; which is
     * perferable over Firefox default of a &lt;br&gt; nested
     * within a &lt;div&gt;.
     */
    function lineBreak(selector) {
      let target = typeof selector === &quot;string&quot; ? document.querySelector(selector) : selector;
      const divs = target.querySelectorAll(&quot;div&quot;);
      divs.forEach(div =&gt; {
        if (!div.querySelector(&quot;br&quot;)) {
          div.append(document.createElement(&quot;BR&quot;));
        }
        unWrap(div);
      });
    }

&lt;!-- language: lang-css --&gt;

    :root {
      font: 5vmin/1.15 &quot;Segoe UI&quot;
    }

    fieldset {
      border-radius: 4px;
      /** 
       * OPTIONAL - All whitespace characters are preserved and consecutive 
       * whitespace characters do not collapse.
       */
      /* white-space: pre-wrap; */
      box-shadow: inset 0.2rem 0.2rem 0.75rem rgba(255, 255, 255, .2), inset -0.2rem -0.25rem 0.25rem rgba(0, 0, 0, .2);
    }

    dialog {
      width: 20rem;
      border-radius: 5px;
      box-shadow: 0 10px 6px -6px #777;
    }

    dialog::backdrop {
      background: rgba(50, 50, 50, 0.3);
    }

    input {
      float: right;
      padding: 0.25rem 0.5rem;
      border-radius: 4px;
      font: inherit;
      font-variant: small-caps;
      cursor: pointer;
      box-shadow: 0 0 4px -4px #bbb;
    }

    input:first-of-type {
      border-top-left-radius: 0;
      border-bottom-left-radius: 0;
    }

    input:last-of-type {
      border-top-right-radius: 0;
      border-bottom-right-radius: 0;
    }

    #editorA {
      min-height: 3rem;
      margin: 10vh 0;
    }

    #editorB {
      min-height: 1.5rem;
      margin: 0.75rem 0;
    }

    .edit {
      /* white-space: pre-wrap; */
    }

&lt;!-- language: lang-html --&gt;

    &lt;fieldset id=&quot;editorA&quot; contenteditable&gt;&lt;/fieldset&gt;

    &lt;dialog id=&quot;modal&quot;&gt;
      &lt;form method=&quot;dialog&quot;&gt;
        &lt;fieldset id=&quot;editorB&quot; contenteditable&gt;&lt;/fieldset&gt;
        &lt;input type=&quot;submit&quot; value=&quot;Change&quot;&gt;
        &lt;input type=&quot;submit&quot; value=&quot;Cancel&quot;&gt;
      &lt;/form&gt;
    &lt;/dialog&gt;

&lt;!-- end snippet --&gt;


  [1]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable
  [2]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace
  [3]: https://developer.mozilla.org/en-US/docs/Web/API/Node
  [4]: https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
  [5]: https://developer.mozilla.org/en-US/docs/Web/API/Selection
  [6]: https://developer.mozilla.org/en-US/docs/Web/API/Range

</details>



# 答案7
**得分**: -2

我建议要么

1. 使用ProseMirror或者...
2. 从ProseMirror的[文档](https://prosemirror.net/docs/)[开源代码](https://github.com/prosemirror)中获取灵感

ProseMirror是一个在底层使用`contenteditable`的编辑器框架

您可能可以简单地修改[ProseMirror的基本示例][1]以适应您的需求选择的开始和结束可以通过`let {from, to} = state.selection`来访问(查看[tooltips示例][3])这些示例支持插入HTML对象如图像和`<HR>`。

我认为ProseMirror相对于普通的`contenteditable` `<div>`有一些优点但它们也有一些成本比如增加的复杂性和陡峭的学习曲线。(对于复杂的特定的用例您可能需要定义自己的文档语法。)

因此另一个选项是简单地模仿ProseMirror的方法

1. ProseMirror将文档内容与`contenteditable` `<div>`分开进行跟踪这避免了您遇到的问题即插入空格时会插入`&nbsp;`。(而且我会惊讶地发现`contenteditable`在所有平台/浏览器上都行为完全相同)。
2. 然后ProseMirror将此文档呈现为HTML

> ProseMirror提供了一组用于构建富文本编辑器的工具和概念使用的用户界面灵感来自所见即所得但试图避免该编辑风格的陷阱
> 
> ProseMirror的主要原则是您的代码对文档及其发生的事情有完全控制这个文档不是HTML的一块而是一个自定义的数据结构只包含您明确允许它包含的元素以您指定的关系所有更新都通过一个单一点进行您可以在那里检查它们并对它们做出反应
> 
> 核心库不是一个容易的插入式组件——我们更注重模块化和可定制性希望未来人们会基于ProseMirror分发插入式编辑器因此这更像是一套乐高积木而不是Matchbox汽车

[1]: https://prosemirror.net/examples/basic/
[2]: https://prosemirror.net/docs/ref/#state.Selection
[3]: https://prosemirror.net/examples/tooltip/

<details>
<summary>英文:</summary>

I suggest either:

1. Using ProseMirror OR...
2. Taking inspiration from ProseMirror&#39;s [documentation](https://prosemirror.net/docs/) and [open source](https://github.com/prosemirror). 

ProseMirror is a (framework for building an) editor that uses `contenteditable` under the hood.

You might be able to just modify the [ProseMirror basic example][1] to fit your needs. The [selection][2] start and end are accessed via `let {from, to} = state.selection`. (Take a look at the [tooltips example][3].) These examples support inserting HTML objects like images and `&lt;HR&gt;`&#39;s.

I think ProseMirror has several advantages over a plain `contenteditable` `&lt;div&gt;`. But they are not without costs like added complexity and a steep learning curve. (For complex, niche use cases you may have to define your own document grammar.)

So another option is to simply emulate ProseMirror&#39;s method:

1. ProseMirror keeps track of the document content separately from the `contenteditable` `&lt;div&gt;`. This avoids the problem you are facing with `&amp;nbsp;` being inserted for spaces. (Also I would be surprised if `contenteditable` behaves exactly the same on all platforms/browsers).
2. Then ProseMirror renders this document into HTML.

&gt; ProseMirror provides a set of tools and concepts for building rich
&gt; text editors, using a user interface inspired by
&gt; what-you-see-is-what-you-get, but trying to avoid the pitfalls of that
&gt; style of editing.
&gt; 
&gt; The main principle of ProseMirror is that your code gets full control
&gt; over the document and what happens to it. This document isn&#39;t a blob
&gt; of HTML, but a custom data structure that only contains elements that
&gt; you explicitly allow it to contain, in relations that you specified.
&gt; All updates go through a single point, where you can inspect them and
&gt; react to them.
&gt; 
&gt; The core library is not an easy drop-in componentwe are prioritizing
&gt; modularity and customizability over simplicity, with the hope that, in
&gt; the future, people will distribute drop-in editors based on
&gt; ProseMirror. As such, this is more of a Lego set than a Matchbox car.


  [1]: https://prosemirror.net/examples/basic/
  [2]: https://prosemirror.net/docs/ref/#state.Selection
  [3]: https://prosemirror.net/examples/tooltip/

</details>



huangapple
  • 本文由 发表于 2023年7月27日 19:29:06
  • 转载请务必保留本文链接:https://go.coder-hub.com/76779293.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定