mirror of https://github.com/vector-im/riot-web
add converted prototype code
parent
6599d605cd
commit
9f98a6c0e6
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue