From 649b4862d554f3e60172ebe8178e0fc207277d25 Mon Sep 17 00:00:00 2001 From: Michael Uray Date: Sun, 18 Oct 2026 04:23:07 +0000 Subject: [PATCH] feat(text-editor): suggest emojis while typing :shortcode Typing ':' followed by a shortcode in the chat or any text editor showed nothing; the emoji extension only converted complete :shortcode: input or pasted text, so users had to know the exact shortcode. The emoji extension now registers a ':' suggestion based on the existing suggestion plugin used for @ mentions and / commands. Once at least two shortcode characters follow a ':' that starts a word (so times like 10:30, URLs and emoticons do not trigger it), a popup lists matching emojis, including custom ones. Arrow keys or the mouse move the selection, Enter or Tab insert the emoji, Escape or a click outside close the list. Code blocks, inline code and links are excluded. Emoticons ending with a letter or digit (:p, :D, :o, :3, ...) were converted as soon as their last character was typed, which turned the start of most shortcode searches (:joy, :smile, :ok, :pizza) into an emoji. They are now converted only when a whitespace follows; other emoticons such as :) are still converted immediately. Matching reuses the emoji picker rules, extracted into a pure helper in emoji-resources (search.ts) together with the suggestion ranking (exact and prefix shortcode matches first). It is exposed as the new emoji function SearchEmojis, which applies the user's skin tone. Unit tests cover the search helper and the emoticon trigger rule. Fixes #6925 Signed-off-by: Michael Uray --- plugins/emoji-resources/package.json | 2 + .../src/__tests__/search.test.ts | 81 +++++++ plugins/emoji-resources/src/index.ts | 5 +- plugins/emoji-resources/src/search.ts | 64 ++++++ plugins/emoji-resources/src/store.ts | 13 +- plugins/emoji-resources/src/utils.ts | 17 +- plugins/emoji/src/plugin.ts | 3 +- plugins/emoji/src/utils.ts | 2 + .../src/components/EmojiSuggestionList.svelte | 204 ++++++++++++++++++ .../src/components/extension/emoji.ts | 132 ++++++++++-- .../src/components/extension/emoticon.test.ts | 60 ++++++ .../src/components/extension/emoticon.ts | 25 +++ 12 files changed, 571 insertions(+), 37 deletions(-) create mode 100644 plugins/emoji-resources/src/__tests__/search.test.ts create mode 100644 plugins/emoji-resources/src/search.ts create mode 100644 plugins/text-editor-resources/src/components/EmojiSuggestionList.svelte create mode 100644 plugins/text-editor-resources/src/components/extension/emoticon.test.ts create mode 100644 plugins/text-editor-resources/src/components/extension/emoticon.ts diff --git a/plugins/emoji-resources/package.json b/plugins/emoji-resources/package.json index 62d3133e322..b73585bb716 100644 --- a/plugins/emoji-resources/package.json +++ b/plugins/emoji-resources/package.json @@ -13,6 +13,8 @@ "build:watch": "compile ui", "_phase:build": "compile ui", "_phase:format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:test": "jest --passWithNoTests --silent", "_phase:validate": "compile validate" }, "devDependencies": { diff --git a/plugins/emoji-resources/src/__tests__/search.test.ts b/plugins/emoji-resources/src/__tests__/search.test.ts new file mode 100644 index 00000000000..75135f85c18 --- /dev/null +++ b/plugins/emoji-resources/src/__tests__/search.test.ts @@ -0,0 +1,81 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 type { EmojiWithGroup } from '@hcengineering/emoji' +import { emojiMatchesSearch, searchEmojisForSuggestion } from '../search' + +function unicode (emoji: string, label: string, shortcodes: string[], tags: string[] = []): EmojiWithGroup { + return { emoji, label, shortcodes, tags, hexcode: label, key: 'smileys-people' } as any +} + +function custom (shortcode: string): EmojiWithGroup { + return { _id: shortcode, shortcode, image: `${shortcode}-blob`, key: 'custom' } as any +} + +const sob = unicode('😭', 'loudly crying face', ['sob'], ['cry', 'sad']) +const joy = unicode('😂', 'face with tears of joy', ['joy'], ['face', 'joy', 'laugh', 'tear']) +const joystick = unicode('🕹️', 'joystick', ['joystick'], ['game', 'video game']) +const catJoy = unicode('😹', 'cat with tears of joy', ['joy_cat'], ['cat', 'joy']) +const thumbsUp = unicode('👍', 'thumbs up', ['+1', 'thumbsup'], ['+1', 'hand', 'up']) +const partyParrot = custom('party_parrot') + +const emojis = [sob, catJoy, joystick, joy, thumbsUp, partyParrot] + +describe('searchEmojisForSuggestion', () => { + it('requires at least two characters after the colon', () => { + expect(searchEmojisForSuggestion(emojis, '')).toEqual([]) + expect(searchEmojisForSuggestion(emojis, 'j')).toEqual([]) + expect(searchEmojisForSuggestion(emojis, 'jo').length).toBeGreaterThan(0) + }) + + it('ignores text that cannot be a shortcode, like emoticons', () => { + expect(searchEmojisForSuggestion(emojis, '-)')).toEqual([]) + expect(searchEmojisForSuggestion(emojis, 'jo)')).toEqual([]) + expect(searchEmojisForSuggestion(emojis, '//example')).toEqual([]) + }) + + it('lists exact shortcode matches first, then shortcode prefixes, then other matches', () => { + expect(searchEmojisForSuggestion(emojis, 'joy')).toEqual([joy, catJoy, joystick]) + expect(searchEmojisForSuggestion(emojis, 'jo')).toEqual([catJoy, joystick, joy]) + }) + + it('matches tags and labels like the emoji picker', () => { + expect(searchEmojisForSuggestion(emojis, 'cry')).toEqual([sob]) + expect(searchEmojisForSuggestion(emojis, 'tears')).toEqual([catJoy, joy]) + }) + + it('is case insensitive and supports shortcodes with + and _', () => { + expect(searchEmojisForSuggestion(emojis, 'JOY')[0]).toBe(joy) + expect(searchEmojisForSuggestion(emojis, '+1')[0]).toBe(thumbsUp) + expect(searchEmojisForSuggestion(emojis, 'joy_')).toEqual([catJoy]) + }) + + it('finds custom emojis by shortcode', () => { + expect(searchEmojisForSuggestion(emojis, 'party')).toEqual([partyParrot]) + expect(searchEmojisForSuggestion(emojis, 'parrot')).toEqual([partyParrot]) + }) + + it('limits the number of results', () => { + expect(searchEmojisForSuggestion(emojis, 'jo', 2)).toEqual([catJoy, joystick]) + }) +}) + +describe('emojiMatchesSearch', () => { + it('matches shortcode and tag prefixes and label substrings', () => { + expect(emojiMatchesSearch(joystick, 'joy')).toBe(true) + expect(emojiMatchesSearch(joystick, 'vid')).toBe(true) + expect(emojiMatchesSearch(sob, 'crying')).toBe(true) + expect(emojiMatchesSearch(sob, 'ob')).toBe(false) + }) +}) diff --git a/plugins/emoji-resources/src/index.ts b/plugins/emoji-resources/src/index.ts index c9d3edd9bb7..25f2fc446c2 100644 --- a/plugins/emoji-resources/src/index.ts +++ b/plugins/emoji-resources/src/index.ts @@ -2,7 +2,7 @@ import type { Resources } from '@hcengineering/platform' import EmojiPopup from './components/EmojiPopup.svelte' import SettingsEmojiTable from './components/settings/SettingsEmojiTable.svelte' import WorkbenchExtension from './components/WorkbenchExtension.svelte' -import { getCustomEmoji, getEmojiByEmoticon, getEmojiByShortCode, parseTextWithEmojis } from './utils' +import { getCustomEmoji, getEmojiByEmoticon, getEmojiByShortCode, parseTextWithEmojis, searchEmojis } from './utils' export { default as EmojiPresenter } from './components/EmojiPresenter.svelte' @@ -18,6 +18,7 @@ export default async (): Promise => ({ GetEmojiByEmoticon: getEmojiByEmoticon, GetEmojiByShortCode: getEmojiByShortCode, GetCustomEmoji: getCustomEmoji, - ParseTextWithEmojis: parseTextWithEmojis + ParseTextWithEmojis: parseTextWithEmojis, + SearchEmojis: searchEmojis } }) diff --git a/plugins/emoji-resources/src/search.ts b/plugins/emoji-resources/src/search.ts new file mode 100644 index 00000000000..76728467d37 --- /dev/null +++ b/plugins/emoji-resources/src/search.ts @@ -0,0 +1,64 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 { isCustomEmoji, type EmojiWithGroup } from '@hcengineering/emoji' + +/** Minimal number of characters after ':' before emoji suggestions are shown */ +export const minEmojiSuggestionQueryLength = 2 + +const defaultEmojiSuggestionLimit = 10 +const shortcodeQueryRegex = /^[\p{L}\p{N}_+-]+$/u + +/** + * Same matching rules as the emoji picker search. + */ +export function emojiMatchesSearch (emoji: EmojiWithGroup, search: string): boolean { + const query = search.toLowerCase() + if (isCustomEmoji(emoji)) { + return emoji.shortcode.toLowerCase().includes(query) + } + return ( + (emoji.tags?.some((tag: string) => tag.toLowerCase().startsWith(query)) ?? false) || + (emoji.shortcodes?.some((shortcode: string) => shortcode.toLowerCase().startsWith(query)) ?? false) || + emoji.label.toLowerCase().includes(query) + ) +} + +function getMatchRank (emoji: EmojiWithGroup, query: string): number { + const shortcodes = isCustomEmoji(emoji) ? [emoji.shortcode] : (emoji.shortcodes ?? []) + const lowerShortcodes = shortcodes.map((s) => s.toLowerCase()) + if (lowerShortcodes.includes(query)) return 0 + if (lowerShortcodes.some((s) => s.startsWith(query))) return 1 + return 2 +} + +/** + * Returns emojis matching the text typed after ':' for the inline emoji suggestion popup. + * Returns an empty list for queries that are too short or cannot be a shortcode (e.g. emoticons like ':-)'), + * exact and prefix shortcode matches are listed first. + */ +export function searchEmojisForSuggestion ( + emojis: EmojiWithGroup[], + query: string, + limit: number = defaultEmojiSuggestionLimit +): EmojiWithGroup[] { + if (query.length < minEmojiSuggestionQueryLength || !shortcodeQueryRegex.test(query)) return [] + const lowerQuery = query.toLowerCase() + return emojis + .filter((emoji) => emojiMatchesSearch(emoji, lowerQuery)) + .map((emoji, index) => ({ emoji, index, rank: getMatchRank(emoji, lowerQuery) })) + .sort((a, b) => (a.rank !== b.rank ? a.rank - b.rank : a.index - b.index)) + .slice(0, limit) + .map(({ emoji }) => emoji) +} diff --git a/plugins/emoji-resources/src/store.ts b/plugins/emoji-resources/src/store.ts index 8d470d33e90..de62b399282 100644 --- a/plugins/emoji-resources/src/store.ts +++ b/plugins/emoji-resources/src/store.ts @@ -1,6 +1,7 @@ import { writable, derived, get } from 'svelte/store' import { isCustomEmoji, type EmojiWithGroup } from '@hcengineering/emoji' import { getCurrentAccount } from '@hcengineering/core' +import { emojiMatchesSearch } from './search' export const unicodeEmojiStore = writable([]) export const customEmojiStore = writable([]) @@ -8,17 +9,7 @@ export const searchEmoji = writable('') export const resultEmojis = derived([unicodeEmojiStore, customEmojiStore, searchEmoji], ([unicode, custom, search]) => { return search !== '' - ? unicode.concat(custom).filter((emoji) => { - if (isCustomEmoji(emoji)) { - return emoji.shortcode.toLowerCase().includes(search.toLowerCase()) - } - return ( - (emoji.tags?.some((tag: string) => tag.toLowerCase().startsWith(search.toLowerCase())) ?? false) || - (emoji.shortcodes?.some((shortcode: string) => shortcode.toLowerCase().startsWith(search.toLowerCase())) ?? - false) || - emoji.label.toLowerCase().includes(search.toLowerCase()) - ) - }) + ? unicode.concat(custom).filter((emoji) => emojiMatchesSearch(emoji, search)) : unicode.concat(custom) }) diff --git a/plugins/emoji-resources/src/utils.ts b/plugins/emoji-resources/src/utils.ts index bbb605cc7e3..baa91769a5c 100644 --- a/plugins/emoji-resources/src/utils.ts +++ b/plugins/emoji-resources/src/utils.ts @@ -10,6 +10,7 @@ import type { } from '@hcengineering/emoji' import { emojiCategories } from './types' import { unicodeEmojiStore, customEmojiStore, getSkinTone } from './store' +import { searchEmojisForSuggestion } from './search' import { get } from 'svelte/store' import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui' @@ -114,10 +115,20 @@ function findEmoji ( const emojis = get(unicodeEmojiStore).concat(get(customEmojiStore)) const matchEmoji = emojis.find(predicate) if (matchEmoji === undefined) return undefined - if (isCustomEmoji(matchEmoji)) return matchEmoji + return applySkinTone(matchEmoji, skinTone) +} + +function applySkinTone (emoji: ExtendedEmoji, skinTone?: number): ExtendedEmoji { + if (isCustomEmoji(emoji)) return emoji if (skinTone === undefined) skinTone = getSkinTone() - if (skinTone === 0 || matchEmoji.skins === undefined) return matchEmoji - return matchEmoji.skins[skinTone - 1] + if (skinTone === 0 || emoji.skins === undefined) return emoji + return emoji.skins[skinTone - 1] +} + +export function searchEmojis (query: string, limit?: number): ExtendedEmoji[] { + const emojis = get(unicodeEmojiStore).concat(get(customEmojiStore)) + const skinTone = getSkinTone() + return searchEmojisForSuggestion(emojis, query, limit).map((emoji) => applySkinTone(emoji, skinTone)) } export function getEmojiSkins (emoji: ExtendedEmoji): Emoji.Emoji[] | undefined { diff --git a/plugins/emoji/src/plugin.ts b/plugins/emoji/src/plugin.ts index bc38994a4fc..4b2a84a5d2e 100644 --- a/plugins/emoji/src/plugin.ts +++ b/plugins/emoji/src/plugin.ts @@ -91,7 +91,8 @@ export const emojiPlugin = plugin(emojiId, { (shortcode: string | undefined, skinTone?: number) => ExtendedEmoji | undefined >, GetCustomEmoji: '' as Resource<(shortcode: string | undefined, skinTone?: number) => CustomEmoji | undefined>, - ParseTextWithEmojis: '' as Resource<(text: string) => ParsedTextWithEmojis> + ParseTextWithEmojis: '' as Resource<(text: string) => ParsedTextWithEmojis>, + SearchEmojis: '' as Resource<(query: string, limit?: number) => ExtendedEmoji[]> } }) diff --git a/plugins/emoji/src/utils.ts b/plugins/emoji/src/utils.ts index 24fd921d96d..01124f47b96 100644 --- a/plugins/emoji/src/utils.ts +++ b/plugins/emoji/src/utils.ts @@ -35,6 +35,8 @@ export const emojiRegex = new RegExp(`(?:^|\\s)(${EMOJI_REGEX.source})$`) export const emojiGlobalRegex = new RegExp(EMOJI_REGEX.source, EMOJI_REGEX.flags + 'g') export const emoticonRegex = new RegExp(`(?:^|\\s)(${EMOTICON_REGEX.source})$`) +// Emoticon followed by the whitespace that has just been typed +export const emoticonBeforeSpaceRegex = new RegExp(`(?:^|\\s)(${EMOTICON_REGEX.source})\\s$`) export const emoticonGlobalRegex = new RegExp(EMOTICON_REGEX.source, EMOTICON_REGEX.flags + 'g') export const shortcodeRegex = new RegExp(`(?:^|\\s)(${SHORTCODE_REGEX.source})$`) diff --git a/plugins/text-editor-resources/src/components/EmojiSuggestionList.svelte b/plugins/text-editor-resources/src/components/EmojiSuggestionList.svelte new file mode 100644 index 00000000000..9322f22b336 --- /dev/null +++ b/plugins/text-editor-resources/src/components/EmojiSuggestionList.svelte @@ -0,0 +1,204 @@ + + + + { + updateStyle() + }} +/> +{#if visible} + + +
{ + close() + }} + /> +
{ + wPopup = element.clientWidth + updateStyle() + }} + > +
+
+
+
+ + + {@const emoji = items[num]} + + +
{ + command(emoji) + }} + > + + {#if isCustomEmoji(emoji)} + {#await getBlobRef(emoji.image) then blob} + {`:${emoji.shortcode}:`} + {/await} + {:else} + {emoji.emoji} + {/if} + + :{getShortcode(emoji)}: +
+
+
+
+
+
+
+
+{/if} + + diff --git a/plugins/text-editor-resources/src/components/extension/emoji.ts b/plugins/text-editor-resources/src/components/extension/emoji.ts index 335cf0d1720..72d59904575 100644 --- a/plugins/text-editor-resources/src/components/extension/emoji.ts +++ b/plugins/text-editor-resources/src/components/extension/emoji.ts @@ -1,6 +1,7 @@ import { EmojiNode, type EmojiNodeOptions } from '@hcengineering/text' import { emoticonRegex, + emoticonBeforeSpaceRegex, emoticonGlobalRegex, shortcodeRegex, shortcodeGlobalRegex, @@ -19,9 +20,13 @@ import { InputRule, PasteRule } from '@tiptap/core' -import { type EditorState } from '@tiptap/pm/state' +import { type EditorState, PluginKey } from '@tiptap/pm/state' import { getBlobRef } from '@hcengineering/presentation' import { getResource } from '@hcengineering/platform' +import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps } from './suggestion' +import { SvelteRenderer } from '../node-view' +import EmojiSuggestionList from '../EmojiSuggestionList.svelte' +import { isWordLikeEmoticon } from './emoticon' const invalidMarks = ['link'] @@ -51,27 +56,26 @@ function handleEmoji ( if (emoji === undefined) return if (match.length > 0) range.from = range.to - emojiString.length + 1 + commands.insertContentAt(range, [getEmojiNodeContent(emoji)]) +} + +function getEmojiNodeContent (emoji: ExtendedEmoji | string): Record { if (typeof emoji === 'string' || !isCustomEmoji(emoji)) { - commands.insertContentAt(range, [ - { - type: 'emoji', - attrs: { - emoji: typeof emoji === 'string' ? emoji : emoji.emoji, - kind: 'unicode' - } - } - ]) - } else { - commands.insertContentAt(range, [ - { - type: 'emoji', - attrs: { - emoji: `:${emoji.shortcode}:`, - kind: 'image', - image: emoji.image - } + return { + type: 'emoji', + attrs: { + emoji: typeof emoji === 'string' ? emoji : emoji.emoji, + kind: 'unicode' } - ]) + } + } + return { + type: 'emoji', + attrs: { + emoji: `:${emoji.shortcode}:`, + kind: 'image', + image: emoji.image + } } } @@ -171,8 +175,96 @@ export const EmojiExtension = EmojiNode.extend({ new InputRule({ find: emoticonRegex, handler: ({ state, range, match, commands }) => { + const emoticon = match[match.length - 1] + if (emoticon === undefined || isWordLikeEmoticon(emoticon)) return null handleEmoji(state, range, match, commands, emoticonFn) } + }), + new InputRule({ + find: emoticonBeforeSpaceRegex, + handler: ({ state, range, match, commands }) => { + const emoticon = match[1] + if (emoticon === undefined || !isWordLikeEmoticon(emoticon)) return null + const emoji = emoticonFn?.(emoticon) + if (emoji === undefined || !isValidEmojiPosition(state.doc.resolve(range.from))) return null + // the typed whitespace is not in the document yet, the range ends right after the emoticon + commands.insertContentAt({ from: range.to - emoticon.length, to: range.to }, [ + getEmojiNodeContent(emoji), + { type: 'text', text: match[0].slice(-1) } + ]) + } + }) + ] + }, + addProseMirrorPlugins () { + let searchFn: ((query: string, limit?: number) => ExtendedEmoji[]) | undefined + void getResource(emojiPlugin.functions.SearchEmojis).then((res) => { + searchFn = res + }) + return [ + Suggestion({ + pluginKey: new PluginKey('emoji-suggestion'), + editor: this.editor, + char: ':', + decorationClass: 'emoji-suggestion', + items: ({ query }) => searchFn?.(query) ?? [], + allow: ({ state, range }) => { + if (range.from > state.doc.content.size) return false + const $from = state.doc.resolve(range.from) + if ($from.parent.type.spec.code === true) return false + return isValidEmojiPosition($from) && !$from.marks().some((m) => m.type.name === 'code') + }, + command: ({ editor, range, props }) => { + // replace the following space as well, a new one is inserted after the emoji + const nodeAfter = editor.view.state.selection.$to.nodeAfter + if (nodeAfter?.text?.startsWith(' ') === true) { + range.to += 1 + } + editor + .chain() + .focus() + .insertContentAt(range, [getEmojiNodeContent(props), { type: 'text', text: ' ' }]) + .run() + }, + render: () => { + let component: SvelteRenderer | undefined + let hasItems = false + + const destroy = (): void => { + component?.destroy() + component = undefined + } + const close = (): void => { + destroy() + hasItems = false + } + + return { + onStart: (props: SuggestionProps) => { + hasItems = props.items.length > 0 + component = new SvelteRenderer(EmojiSuggestionList, { + element: document.body, + props: { ...props, close } + }) + }, + onUpdate (props: SuggestionProps) { + hasItems = component !== undefined && props.items.length > 0 + component?.updateProps(props) + }, + onKeyDown (props: SuggestionKeyDownProps) { + if (props.event.key === 'Escape') { + // The suggestion plugin cancels itself on Escape (and may already have called onExit), + // keep the key from also closing the surrounding popup or panel when the list was shown + if (!hasItems) return false + props.event.stopPropagation() + close() + return true + } + return component?.onKeyDown(props) ?? false + }, + onExit: destroy + } + } }) ] } diff --git a/plugins/text-editor-resources/src/components/extension/emoticon.test.ts b/plugins/text-editor-resources/src/components/extension/emoticon.test.ts new file mode 100644 index 00000000000..a96af4b9438 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/emoticon.test.ts @@ -0,0 +1,60 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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 { emoticonBeforeSpaceRegex, emoticonRegex } from '@hcengineering/emoji' +import { isWordLikeEmoticon } from './emoticon' + +// Mirrors the two emoticon input rules of the emoji extension: returns the emoticon that would be converted +// after typing the last character of `textBefore` +function convertedEmoticon (textBefore: string): string | undefined { + const immediate = textBefore.match(emoticonRegex)?.[1] + if (immediate !== undefined && !isWordLikeEmoticon(immediate)) return immediate + const beforeSpace = textBefore.match(emoticonBeforeSpaceRegex)?.[1] + if (beforeSpace !== undefined && isWordLikeEmoticon(beforeSpace)) return beforeSpace + return undefined +} + +describe('isWordLikeEmoticon', () => { + it('detects emoticons ending with a letter or digit', () => { + expect(isWordLikeEmoticon(':p')).toBe(true) + expect(isWordLikeEmoticon(':D')).toBe(true) + expect(isWordLikeEmoticon(':-o')).toBe(true) + expect(isWordLikeEmoticon(':3')).toBe(true) + expect(isWordLikeEmoticon(':)')).toBe(false) + expect(isWordLikeEmoticon(';-(')).toBe(false) + expect(isWordLikeEmoticon(':/')).toBe(false) + }) +}) + +describe('emoticon input rules', () => { + it('does not convert while a shortcode is being typed', () => { + const typing = [':j', ':jo', ':s', ':sm', ':o', ':ok', ':p', ':pi', ':c', ':l', ':x', ':z', ':D', ':3', 'hi :j'] + for (const text of typing) { + expect(convertedEmoticon(text)).toBeUndefined() + } + }) + + it('converts letter emoticons once a whitespace follows', () => { + expect(convertedEmoticon(':p ')).toBe(':p') + expect(convertedEmoticon('hi :D ')).toBe(':D') + expect(convertedEmoticon(':joy ')).toBeUndefined() + expect(convertedEmoticon('word:p ')).toBeUndefined() + }) + + it('keeps converting other emoticons immediately', () => { + expect(convertedEmoticon(':)')).toBe(':)') + expect(convertedEmoticon('ok :-(')).toBe(':-(') + expect(convertedEmoticon('10:30')).toBeUndefined() + }) +}) diff --git a/plugins/text-editor-resources/src/components/extension/emoticon.ts b/plugins/text-editor-resources/src/components/extension/emoticon.ts new file mode 100644 index 00000000000..3400fba5936 --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/emoticon.ts @@ -0,0 +1,25 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. +// + +const wordLikeEndRegex = /[\p{L}\p{N}]$/u + +/** + * Emoticons ending with a letter or digit (":p", ":D", ":3", ":-o") may just be the beginning of a word + * or of a ':' shortcode search (":pizza", ":joy"), so they are converted only once a whitespace follows. + * Other emoticons (":)", ";-(", "<3") are converted as soon as they are typed. + */ +export function isWordLikeEmoticon (emoticon: string): boolean { + return wordLikeEndRegex.test(emoticon) +}