add converted prototype code

This commit is contained in:
Bruno Windels 2019-05-06 18:21:28 +02:00
parent 6599d605cd
commit 9f98a6c0e6
4 changed files with 499 additions and 0 deletions

78
src/editor/caret.js Normal file
View file

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

78
src/editor/diff.js Normal file
View file

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

169
src/editor/model.js Normal file
View file

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

174
src/editor/parts.js Normal file
View file

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