Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #3126 from matrix-org/bwindels/caret-refactoring
Browse files Browse the repository at this point in the history
Editor caret improvements
  • Loading branch information
bwindels authored Jun 21, 2019
2 parents 5031555 + c443dd7 commit 1c7af38
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 102 deletions.
10 changes: 9 additions & 1 deletion src/components/views/elements/MessageEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
116 changes: 85 additions & 31 deletions src/editor/caret.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
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];

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;
}
}
// 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};
}
71 changes: 58 additions & 13 deletions src/editor/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 foundCaret = false;
let text = "";

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];
caretOffset = focusNode.textContent.length;
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 = "";

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;
}
Expand All @@ -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;
}
}
23 changes: 6 additions & 17 deletions src/editor/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
Loading

0 comments on commit 1c7af38

Please sign in to comment.