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 上测试了代码。
- 在
getCursorPosition
和 setCursorPosition
中使用递归解决方案进行演示并保持简单。
可以用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.
我有一个 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 上测试了代码。
- 在
getCursorPosition
和setCursorPosition
中使用递归解决方案进行演示并保持简单。
可以用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.