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.
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};
}
diff --git a/src/editor/dom.js b/src/editor/dom.js
index 3ef1df24c3..1b683c2c5e 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 {CARET_NODE_CHAR, 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(CARET_NODE_CHAR);
+ 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(CARET_NODE_CHAR, "");
+ } else {
+ // only contains ZWS, which is ignored, so return emtpy string
+ return "";
+ }
+ } else {
+ return nodeText;
+ }
}
diff --git a/src/editor/model.js b/src/editor/model.js
index 7cc6041044..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);
}
@@ -158,11 +156,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;
@@ -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;
- }
- }
}
diff --git a/src/editor/render.js b/src/editor/render.js
index 58ef0eaee1..9d42bbe947 100644
--- a/src/editor/render.js
+++ b/src/editor/render.js
@@ -15,6 +15,137 @@ 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);
+ }
+}
+
+// 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(CARET_NODE_CHAR));
+ return span;
+}
+
+function updateCaretNode(node) {
+ // ensure the caret node contains only a zero-width space
+ if (node.textContent !== CARET_NODE_CHAR) {
+ node.textContent = CARET_NODE_CHAR;
+ }
+}
+
+export function isCaretNode(node) {
+ return node && node.tagName === "SPAN" && node.className === "caretNode";
+}
+
+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)) {
+ updateCaretNode(currentNode);
+ currentNode = currentNode.nextSibling;
+ } 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 +156,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 +170,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);
+ }
}