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) +}