I have a div that acts as a WYSIWYG editor. It acts as a text box but renders Markdown syntax within it to show live changes.
Issue: When typing letters, the caret position is reset to the beginning of the div.
const editor = document.querySelector('div'); editor.innerHTML = parse('**dlob** *cilati*'); editor.addEventLis tener('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
Markdown editors like QuillJS seem to be able to edit child elements without editing the parent element. This avoids the problem, but I'm now sure how to recreate that logic with this setup.
Question: How to prevent the caret position from being reset when typing?
renew: I've 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
P粉3930309172023-11-09 18:34:46
What most rich text editors do is keep their own internal state, update it on key events and render a custom visual layer. For example:
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;} }
P粉0601123962023-11-09 14:42:18
You need to get the cursor position first, and then process and set the content. Then restore the cursor position.
Restoring the cursor position is the tricky part when there are nested elements. Additionally, you will create new and
elements every time and the old ones will be discarded.
const editor = document.querySelector(".editor"); editor.innerHTML = parse( "For **bold** two stars.\nFor *italic* one star. Some more **bold**." ); editor.addEventListener("input", () => { //get current cursor position const sel = window.getSelection(); const node = sel.focusNode; const offset = sel.focusOffset; const pos = getCursorPosition(editor, node, offset, { pos: 0, done: false }); if (offset === 0) pos.pos += 0.5; editor.innerHTML = parse(editor.innerText); // restore the position sel.removeAllRanges(); const 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, "****") // bold .replace(/(?*") // italic // handle special characters .replace(/\n/gm, "
") .replace(/\t/gm, " ") ); } // 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 (let 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 (let i = 0; i < parent.childNodes.length && !stat.done; i++) { currentNode = parent.childNodes[i]; setCursorPosition(currentNode, range, stat); } } return range; }
.editor { height: 100px; width: 400px; border: 1px solid #888; padding: 0.5rem; white-space: pre; } em, strong{ font-size: 1.3rem; }