diff --git a/src/editor/caret.js b/src/editor/caret.js new file mode 100644 index 0000000000..3b803f35c3 --- /dev/null +++ b/src/editor/caret.js @@ -0,0 +1,78 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export function getCaretPosition(editor) { + const sel = document.getSelection(); + const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + let position = sel.focusOffset; + let node = sel.focusNode; + + // when deleting the last character of a node, + // the caret gets reported as being after the focusOffset-th node, + // with the focusNode being the editor + if (node === editor) { + let position = 0; + for (let i = 0; i < sel.focusOffset; ++i) { + position += editor.childNodes[i].textContent.length; + } + return {position, atNodeEnd: false}; + } + + // first make sure we're at the level of a direct child of editor + if (node.parentElement !== editor) { + // include all preceding siblings of the non-direct editor children + while (node.previousSibling) { + node = node.previousSibling; + position += node.textContent.length; + } + // then move up + // I guess technically there could be preceding text nodes in the parents here as well, + // but we're assuming there are no mixed text and element nodes + while (node.parentElement !== editor) { + node = node.parentElement; + } + } + // now include the text length of all preceding direct editor children + while (node.previousSibling) { + node = node.previousSibling; + position += node.textContent.length; + } + { + const {focusOffset, focusNode} = sel; + console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); + } + return {position, atNodeEnd}; +} + +export function setCaretPosition(editor, caretPosition) { + if (caretPosition) { + let focusNode = editor.childNodes[caretPosition.index]; + if (!focusNode) { + focusNode = editor; + } else { + // make sure we have a text node + if (focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + } + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(focusNode, caretPosition.offset); + range.collapse(true); + sel.addRange(range); + } +} diff --git a/src/editor/diff.js b/src/editor/diff.js new file mode 100644 index 0000000000..6dc8b746e4 --- /dev/null +++ b/src/editor/diff.js @@ -0,0 +1,78 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +function firstDiff(a, b) { + const compareLen = Math.min(a.length, b.length); + for (let i = 0; i < compareLen; ++i) { + if (a[i] !== b[i]) { + return i; + } + } + return compareLen; +} + +function lastDiff(a, b) { + const compareLen = Math.min(a.length, b.length); + for (let i = 0; i < compareLen; ++i) { + if (a[a.length - i] !== b[b.length - i]) { + return i; + } + } + return compareLen; +} + +function diffStringsAtEnd(oldStr, newStr) { + const len = Math.min(oldStr.length, newStr.length); + const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len); + if (startInCommon && oldStr.length > newStr.length) { + return {removed: oldStr.substr(len), at: len}; + } else if (startInCommon && oldStr.length < newStr.length) { + return {added: newStr.substr(len), at: len}; + } else { + const commonStartLen = firstDiff(oldStr, newStr); + return { + removed: oldStr.substr(commonStartLen), + added: newStr.substr(commonStartLen), + at: commonStartLen, + }; + } +} + +export function diffDeletion(oldStr, newStr) { + if (oldStr === newStr) { + return {}; + } + const firstDiffIdx = firstDiff(oldStr, newStr); + const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1; + return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)}; +} + +export function diffInsertion(oldStr, newStr) { + const diff = diffDeletion(newStr, oldStr); + if (diff.removed) { + return {at: diff.at, added: diff.removed}; + } else { + return diff; + } +} + +export function diffAtCaret(oldValue, newValue, caretPosition) { + const diffLen = newValue.length - oldValue.length; + const caretPositionBeforeInput = caretPosition - diffLen; + const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput); + const newValueBeforeCaret = newValue.substr(0, caretPosition); + return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret); +} diff --git a/src/editor/model.js b/src/editor/model.js new file mode 100644 index 0000000000..ffd2e17c01 --- /dev/null +++ b/src/editor/model.js @@ -0,0 +1,169 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {PlainPart, RoomPillPart, UserPillPart} from "./parts"; +import {diffAtCaret, diffDeletion} from "./diff"; + +export default class EditorModel { + constructor(parts = []) { + this._parts = parts; + this.actions = null; + this._previousValue = parts.reduce((text, p) => text + p.text, ""); + } + + _insertPart(index, part) { + this._parts.splice(index, 0, part); + } + + _removePart(index) { + this._parts.splice(index, 1); + } + + _replacePart(index, part) { + this._parts.splice(index, 1, part); + } + + get parts() { + return this._parts; + } + + _diff(newValue, inputType, caret) { + if (inputType === "deleteByDrag") { + return diffDeletion(this._previousValue, newValue); + } else { + return diffAtCaret(this._previousValue, newValue, caret.position); + } + } + + update(newValue, inputType, caret) { + const diff = this._diff(newValue, inputType, caret); + const position = this._positionForOffset(diff.at, caret.atNodeEnd); + console.log("update at", {position, diff}); + if (diff.removed) { + this._removeText(position, diff.removed.length); + } + if (diff.added) { + this._addText(position, diff.added); + } + this._mergeAdjacentParts(); + this._previousValue = newValue; + const caretOffset = diff.at + (diff.added ? diff.added.length : 0); + return this._positionForOffset(caretOffset, true); + } + + _mergeAdjacentParts(docPos) { + let prevPart = this._parts[0]; + for (let i = 1; i < this._parts.length; ++i) { + let part = this._parts[i]; + const isEmpty = !part.text.length; + const isMerged = !isEmpty && prevPart.merge(part); + if (isEmpty || isMerged) { + // remove empty or merged part + part = prevPart; + this._removePart(i); + //repeat this index, as it's removed now + --i; + } + prevPart = part; + } + } + + _removeText(pos, len) { + let {index, offset} = pos; + while (len !== 0) { + // part might be undefined here + let part = this._parts[index]; + const amount = Math.min(len, part.text.length - offset); + const replaceWith = part.remove(offset, amount); + if (typeof replaceWith === "string") { + this._replacePart(index, new PlainPart(replaceWith)); + } + part = this._parts[index]; + // remove empty part + if (!part.text.length) { + this._removePart(index); + } else { + index += 1; + } + len -= amount; + offset = 0; + } + } + + _addText(pos, str, actions) { + let {index, offset} = pos; + const part = this._parts[index]; + if (part) { + if (part.insertAll(offset, str)) { + str = null; + } else { + // console.log("splitting", offset, [part.text]); + const splitPart = part.split(offset); + // console.log("splitted", [part.text, splitPart.text]); + index += 1; + this._insertPart(index, splitPart); + } + } + while (str) { + let newPart; + switch (str[0]) { + case "#": + newPart = new RoomPillPart(); + break; + case "@": + newPart = new UserPillPart(); + break; + default: + newPart = new PlainPart(); + } + str = newPart.appendUntilRejected(str); + this._insertPart(index, newPart); + index += 1; + } + } + + _positionForOffset(totalOffset, atPartEnd) { + let currentOffset = 0; + const index = this._parts.findIndex(part => { + const partLen = part.text.length; + if ( + (atPartEnd && (currentOffset + partLen) >= totalOffset) || + (!atPartEnd && (currentOffset + partLen) > totalOffset) + ) { + return true; + } + currentOffset += partLen; + return false; + }); + + return new DocumentPosition(index, totalOffset - currentOffset); + } +} + +class DocumentPosition { + constructor(index, offset) { + this._index = index; + this._offset = offset; + } + + get index() { + return this._index; + } + + get offset() { + return this._offset; + } +} diff --git a/src/editor/parts.js b/src/editor/parts.js new file mode 100644 index 0000000000..be5326d98f --- /dev/null +++ b/src/editor/parts.js @@ -0,0 +1,174 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +class BasePart { + constructor(text = "") { + this._text = text; + } + + acceptsInsertion(chr) { + return true; + } + + acceptsRemoval(position, chr) { + return true; + } + + merge(part) { + return false; + } + + split(offset) { + const splitText = this.text.substr(offset); + this._text = this.text.substr(0, offset); + return new PlainPart(splitText); + } + + // removes len chars, or returns the plain text this part should be replaced with + // if the part would become invalid if it removed everything. + + // TODO: this should probably return the Part and caret position within this should be replaced with + remove(offset, len) { + // validate + const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); + for(let i = offset; i < (len + offset); ++i) { + const chr = this.text.charAt(i); + if (!this.acceptsRemoval(i, chr)) { + return strWithRemoval; + } + } + this._text = strWithRemoval; + } + + // append str, returns the remaining string if a character was rejected. + appendUntilRejected(str) { + for(let i = 0; i < str.length; ++i) { + const chr = str.charAt(i); + if (!this.acceptsInsertion(chr)) { + this._text = this._text + str.substr(0, i); + return str.substr(i); + } + } + this._text = this._text + str; + } + + // inserts str at offset if all the characters in str were accepted, otherwise don't do anything + // return whether the str was accepted or not. + insertAll(offset, str) { + for(let i = 0; i < str.length; ++i) { + const chr = str.charAt(i); + if (!this.acceptsInsertion(chr)) { + return false; + } + } + const beforeInsert = this._text.substr(0, offset); + const afterInsert = this._text.substr(offset); + this._text = beforeInsert + str + afterInsert; + return true; + } + + + trim(len) { + const remaining = this._text.substr(len); + this._text = this._text.substr(0, len); + return remaining; + } + + get text() { + return this._text; + } +} + +export class PlainPart extends BasePart { + acceptsInsertion(chr) { + return chr !== "@" && chr !== "#"; + } + + toDOMNode() { + return document.createTextNode(this.text); + } + + merge(part) { + if (part.type === this.type) { + this._text = this.text + part.text; + return true; + } + return false; + } + + get type() { + return "plain"; + } + + updateDOMNode(node) { + if (node.textContent !== this.text) { + // console.log("changing plain text from", node.textContent, "to", this.text); + node.textContent = this.text; + } + } + + canUpdateDOMNode(node) { + return node.nodeType === Node.TEXT_NODE; + } +} + +class PillPart extends BasePart { + acceptsInsertion(chr) { + return chr !== " "; + } + + acceptsRemoval(position, chr) { + return position !== 0; //if you remove initial # or @, pill should become plain + } + + toDOMNode() { + const container = document.createElement("span"); + container.className = this.type; + container.appendChild(document.createTextNode(this.text)); + return container; + } + + updateDOMNode(node) { + const textNode = node.childNodes[0]; + if (textNode.textContent !== this.text) { + // console.log("changing pill text from", textNode.textContent, "to", this.text); + textNode.textContent = this.text; + } + if (node.className !== this.type) { + // console.log("turning", node.className, "into", this.type); + node.className = this.type; + } + } + + canUpdateDOMNode(node) { + return node.nodeType === Node.ELEMENT_NODE && + node.nodeName === "SPAN" && + node.childNodes.length === 1 && + node.childNodes[0].nodeType === Node.TEXT_NODE; + } +} + +export class RoomPillPart extends PillPart { + get type() { + return "room-pill"; + } +} + +export class UserPillPart extends PillPart { + get type() { + return "user-pill"; + } +}