Merge pull request #3126 from matrix-org/bwindels/caret-refactoring

Editor caret improvements
pull/21833/head
Bruno Windels 2019-06-21 15:02:41 +00:00 committed by GitHub
commit 1c7af38d83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 300 additions and 104 deletions

View File

@ -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.

View File

@ -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 <br>
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 <br> 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};
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

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