HTML contenteditable:当内部 HTML 改变时保持插入位置

HTML contenteditable: Keep Caret Position When Inner HTML Changes

我有一个 div 可以充当所见即所得的编辑器。这充当文本框,但在其中呈现降价语法,以显示实时更改。

问题:键入字母时,插入符号位置重置为 div.

的开头

const editor = document.querySelector('div');
editor.innerHTML = parse('**dlob**  *cilati*');

editor.addEventListener('input', () => {
  editor.innerHTML = parse(editor.innerText);
});

function parse(text) {
  return text
    .replace(/\*\*(.*)\*\*/gm, '**<strong></strong>**')     // bold
    .replace(/\*(.*)\*/gm, '*<em></em>*');                  // italic
}
div {
  height: 100vh;
  width: 100vw;
}
<div contenteditable />

Codepen: https://codepen.io/ADAMJR/pen/MWvPebK

像 QuillJS 这样的 Markdown 编辑器似乎可以在不编辑父元素的情况下编辑子元素。这避免了问题,但我现在确定如何使用此设置重新创建该逻辑。

问题:如何让插入符位置在打字时不重置?

Update: I have managed to send the caret position to the end of the div, on each input. However, this still essentially resets the position. https://codepen.io/ADAMJR/pen/KKvGNbY

您需要保持持仓状态并在每次输入时恢复它。没有别的办法。您可以查看我的项目 jQuery Terminal 中如何处理可编辑的内容(链接指向源代码中的特定行并使用提交哈希,我写这篇文章时是当前主控,因此它们将始终指向这些行) .

  • insert method 当用户键入内容(或复制粘贴)时使用。
  • fix_textarea - 添加内容可编辑后功能没有改变。该函数确保 textarea 或 contenteditable(隐藏的)具有与可见光标相同的状态。
  • clip object(即文本区域或内容可编辑 - 另一个未重构的名称,开始时仅用于剪贴板)。

位置我用的是jQuery Caret,这是移动光标的核心。您可以轻松修改此代码并使其按您的需要工作。 jQuery 插件可以轻松重构为函数 move_cursor.

这应该会让您了解如何在您的项目中自行实现它。

大多数富文本编辑器的做法是保持自己的内部状态,在按下事件时更新它并呈现自定义可视层。例如像这样:

const $editor = document.querySelector('.editor');
const state = {
 cursorPosition: 0,
 contents: 'hello world'.split(''),
 isFocused: false,
};


const $cursor = document.createElement('span');
$cursor.classList.add('cursor');
$cursor.innerText = '᠎'; // Mongolian vowel separator

const renderEditor = () => {
  const $contents = state.contents
    .map(char => {
      const $span = document.createElement('span');
      $span.innerText = char;
      return $span;
    });
  
  $contents.splice(state.cursorPosition, 0, $cursor);
  
  $editor.innerHTML = '';
  $contents.forEach(el => $editor.append(el));
}

document.addEventListener('click', (ev) => {
  if (ev.target === $editor) {
    $editor.classList.add('focus');
    state.isFocused = true;
  } else {
    $editor.classList.remove('focus');
    state.isFocused = false;
  }
});

document.addEventListener('keydown', (ev) => {
  if (!state.isFocused) return;
  
  switch(ev.key) {
    case 'ArrowRight':
      state.cursorPosition = Math.min(
        state.contents.length, 
        state.cursorPosition + 1
      );
      renderEditor();
      return;
    case 'ArrowLeft':
      state.cursorPosition = Math.max(
        0, 
        state.cursorPosition - 1
      );
      renderEditor();
      return;
    case 'Backspace':
      if (state.cursorPosition === 0) return;
      delete state.contents[state.cursorPosition-1];
      state.contents = state.contents.filter(Boolean);
      state.cursorPosition = Math.max(
        0, 
        state.cursorPosition - 1
      );
      renderEditor();
      return;
    default:
      // This is very naive
      if (ev.key.length > 1) return;
      state.contents.splice(state.cursorPosition, 0, ev.key);
      state.cursorPosition += 1;
      renderEditor();
      return;
  }  
});

renderEditor();
.editor {
  position: relative;
  min-height: 100px;
  max-height: max-content;
  width: 100%;
  border: black 1px solid;
}

.editor.focus {
  border-color: blue;
}

.editor.focus .cursor {
  position: absolute;
  border: black solid 1px;
  border-top: 0;
  border-bottom: 0;
  animation-name: blink;
  animation-duration: 1s;
  animation-iteration-count: infinite;
}

@keyframes blink {
  from {opacity: 0;}
  50% {opacity: 1;}
  to {opacity: 0;}
}
<div class="editor"></div>

您需要先获取光标的位置,然后再处理和设置内容。然后恢复光标位置。

当存在嵌套元素时,恢复光标位置是一个棘手的部分。此外,您每次都在创建新的 <strong><em> 元素,旧元素将被丢弃。

const editor = document.querySelector('.editor');
editor.innerHTML = parse('For **bold** two stars. For *italic* one star. Some more **bold**.');

editor.addEventListener('input', () => {
  //get current cursor position
  let sel = window.getSelection();
  let node = sel.focusNode;
  let offset = sel.focusOffset;
  let pos = getCursorPosition(editor, node, offset, {pos: 0, done: false});
  //console.log('Position: ', pos);

  editor.innerHTML = parse(editor.innerText);

  // restore the position
  sel.removeAllRanges();
  let range = setCursorPosition(editor, document.createRange(), {  pos: pos.pos, done: false});
  range.collapse(true);
  sel.addRange(range);
  
});

function parse(text) {
  //use (.*?) lazy quantifiers to match content inside
  return text
    .replace(/\*{2}(.*?)\*{2}/gm, '**<strong></strong>**') // bold
    .replace(/(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)/gm, '*<em></em>*'); // italic
}

// get the cursor position from .editor start
function getCursorPosition(parent, node, offset, stat) {
  if (stat.done) return stat;

  let currentNode = null;
  if (parent.childNodes.length == 0) {
    stat.pos += parent.textContent.length;
  } else {
    for (var i = 0; i < parent.childNodes.length && !stat.done; i++) {
      currentNode = parent.childNodes[i];
      if (currentNode === node) {
        stat.pos += offset;
        stat.done = true;
        return stat;
      } else
        getCursorPosition(currentNode, node, offset, stat);
    }
  }
  return stat;
}

//find the child node and relative position and set it on range
function setCursorPosition(parent, range, stat) {
  if (stat.done) return range;

  if (parent.childNodes.length == 0) {
    if (parent.textContent.length >= stat.pos) {
      range.setStart(parent, stat.pos);
      stat.done = true;
    } else {
      stat.pos = stat.pos - parent.textContent.length;
    }
  } else {
    for (var i = 0; i < parent.childNodes.length && !stat.done; i++) {
      currentNode = parent.childNodes[i];
      setCursorPosition(currentNode, range, stat);
    }
  }
  return range;
}
.editor {
  height: 100px;
  width: 300px;
  border: 1px solid #888;
  padding: 0.5rem;
}

em,strong{
font-size: 1.3rem;
}
<div class='editor' contenteditable />

API window.getSelection returns 节点和相对于它的位置。每次您创建全新的元素时,我们都无法使用旧节点对象恢复位置。因此,为了保持简单并获得更多控制,我们使用 getCursorPosition 函数获取相对于 .editor 的位置。并且,在我们设置 innerHTML 内容后,我们使用 setCursorPosition 恢复光标位置。
这两个函数都适用于嵌套元素。

此外,改进了正则表达式:使用 (.*?) 惰性量词和前瞻和后视以获得更好的匹配。你可以找到更好的表达方式。

注:

  • 我已经在 Chrome 97 和 Windows 10 上测试了代码。
  • getCursorPositionsetCursorPosition 中使用递归解决方案进行演示并保持简单。

可以用window.getSelection获取当前位置,解析后,再用sel.modify移动光标到这个位置。

const editor = document.querySelector('div')
editor.innerHTML = parse('**dlob**  *cilati*')

sel = window.getSelection()

editor.addEventListener('input', () => {
 
  sel.extend(editor, 0)
  pos = sel.toString().length

  editor.innerHTML = parse(editor.innerText)

  while (pos-->0)
    sel.modify('move', 'forward', "character")
})

function parse(text) {
  return text
    .replace(/\*\*(.*)\*\*/gm, '**<strong></strong>**')     // bold
    .replace(/\*(.*)\*/gm, '*<em></em>*');                  // italic
}
div {
  height: 100vh;
  width: 100vw;
}
<div contenteditable />

也就是说,请注意,使用 editor.innerHTML = ....

时,编辑历史记录已消失(即无法撤消)

正如其他人所说,将编辑和渲染分开似乎更好。 我称之为 pseudo-contenteditable。我问了一个与此相关的问题 Pseudo contenteditable: how does codemirror works?。仍在等待答案。 但基本思路可能看起来像这样 https://jsfiddle.net/Lfbt4c7p.