如何使自定义文本编辑器在修改 DOM 后使用撤消和重做快捷方式?

swi*_*ynx 5 javascript events

contenteditable我正在使用div 上的属性构建一个小型自定义编辑器。

默认情况下,当用户粘贴 HTML 内容时,HTML 会插入到 div 中contenteditable,这是我不想要的。

为了解决这个问题,我使用了一个自定义粘贴处理程序,如下问题所示:

停止在 contenteditable div 中粘贴 html 样式,仅粘贴纯文本

editor.addEventListener("paste", (e) => {
  e.preventDefault();

  const text = e.clipboardData.getData('text/plain');

  document.execCommand("insertText", false, text.replaceAll("\n", "<div><br></div>"));
})
Run Code Online (Sandbox Code Playgroud)

到目前为止,一切都很好。因为execCommand已弃用,所以我使用了替代品,即基本上使用范围来手动插入文本(去除 HTML)。

execCommand() 现已过时,有什么替代方法?

替换 contenteditable div 中选定的文本

editor.addEventListener("paste", (e) => {
  e.preventDefault();

  const text = (e.originalEvent || e).clipboardData.getData("text/plain");

  const selection = window.getSelection();
  selection.deleteFromDocument();

  const range = selection.getRangeAt(0);
  range.insertNode(document.createTextNode(text));
  range.collapse();
});
Run Code Online (Sandbox Code Playgroud)

这很有效,直到我注意到粘贴某些内容后撤消和重做命令不再起作用。这是因为在自定义粘贴事件中完成了 DOM 修改,这是一个破坏因素。


在寻找解决方案时,我发现了以下问题:

允许 contenteditable 在 dom 修改后撤消

第一个答案建议使用execCommand,但它已被弃用,因此这不是一个好的解决方案

第二个答案建议构建一个自定义撤消重做堆栈来手动处理所有这些事件。所以我选择了第二种解决方案。


我使用简单的数组来存储版本来构建自定义撤消重做处理程序。为此,我使用该beforeinput事件来侦听撤消和重做事件。

editor.addEventListener("beforeinput", (e) => {
  if (e.inputType === "historyUndo") {
    e.preventDefault();
    console.log("undo");
  };

  if (e.inputType === "historyRedo") {
    e.preventDefault();
    console.log("redo");
  };
});
Run Code Online (Sandbox Code Playgroud)

这对于撤消非常有效,但是,由于我阻止了默认设置,浏览器撤消/重做堆栈永远不会处于重做状态,因此在按下或在窗口beforeinput上时永远不会触发侦听器。cmd + zctrl + z

我也使用事件进行了尝试,input得到了相同的结果。

我已经搜索了其他解决方案来使用 JavaScript 处理撤消/重做事件,但是这里没有使用构建自定义处理程序的keydown选项,因为每个操作系统都使用不同的快捷方式,尤其是移动设备!

问题

现在,有多种可能的解决方案可以解决我的问题,因此任何有效的解决方案都非常受欢迎。

有没有办法手动处理粘贴事件并将粘贴的纯文本插入到文档中,同时保留撤消/重做功能或手动实现此类功能的替换?

例子

尝试粘贴一些内容然后撤消,这是行不通的。

const editor = document.getElementById("editor");

editor.addEventListener("paste", (e) => {
  e.preventDefault();

  const text = (e.originalEvent || e).clipboardData.getData("text/plain").replaceAll("\n", "<div><br></div>");
        
  const selection = window.getSelection();
  selection.deleteFromDocument();

  const range = selection.getRangeAt(0);
  range.insertNode(document.createTextNode(text));
  range.collapse();
});
Run Code Online (Sandbox Code Playgroud)
#editor {
  border: 1px solid black;
  height: 100px;
}
Run Code Online (Sandbox Code Playgroud)
<div id="editor" contenteditable="true"></div>
Run Code Online (Sandbox Code Playgroud)

编辑

修改ClipboardEvent不起作用,因为它是只读的。调度新事件也不起作用,因为该事件不受信任,因此浏览器不会粘贴内容。

const event = new ClipboardEvent("paste", {
  clipboardData: new DataTransfer(),
});

event.clipboardData.setData("text/plain", "blah");

editor.dispatchEvent(event);
Run Code Online (Sandbox Code Playgroud)

Ste*_*ris 0

我面临着同样的问题,并使用本文中讨论的方法解决了它,并使用他的原始代码。这是一个非常聪明的技巧。最后,他的方法仍然使用execCommand,但这是一种非常狭窄和隐藏的用途,execCommand只是将整数索引推送到本机撤消堆栈上,以便可以在单独的堆栈上跟踪您的“撤消数据”。它还具有能够将正常的撤消操作(例如恢复键入的输入)与推送到单独的撤消堆栈的操作散布的好处。

我必须修改他的撤销构造函数以使用“input”元素而不是 contentEditable“div”。因为我已经在我自己的 contentEditable“div”中监听“输入”,所以我需要stopImmediatePropagation隐藏输入。

constructor(callback, zero=null) {
  this._duringUpdate = false;
  this._stack = [zero];
  
  // Using an input element rather than contentEditable div because parent is already a
  // contentEditable div
  this._ctrl = document.createElement('input');
  this._ctrl.setAttribute('aria-hidden', 'true');
  this._ctrl.setAttribute('id', 'hiddenInput');
  this._ctrl.style.opacity = 0;
  this._ctrl.style.position = 'fixed';
  this._ctrl.style.top = '-1000px';
  this._ctrl.style.pointerEvents = 'none';
  this._ctrl.tabIndex = -1;
  
  this._ctrl.textContent = '0';
  this._ctrl.style.visibility = 'hidden';  // hide element while not used
  
  this._ctrl.addEventListener('focus', (ev) => {
    window.setTimeout(() => void this._ctrl.blur(), 1);
  });
  this._ctrl.addEventListener('input', (ev) => {
    ev.stopImmediatePropagation();  // We don't want this event to be seen by the parent
    if (!this._duringUpdate) {
        callback(this.data);
        this._ctrl.textContent = this._depth - 1;
    } else {
        this._ctrl.textContent = ev.data;
    }
    const s = window.getSelection();
    if (s.containsNode(this._ctrl, true)) {
        s.removeAllRanges();
    }
  });
}
Run Code Online (Sandbox Code Playgroud)

我创建了撤消程序以及一种捕获撤消数据的方法,用于粘贴和其他需要在操作发生时访问范围的操作。为了使用它进行粘贴,我在我自己的 contentEditable div 中监听粘贴事件(在editor下面调用它)。

const undoer = new Undoer(_undoOperation, null);

const _undoerData = function(operation, data) {
  const undoData = {
    operation: operation,
    range: _rangeProxy(), // <- Capture document.currentSelection() as a range
    data: data
  }
  return undoData;
};

editor.addEventListener('paste', function(e) {
  e.preventDefault();
  var pastedText = undefined;
  if (e.clipboardData && e.clipboardData.getData) {
      pastedText = e.clipboardData.getData('text/plain');
  }
  const undoerData = _undoerData('pasteText', pastedText);
  undoer.push(undoerData, editor);
  _doOperation(undoerData);
});
Run Code Online (Sandbox Code Playgroud)

然后_doOperation打开_undoOperation操作并获取 undoerData 中的信息以采取正确的操作:

const _doOperation = function(undoerData) {
  switch (undoerData.operation) {
    case 'pasteText':
      let pastedText = undoerData.data;
      let range = undoerData.range;
      // Do whatever is needed to paste the pastedText at the location
      // identified in range
      break;
    default:
      // Throw an error or do something reasonable
  };
};

const _undoOperation = function(undoerData) {
    switch (undoerData.operation) {
      case 'pasteText':
        let pastedText = undoerData.data;
        let range = undoerData.range;
        // Do whatever is needed to remove the pastedText at the location
        // identified in range
        break;
      default:
        // Throw an error or do something reasonable
    };
};
Run Code Online (Sandbox Code Playgroud)

通过此方案和实施的pasteText操作,您现在可以复制文本,粘贴 - 键入 - 删除文本 - 键入 - 重新定位光标 - 粘贴,然后撤消 6 次,在第 1 次和第 6 次撤消时调用 _undoOperation,而其他撤消则正常工作就像他们应该做的那样。

  • 也许有一天,我们会正确地公开一个撤消 API,从而避免这一切。这看起来像是早期草案...... https://rniwa.github.io/undo-api/ (2认同)