diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss
index 03c3741d5e..6402de1b62 100644
--- a/res/css/views/elements/_RichText.scss
+++ b/res/css/views/elements/_RichText.scss
@@ -86,6 +86,11 @@ a.mx_Pill {
margin-right: 0.24rem;
}
+.mx_Emoji {
+ font-size: 1.8rem;
+ vertical-align: bottom;
+}
+
.mx_Markdown_BOLD {
font-weight: bold;
}
diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss
index 5b18797527..8ab391facf 100644
--- a/res/css/views/rooms/_EventBubbleTile.scss
+++ b/res/css/views/rooms/_EventBubbleTile.scss
@@ -88,6 +88,8 @@ limitations under the License.
.mx_EventTile_line {
width: fit-content;
max-width: 70%;
+ // fixed line height to prevent emoji from being taller than text
+ line-height: $font-18px;
}
> .mx_SenderProfile {
diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index ddf10840c8..3cd5bb9c5f 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -233,11 +233,6 @@ $left-gutter: 64px;
overflow-y: hidden;
}
- .mx_EventTile_Emoji {
- font-size: 1.8rem;
- vertical-align: bottom;
- }
-
&.mx_EventTile_selected .mx_EventTile_line,
&:hover .mx_EventTile_line {
border-top-left-radius: 4px;
@@ -391,7 +386,7 @@ $left-gutter: 64px;
position: absolute;
}
-.mx_EventTile_bigEmoji .mx_EventTile_Emoji {
+.mx_EventTile_bigEmoji .mx_Emoji {
font-size: 48px !important;
line-height: 57px;
}
diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss
index c7e6ea6a6e..1e2b060096 100644
--- a/res/css/views/rooms/_SendMessageComposer.scss
+++ b/res/css/views/rooms/_SendMessageComposer.scss
@@ -19,6 +19,8 @@ limitations under the License.
display: flex;
flex-direction: column;
font-size: $font-14px;
+ // fixed line height to prevent emoji from being taller than text
+ line-height: calc(1.2 * $font-14px);
justify-content: center;
margin-right: 6px;
// don't grow wider than available space
diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx
index 2f0e4fc8c5..3c8d100be3 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -89,9 +89,8 @@ const MEDIA_API_MXC_REGEX = /\/_matrix\/media\/r0\/(?:download|thumbnail)\/(.+?)
* Uses a much, much simpler regex than emojibase's so will give false
* positives, but useful for fast-path testing strings to see if they
* need emojification.
- * unicodeToImage uses this function.
*/
-function mightContainEmoji(str: string): boolean {
+export function mightContainEmoji(str: string): boolean {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
}
@@ -412,9 +411,9 @@ export interface IOptsReturnString extends IOpts {
}
const emojiToHtmlSpan = (emoji: string) =>
- `${emoji}`;
+ `${emoji}`;
const emojiToJsxSpan = (emoji: string, key: number) =>
- { emoji };
+ { emoji };
/**
* Wraps emojis in to style them separately from the rest of message. Consecutive emojis (and modifiers) are wrapped
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index 2d5598af92..1bce5031da 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -199,7 +199,7 @@ export default class BasicMessageEditor extends React.Component
// this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted.
- return range.replace([partCreator.plain(data.unicode)]);
+ return range.replace([partCreator.emoji(data.unicode)]);
}
}
}
@@ -831,7 +831,7 @@ export default class BasicMessageEditor extends React.Component
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
model.transform(() => {
- const addedLen = model.insert([partCreator.plain(text)], position);
+ const addedLen = model.insert(partCreator.plainWithEmoji(text), position);
return model.positionForOffset(caret.offset + addedLen, true);
});
}
diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts
index 10e1c60695..7a6cda9d44 100644
--- a/src/editor/autocomplete.ts
+++ b/src/editor/autocomplete.ts
@@ -111,7 +111,7 @@ export default class AutocompleteWrapperModel {
return [(this.partCreator as CommandPartCreator).command(text)];
default:
// used for emoji and other plain text completion replacement
- return [this.partCreator.plain(text)];
+ return this.partCreator.plainWithEmoji(text);
}
}
}
diff --git a/src/editor/caret.ts b/src/editor/caret.ts
index 2b5035b567..b2b7846880 100644
--- a/src/editor/caret.ts
+++ b/src/editor/caret.ts
@@ -99,7 +99,7 @@ export function getLineAndNodePosition(model: EditorModel, caretPosition: IPosit
offset = 0;
} else {
// move caret out of uneditable part (into caret node, or empty line br) if needed
- ({ nodeIndex, offset } = moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset));
+ ({ nodeIndex, offset } = moveOutOfUnselectablePart(parts, partIndex, nodeIndex, offset));
}
return { lineIndex, nodeIndex, offset };
}
@@ -123,7 +123,7 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
nodeIndex += 1;
}
// only jump over caret node if we're not at our destination node already,
- // as we'll assume in moveOutOfUneditablePart that nodeIndex
+ // as we'll assume in moveOutOfUnselectablePart that nodeIndex
// refers to the node corresponding to the part,
// and not an adjacent caret node
if (i < partIndex) {
@@ -140,10 +140,10 @@ function findNodeInLineForPart(parts: Part[], partIndex: number) {
return { lineIndex, nodeIndex };
}
-function moveOutOfUneditablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) {
- // move caret before or after uneditable part
+function moveOutOfUnselectablePart(parts: Part[], partIndex: number, nodeIndex: number, offset: number) {
+ // move caret before or after unselectable part
const part = parts[partIndex];
- if (part && !part.canEdit) {
+ if (part && !part.acceptsCaret) {
if (offset === 0) {
nodeIndex -= 1;
const prevPart = parts[partIndex - 1];
diff --git a/src/editor/deserialize.ts b/src/editor/deserialize.ts
index 0215785acf..f016a1f61c 100644
--- a/src/editor/deserialize.ts
+++ b/src/editor/deserialize.ts
@@ -29,7 +29,7 @@ function parseAtRoomMentions(text: string, partCreator: PartCreator): Part[] {
const parts: Part[] = [];
text.split(ATROOM).forEach((textPart, i, arr) => {
if (textPart.length) {
- parts.push(partCreator.plain(textPart));
+ parts.push(...partCreator.plainWithEmoji(textPart));
}
// it's safe to never append @room after the last textPart
// as split will report an empty string at the end if
@@ -42,28 +42,28 @@ function parseAtRoomMentions(text: string, partCreator: PartCreator): Part[] {
return parts;
}
-function parseLink(a: HTMLAnchorElement, partCreator: PartCreator): Part {
+function parseLink(a: HTMLAnchorElement, partCreator: PartCreator): Part[] {
const { href } = a;
const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID
const prefix = resourceId ? resourceId[0] : undefined; // First character of ID
switch (prefix) {
case "@":
- return partCreator.userPill(a.textContent, resourceId);
+ return [partCreator.userPill(a.textContent, resourceId)];
case "#":
- return partCreator.roomPill(resourceId);
+ return [partCreator.roomPill(resourceId)];
default: {
if (href === a.textContent) {
- return partCreator.plain(a.textContent);
+ return partCreator.plainWithEmoji(a.textContent);
} else {
- return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`);
+ return partCreator.plainWithEmoji(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`);
}
}
}
}
-function parseImage(img: HTMLImageElement, partCreator: PartCreator): Part {
+function parseImage(img: HTMLImageElement, partCreator: PartCreator): Part[] {
const { src } = img;
- return partCreator.plain(`![${img.alt.replace(/[[\\\]]/g, c => "\\" + c)}](${src})`);
+ return partCreator.plainWithEmoji(`![${img.alt.replace(/[[\\\]]/g, c => "\\" + c)}](${src})`);
}
function parseCodeBlock(n: HTMLElement, partCreator: PartCreator): Part[] {
@@ -79,7 +79,7 @@ function parseCodeBlock(n: HTMLElement, partCreator: PartCreator): Part[] {
}
const preLines = ("```" + language + "\n" + n.textContent + "```").split("\n");
preLines.forEach((l, i) => {
- parts.push(partCreator.plain(l));
+ parts.push(...partCreator.plainWithEmoji(l));
if (i < preLines.length - 1) {
parts.push(partCreator.newline());
}
@@ -126,21 +126,21 @@ function parseElement(
partCreator.newline(),
];
case "EM":
- return partCreator.plain(`_${n.textContent}_`);
+ return partCreator.plainWithEmoji(`_${n.textContent}_`);
case "STRONG":
- return partCreator.plain(`**${n.textContent}**`);
+ return partCreator.plainWithEmoji(`**${n.textContent}**`);
case "PRE":
return parseCodeBlock(n, partCreator);
case "CODE":
- return partCreator.plain(`\`${n.textContent}\``);
+ return partCreator.plainWithEmoji(`\`${n.textContent}\``);
case "DEL":
- return partCreator.plain(`${n.textContent}`);
+ return partCreator.plainWithEmoji(`${n.textContent}`);
case "SUB":
- return partCreator.plain(`${n.textContent}`);
+ return partCreator.plainWithEmoji(`${n.textContent}`);
case "SUP":
- return partCreator.plain(`${n.textContent}`);
+ return partCreator.plainWithEmoji(`${n.textContent}`);
case "U":
- return partCreator.plain(`${n.textContent}`);
+ return partCreator.plainWithEmoji(`${n.textContent}`);
case "LI": {
const BASE_INDENT = 4;
const depth = state.listDepth - 1;
@@ -171,9 +171,9 @@ function parseElement(
((SdkConfig.get()['latex_maths_delims'] || {})['inline'] || {})['right'] || "\\)" :
((SdkConfig.get()['latex_maths_delims'] || {})['display'] || {})['right'] || "\\]";
const tex = n.getAttribute("data-mx-maths");
- return partCreator.plain(delimLeft + tex + delimRight);
+ return partCreator.plainWithEmoji(delimLeft + tex + delimRight);
} else if (!checkDescendInto(n)) {
- return partCreator.plain(n.textContent);
+ return partCreator.plainWithEmoji(n.textContent);
}
break;
}
@@ -186,7 +186,7 @@ function parseElement(
default:
// don't textify block nodes we'll descend into
if (!checkDescendInto(n)) {
- return partCreator.plain(n.textContent);
+ return partCreator.plainWithEmoji(n.textContent);
}
}
}
diff --git a/src/editor/parts.ts b/src/editor/parts.ts
index 277b4bb526..70e6f82518 100644
--- a/src/editor/parts.ts
+++ b/src/editor/parts.ts
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import { split } from "lodash";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Room } from "matrix-js-sdk/src/models/room";
@@ -24,12 +25,13 @@ import AutocompleteWrapperModel, {
UpdateCallback,
UpdateQuery,
} from "./autocomplete";
+import { mightContainEmoji, unicodeToShortcode } from "../HtmlUtils";
import * as Avatar from "../Avatar";
import defaultDispatcher from "../dispatcher/dispatcher";
import { Action } from "../dispatcher/actions";
interface ISerializedPart {
- type: Type.Plain | Type.Newline | Type.Command | Type.PillCandidate;
+ type: Type.Plain | Type.Newline | Type.Emoji | Type.Command | Type.PillCandidate;
text: string;
}
@@ -44,6 +46,7 @@ export type SerializedPart = ISerializedPart | ISerializedPillPart;
export enum Type {
Plain = "plain",
Newline = "newline",
+ Emoji = "emoji",
Command = "command",
UserPill = "user-pill",
RoomPill = "room-pill",
@@ -53,8 +56,9 @@ export enum Type {
interface IBasePart {
text: string;
- type: Type.Plain | Type.Newline;
+ type: Type.Plain | Type.Newline | Type.Emoji;
canEdit: boolean;
+ acceptsCaret: boolean;
createAutoComplete(updateCallback: UpdateCallback): void;
@@ -165,6 +169,10 @@ abstract class BasePart {
return true;
}
+ public get acceptsCaret(): boolean {
+ return this.canEdit;
+ }
+
public toString(): string {
return `${this.type}(${this.text})`;
}
@@ -183,7 +191,7 @@ abstract class BasePart {
abstract class PlainBasePart extends BasePart {
protected acceptsInsertion(chr: string, offset: number, inputType: string): boolean {
- if (chr === "\n") {
+ if (chr === "\n" || mightContainEmoji(chr)) {
return false;
}
// when not pasting or dropping text, reject characters that should start a pill candidate
@@ -351,6 +359,48 @@ class NewlinePart extends BasePart implements IBasePart {
}
}
+class EmojiPart extends BasePart implements IBasePart {
+ protected acceptsInsertion(chr: string, offset: number): boolean {
+ return false;
+ }
+
+ protected acceptsRemoval(position: number, chr: string): boolean {
+ return false;
+ }
+
+ public toDOMNode(): Node {
+ const span = document.createElement("span");
+ span.className = "mx_Emoji";
+ span.setAttribute("title", unicodeToShortcode(this.text));
+ span.appendChild(document.createTextNode(this.text));
+ return span;
+ }
+
+ public updateDOMNode(node: HTMLElement): void {
+ const textNode = node.childNodes[0];
+ if (textNode.textContent !== this.text) {
+ node.setAttribute("title", unicodeToShortcode(this.text));
+ textNode.textContent = this.text;
+ }
+ }
+
+ public canUpdateDOMNode(node: HTMLElement): boolean {
+ return node.className === "mx_Emoji";
+ }
+
+ public get type(): IBasePart["type"] {
+ return Type.Emoji;
+ }
+
+ public get canEdit(): boolean {
+ return false;
+ }
+
+ public get acceptsCaret(): boolean {
+ return true;
+ }
+}
+
class RoomPillPart extends PillPart {
constructor(resourceId: string, label: string, private room: Room) {
super(resourceId, label);
@@ -503,6 +553,9 @@ export class PartCreator {
case "\n":
return new NewlinePart();
default:
+ if (mightContainEmoji(input[0])) {
+ return new EmojiPart();
+ }
return new PlainPart();
}
}
@@ -517,6 +570,8 @@ export class PartCreator {
return this.plain(part.text);
case Type.Newline:
return this.newline();
+ case Type.Emoji:
+ return this.emoji(part.text);
case Type.AtRoomPill:
return this.atRoomPill(part.text);
case Type.PillCandidate:
@@ -536,6 +591,10 @@ export class PartCreator {
return new NewlinePart("\n");
}
+ public emoji(text: string): EmojiPart {
+ return new EmojiPart(text);
+ }
+
public pillCandidate(text: string): PillCandidatePart {
return new PillCandidatePart(text, this.autoCompleteCreator);
}
@@ -562,6 +621,28 @@ export class PartCreator {
return new UserPillPart(userId, displayName, member);
}
+ public plainWithEmoji(text: string): (PlainPart | EmojiPart)[] {
+ const parts = [];
+ let plainText = "";
+
+ // We use lodash's grapheme splitter to avoid breaking apart compound emojis
+ for (const char of split(text, "")) {
+ if (mightContainEmoji(char)) {
+ if (plainText) {
+ parts.push(this.plain(plainText));
+ plainText = "";
+ }
+ parts.push(this.emoji(char));
+ } else {
+ plainText += char;
+ }
+ }
+ if (plainText) {
+ parts.push(this.plain(plainText));
+ }
+ return parts;
+ }
+
public createMentionParts(
insertTrailingCharacter: boolean,
displayName: string,
diff --git a/src/editor/render.ts b/src/editor/render.ts
index d9997de855..e3e6fcb413 100644
--- a/src/editor/render.ts
+++ b/src/editor/render.ts
@@ -20,11 +20,11 @@ import EditorModel from "./model";
export function needsCaretNodeBefore(part: Part, prevPart: Part): boolean {
const isFirst = !prevPart || prevPart.type === Type.Newline;
- return !part.canEdit && (isFirst || !prevPart.canEdit);
+ return !part.acceptsCaret && (isFirst || !prevPart.acceptsCaret);
}
export function needsCaretNodeAfter(part: Part, isLastOfLine: boolean): boolean {
- return !part.canEdit && isLastOfLine;
+ return !part.acceptsCaret && isLastOfLine;
}
function insertAfter(node: HTMLElement, nodeToInsert: HTMLElement): void {
diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts
index 8dc4ed58df..4618cf79c1 100644
--- a/src/editor/serialize.ts
+++ b/src/editor/serialize.ts
@@ -31,6 +31,7 @@ export function mdSerialize(model: EditorModel): string {
case Type.Newline:
return html + "\n";
case Type.Plain:
+ case Type.Emoji:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
@@ -164,6 +165,7 @@ export function textSerialize(model: EditorModel): string {
case Type.Newline:
return text + "\n";
case Type.Plain:
+ case Type.Emoji:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill: