From 75fc7697423eb8f2037d30a44b16baae30ebb237 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 18 Jun 2019 18:53:55 +0200 Subject: [PATCH 1/9] insert "caret nodes" where pills don't have an adjacent text node just empty spans, where the caret can be placed. --- src/editor/render.js | 176 +++++++++++++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 40 deletions(-) diff --git a/src/editor/render.js b/src/editor/render.js index 58ef0eaee1..ed354ab8f5 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -15,6 +15,133 @@ See the License for the specific language governing permissions and limitations under the License. */ +export function needsCaretNodeBefore(part, prevPart) { + const isFirst = !prevPart || prevPart.type === "newline"; + return !part.canEdit && (isFirst || !prevPart.canEdit); +} + +export function needsCaretNodeAfter(part, isLastOfLine) { + return !part.canEdit && isLastOfLine; +} + +function insertAfter(node, nodeToInsert) { + const next = node.nextSibling; + if (next) { + node.parentElement.insertBefore(nodeToInsert, next); + } else { + node.parentElement.appendChild(nodeToInsert); + } +} + +// a caret node is an empty node that allows the caret to be place +// 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"; + 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 = ""; +} + +function isCaretNode(node) { + return node && node.tagName === "SPAN" && node.className === "caret"; +} + +function removeNextSiblings(node) { + if (!node) { + return; + } + node = node.nextSibling; + while (node) { + const removeNode = node; + node = node.nextSibling; + removeNode.remove(); + } +} + +function removeChildren(parent) { + const firstChild = parent.firstChild; + if (firstChild) { + removeNextSiblings(firstChild); + firstChild.remove(); + } +} + +function reconcileLine(lineContainer, parts) { + let currentNode; + let prevPart; + const lastPart = parts[parts.length - 1]; + + for (const part of parts) { + const isFirst = !prevPart; + currentNode = isFirst ? lineContainer.firstChild : currentNode.nextSibling; + + if (needsCaretNodeBefore(part, prevPart)) { + if (isCaretNode(currentNode)) { + currentNode = currentNode.nextSibling; + updateCaretNode(currentNode); + } else { + lineContainer.insertBefore(createCaretNode(), currentNode); + } + } + // remove nodes until matching current part + while (currentNode && !part.canUpdateDOMNode(currentNode)) { + const nextNode = currentNode.nextSibling; + lineContainer.removeChild(currentNode); + currentNode = nextNode; + } + // update or insert node for current part + if (currentNode && part) { + part.updateDOMNode(currentNode); + } else if (part) { + currentNode = part.toDOMNode(); + // hooks up nextSibling for next iteration + lineContainer.appendChild(currentNode); + } + + if (needsCaretNodeAfter(part, part === lastPart)) { + if (isCaretNode(currentNode.nextSibling)) { + currentNode = currentNode.nextSibling; + updateCaretNode(currentNode); + } else { + const caretNode = createCaretNode(); + insertAfter(currentNode, caretNode); + currentNode = caretNode; + } + } + + prevPart = part; + } + + removeNextSiblings(currentNode); +} + +function reconcileEmptyLine(lineContainer) { + // empty div needs to have a BR in it to give it height + let foundBR = false; + let partNode = lineContainer.firstChild; + while (partNode) { + const nextNode = partNode.nextSibling; + if (!foundBR && partNode.tagName === "BR") { + foundBR = true; + } else { + partNode.remove(); + } + partNode = nextNode; + } + if (!foundBR) { + lineContainer.appendChild(document.createElement("br")); + } +} + export function renderModel(editor, model) { const lines = model.parts.reduce((lines, part) => { if (part.type === "newline") { @@ -25,8 +152,9 @@ export function renderModel(editor, model) { } return lines; }, [[]]); - // TODO: refactor this code, DRY it lines.forEach((parts, i) => { + // find first (and remove anything else) div without className + // (as browsers insert these in contenteditable) line container let lineContainer = editor.childNodes[i]; while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) { editor.removeChild(lineContainer); @@ -38,46 +166,14 @@ export function renderModel(editor, model) { } if (parts.length) { - parts.forEach((part, j) => { - let partNode = lineContainer.childNodes[j]; - while (partNode && !part.canUpdateDOMNode(partNode)) { - lineContainer.removeChild(partNode); - partNode = lineContainer.childNodes[j]; - } - if (partNode && part) { - part.updateDOMNode(partNode); - } else if (part) { - lineContainer.appendChild(part.toDOMNode()); - } - }); - - let surplusElementCount = Math.max(0, lineContainer.childNodes.length - parts.length); - while (surplusElementCount) { - lineContainer.removeChild(lineContainer.lastChild); - --surplusElementCount; - } + reconcileLine(lineContainer, parts); } else { - // empty div needs to have a BR in it to give it height - let foundBR = false; - let partNode = lineContainer.firstChild; - while (partNode) { - const nextNode = partNode.nextSibling; - if (!foundBR && partNode.tagName === "BR") { - foundBR = true; - } else { - lineContainer.removeChild(partNode); - } - partNode = nextNode; - } - if (!foundBR) { - lineContainer.appendChild(document.createElement("br")); - } - } - - let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length); - while (surplusElementCount) { - editor.removeChild(editor.lastChild); - --surplusElementCount; + reconcileEmptyLine(lineContainer); } }); + if (lines.length) { + removeNextSiblings(editor.children[lines.length]); + } else { + removeChildren(editor); + } } From 607fc328ed775579579fe48170dafd30e701e4e2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Jun 2019 10:57:29 +0200 Subject: [PATCH 2/9] also process first part when processing empty and mergeable parts this was preventing clearing an emtpy plain part when inserting a pill-candidate at the beginning of the model, which prevented a caret node from being inserted before the pill. --- src/editor/model.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 7cc6041044..c8fa20efce 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -158,11 +158,11 @@ export default class EditorModel { } _mergeAdjacentParts(docPos) { - let prevPart = this._parts[0]; - for (let i = 1; i < this._parts.length; ++i) { + let prevPart; + for (let i = 0; i < this._parts.length; ++i) { let part = this._parts[i]; const isEmpty = !part.text.length; - const isMerged = !isEmpty && prevPart.merge(part); + const isMerged = !isEmpty && prevPart && prevPart.merge(part); if (isEmpty || isMerged) { // remove empty or merged part part = prevPart; From a229641985364db2687c0b1a6cd30134cc79f862 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Jun 2019 17:36:17 +0200 Subject: [PATCH 3/9] use caret nodes in caret positioning code, to move caret out of pills --- src/editor/caret.js | 118 ++++++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index f93e9604d5..c56022d8c6 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -15,50 +15,104 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render"; + export function setCaretPosition(editor, model, caretPosition) { const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); + const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, caretPosition); + const lineNode = editor.childNodes[lineIndex]; + + let focusNode; + // empty line with just a
+ if (nodeIndex === -1) { + focusNode = lineNode; + } else { + focusNode = lineNode.childNodes[nodeIndex]; + // make sure we have a text node + if (focusNode.nodeType === Node.ELEMENT_NODE && focusNode.firstChild) { + focusNode = focusNode.firstChild; + } + } + range.setStart(focusNode, offset); + range.collapse(true); + sel.addRange(range); +} + +function getLineAndNodePosition(model, caretPosition) { const {parts} = model; - const {index} = caretPosition; + const partIndex = caretPosition.index; + const lineResult = findNodeInLineForPart(parts, partIndex); + const {lineIndex} = lineResult; + let {nodeIndex} = lineResult; let {offset} = caretPosition; + // we're at an empty line between a newline part + // and another newline part or end/start of parts. + // set offset to 0 so it gets set to the
inside the line container + if (nodeIndex === -1) { + offset = 0; + } else { + // move caret out of uneditable part (into caret node, or empty line br) if needed + ({nodeIndex, offset} = moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset)); + } + return {lineIndex, nodeIndex, offset}; +} + +function findNodeInLineForPart(parts, partIndex) { let lineIndex = 0; let nodeIndex = -1; - for (let i = 0; i <= index; ++i) { + + let prevPart = null; + // go through to parts up till (and including) the index + // to find newline parts + for (let i = 0; i <= partIndex; ++i) { const part = parts[i]; - if (part && part.type === "newline") { - if (i < index) { - lineIndex += 1; - nodeIndex = -1; - } else { - // if index points at a newline part, - // put the caret at the end of the previous part - // so it stays on the same line - const prevPart = parts[i - 1]; - offset = prevPart ? prevPart.text.length : 0; + if (part.type === "newline") { + lineIndex += 1; + nodeIndex = -1; + prevPart = null; + } else { + nodeIndex += 1; + if (needsCaretNodeBefore(part, prevPart)) { + nodeIndex += 1; + } + // only jump over caret node if we're not at our destination node already, + // as we'll assume in moveOutOfUneditablePart that nodeIndex + // refers to the node corresponding to the part, + // and not an adjacent caret node + if (i < partIndex) { + const nextPart = parts[i + 1]; + const isLastOfLine = !nextPart || nextPart.type === "newline"; + if (needsCaretNodeAfter(part, isLastOfLine)) { + nodeIndex += 1; + } + } + prevPart = part; + } + } + + return {lineIndex, nodeIndex}; +} + +function moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset) { + // move caret before or after uneditable part + const part = parts[partIndex]; + if (part && !part.canEdit) { + if (offset === 0) { + nodeIndex -= 1; + const prevPart = parts[partIndex - 1]; + // if the previous node is a caret node, it's empty + // so the offset can stay at 0 + // only when it's not, we need to set the offset + // at the end of the node + if (!needsCaretNodeBefore(part, prevPart)) { + offset = prevPart.text.length; } } else { nodeIndex += 1; + offset = 0; } } - let focusNode; - const lineNode = editor.childNodes[lineIndex]; - if (lineNode) { - focusNode = lineNode.childNodes[nodeIndex]; - if (!focusNode) { - focusNode = lineNode; - } else if (focusNode.nodeType === Node.ELEMENT_NODE) { - focusNode = focusNode.childNodes[0]; - } - } - // node not found, set caret at end - if (!focusNode) { - range.selectNodeContents(editor); - range.collapse(false); - } else { - // make sure we have a text node - range.setStart(focusNode, offset); - range.collapse(true); - } - sel.addRange(range); + return {nodeIndex, offset}; } From f0271b593d461a8f4417707c42fc7a76eef48743 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Jun 2019 17:37:17 +0200 Subject: [PATCH 4/9] remove special casing for moving caret after newline and pills not needed anymore with new caret logic and having caret nodes --- src/editor/model.js | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index c8fa20efce..1080df67ba 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -103,8 +103,7 @@ export default class EditorModel { } this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; - let newPosition = this.positionForOffset(caretOffset, true); - newPosition = newPosition.skipUneditableParts(this._parts); + const newPosition = this.positionForOffset(caretOffset, true); this._setActivePart(newPosition); this._updateCallback(newPosition); } @@ -140,10 +139,9 @@ export default class EditorModel { let pos; if (replacePart) { this._replacePart(this._autoCompletePartIdx, replacePart); - let index = this._autoCompletePartIdx; + const index = this._autoCompletePartIdx; if (caretOffset === undefined) { - caretOffset = 0; - index += 1; + caretOffset = replacePart.text.length; } pos = new DocumentPosition(index, caretOffset); } @@ -283,13 +281,4 @@ class DocumentPosition { get offset() { return this._offset; } - - skipUneditableParts(parts) { - const part = parts[this.index]; - if (part && !part.canEdit) { - return new DocumentPosition(this.index + 1, 0); - } else { - return this; - } - } } From b16bc0178aecd6aa8775bd71709f7011b2354dba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 19 Jun 2019 17:37:52 +0200 Subject: [PATCH 5/9] insert manually, as insertHTML command moves caret inconsistently across browsers --- src/components/views/elements/MessageEditor.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 9f5265cfd3..0830708701 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -78,6 +78,14 @@ export default class MessageEditor extends React.Component { this.model.update(text, event.inputType, caret); } + _insertText(textToInsert, inputType = "insertText") { + const sel = document.getSelection(); + const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); + const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset); + caret.offset += textToInsert.length; + this.model.update(newText, inputType, caret); + } + _isCaretAtStart() { const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection()); return caret.offset === 0; @@ -92,7 +100,7 @@ export default class MessageEditor extends React.Component { // insert newline on Shift+Enter if (event.shiftKey && event.key === "Enter") { event.preventDefault(); // just in case the browser does support this - document.execCommand("insertHTML", undefined, "\n"); + this._insertText("\n"); return; } // autocomplete or enter to send below shouldn't have any modifier keys pressed. From 366a4aa3081c91d8bfba302294236333161f1172 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 20 Jun 2019 14:44:18 +0200 Subject: [PATCH 6/9] put zero-width spaces in caret nodes so chrome doesn't ignore them this requires an update of the editor DOM > text & caret offset logic, as the ZWS need to be ignored. --- src/editor/dom.js | 73 +++++++++++++++++++++++++++++++++++--------- src/editor/render.js | 18 +++++------ 2 files changed, 68 insertions(+), 23 deletions(-) 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); } From c5c987f62ed3bd964655de4683eb7821e4ee5cba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Jun 2019 11:21:13 +0200 Subject: [PATCH 7/9] use BOM marker instead of ZWS that's what others do ... --- src/editor/render.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/editor/render.js b/src/editor/render.js index 97d84d70b2..eb15230580 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -33,7 +33,8 @@ function insertAfter(node, nodeToInsert) { } } -export const ZERO_WIDTH_SPACE = "\u200b"; +// this is a BOM marker actually +export const ZERO_WIDTH_SPACE = "\ufeff"; // 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) From da766b8cbaa1c39a4402144fae67361b825f7c89 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Jun 2019 11:21:38 +0200 Subject: [PATCH 8/9] caretNode is a better className --- src/editor/render.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/editor/render.js b/src/editor/render.js index eb15230580..690851c4ea 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -40,7 +40,7 @@ export const ZERO_WIDTH_SPACE = "\ufeff"; // (e.g. next to a pill span without adjacent text node) function createCaretNode() { const span = document.createElement("span"); - span.className = "caret"; + span.className = "caretNode"; span.appendChild(document.createTextNode(ZERO_WIDTH_SPACE)); return span; } @@ -53,7 +53,7 @@ function updateCaretNode(node) { } export function isCaretNode(node) { - return node && node.tagName === "SPAN" && node.className === "caret"; + return node && node.tagName === "SPAN" && node.className === "caretNode"; } function removeNextSiblings(node) { From c443dd7a32665c67c6730f0099859ee11ecf0395 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 21 Jun 2019 16:37:29 +0200 Subject: [PATCH 9/9] clarify why use a BOM marker for the caret nodes --- src/editor/dom.js | 6 +++--- src/editor/render.js | 13 ++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/editor/dom.js b/src/editor/dom.js index 5c873034b2..1b683c2c5e 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ZERO_WIDTH_SPACE, isCaretNode} from "./render"; +import {CARET_NODE_CHAR, isCaretNode} from "./render"; export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) { let node = rootNode.firstChild; @@ -57,7 +57,7 @@ export function getCaretOffsetAndText(editor, sel) { 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); + const zwsIdx = focusNode.nodeValue.indexOf(CARET_NODE_CHAR); if (zwsIdx !== -1 && zwsIdx < focusOffset) { focusOffset -= 1; } @@ -120,7 +120,7 @@ function getTextNodeValue(node) { // 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, ""); + return nodeText.replace(CARET_NODE_CHAR, ""); } else { // only contains ZWS, which is ignored, so return emtpy string return ""; diff --git a/src/editor/render.js b/src/editor/render.js index 690851c4ea..9d42bbe947 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -33,22 +33,25 @@ function insertAfter(node, nodeToInsert) { } } -// this is a BOM marker actually -export const ZERO_WIDTH_SPACE = "\ufeff"; +// Use a BOM marker for caret nodes. +// On a first test, they seem to be filtered out when copying text out of the editor, +// but this could be platform dependent. +// As a precautionary measure, I chose the character that slate also uses. +export const CARET_NODE_CHAR = "\ufeff"; // 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 = "caretNode"; - span.appendChild(document.createTextNode(ZERO_WIDTH_SPACE)); + span.appendChild(document.createTextNode(CARET_NODE_CHAR)); return span; } function updateCaretNode(node) { // ensure the caret node contains only a zero-width space - if (node.textContent !== ZERO_WIDTH_SPACE) { - node.textContent = ZERO_WIDTH_SPACE; + if (node.textContent !== CARET_NODE_CHAR) { + node.textContent = CARET_NODE_CHAR; } }