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:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View 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",
},
]
`;

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

View 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 = "> &lt;del&gt;no formatting here&lt;/del&gt;";
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",
});
});
});
});

View 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();
});
});
});

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

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

View 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");
});
});
});

View 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" },
]);
});
});
});

View 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();
});
});

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

View 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("");
});
});

View 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 &gt;em&lt;isn't&gt;em&lt; 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);
});
});
});

View 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 &lt; 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 &lt; 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>
"
`);
});
});
});