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); + } }