mirror of https://github.com/vector-im/riot-web
Merge pull request #3126 from matrix-org/bwindels/caret-refactoring
Editor caret improvementspull/21833/head
commit
1c7af38d83
|
@ -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.
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue