Merge matrix-react-sdk into element-web
Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
178
test/unit-tests/editor/__snapshots__/deserialize-test.ts.snap
Normal file
178
test/unit-tests/editor/__snapshots__/deserialize-test.ts.snap
Normal file
|
@ -0,0 +1,178 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`editor/deserialize html messages escapes angle brackets 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "\\> \\\\<del>no formatting here\\\\</del>",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages escapes asterisks 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "\\*hello\\*",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages escapes backslashes 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "C:\\\\My Documents",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages escapes backticks in code blocks 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "\`\`this → \` is a backtick\`\`",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "\`\`\`\`",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "and here are 3 of them:",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "\`\`\`",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "\`\`\`\`",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages escapes backticks outside of code blocks 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "some \\\`backticks\\\`",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages escapes square brackets 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "\\[not an actual link\\](https://example.org)",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages escapes underscores 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "\\_\\_emphasis\\_\\_",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages preserves nested formatting 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "a<sub>b_c**d<u>e</u>**_</sub>",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages preserves nested quotes 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "> foo",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "> ",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "> > bar",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`editor/deserialize html messages surrounds lists with newlines 1`] = `
|
||||
[
|
||||
{
|
||||
"text": "foo",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "- bar",
|
||||
"type": "plain",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "
|
||||
",
|
||||
"type": "newline",
|
||||
},
|
||||
{
|
||||
"text": "baz",
|
||||
"type": "plain",
|
||||
},
|
||||
]
|
||||
`;
|
168
test/unit-tests/editor/caret-test.ts
Normal file
168
test/unit-tests/editor/caret-test.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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 start of first line which is empty", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.newline(), pc.plain("hello world")], pc);
|
||||
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, { index: 0, offset: 0 });
|
||||
expect(lineIndex).toBe(0);
|
||||
expect(nodeIndex).toBe(-1);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
it("at end of last line", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.newline(), pc.plain("world")], pc);
|
||||
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")], pc);
|
||||
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, { index: 2, offset: 0 });
|
||||
expect(lineIndex).toBe(1);
|
||||
expect(nodeIndex).toBe(0);
|
||||
expect(offset).toBe(0);
|
||||
});
|
||||
it("before empty line", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.newline(), pc.newline(), pc.plain("world")], pc);
|
||||
const { offset, lineIndex, nodeIndex } = getLineAndNodePosition(model, { index: 0, offset: 5 });
|
||||
expect(lineIndex).toBe(0);
|
||||
expect(nodeIndex).toBe(0);
|
||||
expect(offset).toBe(5);
|
||||
});
|
||||
it("in empty line", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.newline(), pc.newline(), pc.plain("world")], pc);
|
||||
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")], pc);
|
||||
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("!")],
|
||||
pc,
|
||||
);
|
||||
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("!")],
|
||||
pc,
|
||||
);
|
||||
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")], pc);
|
||||
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")], pc);
|
||||
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")],
|
||||
pc,
|
||||
);
|
||||
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")],
|
||||
pc,
|
||||
);
|
||||
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")],
|
||||
pc,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
437
test/unit-tests/editor/deserialize-test.ts
Normal file
437
test/unit-tests/editor/deserialize-test.ts
Normal file
|
@ -0,0 +1,437 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { parseEvent } from "../../../src/editor/deserialize";
|
||||
import { Part } from "../../../src/editor/parts";
|
||||
import { createPartCreator } from "./mock";
|
||||
|
||||
const FOUR_SPACES = " ".repeat(4);
|
||||
|
||||
function htmlMessage(formattedBody: string, msgtype = "m.text") {
|
||||
return {
|
||||
getContent() {
|
||||
return {
|
||||
msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: formattedBody,
|
||||
};
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
function textMessage(body: string, msgtype = "m.text") {
|
||||
return {
|
||||
getContent() {
|
||||
return {
|
||||
msgtype,
|
||||
body,
|
||||
};
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
function textMessageReply(body: string, msgtype = "m.text") {
|
||||
return {
|
||||
...textMessage(body, msgtype),
|
||||
replyEventId: "!foo:bar",
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
function mergeAdjacentParts(parts: Part[]) {
|
||||
let prevPart: Part | undefined;
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
let part: Part | undefined = 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: Part[]) {
|
||||
// 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[0]).toStrictEqual({ type: "plain", text: "hello" });
|
||||
expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "world" });
|
||||
expect(parts.length).toBe(3);
|
||||
});
|
||||
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!" });
|
||||
});
|
||||
it("spoiler", function () {
|
||||
const parts = normalize(parseEvent(textMessage("/spoiler broiler"), createPartCreator()));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({ type: "plain", text: "/spoiler broiler" });
|
||||
});
|
||||
});
|
||||
describe("html messages", function () {
|
||||
it("inline styling", function () {
|
||||
const html = "<strong>bold</strong> and <em>emphasized</em> 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 <a href="http://example.com/">this</a>!';
|
||||
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 = "<p>hello</p><p>world</p>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts.length).toBe(4);
|
||||
expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" });
|
||||
expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[2]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[3]).toStrictEqual({ type: "plain", text: "world" });
|
||||
});
|
||||
it("multiple lines with line breaks", function () {
|
||||
const html = "hello<br>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 = "<p>hello<br>warm</p><p>world</p>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts.length).toBe(6);
|
||||
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: "newline", text: "\n" });
|
||||
expect(parts[5]).toStrictEqual({ type: "plain", text: "world" });
|
||||
});
|
||||
it("quote", function () {
|
||||
const html = "<blockquote><p><em>wise</em><br><strong>words</strong></p></blockquote><p>indeed</p>";
|
||||
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 <a href="https://matrix.to/#/@alice:hs.tld">Alice</a>!';
|
||||
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", resourceId: "@alice:hs.tld" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "!" });
|
||||
});
|
||||
it("user pill with displayname containing backslash", function () {
|
||||
const html = 'Hi <a href="https://matrix.to/#/@alice:hs.tld">Alice\\</a>!';
|
||||
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\\", resourceId: "@alice:hs.tld" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "!" });
|
||||
});
|
||||
it("user pill with displayname containing opening square bracket", function () {
|
||||
const html = 'Hi <a href="https://matrix.to/#/@alice:hs.tld">Alice[[</a>!';
|
||||
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[[", resourceId: "@alice:hs.tld" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "!" });
|
||||
});
|
||||
it("user pill with displayname containing closing square bracket", function () {
|
||||
const html = 'Hi <a href="https://matrix.to/#/@alice:hs.tld">Alice]</a>!';
|
||||
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]", resourceId: "@alice:hs.tld" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "!" });
|
||||
});
|
||||
it("user pill with displayname containing linebreak", function () {
|
||||
const html = 'Hi <a href="https://matrix.to/#/@alice:hs.tld">Alice<br>123</a>!';
|
||||
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: "Alice123", resourceId: "@alice:hs.tld" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "!" });
|
||||
});
|
||||
it("room pill", function () {
|
||||
const html = 'Try <a href="https://matrix.to/#/#room:hs.tld">#room:hs.tld</a>?';
|
||||
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", resourceId: "#room:hs.tld" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: "?" });
|
||||
});
|
||||
it("@room pill", function () {
|
||||
const html = "<em>formatted</em> 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 <code>127.0.0.1</code>!";
|
||||
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 = "<pre><code>0xDEADBEEF\n</code></pre>\n";
|
||||
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: "```" });
|
||||
});
|
||||
// failing likely because of https://github.com/vector-im/element-web/issues/10316
|
||||
it.skip("code block with no trailing text and no newlines", function () {
|
||||
const html = "<pre><code>0xDEADBEEF</code></pre>";
|
||||
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 = "<ul><li>Oak</li><li>Spruce</li><li>Birch</li></ul>";
|
||||
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 = "<ol><li>Start</li><li>Continue</li><li>Finish</li></ol>";
|
||||
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: "2. Continue" });
|
||||
expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[4]).toStrictEqual({ type: "plain", text: "3. Finish" });
|
||||
});
|
||||
it("nested unordered lists", () => {
|
||||
const html = "<ul><li>Oak<ul><li>Spruce<ul><li>Birch</li></ul></li></ul></li></ul>";
|
||||
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: `${FOUR_SPACES}- Spruce` });
|
||||
expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[4]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES.repeat(2)}- Birch` });
|
||||
});
|
||||
it("nested ordered lists", () => {
|
||||
const html = "<ol><li>Oak<ol><li>Spruce<ol><li>Birch</li></ol></li></ol></li></ol>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts.length).toBe(5);
|
||||
expect(parts[0]).toStrictEqual({ type: "plain", text: "1. Oak" });
|
||||
expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES}1. Spruce` });
|
||||
expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[4]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES.repeat(2)}1. Birch` });
|
||||
});
|
||||
it("nested lists", () => {
|
||||
const html = "<ol><li>Oak\n<ol><li>Spruce\n<ol><li>Birch</li></ol></li></ol></li></ol>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts.length).toBe(5);
|
||||
expect(parts[0]).toStrictEqual({ type: "plain", text: "1. Oak\n" });
|
||||
expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[2]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES}1. Spruce\n` });
|
||||
expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" });
|
||||
expect(parts[4]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES.repeat(2)}1. Birch` });
|
||||
});
|
||||
it("mx-reply is stripped", function () {
|
||||
const html = "<mx-reply>foo</mx-reply>bar";
|
||||
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 <em>DON'T SHOUT</em>!";
|
||||
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_!" });
|
||||
});
|
||||
it("spoiler", function () {
|
||||
const parts = normalize(
|
||||
parseEvent(htmlMessage("<span data-mx-spoiler>broiler</span>"), createPartCreator()),
|
||||
);
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({ type: "plain", text: "/spoiler broiler" });
|
||||
});
|
||||
it("preserves nested quotes", () => {
|
||||
const html = "<blockquote>foo<blockquote>bar</blockquote></blockquote>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("surrounds lists with newlines", () => {
|
||||
const html = "foo<ul><li>bar</li></ul>baz";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("preserves nested formatting", () => {
|
||||
const html = "a<sub>b<em>c<strong>d<u>e</u></strong></em></sub>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes backticks in code blocks", () => {
|
||||
const html =
|
||||
"<p><code>this → ` is a backtick</code></p>" + "<pre><code>and here are 3 of them:\n```</code></pre>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes backticks outside of code blocks", () => {
|
||||
const html = "some `backticks`";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes backslashes", () => {
|
||||
const html = "C:\\My Documents";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes asterisks", () => {
|
||||
const html = "*hello*";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes underscores", () => {
|
||||
const html = "__emphasis__";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes square brackets", () => {
|
||||
const html = "[not an actual link](https://example.org)";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
it("escapes angle brackets", () => {
|
||||
const html = "> \\<del>no formatting here\\</del>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator()));
|
||||
expect(parts).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe("plaintext messages", function () {
|
||||
it("turns html tags back into markdown", function () {
|
||||
const html = '<strong>bold</strong> and <em>emphasized</em> text <a href="http://example.com/">this</a>!';
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "**bold** and _emphasized_ text [this](http://example.com/)!",
|
||||
});
|
||||
});
|
||||
it("keeps backticks unescaped", () => {
|
||||
const html = "this → ` is a backtick and here are 3 of them:\n```";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "this → ` is a backtick and here are 3 of them:\n```",
|
||||
});
|
||||
});
|
||||
it("keeps backticks outside of code blocks", () => {
|
||||
const html = "some `backticks`";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "some `backticks`",
|
||||
});
|
||||
});
|
||||
it("keeps backslashes", () => {
|
||||
const html = "C:\\My Documents";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "C:\\My Documents",
|
||||
});
|
||||
});
|
||||
it("keeps asterisks", () => {
|
||||
const html = "*hello*";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "*hello*",
|
||||
});
|
||||
});
|
||||
it("keeps underscores", () => {
|
||||
const html = "__emphasis__";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "__emphasis__",
|
||||
});
|
||||
});
|
||||
it("keeps square brackets", () => {
|
||||
const html = "[not an actual link](https://example.org)";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "[not an actual link](https://example.org)",
|
||||
});
|
||||
});
|
||||
it("escapes angle brackets", () => {
|
||||
const html = "> <del>no formatting here</del>";
|
||||
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "> <del>no formatting here</del>",
|
||||
});
|
||||
});
|
||||
it("strips plaintext replies", () => {
|
||||
const body = "> Sender: foo\n\nMessage";
|
||||
const parts = normalize(parseEvent(textMessageReply(body), createPartCreator(), { shouldEscape: false }));
|
||||
expect(parts.length).toBe(1);
|
||||
expect(parts[0]).toStrictEqual({
|
||||
type: "plain",
|
||||
text: "Message",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
137
test/unit-tests/editor/diff-test.ts
Normal file
137
test/unit-tests/editor/diff-test.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
143
test/unit-tests/editor/history-test.ts
Normal file
143
test/unit-tests/editor/history-test.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import HistoryManager, { IHistory, MAX_STEP_LENGTH } from "../../../src/editor/history";
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
describe("editor/history", function () {
|
||||
it("push, then undo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const caret1 = new DocumentPosition(0, 0);
|
||||
const result1 = history.tryPush(model, caret1, "insertText", {});
|
||||
expect(result1).toEqual(true);
|
||||
parts[0] = "hello world";
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
const undoState = history.undo(model) as IHistory;
|
||||
expect(undoState.caret).toBe(caret1);
|
||||
expect(undoState.parts).toEqual(["hello"]);
|
||||
expect(history.canUndo()).toEqual(false);
|
||||
});
|
||||
it("push, undo, then redo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
parts[0] = "hello world";
|
||||
const caret2 = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, caret2, "insertText", {});
|
||||
history.undo(model);
|
||||
expect(history.canRedo()).toEqual(true);
|
||||
const redoState = history.redo() as IHistory;
|
||||
expect(redoState.caret).toBe(caret2);
|
||||
expect(redoState.parts).toEqual(["hello world"]);
|
||||
expect(history.canRedo()).toEqual(false);
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
});
|
||||
it("push, undo, push, ensure you can`t redo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
parts[0] = "hello world";
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
history.undo(model);
|
||||
parts[0] = "hello world!!";
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
expect(history.canRedo()).toEqual(false);
|
||||
});
|
||||
it("not every keystroke stores a history step", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const firstCaret = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, firstCaret, "insertText", {});
|
||||
const diff = { added: "o" };
|
||||
let keystrokeCount = 0;
|
||||
do {
|
||||
parts[0] = parts[0] + diff.added;
|
||||
keystrokeCount += 1;
|
||||
} while (!history.tryPush(model, new DocumentPosition(0, 0), "insertText", diff));
|
||||
const undoState = history.undo(model) as IHistory;
|
||||
expect(undoState.caret).toBe(firstCaret);
|
||||
expect(undoState.parts).toEqual(["hello"]);
|
||||
expect(history.canUndo()).toEqual(false);
|
||||
expect(keystrokeCount).toEqual(MAX_STEP_LENGTH + 1); // +1 before we type before checking
|
||||
});
|
||||
it("history step is added at word boundary", function () {
|
||||
const history = new HistoryManager();
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const parts = ["h"];
|
||||
let diff = { added: "h" };
|
||||
const blankCaret = new DocumentPosition(0, 0);
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: "i" };
|
||||
parts[0] = "hi";
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: " " };
|
||||
parts[0] = "hi ";
|
||||
const spaceCaret = new DocumentPosition(1, 1);
|
||||
expect(history.tryPush(model, spaceCaret, "insertText", diff)).toEqual(true);
|
||||
diff = { added: "y" };
|
||||
parts[0] = "hi y";
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: "o" };
|
||||
parts[0] = "hi yo";
|
||||
expect(history.tryPush(model, blankCaret, "insertText", diff)).toEqual(false);
|
||||
diff = { added: "u" };
|
||||
parts[0] = "hi you";
|
||||
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
const undoResult = history.undo(model) as IHistory;
|
||||
expect(undoResult.caret).toEqual(spaceCaret);
|
||||
expect(undoResult.parts).toEqual(["hi "]);
|
||||
});
|
||||
it("keystroke that didn't add a step can undo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const firstCaret = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, firstCaret, "insertText", {});
|
||||
parts[0] = "helloo";
|
||||
const result = history.tryPush(model, new DocumentPosition(0, 0), "insertText", { added: "o" });
|
||||
expect(result).toEqual(false);
|
||||
expect(history.canUndo()).toEqual(true);
|
||||
const undoState = history.undo(model) as IHistory;
|
||||
expect(undoState.caret).toEqual(firstCaret);
|
||||
expect(undoState.parts).toEqual(["hello"]);
|
||||
});
|
||||
it("undo after keystroke that didn't add a step is able to redo", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
history.tryPush(model, new DocumentPosition(0, 0), "insertText", {});
|
||||
parts[0] = "helloo";
|
||||
const caret = new DocumentPosition(1, 1);
|
||||
history.tryPush(model, caret, "insertText", { added: "o" });
|
||||
history.undo(model);
|
||||
expect(history.canRedo()).toEqual(true);
|
||||
const redoState = history.redo() as IHistory;
|
||||
expect(redoState.caret).toBe(caret);
|
||||
expect(redoState.parts).toEqual(["helloo"]);
|
||||
});
|
||||
|
||||
it("overwriting text always stores a step", function () {
|
||||
const history = new HistoryManager();
|
||||
const parts = ["hello"];
|
||||
const model = { serializeParts: () => parts.slice() } as unknown as EditorModel;
|
||||
const firstCaret = new DocumentPosition(0, 0);
|
||||
history.tryPush(model, firstCaret, "insertText", {});
|
||||
const diff = { at: 1, added: "a", removed: "e" };
|
||||
const secondCaret = new DocumentPosition(1, 1);
|
||||
const result = history.tryPush(model, secondCaret, "insertText", diff);
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
89
test/unit-tests/editor/mock.ts
Normal file
89
test/unit-tests/editor/mock.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019-2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Room, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import AutocompleteWrapperModel, { UpdateCallback } from "../../../src/editor/autocomplete";
|
||||
import { Caret } from "../../../src/editor/caret";
|
||||
import { PillPart, Part, PartCreator } from "../../../src/editor/parts";
|
||||
import DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
export class MockAutoComplete {
|
||||
public _updateCallback;
|
||||
public _partCreator;
|
||||
public _completions;
|
||||
public _part: Part | null;
|
||||
|
||||
constructor(updateCallback: UpdateCallback, partCreator: PartCreator, completions: PillPart[]) {
|
||||
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 this._part && o.resourceId.startsWith(this._part.text);
|
||||
});
|
||||
if (matches.length === 1 && this._part && this._part.text.length > 1) {
|
||||
const match = matches[0];
|
||||
let pill: PillPart;
|
||||
if (match.resourceId[0] === "@") {
|
||||
pill = this._partCreator.userPill(match.text, match.resourceId);
|
||||
} else {
|
||||
pill = this._partCreator.roomPill(match.resourceId);
|
||||
}
|
||||
this._updateCallback({ replaceParts: [pill], close });
|
||||
}
|
||||
}
|
||||
|
||||
// called by EditorModel when typing into pill-candidate part
|
||||
onPartUpdate(part: Part, pos: DocumentPosition) {
|
||||
this._part = part;
|
||||
}
|
||||
}
|
||||
|
||||
// MockClient & MockRoom are only used for avatars in room and user pills,
|
||||
// which is not tested
|
||||
class MockRoom {
|
||||
getMember(): RoomMember | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createPartCreator(completions: PillPart[] = []) {
|
||||
const autoCompleteCreator = (partCreator: PartCreator) => {
|
||||
return (updateCallback: UpdateCallback) =>
|
||||
new MockAutoComplete(updateCallback, partCreator, completions) as unknown as AutocompleteWrapperModel;
|
||||
};
|
||||
const room = new MockRoom() as unknown as Room;
|
||||
const client = {
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getRoom: jest.fn().mockReturnValue(null),
|
||||
} as unknown as MatrixClient;
|
||||
return new PartCreator(room, client, autoCompleteCreator);
|
||||
}
|
||||
|
||||
export function createRenderer() {
|
||||
const render = (c?: Caret) => {
|
||||
render.caret = c;
|
||||
render.count += 1;
|
||||
};
|
||||
render.count = 0;
|
||||
render.caret = null as unknown as Caret | undefined;
|
||||
return render;
|
||||
}
|
||||
|
||||
// in many tests we need to narrow the caret type
|
||||
export function isDocumentPosition(caret: Caret): caret is DocumentPosition {
|
||||
return caret instanceof DocumentPosition;
|
||||
}
|
374
test/unit-tests/editor/model-test.ts
Normal file
374
test/unit-tests/editor/model-test.ts
Normal file
|
@ -0,0 +1,374 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer, MockAutoComplete } from "./mock";
|
||||
import DocumentOffset from "../../../src/editor/offset";
|
||||
import { PillPart } from "../../../src/editor/parts";
|
||||
import DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
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", new DocumentOffset(5, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(11, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(6, false));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(6, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(14, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(4);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(10, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(7, false));
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(17, false));
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(8, true));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(-1);
|
||||
expect((renderer.caret as DocumentPosition).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", new DocumentOffset(0, false));
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(-1);
|
||||
expect((renderer.caret as DocumentPosition).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", text: "Alice" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("hello ")], pc, renderer);
|
||||
|
||||
model.update("hello @a", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("hello ")], pc, renderer);
|
||||
|
||||
model.update("hello #r", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("hello ")], pc, renderer);
|
||||
|
||||
model.update("hello #r", "insertText", new DocumentOffset(8, true));
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
model.update("hello #riot-dev!!", "insertText", new DocumentOffset(17, true));
|
||||
|
||||
expect(renderer.count).toBe(3);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("try ")], pc, renderer);
|
||||
|
||||
model.update("try #define", "insertFromPaste", new DocumentOffset(11, true));
|
||||
|
||||
expect(model.autoComplete).toBeFalsy();
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("try ")], pc, renderer);
|
||||
|
||||
model.update("try #define", "insertFromDrop", new DocumentOffset(11, true));
|
||||
|
||||
expect(model.autoComplete).toBeFalsy();
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).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("insert room pill without splitting at the colon", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator([{ resourceId: "#room:server" } as PillPart]);
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
|
||||
model.update("#roo", "insertText", new DocumentOffset(4, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#roo");
|
||||
|
||||
model.update("#room:s", "insertText", new DocumentOffset(7, true));
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#room:s");
|
||||
});
|
||||
|
||||
it("allow typing e-mail addresses without splitting at the @", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator([{ resourceId: "@alice", text: "Alice" } as PillPart]);
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
|
||||
model.update("foo@a", "insertText", new DocumentOffset(5, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("plain");
|
||||
expect(model.parts[0].text).toBe("foo@a");
|
||||
});
|
||||
|
||||
it("should allow auto-completing multiple times with resets between them", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator([{ resourceId: "#riot-dev" } as PillPart]);
|
||||
const model = new EditorModel([pc.plain("")], pc, renderer);
|
||||
|
||||
model.update("#r", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(renderer.count).toBe(1);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).offset).toBe(2);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#r");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(renderer.count).toBe(2);
|
||||
expect((renderer.caret as DocumentPosition).index).toBe(0);
|
||||
expect((renderer.caret as DocumentPosition).offset).toBe(9);
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("room-pill");
|
||||
expect(model.parts[0].text).toBe("#riot-dev");
|
||||
|
||||
model.reset([]);
|
||||
model.update("#r", "insertText", new DocumentOffset(8, true));
|
||||
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("pill-candidate");
|
||||
expect(model.parts[0].text).toBe("#r");
|
||||
|
||||
// this is a hacky mock function
|
||||
(model.autoComplete as unknown as MockAutoComplete).tryComplete();
|
||||
|
||||
expect(model.parts.length).toBe(1);
|
||||
expect(model.parts[0].type).toBe("room-pill");
|
||||
expect(model.parts[0].text).toBe("#riot-dev");
|
||||
});
|
||||
});
|
||||
describe("emojis", function () {
|
||||
it("regional emojis should be separated to prevent them to be converted to flag", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
const regionalEmojiA = String.fromCodePoint(127462);
|
||||
const regionalEmojiZ = String.fromCodePoint(127487);
|
||||
const caret = new DocumentOffset(0, true);
|
||||
|
||||
const regionalEmojis: string[] = [];
|
||||
regionalEmojis.push(regionalEmojiA);
|
||||
regionalEmojis.push(regionalEmojiZ);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
|
||||
model.transform(() => {
|
||||
const addedLen = model.insert(pc.plainWithEmoji(regionalEmojis[i]), position);
|
||||
caret.offset += addedLen;
|
||||
return model.positionForOffset(caret.offset, true);
|
||||
});
|
||||
}
|
||||
|
||||
expect(model.parts.length).toBeGreaterThanOrEqual(4);
|
||||
expect(model.parts[0].type).toBe("emoji");
|
||||
expect(model.parts[1].type).not.toBe("emoji");
|
||||
expect(model.parts[2].type).toBe("emoji");
|
||||
expect(model.parts[3].type).not.toBe("emoji");
|
||||
});
|
||||
});
|
||||
});
|
487
test/unit-tests/editor/operations-test.ts
Normal file
487
test/unit-tests/editor/operations-test.ts
Normal file
|
@ -0,0 +1,487 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "./mock";
|
||||
import {
|
||||
formatRange,
|
||||
formatRangeAsCode,
|
||||
formatRangeAsLink,
|
||||
selectRangeOfWordAtCaret,
|
||||
toggleInlineFormat,
|
||||
} from "../../../src/editor/operations";
|
||||
import { Formatting } from "../../../src/components/views/rooms/MessageComposerFormatBar";
|
||||
import { longestBacktickSequence } from "../../../src/editor/deserialize";
|
||||
import DocumentPosition from "../../../src/editor/position";
|
||||
|
||||
const SERIALIZED_NEWLINE = { text: "\n", type: "newline" };
|
||||
|
||||
describe("editor/operations: formatting operations", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
|
||||
describe("formatRange", () => {
|
||||
it.each([[Formatting.Bold, "hello **world**!"]])(
|
||||
"should correctly wrap format %s",
|
||||
(formatting: Formatting, expected: string) => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(11, false)); // around "world"
|
||||
|
||||
expect(range.parts[0].text).toBe("world");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, formatting);
|
||||
expect(model.serializeParts()).toEqual([{ text: expected, type: "plain" }]);
|
||||
},
|
||||
);
|
||||
|
||||
it("should apply to word range is within if length 0", () => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false));
|
||||
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello **world!**", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("should do nothing for a range with length 0 at initialisation", () => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false));
|
||||
range.setWasEmpty(false);
|
||||
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRangeAsLink", () => {
|
||||
it.each([
|
||||
// Caret is denoted by | in the expectation string
|
||||
["testing", "[testing](|)", ""],
|
||||
["testing", "[testing](foobar|)", "foobar"],
|
||||
["[testing]()", "testing|", ""],
|
||||
["[testing](foobar)", "testing|", ""],
|
||||
])("converts %s -> %s", (input: string, expectation: string, text: string) => {
|
||||
const model = new EditorModel([pc.plain(`foo ${input} bar`)], pc, renderer);
|
||||
|
||||
const range = model.startRange(
|
||||
model.positionForOffset(4, false),
|
||||
model.positionForOffset(4 + input.length, false),
|
||||
); // around input
|
||||
|
||||
expect(range.parts[0].text).toBe(input);
|
||||
formatRangeAsLink(range, text);
|
||||
expect((renderer.caret as DocumentPosition).offset).toBe(4 + expectation.indexOf("|"));
|
||||
expect(model.parts[0].text).toBe("foo " + expectation.replace("|", "") + " bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleInlineFormat", () => {
|
||||
it("works for words", () => {
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(11, false)); // around "world"
|
||||
|
||||
expect(range.parts[0].text).toBe("world");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
formatRange(range, Formatting.Italics);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello *world*!", type: "plain" }]);
|
||||
});
|
||||
|
||||
describe("escape backticks", () => {
|
||||
it("works for escaping backticks in between texts", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello ` world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.positionForOffset(13, false)); // hello ` world
|
||||
|
||||
expect(range.parts[0].text.trim().includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text.trim())).toBe(1);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello ` world!", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "``hello ` world``!", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("escapes longer backticks in between text", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello```world")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hello```world
|
||||
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(3);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello```world", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "````hello```world````", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("escapes non-consecutive with varying length backticks in between text", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hell```o`w`o``rld")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hell```o`w`o``rld
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(3);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hell```o`w`o``rld", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "````hell```o`w`o``rld````", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("untoggles correctly if its already formatted", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("```hello``world```")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hello``world
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(3);
|
||||
expect(model.serializeParts()).toEqual([{ text: "```hello``world```", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello``world", type: "plain" }]);
|
||||
});
|
||||
it("untoggles correctly it contains varying length of backticks between text", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("````hell```o`w`o``rld````")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // hell```o`w`o``rld
|
||||
expect(range.parts[0].text.includes("`")).toBeTruthy();
|
||||
expect(longestBacktickSequence(range.parts[0].text)).toBe(4);
|
||||
expect(model.serializeParts()).toEqual([{ text: "````hell```o`w`o``rld````", type: "plain" }]);
|
||||
formatRangeAsCode(range);
|
||||
expect(model.serializeParts()).toEqual([{ text: "hell```o`w`o``rld", type: "plain" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("works for parts of words", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello world!")], pc, renderer);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(7, false), model.positionForOffset(10, false)); // around "orl"
|
||||
|
||||
expect(range.parts[0].text).toBe("orl");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello world!", type: "plain" }]);
|
||||
toggleInlineFormat(range, "*");
|
||||
expect(model.serializeParts()).toEqual([{ text: "hello w*orl*d!", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("works for around pills", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello there "), pc.atRoomPill("@room"), pc.plain(", how are you doing?")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(30, false)); // around "there @room, how are you"
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("there @room, how are you");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello there ", type: "plain" },
|
||||
{ text: "@room", type: "at-room-pill" },
|
||||
{ text: ", how are you doing?", type: "plain" },
|
||||
]);
|
||||
formatRange(range, Formatting.Italics);
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello *there ", type: "plain" },
|
||||
{ text: "@room", type: "at-room-pill" },
|
||||
{ text: ", how are you* doing?", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("works for a paragraph", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello world,"), pc.newline(), pc.plain("how are you doing?")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(6, false), model.positionForOffset(16, false)); // around "world,\nhow"
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("world,\nhow");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello **world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how** are you doing?", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("works for a paragraph with spurious breaks around it in selected range", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[
|
||||
pc.newline(),
|
||||
pc.newline(),
|
||||
pc.plain("hello world,"),
|
||||
pc.newline(),
|
||||
pc.plain("how are you doing?"),
|
||||
pc.newline(),
|
||||
pc.newline(),
|
||||
],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("\n\nhello world,\nhow are you doing?\n\n");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
formatRange(range, Formatting.Bold);
|
||||
expect(model.serializeParts()).toEqual([
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "**hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?**", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("works for multiple paragraph", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[
|
||||
pc.plain("hello world,"),
|
||||
pc.newline(),
|
||||
pc.plain("how are you doing?"),
|
||||
pc.newline(),
|
||||
pc.newline(),
|
||||
pc.plain("new paragraph"),
|
||||
],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
let range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "new paragraph", type: "plain" },
|
||||
]);
|
||||
toggleInlineFormat(range, "__");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "__hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?__", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "__new paragraph__", type: "plain" },
|
||||
]);
|
||||
range = model.startRange(model.positionForOffset(0, true), model.getPositionAtEnd()); // select-all
|
||||
toggleInlineFormat(range, "__");
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello world,", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "how are you doing?", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "new paragraph", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("format word at caret position at beginning of new line without previous selection", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.newline(), pc.plain("hello!")], pc, renderer);
|
||||
|
||||
let range = model.startRange(model.positionForOffset(1, false));
|
||||
|
||||
// Initial position should equal start and end since we did not select anything
|
||||
expect(range.getLastStartingPosition()).toBe(range.start);
|
||||
expect(range.getLastStartingPosition()).toBe(range.end);
|
||||
|
||||
formatRange(range, Formatting.Bold); // Toggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "**hello!**", type: "plain" }]);
|
||||
|
||||
formatRange(range, Formatting.Bold); // Untoggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "hello!", type: "plain" }]);
|
||||
|
||||
// Check if it also works for code as it uses toggleInlineFormatting only indirectly
|
||||
range = model.startRange(model.positionForOffset(1, false));
|
||||
selectRangeOfWordAtCaret(range);
|
||||
|
||||
formatRange(range, Formatting.Code); // Toggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "`hello!`", type: "plain" }]);
|
||||
|
||||
formatRange(range, Formatting.Code); // Untoggle
|
||||
expect(model.serializeParts()).toEqual([SERIALIZED_NEWLINE, { text: "hello!", type: "plain" }]);
|
||||
});
|
||||
|
||||
it("caret resets correctly to current line when untoggling formatting while caret at line end", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello **hello!**"), pc.newline(), pc.plain("world")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello **hello!**", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world", type: "plain" },
|
||||
]);
|
||||
|
||||
const endOfFirstLine = 16;
|
||||
const range = model.startRange(model.positionForOffset(endOfFirstLine, true));
|
||||
|
||||
formatRange(range, Formatting.Bold); // Untoggle
|
||||
formatRange(range, Formatting.Italics); // Toggle
|
||||
|
||||
// We expect formatting to still happen in the first line as the caret should not jump down
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello *hello!*", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("format link in front of new line part", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("hello!"), pc.newline(), pc.plain("world!"), pc.newline()],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
let range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
|
||||
formatRange(range, Formatting.InsertLink); // Toggle
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "[world!]()", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
|
||||
range = model.startRange(model.getPositionAtEnd().asOffset(model).add(-1).asPosition(model)); // select-all
|
||||
formatRange(range, Formatting.InsertLink); // Untoggle
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "hello!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "world!", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
]);
|
||||
});
|
||||
|
||||
it("format multi line code", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("int x = 1;"), pc.newline(), pc.newline(), pc.plain("int y = 42;")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
let range = model.startRange(model.positionForOffset(0), model.getPositionAtEnd()); // select-all
|
||||
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe("int x = 1;\n\nint y = 42;");
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "int x = 1;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int y = 42;", type: "plain" },
|
||||
]);
|
||||
|
||||
formatRange(range, Formatting.Code); // Toggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "```", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int x = 1;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int y = 42;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "```", type: "plain" },
|
||||
]);
|
||||
|
||||
range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd()); // select-all
|
||||
formatRange(range, Formatting.Code); // Untoggle
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: "int x = 1;", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: "int y = 42;", type: "plain" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not format pure white space", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain(" "), pc.newline(), pc.newline(), pc.plain(" ")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
|
||||
const range = model.startRange(model.positionForOffset(0), model.getPositionAtEnd()); // select-all
|
||||
expect(range.parts.map((p) => p.text).join("")).toBe(" \n\n ");
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: " ", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: " ", type: "plain" },
|
||||
]);
|
||||
|
||||
formatRange(range, Formatting.Bold);
|
||||
|
||||
expect(model.serializeParts()).toEqual([
|
||||
{ text: " ", type: "plain" },
|
||||
SERIALIZED_NEWLINE,
|
||||
SERIALIZED_NEWLINE,
|
||||
{ text: " ", type: "plain" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
34
test/unit-tests/editor/parts-test.ts
Normal file
34
test/unit-tests/editor/parts-test.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EmojiPart, PlainPart } from "../../../src/editor/parts";
|
||||
import { createPartCreator } from "./mock";
|
||||
|
||||
describe("editor/parts", () => {
|
||||
describe("appendUntilRejected", () => {
|
||||
const femaleFacepalmEmoji = "🤦♀️";
|
||||
|
||||
it("should not accept emoji strings into type=plain", () => {
|
||||
const part = new PlainPart();
|
||||
expect(part.appendUntilRejected(femaleFacepalmEmoji, "")).toEqual(femaleFacepalmEmoji);
|
||||
expect(part.text).toEqual("");
|
||||
});
|
||||
|
||||
it("should accept emoji strings into type=emoji", () => {
|
||||
const part = new EmojiPart();
|
||||
expect(part.appendUntilRejected(femaleFacepalmEmoji, "")).toBeUndefined();
|
||||
expect(part.text).toEqual(femaleFacepalmEmoji);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not explode on room pills for unknown rooms", () => {
|
||||
const pc = createPartCreator();
|
||||
const part = pc.roomPill("#room:server");
|
||||
expect(() => part.toDOMNode()).not.toThrow();
|
||||
});
|
||||
});
|
73
test/unit-tests/editor/position-test.ts
Normal file
73
test/unit-tests/editor/position-test.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "./mock";
|
||||
|
||||
describe("editor/position", function () {
|
||||
it("move first position backward in empty model", function () {
|
||||
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||
const pos = model.positionForOffset(0, true);
|
||||
const pos2 = pos.backwardsWhile(model, () => true);
|
||||
expect(pos).toBe(pos2);
|
||||
});
|
||||
it("move first position forwards in empty model", function () {
|
||||
const model = new EditorModel([], createPartCreator(), createRenderer());
|
||||
const pos = model.positionForOffset(0, true);
|
||||
const pos2 = pos.forwardsWhile(model, () => true);
|
||||
expect(pos).toBe(pos2);
|
||||
});
|
||||
it("move forwards within one part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(1);
|
||||
let n = 3;
|
||||
const pos2 = pos.forwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(0);
|
||||
expect(pos2.offset).toBe(4);
|
||||
});
|
||||
it("move forwards crossing to other part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(4);
|
||||
let n = 3;
|
||||
const pos2 = pos.forwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(1);
|
||||
expect(pos2.offset).toBe(2);
|
||||
});
|
||||
it("move backwards within one part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(4);
|
||||
let n = 3;
|
||||
const pos2 = pos.backwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(0);
|
||||
expect(pos2.offset).toBe(1);
|
||||
});
|
||||
it("move backwards crossing to other part", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer());
|
||||
const pos = model.positionForOffset(7);
|
||||
let n = 3;
|
||||
const pos2 = pos.backwardsWhile(model, () => {
|
||||
n -= 1;
|
||||
return n >= 0;
|
||||
});
|
||||
expect(pos2.index).toBe(0);
|
||||
expect(pos2.offset).toBe(4);
|
||||
});
|
||||
});
|
104
test/unit-tests/editor/range-test.ts
Normal file
104
test/unit-tests/editor/range-test.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { createPartCreator, createRenderer } from "./mock";
|
||||
|
||||
const pillChannel = "#riot-dev:matrix.org";
|
||||
|
||||
describe("editor/range", function () {
|
||||
it("range on empty model", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(0, true)); // after "world"
|
||||
let called = false;
|
||||
range.expandBackwardsWhile((chr) => {
|
||||
called = true;
|
||||
return true;
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(range.text).toBe("");
|
||||
});
|
||||
it("range replace within a part", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello world!!!!")], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(11)); // after "world"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("world");
|
||||
range.replace([pc.roomPill(pillChannel)]);
|
||||
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(pillChannel);
|
||||
expect(model.parts[2].type).toBe("plain");
|
||||
expect(model.parts[2].text).toBe("!!!!");
|
||||
expect(model.parts.length).toBe(3);
|
||||
});
|
||||
it("range replace across parts", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel(
|
||||
[pc.plain("try to re"), pc.plain("pla"), pc.plain("ce "), pc.plain("me")],
|
||||
pc,
|
||||
renderer,
|
||||
);
|
||||
const range = model.startRange(model.positionForOffset(14)); // after "replace"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("replace");
|
||||
range.replace([pc.roomPill(pillChannel)]);
|
||||
expect(model.parts[0].type).toBe("plain");
|
||||
expect(model.parts[0].text).toBe("try to ");
|
||||
expect(model.parts[1].type).toBe("room-pill");
|
||||
expect(model.parts[1].text).toBe(pillChannel);
|
||||
expect(model.parts[2].type).toBe("plain");
|
||||
expect(model.parts[2].text).toBe(" me");
|
||||
expect(model.parts.length).toBe(3);
|
||||
});
|
||||
// bug found while implementing tab completion
|
||||
it("replace a part with an identical part with start position at end of previous part", function () {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello "), pc.pillCandidate("man")], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(9, true)); // before "man"
|
||||
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
|
||||
expect(range.text).toBe("man");
|
||||
range.replace([pc.pillCandidate(range.text)]);
|
||||
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("man");
|
||||
expect(model.parts.length).toBe(2);
|
||||
});
|
||||
it("range trim spaces off both ends", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("abc abc abc")], pc, renderer);
|
||||
const range = model.startRange(
|
||||
model.positionForOffset(3, false), // at end of first `abc`
|
||||
model.positionForOffset(8, false), // at start of last `abc`
|
||||
);
|
||||
|
||||
expect(range.parts[0].text).toBe(" abc ");
|
||||
range.trim();
|
||||
expect(range.parts[0].text).toBe("abc");
|
||||
});
|
||||
// test for edge case when the selection just consists of whitespace
|
||||
it("range trim just whitespace", () => {
|
||||
const renderer = createRenderer();
|
||||
const pc = createPartCreator();
|
||||
const whitespace = " \n \n\n";
|
||||
const model = new EditorModel([pc.plain(whitespace)], pc, renderer);
|
||||
const range = model.startRange(model.positionForOffset(0, false), model.getPositionAtEnd());
|
||||
|
||||
expect(range.text).toBe(whitespace);
|
||||
range.trim();
|
||||
expect(range.text).toBe("");
|
||||
});
|
||||
});
|
157
test/unit-tests/editor/roundtrip-test.ts
Normal file
157
test/unit-tests/editor/roundtrip-test.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { parseEvent } from "../../../src/editor/deserialize";
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import DocumentOffset from "../../../src/editor/offset";
|
||||
import { htmlSerializeIfNeeded, textSerialize } from "../../../src/editor/serialize";
|
||||
import { createPartCreator } from "./mock";
|
||||
|
||||
function htmlMessage(formattedBody: string, msgtype = "m.text") {
|
||||
return {
|
||||
getContent() {
|
||||
return {
|
||||
msgtype,
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: formattedBody,
|
||||
};
|
||||
},
|
||||
} as unknown as MatrixEvent;
|
||||
}
|
||||
|
||||
async function md2html(markdown: string): Promise<string> {
|
||||
const pc = createPartCreator();
|
||||
const oldModel = new EditorModel([], pc, () => {});
|
||||
await oldModel.update(markdown, "insertText", new DocumentOffset(markdown.length, false));
|
||||
return htmlSerializeIfNeeded(oldModel, { forceHTML: true })!;
|
||||
}
|
||||
|
||||
function html2md(html: string): string {
|
||||
const pc = createPartCreator();
|
||||
const parts = parseEvent(htmlMessage(html), pc);
|
||||
const newModel = new EditorModel(parts, pc);
|
||||
return textSerialize(newModel);
|
||||
}
|
||||
|
||||
async function roundTripMarkdown(markdown: string): Promise<string> {
|
||||
return html2md(await md2html(markdown));
|
||||
}
|
||||
|
||||
async function roundTripHtml(html: string): Promise<string> {
|
||||
return await md2html(html2md(html));
|
||||
}
|
||||
|
||||
describe("editor/roundtrip", function () {
|
||||
describe("markdown messages should round-trip if they contain", function () {
|
||||
test.each([
|
||||
["newlines", "hello\nworld"],
|
||||
["pills", "text message for @room"],
|
||||
["pills with interesting characters in mxid", "text message for @alice\\\\\\_\\]#>&:hs.example.com"],
|
||||
["styling", "**bold** and _emphasised_"],
|
||||
["bold within a word", "abso**fragging**lutely"],
|
||||
["escaped html", "a\\<foo>b"],
|
||||
["escaped markdown", "\\*\\*foo\\*\\* \\_bar\\_ \\[a\\](b)"],
|
||||
["escaped backslashes", "C:\\\\Program Files"],
|
||||
["code in backticks", "foo ->`x`"],
|
||||
["code blocks containing backticks", "```\nfoo ->`x`\nbar\n```"],
|
||||
["code blocks containing markdown", "```\n__init__.py\n```"],
|
||||
["nested formatting", "a<del>b **c _d_ e** f</del>g"],
|
||||
["an ordered list", "A\n\n1. b\n2. c\n3. d\nE"],
|
||||
["an ordered list starting later", "A\n\n9. b\n10. c\n11. d\nE"],
|
||||
["an unordered list", "A\n\n- b\n- c\n- d\nE"],
|
||||
["code block followed by text after a blank line", "```A\nfoo(bar).baz();\n\n3\n```\n\nB"],
|
||||
["just a code block", "```\nfoo(bar).baz();\n\n3\n```"],
|
||||
["code block with language specifier", "```bash\nmake install\n\n```"],
|
||||
["inline code", "there's no place `127.0.0.1` like"],
|
||||
["nested quotations", "saying\n\n> > foo\n\n> NO\n\nis valid"],
|
||||
["quotations", "saying\n\n> NO\n\nis valid"],
|
||||
["links", "click [this](http://example.com/)!"],
|
||||
])("%s", async (_name, markdown) => {
|
||||
expect(await roundTripMarkdown(markdown)).toEqual(markdown);
|
||||
});
|
||||
|
||||
test.skip.each([
|
||||
// Removes trailing spaces
|
||||
["a code block followed by newlines", "```\nfoo(bar).baz();\n\n3\n```\n\n"],
|
||||
// Adds a space after the code block
|
||||
["a code block surrounded by text", "```A\nfoo(bar).baz();\n\n3\n```\nB"],
|
||||
// Adds a space before the list
|
||||
["an unordered list directly preceded by text", "A\n- b\n- c\n- d\nE"],
|
||||
// Re-numbers to 1, 2, 3
|
||||
["an ordered list where everything is 1", "A\n\n1. b\n1. c\n1. d\nE"],
|
||||
// Adds a space before the list
|
||||
["an ordered list directly preceded by text", "A\n1. b\n2. c\n3. d\nE"],
|
||||
// Adds and removes spaces before the nested list
|
||||
["nested unordered lists", "A\n- b\n- c\n - c1\n - c2\n- d\nE"],
|
||||
// Adds and removes spaces before the nested list
|
||||
["nested ordered lists", "A\n\n1. b\n2. c\n 1. c1\n 2. c2\n3. d\nE"],
|
||||
// Adds and removes spaces before the nested list
|
||||
["nested mixed lists", "A\n\n1. b\n2. c\n - c1\n - c2\n3. d\nE"],
|
||||
// Backslashes get doubled
|
||||
["backslashes", "C:\\Program Files"],
|
||||
// Deletes the whitespace
|
||||
["newlines with trailing and leading whitespace", "hello \n world"],
|
||||
// Escapes the underscores
|
||||
["underscores within a word", "abso_fragging_lutely"],
|
||||
// Includes the trailing text into the quotation
|
||||
// https://github.com/vector-im/element-web/issues/22341
|
||||
["quotations without separating newlines", "saying\n> NO\nis valid"],
|
||||
// Removes trailing and leading whitespace
|
||||
["quotations with trailing and leading whitespace", "saying \n\n> NO\n\n is valid"],
|
||||
])("%s", async (_name, markdown) => {
|
||||
expect(await roundTripMarkdown(markdown)).toEqual(markdown);
|
||||
});
|
||||
|
||||
it("styling, but * becomes _ and __ becomes **", async function () {
|
||||
expect(await roundTripMarkdown("__bold__ and *emphasised*")).toEqual("**bold** and _emphasised_");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML messages should round-trip if they contain", function () {
|
||||
test.each([
|
||||
["backslashes", "C:\\Program Files"],
|
||||
[
|
||||
"nested blockquotes",
|
||||
"<blockquote>\n<p>foo</p>\n<blockquote>\n<p>bar</p>\n</blockquote>\n</blockquote>\n",
|
||||
],
|
||||
["ordered lists", "<ol>\n<li>asd</li>\n<li>fgd</li>\n</ol>\n"],
|
||||
["ordered lists starting later", '<ol start="3">\n<li>asd</li>\n<li>fgd</li>\n</ol>\n'],
|
||||
["unordered lists", "<ul>\n<li>asd</li>\n<li>fgd</li>\n</ul>\n"],
|
||||
["code blocks with surrounding text", "<p>a</p>\n<pre><code>a\ny;\n</code></pre>\n<p>b</p>\n"],
|
||||
["code blocks", "<pre><code>a\ny;\n</code></pre>\n"],
|
||||
["code blocks containing markdown", "<pre><code>__init__.py\n</code></pre>\n"],
|
||||
["code blocks with language specifier", '<pre><code class="language-bash">__init__.py\n</code></pre>\n'],
|
||||
["paragraphs including formatting", "<p>one</p>\n<p>t <em>w</em> o</p>\n"],
|
||||
["paragraphs", "<p>one</p>\n<p>two</p>\n"],
|
||||
["links", "http://more.example.com/"],
|
||||
["escaped html", "This >em<isn't>em< important"],
|
||||
["markdown-like symbols", "You _would_ **type** [a](http://this.example.com) this."],
|
||||
["formatting within a word", "abso<strong>fragging</strong>lutely"],
|
||||
["formatting", "This <em>is</em> im<strong>port</strong>ant"],
|
||||
["line breaks", "one<br>two"],
|
||||
])("%s", async (_name, html) => {
|
||||
expect(await roundTripHtml(html)).toEqual(html);
|
||||
});
|
||||
|
||||
test.skip.each([
|
||||
// Strips out the pill - maybe needs some user lookup to work?
|
||||
["user pills", '<a href="https://matrix.to/#/@alice:hs.tld">Alice</a>'],
|
||||
// Appends a slash to the URL
|
||||
// https://github.com/vector-im/element-web/issues/22342
|
||||
["links without trailing slashes", 'Go <a href="http://more.example.com">here</a> to see more'],
|
||||
// Inserts newlines after tags
|
||||
["paragraphs without newlines", "<p>one</p><p>two</p>"],
|
||||
// Inserts a code block
|
||||
["nested lists", "<ol>\n<li>asd</li>\n<li>\n<ul>\n<li>fgd</li>\n<li>sdf</li>\n</ul>\n</li>\n</ol>\n"],
|
||||
])("%s", async (_name, html) => {
|
||||
expect(await roundTripHtml(html)).toEqual(html);
|
||||
});
|
||||
});
|
||||
});
|
195
test/unit-tests/editor/serialize-test.ts
Normal file
195
test/unit-tests/editor/serialize-test.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import EditorModel from "../../../src/editor/model";
|
||||
import { htmlSerializeIfNeeded } from "../../../src/editor/serialize";
|
||||
import { createPartCreator } from "./mock";
|
||||
import { IConfigOptions } from "../../../src/IConfigOptions";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import SdkConfig from "../../../src/SdkConfig";
|
||||
|
||||
describe("editor/serialize", function () {
|
||||
describe("with markdown", function () {
|
||||
it("user pill turns message into html", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@alice:hs.tld">Alice</a>');
|
||||
});
|
||||
it("room pill turns message into html", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/#room:hs.tld">#room:hs.tld</a>');
|
||||
});
|
||||
it("@room pill turns message into html", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.atRoomPill("@room")], pc);
|
||||
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")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("<em>hello</em> world");
|
||||
});
|
||||
it("displaynames ending in a backslash work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Displayname\\</a>');
|
||||
});
|
||||
it("displaynames containing an opening square bracket work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Displayname[[</a>');
|
||||
});
|
||||
it("displaynames containing a closing square bracket work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Displayname]", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Displayname]</a>');
|
||||
});
|
||||
it("displaynames containing a newline work", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Display\nname", "@user:server")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@user:server">Display<br>name</a>');
|
||||
});
|
||||
it("escaped markdown should not retain backslashes", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("*hello* world");
|
||||
});
|
||||
it("escaped markdown should convert HTML entities", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world < hey world!")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe("*hello* world < hey world!");
|
||||
});
|
||||
|
||||
it("lists with a single empty item are not considered markdown", function () {
|
||||
const pc = createPartCreator();
|
||||
|
||||
const model1 = new EditorModel([pc.plain("-")], pc);
|
||||
const html1 = htmlSerializeIfNeeded(model1, {});
|
||||
expect(html1).toBe(undefined);
|
||||
|
||||
const model2 = new EditorModel([pc.plain("* ")], pc);
|
||||
const html2 = htmlSerializeIfNeeded(model2, {});
|
||||
expect(html2).toBe(undefined);
|
||||
|
||||
const model3 = new EditorModel([pc.plain("2021.")], pc);
|
||||
const html3 = htmlSerializeIfNeeded(model3, {});
|
||||
expect(html3).toBe(undefined);
|
||||
});
|
||||
|
||||
it("lists with a single non-empty item are still markdown", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("2021. foo")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<ol start="2021">\n<li>foo</li>\n</ol>\n');
|
||||
});
|
||||
describe("with permalink_prefix set", function () {
|
||||
const sdkConfigGet = SdkConfig.get;
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key: keyof IConfigOptions, altCaseName?: string) => {
|
||||
if (key === "permalink_prefix") {
|
||||
return "https://element.fs.tld";
|
||||
} else return sdkConfigGet(key, altCaseName);
|
||||
});
|
||||
});
|
||||
|
||||
it("user pill uses matrix.to", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/@alice:hs.tld">Alice</a>');
|
||||
});
|
||||
it("room pill uses matrix.to", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toBe('<a href="https://matrix.to/#/#room:hs.tld">#room:hs.tld</a>');
|
||||
});
|
||||
afterEach(() => {
|
||||
mocked(SdkConfig.get).mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("with plaintext", function () {
|
||||
it("markdown remains plaintext", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("*hello* world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||
expect(html).toBe("*hello* world");
|
||||
});
|
||||
it("markdown should retain backslashes", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||
expect(html).toBe("\\*hello\\* world");
|
||||
});
|
||||
it("markdown should convert HTML entities", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("\\*hello\\* world < hey world!")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { useMarkdown: false });
|
||||
expect(html).toBe("\\*hello\\* world < hey world!");
|
||||
});
|
||||
it("plaintext remains plaintext even when forcing html", function () {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, { forceHTML: true, useMarkdown: false });
|
||||
expect(html).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature_latex_maths", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_latex_maths");
|
||||
});
|
||||
|
||||
it("should support inline katex", () => {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello $\\xi$ world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toMatchInlineSnapshot(`"hello <span data-mx-maths="\\xi"><code>\\xi</code></span> world"`);
|
||||
});
|
||||
|
||||
it("should support block katex", () => {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello \n$$\\xi$$\n world")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toMatchInlineSnapshot(`
|
||||
"<p>hello</p>
|
||||
<div data-mx-maths="\\xi"><code>\\xi</code></div>
|
||||
<p>world</p>
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not mangle code blocks", () => {
|
||||
const pc = createPartCreator();
|
||||
const model = new EditorModel([pc.plain("hello\n```\n$\\xi$\n```\nworld")], pc);
|
||||
const html = htmlSerializeIfNeeded(model, {});
|
||||
expect(html).toMatchInlineSnapshot(`
|
||||
"<p>hello</p>
|
||||
<pre><code>$\\xi$
|
||||
</code></pre>
|
||||
<p>world</p>
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue