diff --git a/src/editor/dom.js b/src/editor/dom.js index 3ef1df24c3..5c873034b2 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -15,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {ZERO_WIDTH_SPACE, isCaretNode} from "./render"; + export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) { let node = rootNode.firstChild; while (node && node !== rootNode) { @@ -38,27 +40,54 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback } export function getCaretOffsetAndText(editor, sel) { - let {focusNode} = sel; - const {focusOffset} = sel; - let caretOffset = focusOffset; + let {focusNode, focusOffset} = sel; + // sometimes focusNode is an element, and then focusOffset means + // the index of a child element ... - 1 🤷 + if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) { + focusNode = focusNode.childNodes[focusOffset - 1]; + focusOffset = focusNode.textContent.length; + } + const {text, focusNodeOffset} = getTextAndFocusNodeOffset(editor, focusNode, focusOffset); + const caret = getCaret(focusNode, focusNodeOffset, focusOffset); + return {caret, text}; +} + +// gets the caret position details, ignoring and adjusting to +// the ZWS if you're typing in a caret node +function getCaret(focusNode, focusNodeOffset, focusOffset) { + let atNodeEnd = focusOffset === focusNode.textContent.length; + if (focusNode.nodeType === Node.TEXT_NODE && isCaretNode(focusNode.parentElement)) { + const zwsIdx = focusNode.nodeValue.indexOf(ZERO_WIDTH_SPACE); + if (zwsIdx !== -1 && zwsIdx < focusOffset) { + focusOffset -= 1; + } + // if typing in a caret node, you're either typing before or after the ZWS. + // In both cases, you should be considered at node end because the ZWS is + // not included in the text here, and once the model is updated and rerendered, + // that caret node will be removed. + atNodeEnd = true; + } + return {offset: focusNodeOffset + focusOffset, atNodeEnd}; +} + +// gets the text of the editor as a string, +// and the offset in characters where the focusNode starts in that string +// all ZWS from caret nodes are filtered out +function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) { + let focusNodeOffset = 0; let foundCaret = false; let text = ""; - if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) { - focusNode = focusNode.childNodes[focusOffset - 1]; - caretOffset = focusNode.textContent.length; - } - function enterNodeCallback(node) { - const nodeText = node.nodeType === Node.TEXT_NODE && node.nodeValue; if (!foundCaret) { if (node === focusNode) { foundCaret = true; } } + const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node); if (nodeText) { if (!foundCaret) { - caretOffset += nodeText.length; + focusNodeOffset += nodeText.length; } text += nodeText; } @@ -73,14 +102,30 @@ export function getCaretOffsetAndText(editor, sel) { if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") { text += "\n"; if (!foundCaret) { - caretOffset += 1; + focusNodeOffset += 1; } } } walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback); - const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - const caret = {atNodeEnd, offset: caretOffset}; - return {caret, text}; + return {text, focusNodeOffset}; +} + +// get text value of text node, ignoring ZWS if it's a caret node +function getTextNodeValue(node) { + const nodeText = node.nodeValue; + // filter out ZWS for caret nodes + if (isCaretNode(node.parentElement)) { + // typed in the caret node, so there is now something more in it than the ZWS + // so filter out the ZWS, and take the typed text into account + if (nodeText.length !== 1) { + return nodeText.replace(ZERO_WIDTH_SPACE, ""); + } else { + // only contains ZWS, which is ignored, so return emtpy string + return ""; + } + } else { + return nodeText; + } } diff --git a/src/editor/render.js b/src/editor/render.js index ed354ab8f5..97d84d70b2 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -33,25 +33,25 @@ function insertAfter(node, nodeToInsert) { } } -// a caret node is an empty node that allows the caret to be place +export const ZERO_WIDTH_SPACE = "\u200b"; +// a caret node is a node that allows the caret to be placed // where otherwise it wouldn't be possible // (e.g. next to a pill span without adjacent text node) function createCaretNode() { const span = document.createElement("span"); span.className = "caret"; + span.appendChild(document.createTextNode(ZERO_WIDTH_SPACE)); return span; } function updateCaretNode(node) { - // ensure the caret node is empty - // otherwise they'll break everything - // as only things part of the model should have text in them - // browsers could end up typing in the caret node for any - // number of reasons, so revert this. - node.textContent = ""; + // ensure the caret node contains only a zero-width space + if (node.textContent !== ZERO_WIDTH_SPACE) { + node.textContent = ZERO_WIDTH_SPACE; + } } -function isCaretNode(node) { +export function isCaretNode(node) { return node && node.tagName === "SPAN" && node.className === "caret"; } @@ -86,8 +86,8 @@ function reconcileLine(lineContainer, parts) { if (needsCaretNodeBefore(part, prevPart)) { if (isCaretNode(currentNode)) { - currentNode = currentNode.nextSibling; updateCaretNode(currentNode); + currentNode = currentNode.nextSibling; } else { lineContainer.insertBefore(createCaretNode(), currentNode); }