From 6806c2cdca98fd3867b29d7bffc8d687db0a6f05 Mon Sep 17 00:00:00 2001 From: Robin <robin@robin.town> Date: Mon, 24 Jan 2022 07:53:05 -0500 Subject: [PATCH] Enlarge emoji in composer (#7602) --- res/css/views/elements/_RichText.scss | 5 ++ res/css/views/rooms/_EventBubbleTile.scss | 2 + res/css/views/rooms/_EventTile.scss | 7 +- res/css/views/rooms/_SendMessageComposer.scss | 2 + src/HtmlUtils.tsx | 7 +- .../views/rooms/BasicMessageComposer.tsx | 4 +- src/editor/autocomplete.ts | 2 +- src/editor/caret.ts | 10 +-- src/editor/deserialize.ts | 38 ++++---- src/editor/parts.ts | 87 ++++++++++++++++++- src/editor/render.ts | 4 +- src/editor/serialize.ts | 2 + 12 files changed, 128 insertions(+), 42 deletions(-) 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) => - `<span class='mx_EventTile_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`; + `<span class='mx_Emoji' title='${unicodeToShortcode(emoji)}'>${emoji}</span>`; const emojiToJsxSpan = (emoji: string, key: number) => - <span key={key} className='mx_EventTile_Emoji' title={unicodeToShortcode(emoji)}>{ emoji }</span>; + <span key={key} className='mx_Emoji' title={unicodeToShortcode(emoji)}>{ emoji }</span>; /** * Wraps emojis in <span> 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<IProps, IState> // 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<IProps, IState> 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(`<del>${n.textContent}</del>`); + return partCreator.plainWithEmoji(`<del>${n.textContent}</del>`); case "SUB": - return partCreator.plain(`<sub>${n.textContent}</sub>`); + return partCreator.plainWithEmoji(`<sub>${n.textContent}</sub>`); case "SUP": - return partCreator.plain(`<sup>${n.textContent}</sup>`); + return partCreator.plainWithEmoji(`<sup>${n.textContent}</sup>`); case "U": - return partCreator.plain(`<u>${n.textContent}</u>`); + return partCreator.plainWithEmoji(`<u>${n.textContent}</u>`); 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: