diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js
index f51348ce04..a121f3384e 100644
--- a/src/components/views/elements/MessageEditor.js
+++ b/src/components/views/elements/MessageEditor.js
@@ -26,7 +26,7 @@ import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize';
import {findEditableEvent} from '../../../utils/EventUtils';
import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete';
-import {PartCreator} from '../../../editor/parts';
+import {PartCreator, autoCompleteCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import {MatrixClient} from 'matrix-js-sdk';
@@ -303,8 +303,7 @@ export default class MessageEditor extends React.Component {
const {editState} = this.props;
const room = this._getRoom();
const partCreator = new PartCreator(
- () => this._autocompleteRef,
- query => this.setState({query}),
+ autoCompleteCreator(() => this._autocompleteRef, query => this.setState({query})),
room,
this.context.matrixClient,
);
diff --git a/src/editor/caret.js b/src/editor/caret.js
index c56022d8c6..9b0fa14cfc 100644
--- a/src/editor/caret.js
+++ b/src/editor/caret.js
@@ -40,7 +40,7 @@ export function setCaretPosition(editor, model, caretPosition) {
sel.addRange(range);
}
-function getLineAndNodePosition(model, caretPosition) {
+export function getLineAndNodePosition(model, caretPosition) {
const {parts} = model;
const partIndex = caretPosition.index;
const lineResult = findNodeInLineForPart(parts, partIndex);
diff --git a/src/editor/diff.js b/src/editor/diff.js
index 2c82e22793..27d10689b3 100644
--- a/src/editor/diff.js
+++ b/src/editor/diff.js
@@ -25,16 +25,6 @@ function firstDiff(a, b) {
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);
@@ -52,24 +42,25 @@ function diffStringsAtEnd(oldStr, newStr) {
}
}
+// assumes only characters have been deleted at one location in the string, and none added
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;
- }
+ const amount = oldStr.length - newStr.length;
+ return {at: firstDiffIdx, removed: oldStr.substr(firstDiffIdx, amount)};
}
+/**
+ * Calculates which string was added and removed around the caret position
+ * @param {String} oldValue the previous value
+ * @param {String} newValue the new value
+ * @param {Number} caretPosition the position of the caret after `newValue` was applied.
+ * @return {object} an object with `at` as the offset where characters were removed and/or added,
+ * `added` with the added string (if any), and
+ * `removed` with the removed string (if any)
+ */
export function diffAtCaret(oldValue, newValue, caretPosition) {
const diffLen = newValue.length - oldValue.length;
const caretPositionBeforeInput = caretPosition - diffLen;
diff --git a/src/editor/parts.js b/src/editor/parts.js
index 572a861024..971d93ef33 100644
--- a/src/editor/parts.js
+++ b/src/editor/parts.js
@@ -117,7 +117,8 @@ class BasePart {
}
}
-class PlainPart extends BasePart {
+// exported for unit tests, should otherwise only be used through PartCreator
+export class PlainPart extends BasePart {
acceptsInsertion(chr) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
}
@@ -348,18 +349,24 @@ class PillCandidatePart extends PlainPart {
}
}
-export class PartCreator {
- constructor(getAutocompleterComponent, updateQuery, room, client) {
- this._room = room;
- this._client = client;
- this._autoCompleteCreator = (updateCallback) => {
+export function autoCompleteCreator(updateQuery, getAutocompleterComponent) {
+ return (partCreator) => {
+ return (updateCallback) => {
return new AutocompleteWrapperModel(
updateCallback,
getAutocompleterComponent,
updateQuery,
- this,
+ partCreator,
);
};
+ };
+}
+
+export class PartCreator {
+ constructor(autoCompleteCreator, room, client) {
+ this._room = room;
+ this._client = client;
+ this._autoCompleteCreator = autoCompleteCreator(this);
}
createPartForInput(input) {
diff --git a/src/editor/serialize.js b/src/editor/serialize.js
index 37565b64a0..cb06eede6c 100644
--- a/src/editor/serialize.js
+++ b/src/editor/serialize.js
@@ -56,15 +56,3 @@ export function textSerialize(model) {
}
}, "");
}
-
-export function requiresHtml(model) {
- return model.parts.some(part => {
- switch (part.type) {
- case "room-pill":
- case "user-pill":
- return true;
- default:
- return false;
- }
- });
-}
diff --git a/test/editor/caret-test.js b/test/editor/caret-test.js
new file mode 100644
index 0000000000..9da28bff95
--- /dev/null
+++ b/test/editor/caret-test.js
@@ -0,0 +1,205 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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 expect from 'expect';
+import {getLineAndNodePosition} from "../../src/editor/caret";
+import EditorModel from "../../src/editor/model";
+import {createPartCreator} from "./mock";
+
+describe('editor/caret: DOM position for caret', function() {
+ describe('basic text handling', function() {
+ it('at end of single line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 0, offset: 5});
+ expect(lineIndex).toBe(0);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(5);
+ });
+ it('at start of single line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 0, offset: 0});
+ expect(lineIndex).toBe(0);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(0);
+ });
+ it('at middle of single line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 0, offset: 2});
+ expect(lineIndex).toBe(0);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(2);
+ });
+ });
+ describe('handling line breaks', function() {
+ it('at end of last line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.newline(),
+ pc.plain("world"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 2, offset: 5});
+ expect(lineIndex).toBe(1);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(5);
+ });
+ it('at start of last line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.newline(),
+ pc.plain("world"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 2, offset: 0});
+ expect(lineIndex).toBe(1);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(0);
+ });
+ it('in empty line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.newline(),
+ pc.newline(),
+ pc.plain("world"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 1, offset: 1});
+ expect(lineIndex).toBe(1);
+ expect(nodeIndex).toBe(-1);
+ expect(offset).toBe(0);
+ });
+ it('after empty line', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.newline(),
+ pc.newline(),
+ pc.plain("world"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 3, offset: 0});
+ expect(lineIndex).toBe(2);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(0);
+ });
+ });
+ describe('handling non-editable parts and caret nodes', function() {
+ it('at start of non-editable part (with plain text around)', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.userPill("Alice", "@alice:hs.tld"),
+ pc.plain("!"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 1, offset: 0});
+ expect(lineIndex).toBe(0);
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(5);
+ });
+ it('in middle of non-editable part (with plain text around)', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.userPill("Alice", "@alice:hs.tld"),
+ pc.plain("!"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 1, offset: 2});
+ expect(lineIndex).toBe(0);
+ expect(nodeIndex).toBe(2);
+ expect(offset).toBe(0);
+ });
+ it('at start of non-editable part (without plain text around)', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.userPill("Alice", "@alice:hs.tld"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 0, offset: 0});
+ expect(lineIndex).toBe(0);
+ //presumed nodes on line are (caret, pill, caret)
+ expect(nodeIndex).toBe(0);
+ expect(offset).toBe(0);
+ });
+ it('in middle of non-editable part (without plain text around)', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.userPill("Alice", "@alice:hs.tld"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 0, offset: 1});
+ expect(lineIndex).toBe(0);
+ //presumed nodes on line are (caret, pill, caret)
+ expect(nodeIndex).toBe(2);
+ expect(offset).toBe(0);
+ });
+ it('in middle of a first non-editable part, with another one following', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.userPill("Alice", "@alice:hs.tld"),
+ pc.userPill("Bob", "@bob:hs.tld"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 0, offset: 1});
+ expect(lineIndex).toBe(0);
+ //presumed nodes on line are (caret, pill, caret, pill, caret)
+ expect(nodeIndex).toBe(2);
+ expect(offset).toBe(0);
+ });
+ it('in start of a second non-editable part, with another one before it', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.userPill("Alice", "@alice:hs.tld"),
+ pc.userPill("Bob", "@bob:hs.tld"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 1, offset: 0});
+ expect(lineIndex).toBe(0);
+ //presumed nodes on line are (caret, pill, caret, pill, caret)
+ expect(nodeIndex).toBe(2);
+ expect(offset).toBe(0);
+ });
+ it('in middle of a second non-editable part, with another one before it', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.userPill("Alice", "@alice:hs.tld"),
+ pc.userPill("Bob", "@bob:hs.tld"),
+ ]);
+ const {offset, lineIndex, nodeIndex} =
+ getLineAndNodePosition(model, {index: 1, offset: 1});
+ expect(lineIndex).toBe(0);
+ //presumed nodes on line are (caret, pill, caret, pill, caret)
+ expect(nodeIndex).toBe(4);
+ expect(offset).toBe(0);
+ });
+ });
+});
diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js
new file mode 100644
index 0000000000..c7e0278f52
--- /dev/null
+++ b/test/editor/deserialize-test.js
@@ -0,0 +1,226 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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 expect from 'expect';
+import {parseEvent} from "../../src/editor/deserialize";
+import {createPartCreator} from "./mock";
+
+function htmlMessage(formattedBody, msgtype = "m.text") {
+ return {
+ getContent() {
+ return {
+ msgtype,
+ format: "org.matrix.custom.html",
+ formatted_body: formattedBody,
+ };
+ },
+ };
+}
+
+function textMessage(body, msgtype = "m.text") {
+ return {
+ getContent() {
+ return {
+ msgtype,
+ body,
+ };
+ },
+ };
+}
+
+function mergeAdjacentParts(parts) {
+ let prevPart;
+ for (let i = 0; i < parts.length; ++i) {
+ let part = parts[i];
+ const isEmpty = !part.text.length;
+ const isMerged = !isEmpty && prevPart && prevPart.merge(part);
+ if (isEmpty || isMerged) {
+ // remove empty or merged part
+ part = prevPart;
+ parts.splice(i, 1);
+ //repeat this index, as it's removed now
+ --i;
+ }
+ prevPart = part;
+ }
+}
+
+function normalize(parts) {
+ // merge adjacent parts as this will happen
+ // in the model anyway, and whether 1 or multiple
+ // plain parts are returned is an implementation detail
+ mergeAdjacentParts(parts);
+ // convert to data objects for easier asserting
+ return parts.map(p => p.serialize());
+}
+
+describe('editor/deserialize', function() {
+ describe('text messages', function() {
+ it('test with newlines', function() {
+ const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator()));
+ expect(parts.length).toBe(3);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "world"});
+ });
+ it('@room pill', function() {
+ const parts = normalize(parseEvent(textMessage("text message for @room"), createPartCreator()));
+ expect(parts.length).toBe(2);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "text message for "});
+ expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"});
+ });
+ it('emote', function() {
+ const text = "says DON'T SHOUT!";
+ const parts = normalize(parseEvent(textMessage(text, "m.emote"), createPartCreator()));
+ expect(parts.length).toBe(1);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "/me says DON'T SHOUT!"});
+ });
+ });
+ describe('html messages', function() {
+ it('inline styling', function() {
+ const html = "bold and emphasized text";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(1);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "**bold** and *emphasized* text"});
+ });
+ it('hyperlink', function() {
+ const html = 'click this!';
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(1);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "click [this](http://example.com/)!"});
+ });
+ it('multiple lines with paragraphs', function() {
+ const html = '
hello
world
';
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(3);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "world"});
+ });
+ it('multiple lines with line breaks', function() {
+ const html = 'hello
world';
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(3);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "world"});
+ });
+ it('multiple lines mixing paragraphs and line breaks', function() {
+ const html = 'hello
warm
world
';
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(5);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "hello"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "warm"});
+ expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[4]).toStrictEqual({type: "plain", text: "world"});
+ });
+ it('quote', function() {
+ const html = 'wise
words
indeed
';
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(6);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "> *wise*"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "> **words**"});
+ expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[4]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[5]).toStrictEqual({type: "plain", text: "indeed"});
+ });
+ it('user pill', function() {
+ const html = "Hi Alice!";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(3);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "});
+ expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", userId: "@alice:hs.tld"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "!"});
+ });
+ it('room pill', function() {
+ const html = "Try #room:hs.tld?";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(3);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "Try "});
+ expect(parts[1]).toStrictEqual({type: "room-pill", text: "#room:hs.tld"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "?"});
+ });
+ it('@room pill', function() {
+ const html = "formatted message for @room";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(2);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "*formatted* message for "});
+ expect(parts[1]).toStrictEqual({type: "at-room-pill", text: "@room"});
+ });
+ it('inline code', function() {
+ const html = "there is no place like 127.0.0.1
!";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(1);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "there is no place like `127.0.0.1`!"});
+ });
+ it('code block with no trailing text', function() {
+ const html = "0xDEADBEEF\n
\n";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ console.log(parts);
+ expect(parts.length).toBe(5);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "```"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "0xDEADBEEF"});
+ expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[4]).toStrictEqual({type: "plain", text: "```"});
+ });
+ // failing likely because of https://github.com/vector-im/riot-web/issues/10316
+ xit('code block with no trailing text and no newlines', function() {
+ const html = "0xDEADBEEF
";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(5);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "```"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "0xDEADBEEF"});
+ expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[4]).toStrictEqual({type: "plain", text: "```"});
+ });
+ it('unordered lists', function() {
+ const html = "";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(5);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "- Oak"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "- Spruce"});
+ expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[4]).toStrictEqual({type: "plain", text: "- Birch"});
+ });
+ it('ordered lists', function() {
+ const html = "- Start
- Continue
- Finish
";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(5);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "1. Start"});
+ expect(parts[1]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[2]).toStrictEqual({type: "plain", text: "1. Continue"});
+ expect(parts[3]).toStrictEqual({type: "newline", text: "\n"});
+ expect(parts[4]).toStrictEqual({type: "plain", text: "1. Finish"});
+ });
+ it('mx-reply is stripped', function() {
+ const html = "foobar";
+ const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
+ expect(parts.length).toBe(1);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "bar"});
+ });
+ it('emote', function() {
+ const html = "says DON'T SHOUT!";
+ const parts = normalize(parseEvent(htmlMessage(html, "m.emote"), createPartCreator()));
+ expect(parts.length).toBe(1);
+ expect(parts[0]).toStrictEqual({type: "plain", text: "/me says *DON'T SHOUT*!"});
+ });
+ });
+});
diff --git a/test/editor/diff-test.js b/test/editor/diff-test.js
new file mode 100644
index 0000000000..ebcb058baa
--- /dev/null
+++ b/test/editor/diff-test.js
@@ -0,0 +1,146 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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 expect from 'expect';
+import {diffDeletion, diffAtCaret} from "../../src/editor/diff";
+
+describe('editor/diff', function() {
+ describe('diffDeletion', function() {
+ describe('with a single character removed', function() {
+ it('at start of string', function() {
+ const diff = diffDeletion("hello", "ello");
+ expect(diff.at).toBe(0);
+ expect(diff.removed).toBe("h");
+ });
+ it('in middle of string', function() {
+ const diff = diffDeletion("hello", "hllo");
+ expect(diff.at).toBe(1);
+ expect(diff.removed).toBe("e");
+ });
+ it('in middle of string with duplicate character', function() {
+ const diff = diffDeletion("hello", "helo");
+ expect(diff.at).toBe(3);
+ expect(diff.removed).toBe("l");
+ });
+ it('at end of string', function() {
+ const diff = diffDeletion("hello", "hell");
+ expect(diff.at).toBe(4);
+ expect(diff.removed).toBe("o");
+ });
+ });
+ describe('with a multiple removed', function() {
+ it('at start of string', function() {
+ const diff = diffDeletion("hello", "llo");
+ expect(diff.at).toBe(0);
+ expect(diff.removed).toBe("he");
+ });
+ it('removing whole string', function() {
+ const diff = diffDeletion("hello", "");
+ expect(diff.at).toBe(0);
+ expect(diff.removed).toBe("hello");
+ });
+ it('in middle of string', function() {
+ const diff = diffDeletion("hello", "hlo");
+ expect(diff.at).toBe(1);
+ expect(diff.removed).toBe("el");
+ });
+ it('in middle of string with duplicate character', function() {
+ const diff = diffDeletion("hello", "heo");
+ expect(diff.at).toBe(2);
+ expect(diff.removed).toBe("ll");
+ });
+ it('at end of string', function() {
+ const diff = diffDeletion("hello", "hel");
+ expect(diff.at).toBe(3);
+ expect(diff.removed).toBe("lo");
+ });
+ });
+ });
+ describe('diffAtCaret', function() {
+ it('insert at start', function() {
+ const diff = diffAtCaret("world", "hello world", 6);
+ expect(diff.at).toBe(0);
+ expect(diff.added).toBe("hello ");
+ expect(diff.removed).toBeFalsy();
+ });
+ it('insert at end', function() {
+ const diff = diffAtCaret("hello", "hello world", 11);
+ expect(diff.at).toBe(5);
+ expect(diff.added).toBe(" world");
+ expect(diff.removed).toBeFalsy();
+ });
+ it('insert in middle', function() {
+ const diff = diffAtCaret("hello world", "hello cruel world", 12);
+ expect(diff.at).toBe(6);
+ expect(diff.added).toBe("cruel ");
+ expect(diff.removed).toBeFalsy();
+ });
+ it('replace at start', function() {
+ const diff = diffAtCaret("morning, world!", "afternoon, world!", 9);
+ expect(diff.at).toBe(0);
+ expect(diff.removed).toBe("morning");
+ expect(diff.added).toBe("afternoon");
+ });
+ it('replace at end', function() {
+ const diff = diffAtCaret("morning, world!", "morning, mars?", 14);
+ expect(diff.at).toBe(9);
+ expect(diff.removed).toBe("world!");
+ expect(diff.added).toBe("mars?");
+ });
+ it('replace in middle', function() {
+ const diff = diffAtCaret("morning, blue planet", "morning, red planet", 12);
+ expect(diff.at).toBe(9);
+ expect(diff.removed).toBe("blue");
+ expect(diff.added).toBe("red");
+ });
+ it('remove at start of string', function() {
+ const diff = diffAtCaret("hello", "ello", 0);
+ expect(diff.at).toBe(0);
+ expect(diff.removed).toBe("h");
+ expect(diff.added).toBeFalsy();
+ });
+ it('removing whole string', function() {
+ const diff = diffAtCaret("hello", "", 0);
+ expect(diff.at).toBe(0);
+ expect(diff.removed).toBe("hello");
+ expect(diff.added).toBeFalsy();
+ });
+ it('remove in middle of string', function() {
+ const diff = diffAtCaret("hello", "hllo", 1);
+ expect(diff.at).toBe(1);
+ expect(diff.removed).toBe("e");
+ expect(diff.added).toBeFalsy();
+ });
+ it('forwards remove in middle of string', function() {
+ const diff = diffAtCaret("hello", "hell", 4);
+ expect(diff.at).toBe(4);
+ expect(diff.removed).toBe("o");
+ expect(diff.added).toBeFalsy();
+ });
+ it('forwards remove in middle of string with duplicate character', function() {
+ const diff = diffAtCaret("hello", "helo", 3);
+ expect(diff.at).toBe(3);
+ expect(diff.removed).toBe("l");
+ expect(diff.added).toBeFalsy();
+ });
+ it('remove at end of string', function() {
+ const diff = diffAtCaret("hello", "hell", 4);
+ expect(diff.at).toBe(4);
+ expect(diff.removed).toBe("o");
+ expect(diff.added).toBeFalsy();
+ });
+ });
+});
diff --git a/test/editor/mock.js b/test/editor/mock.js
new file mode 100644
index 0000000000..57ad0c52f3
--- /dev/null
+++ b/test/editor/mock.js
@@ -0,0 +1,69 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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 {PartCreator} from "../../src/editor/parts";
+
+class MockAutoComplete {
+ constructor(updateCallback, partCreator, completions) {
+ this._updateCallback = updateCallback;
+ this._partCreator = partCreator;
+ this._completions = completions;
+ this._part = null;
+ }
+
+ close() {
+ this._updateCallback({close: true});
+ }
+
+ tryComplete(close = true) {
+ const matches = this._completions.filter(o => {
+ return o.resourceId.startsWith(this._part.text);
+ });
+ if (matches.length === 1 && this._part.text.length > 1) {
+ const match = matches[0];
+ let pill;
+ if (match.resourceId[0] === "@") {
+ pill = this._partCreator.userPill(match.label, match.resourceId);
+ } else {
+ pill = this._partCreator.roomPill(match.resourceId);
+ }
+ this._updateCallback({replacePart: pill, close});
+ }
+ }
+
+ // called by EditorModel when typing into pill-candidate part
+ onPartUpdate(part, offset) {
+ this._part = part;
+ }
+}
+
+// MockClient & MockRoom are only used for avatars in room and user pills,
+// which is not tested
+class MockClient {
+ getRooms() { return []; }
+ getRoom() { return null; }
+}
+
+class MockRoom {
+ getMember() { return null; }
+}
+
+export function createPartCreator(completions = []) {
+ const autoCompleteCreator = (partCreator) => {
+ return (updateCallback) => new MockAutoComplete(updateCallback, partCreator, completions);
+ };
+ return new PartCreator(autoCompleteCreator, new MockRoom(), new MockClient());
+}
diff --git a/test/editor/model-test.js b/test/editor/model-test.js
new file mode 100644
index 0000000000..c5f2a2ef12
--- /dev/null
+++ b/test/editor/model-test.js
@@ -0,0 +1,296 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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 expect from 'expect';
+import EditorModel from "../../src/editor/model";
+import {createPartCreator} from "./mock";
+
+function createRenderer() {
+ const render = (c) => {
+ render.caret = c;
+ render.count += 1;
+ };
+ render.count = 0;
+ render.caret = null;
+ return render;
+}
+
+describe('editor/model', function() {
+ describe('plain text manipulation', function() {
+ it('insert text into empty document', function() {
+ const renderer = createRenderer();
+ const model = new EditorModel([], createPartCreator(), renderer);
+ model.update("hello", "insertText", {offset: 5, atNodeEnd: true});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(0);
+ expect(renderer.caret.offset).toBe(5);
+ expect(model.parts.length).toBe(1);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello");
+ });
+ it('append text to existing document', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.plain("hello")], pc, renderer);
+ model.update("hello world", "insertText", {offset: 11, atNodeEnd: true});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(0);
+ expect(renderer.caret.offset).toBe(11);
+ expect(model.parts.length).toBe(1);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello world");
+ });
+ it('prepend text to existing document', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.plain("world")], pc, renderer);
+ model.update("hello world", "insertText", {offset: 6, atNodeEnd: false});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(0);
+ expect(renderer.caret.offset).toBe(6);
+ expect(model.parts.length).toBe(1);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello world");
+ });
+ });
+ describe('handling line breaks', function() {
+ it('insert new line into existing document', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.plain("hello")], pc, renderer);
+ model.update("hello\n", "insertText", {offset: 6, atNodeEnd: true});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(1);
+ expect(renderer.caret.offset).toBe(1);
+ expect(model.parts.length).toBe(2);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello");
+ expect(model.parts[1].type).toBe("newline");
+ expect(model.parts[1].text).toBe("\n");
+ });
+ it('insert multiple new lines into existing document', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.plain("hello")], pc, renderer);
+ model.update("hello\n\n\nworld!", "insertText", {offset: 14, atNodeEnd: true});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(4);
+ expect(renderer.caret.offset).toBe(6);
+ expect(model.parts.length).toBe(5);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello");
+ expect(model.parts[1].type).toBe("newline");
+ expect(model.parts[1].text).toBe("\n");
+ expect(model.parts[2].type).toBe("newline");
+ expect(model.parts[2].text).toBe("\n");
+ expect(model.parts[3].type).toBe("newline");
+ expect(model.parts[3].text).toBe("\n");
+ expect(model.parts[4].type).toBe("plain");
+ expect(model.parts[4].text).toBe("world!");
+ });
+ it('type in empty line', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("hello"),
+ pc.newline(),
+ pc.newline(),
+ pc.plain("world"),
+ ], pc, renderer);
+ model.update("hello\nwarm\nworld", "insertText", {offset: 10, atNodeEnd: true});
+ console.log(model.serializeParts());
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(2);
+ expect(renderer.caret.offset).toBe(4);
+ expect(model.parts.length).toBe(5);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello");
+ expect(model.parts[1].type).toBe("newline");
+ expect(model.parts[1].text).toBe("\n");
+ expect(model.parts[2].type).toBe("plain");
+ expect(model.parts[2].text).toBe("warm");
+ expect(model.parts[3].type).toBe("newline");
+ expect(model.parts[3].text).toBe("\n");
+ expect(model.parts[4].type).toBe("plain");
+ expect(model.parts[4].text).toBe("world");
+ });
+ });
+ describe('non-editable part manipulation', function() {
+ it('typing at start of non-editable part prepends', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("try "),
+ pc.roomPill("#someroom"),
+ ], pc, renderer);
+ model.update("try foo#someroom", "insertText", {offset: 7, atNodeEnd: false});
+ expect(renderer.caret.index).toBe(0);
+ expect(renderer.caret.offset).toBe(7);
+ expect(model.parts.length).toBe(2);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("try foo");
+ expect(model.parts[1].type).toBe("room-pill");
+ expect(model.parts[1].text).toBe("#someroom");
+ });
+ it('typing in middle of non-editable part appends', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([
+ pc.plain("try "),
+ pc.roomPill("#someroom"),
+ pc.plain("?"),
+ ], pc, renderer);
+ model.update("try #some perhapsroom?", "insertText", {offset: 17, atNodeEnd: false});
+ expect(renderer.caret.index).toBe(2);
+ expect(renderer.caret.offset).toBe(8);
+ expect(model.parts.length).toBe(3);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("try ");
+ expect(model.parts[1].type).toBe("room-pill");
+ expect(model.parts[1].text).toBe("#someroom");
+ expect(model.parts[2].type).toBe("plain");
+ expect(model.parts[2].text).toBe(" perhaps?");
+ });
+ it('remove non-editable part with backspace', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.roomPill("#someroom")], pc, renderer);
+ model.update("#someroo", "deleteContentBackward", {offset: 8, atNodeEnd: true});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(-1);
+ expect(renderer.caret.offset).toBe(0);
+ expect(model.parts.length).toBe(0);
+ });
+ it('remove non-editable part with delete', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.roomPill("#someroom")], pc, renderer);
+ model.update("someroom", "deleteContentForward", {offset: 0, atNodeEnd: false});
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(-1);
+ expect(renderer.caret.offset).toBe(0);
+ expect(model.parts.length).toBe(0);
+ });
+ });
+ describe('auto-complete', function() {
+ it('insert user pill', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator([{resourceId: "@alice", label: "Alice"}]);
+ const model = new EditorModel([pc.plain("hello ")], pc, renderer);
+
+ model.update("hello @a", "insertText", {offset: 8, atNodeEnd: true});
+
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(1);
+ expect(renderer.caret.offset).toBe(2);
+ expect(model.parts.length).toBe(2);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello ");
+ expect(model.parts[1].type).toBe("pill-candidate");
+ expect(model.parts[1].text).toBe("@a");
+
+ model.autoComplete.tryComplete(); // see MockAutoComplete
+
+ expect(renderer.count).toBe(2);
+ expect(renderer.caret.index).toBe(1);
+ expect(renderer.caret.offset).toBe(5);
+ expect(model.parts.length).toBe(2);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello ");
+ expect(model.parts[1].type).toBe("user-pill");
+ expect(model.parts[1].text).toBe("Alice");
+ });
+
+ it('insert room pill', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator([{resourceId: "#riot-dev"}]);
+ const model = new EditorModel([pc.plain("hello ")], pc, renderer);
+
+ model.update("hello #r", "insertText", {offset: 8, atNodeEnd: true});
+
+ expect(renderer.count).toBe(1);
+ expect(renderer.caret.index).toBe(1);
+ expect(renderer.caret.offset).toBe(2);
+ expect(model.parts.length).toBe(2);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello ");
+ expect(model.parts[1].type).toBe("pill-candidate");
+ expect(model.parts[1].text).toBe("#r");
+
+ model.autoComplete.tryComplete(); // see MockAutoComplete
+
+ expect(renderer.count).toBe(2);
+ expect(renderer.caret.index).toBe(1);
+ expect(renderer.caret.offset).toBe(9);
+ expect(model.parts.length).toBe(2);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello ");
+ expect(model.parts[1].type).toBe("room-pill");
+ expect(model.parts[1].text).toBe("#riot-dev");
+ });
+
+ it('type after inserting pill', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator([{resourceId: "#riot-dev"}]);
+ const model = new EditorModel([pc.plain("hello ")], pc, renderer);
+
+ model.update("hello #r", "insertText", {offset: 8, atNodeEnd: true});
+ model.autoComplete.tryComplete(); // see MockAutoComplete
+ model.update("hello #riot-dev!!", "insertText", {offset: 17, atNodeEnd: true});
+
+ expect(renderer.count).toBe(3);
+ expect(renderer.caret.index).toBe(2);
+ expect(renderer.caret.offset).toBe(2);
+ expect(model.parts.length).toBe(3);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("hello ");
+ expect(model.parts[1].type).toBe("room-pill");
+ expect(model.parts[1].text).toBe("#riot-dev");
+ expect(model.parts[2].type).toBe("plain");
+ expect(model.parts[2].text).toBe("!!");
+ });
+
+ it('pasting text does not trigger auto-complete', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator([{resourceId: "#define-room"}]);
+ const model = new EditorModel([pc.plain("try ")], pc, renderer);
+
+ model.update("try #define", "insertFromPaste", {offset: 11, atNodeEnd: true});
+
+ expect(model.autoComplete).toBeFalsy();
+ expect(renderer.caret.index).toBe(0);
+ expect(renderer.caret.offset).toBe(11);
+ expect(model.parts.length).toBe(1);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("try #define");
+ });
+
+ it('dropping text does not trigger auto-complete', function() {
+ const renderer = createRenderer();
+ const pc = createPartCreator([{resourceId: "#define-room"}]);
+ const model = new EditorModel([pc.plain("try ")], pc, renderer);
+
+ model.update("try #define", "insertFromDrop", {offset: 11, atNodeEnd: true});
+
+ expect(model.autoComplete).toBeFalsy();
+ expect(renderer.caret.index).toBe(0);
+ expect(renderer.caret.offset).toBe(11);
+ expect(model.parts.length).toBe(1);
+ expect(model.parts[0].type).toBe("plain");
+ expect(model.parts[0].text).toBe("try #define");
+ });
+ });
+});
diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js
new file mode 100644
index 0000000000..2e7712e6e6
--- /dev/null
+++ b/test/editor/serialize-test.js
@@ -0,0 +1,47 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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 expect from 'expect';
+import EditorModel from "../../src/editor/model";
+import {htmlSerializeIfNeeded} from "../../src/editor/serialize";
+import {createPartCreator} from "./mock";
+
+describe('editor/serialize', function() {
+ it('user pill turns message into html', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")]);
+ const html = htmlSerializeIfNeeded(model, {});
+ expect(html).toBe("Alice");
+ });
+ it('room pill turns message into html', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.roomPill("#room:hs.tld")]);
+ const html = htmlSerializeIfNeeded(model, {});
+ expect(html).toBe("#room:hs.tld");
+ });
+ it('@room pill turns message into html', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.atRoomPill("@room")]);
+ const html = htmlSerializeIfNeeded(model, {});
+ expect(html).toBeFalsy();
+ });
+ it('any markdown turns message into html', function() {
+ const pc = createPartCreator();
+ const model = new EditorModel([pc.plain("*hello* world")]);
+ const html = htmlSerializeIfNeeded(model, {});
+ expect(html).toBe("hello world");
+ });
+});