Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 674aec4

Browse files
authored
Fix regression around pasting links (#8537)
* Fix regression around pasting links * Add tests
1 parent 7e21be0 commit 674aec4

File tree

6 files changed

+163
-29
lines changed

6 files changed

+163
-29
lines changed

src/components/views/rooms/BasicMessageComposer.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { formatRange, formatRangeAsLink, replaceRangeAndMoveCaret, toggleInlineF
2828
from '../../../editor/operations';
2929
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
3030
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
31-
import { getAutoCompleteCreator, Type } from '../../../editor/parts';
31+
import { getAutoCompleteCreator, Part, Type } from '../../../editor/parts';
3232
import { parseEvent, parsePlainTextMessage } from '../../../editor/deserialize';
3333
import { renderModel } from '../../../editor/render';
3434
import TypingStore from "../../../stores/TypingStore";
@@ -92,7 +92,7 @@ function selectionEquals(a: Partial<Selection>, b: Selection): boolean {
9292
interface IProps {
9393
model: EditorModel;
9494
room: Room;
95-
threadId: string;
95+
threadId?: string;
9696
placeholder?: string;
9797
label?: string;
9898
initialCaret?: DocumentOffset;
@@ -333,28 +333,29 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
333333

334334
private onPaste = (event: ClipboardEvent<HTMLDivElement>): boolean => {
335335
event.preventDefault(); // we always handle the paste ourselves
336-
if (this.props.onPaste && this.props.onPaste(event, this.props.model)) {
336+
if (this.props.onPaste?.(event, this.props.model)) {
337337
// to prevent double handling, allow props.onPaste to skip internal onPaste
338338
return true;
339339
}
340340

341341
const { model } = this.props;
342342
const { partCreator } = model;
343+
const plainText = event.clipboardData.getData("text/plain");
343344
const partsText = event.clipboardData.getData("application/x-element-composer");
344-
let parts;
345+
346+
let parts: Part[];
345347
if (partsText) {
346348
const serializedTextParts = JSON.parse(partsText);
347-
const deserializedParts = serializedTextParts.map(p => partCreator.deserializePart(p));
348-
parts = deserializedParts;
349+
parts = serializedTextParts.map(p => partCreator.deserializePart(p));
349350
} else {
350-
const text = event.clipboardData.getData("text/plain");
351-
parts = parsePlainTextMessage(text, partCreator, { shouldEscape: false });
351+
parts = parsePlainTextMessage(plainText, partCreator, { shouldEscape: false });
352352
}
353-
const textToInsert = event.clipboardData.getData("text/plain");
353+
354354
this.modifiedFlag = true;
355355
const range = getRangeForSelection(this.editorRef.current, model, document.getSelection());
356-
if (textToInsert && linkify.test(textToInsert)) {
357-
formatRangeAsLink(range, textToInsert);
356+
357+
if (plainText && range.length > 0 && linkify.test(plainText)) {
358+
formatRangeAsLink(range, plainText);
358359
} else {
359360
replaceRangeAndMoveCaret(range, parts);
360361
}

src/editor/dom.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ limitations under the License.
1717

1818
import { CARET_NODE_CHAR, isCaretNode } from "./render";
1919
import DocumentOffset from "./offset";
20+
import EditorModel from "./model";
21+
import Range from "./range";
2022

2123
type Predicate = (node: Node) => boolean;
2224
type Callback = (node: Node) => void;
@@ -122,7 +124,7 @@ function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node) {
122124
let foundNode = false;
123125
let text = "";
124126

125-
function enterNodeCallback(node) {
127+
function enterNodeCallback(node: HTMLElement) {
126128
if (!foundNode) {
127129
if (node === selectionNode) {
128130
foundNode = true;
@@ -148,12 +150,12 @@ function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node) {
148150
return true;
149151
}
150152

151-
function leaveNodeCallback(node) {
153+
function leaveNodeCallback(node: HTMLElement) {
152154
// if this is not the last DIV (which are only used as line containers atm)
153155
// we don't just check if there is a nextSibling because sometimes the caret ends up
154156
// after the last DIV and it creates a newline if you type then,
155157
// whereas you just want it to be appended to the current line
156-
if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
158+
if (node.tagName === "DIV" && (<HTMLElement>node.nextSibling)?.tagName === "DIV") {
157159
text += "\n";
158160
if (!foundNode) {
159161
offsetToNode += 1;
@@ -167,7 +169,7 @@ function getTextAndOffsetToNode(editor: HTMLDivElement, selectionNode: Node) {
167169
}
168170

169171
// get text value of text node, ignoring ZWS if it's a caret node
170-
function getTextNodeValue(node) {
172+
function getTextNodeValue(node: Node): string {
171173
const nodeText = node.nodeValue;
172174
// filter out ZWS for caret nodes
173175
if (isCaretNode(node.parentElement)) {
@@ -184,7 +186,7 @@ function getTextNodeValue(node) {
184186
}
185187
}
186188

187-
export function getRangeForSelection(editor, model, selection) {
189+
export function getRangeForSelection(editor: HTMLDivElement, model: EditorModel, selection: Selection): Range {
188190
const focusOffset = getSelectionOffsetAndText(
189191
editor,
190192
selection.focusNode,

src/editor/operations.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,12 @@ export function formatRangeAsCode(range: Range): void {
219219
export function formatRangeAsLink(range: Range, text?: string) {
220220
const { model } = range;
221221
const { partCreator } = model;
222-
const linkRegex = /\[(.*?)\]\(.*?\)/g;
222+
const linkRegex = /\[(.*?)]\(.*?\)/g;
223223
const isFormattedAsLink = linkRegex.test(range.text);
224224
if (isFormattedAsLink) {
225225
const linkDescription = range.text.replace(linkRegex, "$1");
226226
const newParts = [partCreator.plain(linkDescription)];
227-
const prefixLength = 1;
228-
const suffixLength = range.length - (linkDescription.length + 2);
229-
replaceRangeAndAutoAdjustCaret(range, newParts, true, prefixLength, suffixLength);
227+
replaceRangeAndMoveCaret(range, newParts, 0);
230228
} else {
231229
// We set offset to -1 here so that the caret lands between the brackets
232230
replaceRangeAndMoveCaret(range, [partCreator.plain("[" + range.text + "]" + "(" + (text ?? "") + ")")], -1);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from 'react';
18+
import { mount, ReactWrapper } from 'enzyme';
19+
import { MatrixClient, Room } from 'matrix-js-sdk/src/matrix';
20+
21+
import BasicMessageComposer from '../../../../src/components/views/rooms/BasicMessageComposer';
22+
import * as TestUtils from "../../../test-utils";
23+
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
24+
import EditorModel from "../../../../src/editor/model";
25+
import { createPartCreator, createRenderer } from "../../../editor/mock";
26+
27+
describe("BasicMessageComposer", () => {
28+
const renderer = createRenderer();
29+
const pc = createPartCreator();
30+
31+
beforeEach(() => {
32+
TestUtils.stubClient();
33+
});
34+
35+
it("should allow a user to paste a URL without it being mangled", () => {
36+
const model = new EditorModel([], pc, renderer);
37+
38+
const wrapper = render(model);
39+
40+
wrapper.find(".mx_BasicMessageComposer_input").simulate("paste", {
41+
clipboardData: {
42+
getData: type => {
43+
if (type === "text/plain") {
44+
return "https://element.io";
45+
}
46+
},
47+
},
48+
});
49+
50+
expect(model.parts).toHaveLength(1);
51+
expect(model.parts[0].text).toBe("https://element.io");
52+
});
53+
});
54+
55+
function render(model: EditorModel): ReactWrapper {
56+
const client: MatrixClient = MatrixClientPeg.get();
57+
58+
const roomId = '!1234567890:domain';
59+
const userId = client.getUserId();
60+
const room = new Room(roomId, client, userId);
61+
62+
return mount((
63+
<BasicMessageComposer model={model} room={room} />
64+
));
65+
}

test/editor/mock.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Room, MatrixClient } from "matrix-js-sdk/src/matrix";
1818

1919
import AutocompleteWrapperModel from "../../src/editor/autocomplete";
2020
import { PartCreator } from "../../src/editor/parts";
21+
import DocumentPosition from "../../src/editor/position";
2122

2223
class MockAutoComplete {
2324
public _updateCallback;
@@ -78,11 +79,11 @@ export function createPartCreator(completions = []) {
7879
}
7980

8081
export function createRenderer() {
81-
const render = (c) => {
82+
const render = (c: DocumentPosition) => {
8283
render.caret = c;
8384
render.count += 1;
8485
};
8586
render.count = 0;
86-
render.caret = null;
87+
render.caret = null as DocumentPosition;
8788
return render;
8889
}

test/editor/operations-test.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,88 @@ limitations under the License.
1717
import EditorModel from "../../src/editor/model";
1818
import { createPartCreator, createRenderer } from "./mock";
1919
import {
20-
toggleInlineFormat,
21-
selectRangeOfWordAtCaret,
2220
formatRange,
2321
formatRangeAsCode,
22+
formatRangeAsLink,
23+
selectRangeOfWordAtCaret,
24+
toggleInlineFormat,
2425
} from "../../src/editor/operations";
2526
import { Formatting } from "../../src/components/views/rooms/MessageComposerFormatBar";
2627
import { longestBacktickSequence } from '../../src/editor/deserialize';
2728

2829
const SERIALIZED_NEWLINE = { "text": "\n", "type": "newline" };
2930

30-
describe('editor/operations: formatting operations', () => {
31-
describe('toggleInlineFormat', () => {
32-
it('works for words', () => {
33-
const renderer = createRenderer();
34-
const pc = createPartCreator();
31+
describe("editor/operations: formatting operations", () => {
32+
const renderer = createRenderer();
33+
const pc = createPartCreator();
34+
35+
describe("formatRange", () => {
36+
it.each([
37+
[Formatting.Bold, "hello **world**!"],
38+
])("should correctly wrap format %s", (formatting: Formatting, expected: string) => {
39+
const model = new EditorModel([
40+
pc.plain("hello world!"),
41+
], pc, renderer);
42+
43+
const range = model.startRange(model.positionForOffset(6, false),
44+
model.positionForOffset(11, false)); // around "world"
45+
46+
expect(range.parts[0].text).toBe("world");
47+
expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]);
48+
formatRange(range, formatting);
49+
expect(model.serializeParts()).toEqual([{ "text": expected, "type": "plain" }]);
50+
});
51+
52+
it("should apply to word range is within if length 0", () => {
53+
const model = new EditorModel([
54+
pc.plain("hello world!"),
55+
], pc, renderer);
56+
57+
const range = model.startRange(model.positionForOffset(6, false));
58+
59+
expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]);
60+
formatRange(range, Formatting.Bold);
61+
expect(model.serializeParts()).toEqual([{ "text": "hello **world!**", "type": "plain" }]);
62+
});
63+
64+
it("should do nothing for a range with length 0 at initialisation", () => {
65+
const model = new EditorModel([
66+
pc.plain("hello world!"),
67+
], pc, renderer);
68+
69+
const range = model.startRange(model.positionForOffset(6, false));
70+
range.setWasEmpty(false);
71+
72+
expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]);
73+
formatRange(range, Formatting.Bold);
74+
expect(model.serializeParts()).toEqual([{ "text": "hello world!", "type": "plain" }]);
75+
});
76+
});
77+
78+
describe("formatRangeAsLink", () => {
79+
it.each([
80+
// Caret is denoted by | in the expectation string
81+
["testing", "[testing](|)", ""],
82+
["testing", "[testing](foobar|)", "foobar"],
83+
["[testing]()", "testing|", ""],
84+
["[testing](foobar)", "testing|", ""],
85+
])("converts %s -> %s", (input: string, expectation: string, text: string) => {
86+
const model = new EditorModel([
87+
pc.plain(`foo ${input} bar`),
88+
], pc, renderer);
89+
90+
const range = model.startRange(model.positionForOffset(4, false),
91+
model.positionForOffset(4 + input.length, false)); // around input
92+
93+
expect(range.parts[0].text).toBe(input);
94+
formatRangeAsLink(range, text);
95+
expect(renderer.caret.offset).toBe(4 + expectation.indexOf("|"));
96+
expect(model.parts[0].text).toBe("foo " + expectation.replace("|", "") + " bar");
97+
});
98+
});
99+
100+
describe("toggleInlineFormat", () => {
101+
it("works for words", () => {
35102
const model = new EditorModel([
36103
pc.plain("hello world!"),
37104
], pc, renderer);

0 commit comments

Comments
 (0)