diff --git a/src/editor/parts.ts b/src/editor/parts.ts index 460a74ca86d..8d98c398b54 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -93,6 +93,7 @@ abstract class BasePart { this._text = text; } + // chr can also be a grapheme cluster protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean { return true; } @@ -128,14 +129,20 @@ abstract class BasePart { // append str, returns the remaining string if a character was rejected. public appendUntilRejected(str: string, inputType: string): string | undefined { const offset = this.text.length; - for (let i = 0; i < str.length; ++i) { - const chr = str.charAt(i); - if (!this.acceptsInsertion(chr, offset + i, inputType)) { - this._text = this._text + str.slice(0, i); - return str.slice(i); + // Take a copy as we will be taking chunks off the start of the string as we process them + // To only need to grapheme split the bits of the string we're working on. + let buffer = str; + while (buffer) { + // We use lodash's grapheme splitter to avoid breaking apart compound emojis + const [char] = split(buffer, "", 2); + if (!this.acceptsInsertion(char, offset + str.length - buffer.length, inputType)) { + break; } + buffer = buffer.slice(char.length); } - this._text = this._text + str; + + this._text += str.slice(0, str.length - buffer.length); + return buffer || undefined; } // inserts str at offset if all the characters in str were accepted, otherwise don't do anything @@ -361,7 +368,7 @@ class NewlinePart extends BasePart implements IBasePart { } } -class EmojiPart extends BasePart implements IBasePart { +export class EmojiPart extends BasePart implements IBasePart { protected acceptsInsertion(chr: string, offset: number): boolean { return EMOJIBASE_REGEX.test(chr); } @@ -555,7 +562,8 @@ export class PartCreator { case "\n": return new NewlinePart(); default: - if (EMOJIBASE_REGEX.test(input[0])) { + // We use lodash's grapheme splitter to avoid breaking apart compound emojis + if (EMOJIBASE_REGEX.test(split(input, "", 2)[0])) { return new EmojiPart(); } return new PlainPart(); diff --git a/test/editor/parts-test.ts b/test/editor/parts-test.ts new file mode 100644 index 00000000000..b77971c2aa7 --- /dev/null +++ b/test/editor/parts-test.ts @@ -0,0 +1,35 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EmojiPart, PlainPart } from "../../src/editor/parts"; + +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); + }); + }); +});