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 = "
  1. Start
  2. Continue
  3. 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"); + }); +});