diff --git a/docs/content/docs/features/localization.mdx b/docs/content/docs/features/localization.mdx index 5791f052c4..ee6c510088 100644 --- a/docs/content/docs/features/localization.mdx +++ b/docs/content/docs/features/localization.mdx @@ -25,11 +25,13 @@ BlockNote supports the following languages out of the box: - **Japanese** (`ja`) - 日本語 - **Korean** (`ko`) - 한국어 - **Norwegian** (`no`) - Norsk +- **Persian** (`fa`) - فارسی - **Polish** (`pl`) - Polski - **Portuguese** (`pt`) - Português - **Russian** (`ru`) - Русский - **Slovak** (`sk`) - Slovenčina - **Spanish** (`es`) - Español +- **Turkish** (`tr`) - Türkçe - **Ukrainian** (`uk`) - Українська - **Uzbek** (`uz`) - O'zbekcha - **Vietnamese** (`vi`) - Tiếng Việt @@ -53,25 +55,13 @@ function FrenchEditor() { ## Dynamic Language Switching -You can dynamically change the language based on user preferences or your app's locale: +The dictionary is selected when the editor is created. Pass `[language]` as the +second argument to `useCreateBlockNote` to recreate it when the language changes, +and key `BlockNoteView` by the language to reset its menus. -```tsx -import { useCreateBlockNote, BlockNoteView } from "@blocknote/react"; -import * as locales from "@blocknote/core/locales"; - -function LocalizedEditor({ language = "en" }) { - const editor = useCreateBlockNote({ - dictionary: locales[language as keyof typeof locales] || locales.en, - }); - - return ; -} - -// Usage - - - -``` +To preserve content, save the document outside the editor and pass it back as +`initialContent`. Recreating the editor resets its undo history and selection. +The [localization example](#basic-localization) shows the complete implementation. ## Customizing Text Strings @@ -110,6 +100,24 @@ function CustomEditor() { The dictionary object contains translations for various parts of the editor: +### Locale + +The optional `locale` field selects the localized emoji names, categories, +skin-tone names, and search terms used by the emoji picker. BlockNote's bundled +dictionaries already set this field. When creating a custom dictionary, set it +to the matching locale code; if it is omitted or unsupported, the emoji data +falls back to English. + +The picker interface strings use the same dictionary as the rest of BlockNote: + +```tsx +emoji_picker: { + search: "Search", + search_no_results: "No emoji found", + loading: "Loading…", +} +``` + ### Placeholders Text that appears when blocks are empty: @@ -203,6 +211,8 @@ To add support for a new language, you can: When creating translations, make sure to: +- Set `locale` to the matching locale code so the emoji picker uses the + appropriate localized data - Translate all text strings in the dictionary - Maintain the same structure as the English dictionary - Test the translations with different content types diff --git a/examples/01-basic/10-localization/README.md b/examples/01-basic/10-localization/README.md index 9dc27ec369..f90128ba36 100644 --- a/examples/01-basic/10-localization/README.md +++ b/examples/01-basic/10-localization/README.md @@ -1,6 +1,6 @@ # Localization (i18n) -In this example, we pass in a custom dictionary to change the interface of the editor to use Dutch (NL) strings. +In this example, we use a dropdown to switch the editor between all of the languages that BlockNote ships with, by passing the matching dictionary from `@blocknote/core/locales` to the editor. Selecting a language re-mounts the editor so the interface strings update immediately. The document is saved outside the editor and restored on re-mount, preserving your content (but not the undo history or selection). You can also provide your own dictionary to customize the strings used in the editor, or submit a Pull Request to add support for your language of your choice. diff --git a/examples/01-basic/10-localization/src/App.tsx b/examples/01-basic/10-localization/src/App.tsx index 7000c3e436..e4c294787e 100644 --- a/examples/01-basic/10-localization/src/App.tsx +++ b/examples/01-basic/10-localization/src/App.tsx @@ -1,21 +1,86 @@ -import { nl } from "@blocknote/core/locales"; +import type { Block } from "@blocknote/core"; +import * as locales from "@blocknote/core/locales"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; -// import { useTranslation } from "some-i18n-library"; // You can use any i18n library you like +import { useRef, useState } from "react"; + +type Language = keyof typeof locales; + +function isLanguage(value: string): value is Language { + return Object.hasOwn(locales, value); +} + +// Human-readable names shown in the language picker. +const languageNames = { + ar: "العربية", + de: "Deutsch", + en: "English", + es: "Español", + fa: "فارسی", + fr: "Français", + he: "עברית", + hr: "Hrvatski", + is: "Íslenska", + it: "Italiano", + ja: "日本語", + ko: "한국어", + nl: "Nederlands", + no: "Norsk", + pl: "Polski", + pt: "Português", + ru: "Русский", + sk: "Slovenčina", + tr: "Türkçe", + uk: "Українська", + uz: "Oʻzbekcha", + vi: "Tiếng Việt", + zh: "简体中文", + zhTW: "繁體中文", +} satisfies Record; + +const languageKeys = Object.keys(locales) + .filter(isLanguage) + .sort((a, b) => languageNames[a].localeCompare(languageNames[b])); export default function App() { - // const { lang } = useTranslation('common'); // You can get the current language from the i18n library or alternatively from a router - // Creates a new editor instance. - const editor = useCreateBlockNote({ - // Passes the Dutch (NL) dictionary to the editor instance. - // You can also provide your own dictionary here to customize the strings used in the editor, - // or submit a Pull Request to add support for your language of your choice - dictionary: nl, - // dictionary: locales[lang as keyof typeof locales], // Use the language from the i18n library dynamically - }); + const [language, setLanguage] = useState("en"); + // Recreate the editor when the language changes, preserving its document. + const document = useRef(undefined); + const editor = useCreateBlockNote( + { dictionary: locales[language], initialContent: document.current }, + [language], + ); + + return ( +
+ - // Renders the editor instance using a React component. - return ; + {/* Remount the UI so menus also reset to the new language. */} + { + document.current = editor.document; + }} + /> +
+ ); } diff --git a/packages/core/package.json b/packages/core/package.json index 66aebbfde4..f950acb047 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -117,7 +117,6 @@ "generate-emoji-data": "node scripts/emoji-data/generate.mjs" }, "dependencies": { - "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4.4.3", "@tiptap/core": "^3.31.3", @@ -129,7 +128,6 @@ "@tiptap/extension-underline": "^3.31.3", "@tiptap/extensions": "^3.31.3", "@tiptap/pm": "^3.31.3", - "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "lib0": "1.0.0-rc.36", "prosemirror-highlight": "^0.15.3", diff --git a/packages/core/scripts/emoji-data/README.md b/packages/core/scripts/emoji-data/README.md index 790a28aeeb..80c3c94af7 100644 --- a/packages/core/scripts/emoji-data/README.md +++ b/packages/core/scripts/emoji-data/README.md @@ -2,6 +2,10 @@ Regenerate with `pnpm --filter @blocknote/core generate-emoji-data`. +`generate.mjs` owns package/file loading and writing generated TypeScript modules. +`format.mjs` owns pure transformations, precedence rules, positional validation, +and encoding. Its tests use synthetic inputs without resolving dataset packages. + The runtime contracts in `frimousse/types.ts` are handwritten; generation only writes data modules, not public types. @@ -21,6 +25,24 @@ Each locale module exposes its encoded string as a default export for the loader alongside its existing named export. Generation verifies emoji identity/order against the shared English data before writing positional locale rows. +Search tags are compacted before encoding: empty tags, case-insensitive duplicates, +and tags contained in the label are removed. Compaction uses locale-independent +Unicode casing (equivalent to English casing). Runtime search still +uses the data's locale, as described below. + +Extra-locale labels prefer CLDR, then the overlay, then the English base. Tags +are concatenated in CLDR → overlay → English order and compacted once by the +encoder. Positional validation and encoding have no side effects, so synthetic +fixtures can exercise the pipeline without reading or regenerating corpora. + +This compaction is **not ranking-neutral**: search adds a score for every matching +stored tag, including when the label also matches. The checked-in data therefore +defines ranking over the compacted tags, not over the original upstream corpus. +Restoring upstream tag-weighted ranking requires changing the compaction policy +and regenerating all locale modules together; changing only the generator or +search scorer cannot recover the discarded information. Review ranking changes +alongside any such regeneration. + The shared data stores emoji identities, categories, versions, country-flag markers, and optional skin templates. A template uses `|` for each skin-tone modifier, including sequences with two modifiers. The loader substitutes each @@ -57,5 +79,6 @@ Experiments with per-locale tag dictionaries reduced raw locale data by about 17%, but increased gzip size by about 7%. Inverted indexes and sorting rows also increased gzip size. Sharing tag suffixes across languages reduced aggregate storage but increased the first-load download for several languages because of -the extra shared chunk. These formats were not adopted. Preserve tag order and -content when evaluating alternatives: search ranking counts matching tags. +the extra shared chunk. These formats were not adopted. Preserve the current +compacted tag order and content when evaluating encoding-only alternatives: +search ranking counts matching stored tags. diff --git a/packages/core/scripts/emoji-data/format.mjs b/packages/core/scripts/emoji-data/format.mjs new file mode 100644 index 0000000000..ac98440a65 --- /dev/null +++ b/packages/core/scripts/emoji-data/format.mjs @@ -0,0 +1,245 @@ +/** Pure emoji transformations and positional encoding; no package or file IO. */ + +// Emojibase tone number → Frimousse skin tone key. +const TONE_MAP = { + 1: "light", + 2: "medium-light", + 3: "medium", + 4: "medium-dark", + 5: "dark", +}; + +const COMPONENT_GROUP = 2; +const FIELD_SEPARATOR = "\t"; +const LIST_SEPARATOR = "|"; + +// Correct labels in the upstream message files that are untranslated or wrong. +const EMOJIBASE_LABEL_OVERRIDES = { + de: { skinTones: { "medium-dark": "Mitteldunkler Hautton" } }, + ja: { categories: { 3: "動物と自然", 6: "アクティビティ" } }, + nl: { categories: { 1: "Mensen en lichaam", 9: "Vlaggen" } }, +}; + +function capitalize(str, locale) { + return str.charAt(0).toLocaleUpperCase(locale) + str.slice(1); +} + +function decodeHtmlEntities(str) { + return str.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => { + switch (entity) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case """: + return '"'; + case "'": + return "'"; + default: + throw new Error(`Unexpected HTML entity: ${entity}`); + } + }); +} + +function getSkinToneVariations(emoji) { + if (!emoji.skins) { + return undefined; + } + const variations = {}; + for (const skin of emoji.skins) { + if (typeof skin.tone === "number" && TONE_MAP[skin.tone]) { + variations[TONE_MAP[skin.tone]] = skin.emoji; + } + } + return Object.keys(variations).length === 5 ? variations : undefined; +} + +function normalizeSearchText(value) { + return value.replace(/\s+/g, " ").trim(); +} + +export function buildFrimousseData(emojis, messages, locale) { + const labelOverrides = EMOJIBASE_LABEL_OVERRIDES[locale]; + const countryFlagSubgroups = new Set( + messages.subgroups + .filter( + (subgroup) => + subgroup.key === "country-flag" || + subgroup.key === "subdivision-flag", + ) + .map((subgroup) => subgroup.order), + ); + + const filteredGroups = messages.groups.filter((g) => g.key !== "component"); + const filteredEmojis = emojis.filter( + (e) => "group" in e && e.group !== COMPONENT_GROUP, + ); + + const categories = filteredGroups.map((g) => ({ + index: g.order, + label: + labelOverrides?.categories?.[g.order] ?? + capitalize(decodeHtmlEntities(g.message), locale), + })); + + const skinTones = {}; + for (const key of Object.values(TONE_MAP)) { + const found = messages.skinTones.find((skinTone) => skinTone.key === key); + if (found) { + skinTones[key] = + labelOverrides?.skinTones?.[key] ?? capitalize(found.message, locale); + } + } + + const formattedEmojis = filteredEmojis.map((emoji) => { + const entry = { + emoji: emoji.emoji, + category: emoji.group, + version: emoji.version, + label: capitalize(normalizeSearchText(emoji.label), locale), + tags: (emoji.tags ?? []).map(normalizeSearchText), + }; + if (countryFlagSubgroups.has(emoji.subgroup)) { + entry.countryFlag = true; + } + const skins = getSkinToneVariations(emoji); + if (skins) { + entry.skins = skins; + } + return entry; + }); + + return { + locale, + emojis: formattedEmojis, + categories, + skinTones, + }; +} + +function assertEncodable(value, context) { + if ( + value.includes(FIELD_SEPARATOR) || + value.includes(LIST_SEPARATOR) || + value.includes("\n") || + value.includes("\r") + ) { + throw new Error(`Cannot encode ${context}: ${JSON.stringify(value)}`); + } +} + +function encodeList(values, context) { + return values + .map((value) => { + value = normalizeSearchText(value); + assertEncodable(value, context); + return value; + }) + .join(LIST_SEPARATOR); +} + +export function compactSearchTags(label, tags) { + // Keep the existing English-cased encoding deterministic across host locales. + // Compaction is not ranking-neutral: search scores the remaining stored tags. + // Changing this policy requires regenerating and reviewing all locale modules. + const normalizedLabel = normalizeSearchText(label).toLowerCase(); + const seen = new Set(); + + return tags.filter((tag) => { + const normalizedTag = normalizeSearchText(tag).toLowerCase(); + if ( + !normalizedTag || + seen.has(normalizedTag) || + normalizedLabel.includes(normalizedTag) + ) { + return false; + } + seen.add(normalizedTag); + return true; + }); +} + +export function localizeEmoji(emoji, overlay, annotation, locale) { + const label = annotation?.tts?.[0] || overlay?.name; + return { + ...emoji, + label: label ? capitalize(label, locale) : emoji.label, + // Preserve first-occurrence order; the encoder compacts duplicates once. + tags: [ + ...(annotation?.default ?? []), + ...(overlay?.keywords ?? []), + ...emoji.tags, + ], + }; +} + +export function encodeFrimousseCommonData(data) { + const categoryIndices = data.categories.map(({ index }) => index).join(","); + const emojis = data.emojis + .map((emoji) => { + assertEncodable(emoji.emoji, `emoji ${emoji.emoji}`); + // Store one template instead of all five nearly identical variants. + // Verify every upstream variation so a future exception fails generation + // rather than silently changing the emoji rendered by the picker. + const skins = emoji.skins?.light.replaceAll("🏻", "|") ?? ""; + if (emoji.skins) { + Object.values(emoji.skins).forEach((skin, index) => { + if ( + skins.replaceAll("|", String.fromCodePoint(0x1f3fb + index)) !== + skin + ) { + throw new Error(`Cannot encode skin tones for ${emoji.emoji}`); + } + }); + } + return [ + emoji.emoji, + emoji.category, + emoji.version, + emoji.countryFlag ? "1" : "", + skins, + ].join(FIELD_SEPARATOR); + }) + .join("\n"); + return { categoryIndices, emojis }; +} + +export function encodeFrimousseData(data, commonData) { + const { locale } = data; + const categoryIndices = data.categories.map(({ index }) => index).join(","); + if ( + categoryIndices !== + commonData.categories.map(({ index }) => index).join(",") + ) { + throw new Error(`Category indices differ for locale ${locale}`); + } + if ( + data.emojis.length !== commonData.emojis.length || + data.emojis.some( + (emoji, index) => emoji.emoji !== commonData.emojis[index].emoji, + ) + ) { + throw new Error(`Emoji order differs for locale ${locale}`); + } + const categoryLabels = encodeList( + data.categories.map(({ label }) => label), + `category labels for ${locale}`, + ); + const skinTones = encodeList( + Object.values(data.skinTones), + `skin tones for ${locale}`, + ); + const emojis = data.emojis + .map((emoji) => { + assertEncodable(emoji.label, `label for ${emoji.emoji} in ${locale}`); + const tags = encodeList( + compactSearchTags(emoji.label, emoji.tags), + `tags for ${emoji.emoji} in ${locale}`, + ); + return tags ? `${emoji.label}${FIELD_SEPARATOR}${tags}` : emoji.label; + }) + .join("\n"); + return [locale, categoryLabels, skinTones, emojis].join("\n"); +} diff --git a/packages/core/scripts/emoji-data/format.test.mjs b/packages/core/scripts/emoji-data/format.test.mjs new file mode 100644 index 0000000000..54743acbd0 --- /dev/null +++ b/packages/core/scripts/emoji-data/format.test.mjs @@ -0,0 +1,155 @@ +// @vitest-environment node +import { expect, it } from "vite-plus/test"; + +// Pure transformations: no generator entry point or translation corpora needed. +import { + compactSearchTags, + encodeFrimousseCommonData, + encodeFrimousseData, + localizeEmoji, +} from "./format.mjs"; + +it.each([ + ["IRIS", ["iris", "I", "ISLAND", "island"], ["ISLAND"]], + ["İRIS", ["iris", "İris"], ["iris"]], + ["Face", [" face ", "", " HAPPY ", "happy", "smile"], [" HAPPY ", "smile"]], +])("compacts %s with locale-independent casing", (label, tags, expected) => { + expect(compactSearchTags(label, tags)).toEqual(expected); +}); + +const commonData = { + locale: "en", + categories: [ + { index: 0, label: "Faces" }, + { index: 1, label: "People" }, + ], + skinTones: { + light: "Light", + "medium-light": "Medium light", + medium: "Medium", + "medium-dark": "Medium dark", + dark: "Dark", + }, + emojis: [ + { + emoji: "😀", + label: "Grinning face", + tags: ["happy", " face ", "happy", " "], + }, + { emoji: "👋", label: "Wave", tags: [] }, + ], +}; + +it("encodes compact positional rows and omits empty tag fields", () => { + expect(encodeFrimousseData(commonData, commonData)).toBe( + "en\nFaces|People\nLight|Medium light|Medium|Medium dark|Dark\nGrinning face\thappy\nWave", + ); +}); + +it("encodes shared flags and skin templates, rejecting nonuniform variants", () => { + const emoji = { + emoji: "🧑‍🤝‍🧑", + category: 1, + version: 12, + skins: { + light: "🧑🏻‍🤝‍🧑🏻", + "medium-light": "🧑🏼‍🤝‍🧑🏼", + medium: "🧑🏽‍🤝‍🧑🏽", + "medium-dark": "🧑🏾‍🤝‍🧑🏾", + dark: "🧑🏿‍🤝‍🧑🏿", + }, + }; + const data = { + ...commonData, + emojis: [ + emoji, + { emoji: "🇨🇭", category: 1, version: 1, countryFlag: true }, + ], + }; + expect(encodeFrimousseCommonData(data)).toEqual({ + categoryIndices: "0,1", + emojis: "🧑‍🤝‍🧑\t1\t12\t\t🧑|‍🤝‍🧑|\n🇨🇭\t1\t1\t1\t", + }); + emoji.skins.dark = "invalid"; + expect(() => encodeFrimousseCommonData(data)).toThrow( + "Cannot encode skin tones", + ); +}); + +it.each([ + [undefined, undefined, "Grinning face"], + [{ name: "overlay", keywords: [] }, undefined, "Overlay"], + [{ name: "overlay", keywords: [] }, { tts: [""] }, "Overlay"], + [{ name: "overlay", keywords: [] }, { tts: ["istanbul"] }, "İstanbul"], +])( + "keeps CLDR → overlay → base label precedence", + (overlay, annotation, label) => { + expect( + localizeEmoji(commonData.emojis[0], overlay, annotation, "tr").label, + ).toBe(label); + }, +); + +it("compacts CLDR → overlay → English tags once without mutating the base", () => { + const overlay = { + name: "overlay", + keywords: ["same", "overlay", "HAPPY", " multi word "], + }; + const annotation = { tts: ["localized"], default: ["same", "cldr"] }; + const localized = localizeEmoji( + commonData.emojis[0], + overlay, + annotation, + "en", + ); + expect( + encodeFrimousseData( + { + ...commonData, + emojis: [localized, commonData.emojis[1]], + }, + commonData, + ), + ).toBe( + "en\nFaces|People\nLight|Medium light|Medium|Medium dark|Dark\nLocalized\tsame|cldr|overlay|HAPPY|multi word|face\nWave", + ); + expect(commonData.emojis[0].label).toBe("Grinning face"); + expect(commonData.emojis[0].tags).toEqual(["happy", " face ", "happy", " "]); +}); + +it.each( + [ + [], + commonData.categories.slice(0, 1), + [...commonData.categories].reverse(), + [...commonData.categories, { index: 2, label: "Extra" }], + ].map((categories) => ({ categories })), +)("rejects changed category positions", ({ categories }) => { + expect(() => + encodeFrimousseData({ ...commonData, categories }, commonData), + ).toThrow("Category indices differ for locale en"); +}); + +it.each( + [ + [], + commonData.emojis.slice(0, 1), + [...commonData.emojis].reverse(), + [...commonData.emojis, commonData.emojis[0]], + [{ ...commonData.emojis[0], emoji: "😃" }, commonData.emojis[1]], + ].map((emojis) => ({ emojis })), +)("rejects changed emoji identities or positions", ({ emojis }) => { + expect(() => + encodeFrimousseData({ ...commonData, emojis }, commonData), + ).toThrow("Emoji order differs for locale en"); +}); + +it("rejects separators in labels instead of corrupting positional data", () => { + const emojis = [ + { ...commonData.emojis[0], label: "bad\nlabel" }, + commonData.emojis[1], + ]; + expect(() => + encodeFrimousseData({ ...commonData, emojis }, commonData), + ).toThrow("Cannot encode label"); +}); diff --git a/packages/core/scripts/emoji-data/generate.mjs b/packages/core/scripts/emoji-data/generate.mjs index 759bc565ee..901e8f2277 100644 --- a/packages/core/scripts/emoji-data/generate.mjs +++ b/packages/core/scripts/emoji-data/generate.mjs @@ -13,6 +13,12 @@ import { writeFileSync, readFileSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; +import { + buildFrimousseData, + encodeFrimousseCommonData, + encodeFrimousseData, + localizeEmoji, +} from "./format.mjs"; const require = createRequire(import.meta.url); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -27,18 +33,6 @@ const CLDR_ANNOTATIONS_DERIVED_DIR = resolve( "annotationsDerived", ); -// Skin tone key mapping: emojibase tone number → Frimousse skin tone key -const TONE_MAP = { - 1: "light", - 2: "medium-light", - 3: "medium", - 4: "medium-dark", - 5: "dark", -}; - -// Emojibase groups to exclude -const EXCLUDED_GROUPS = new Set([2]); // component - // Locales with full emojibase data const EMOJIBASE_LOCALES = [ "de", @@ -59,9 +53,6 @@ const EMOJIBASE_LOCALES = [ "zh-hant", ]; -// Extra locales without emojibase data — use English base + existing search overlays -const EXTRA_LOCALES = ["ar", "fa", "he", "hr", "is", "sk", "tr", "uz"]; - function loadCldrAnnotations(locale) { const annotationsPath = resolve( CLDR_ANNOTATIONS_DIR, @@ -253,232 +244,19 @@ const EXTRA_LOCALE_METADATA = { }, }; -// Correct labels in the upstream message files that are untranslated or wrong. -const EMOJIBASE_LABEL_OVERRIDES = { - de: { skinTones: { "medium-dark": "Mitteldunkler Hautton" } }, - ja: { categories: { 3: "動物と自然", 6: "アクティビティ" } }, - nl: { categories: { 1: "Mensen en lichaam", 9: "Vlaggen" } }, -}; - -function capitalize(str, locale) { - return str.charAt(0).toLocaleUpperCase(locale) + str.slice(1); -} - -function toVarName(locale) { - return locale.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); -} - -function decodeHtmlEntities(str) { - return str.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => { - switch (entity) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case """: - return '"'; - case "'": - return "'"; - default: - throw new Error(`Unexpected HTML entity: ${entity}`); - } - }); -} - -function getSkinToneVariations(emoji) { - if (!emoji.skins) { - return undefined; - } - const variations = {}; - for (const skin of emoji.skins) { - if (typeof skin.tone === "number" && TONE_MAP[skin.tone]) { - variations[TONE_MAP[skin.tone]] = skin.emoji; - } - } - return Object.keys(variations).length === 5 ? variations : undefined; -} - -function normalizeSearchText(value) { - return value.replace(/\s+/g, " ").trim(); -} - -function buildFrimousseData(emojis, messages, locale) { - const labelOverrides = EMOJIBASE_LABEL_OVERRIDES[locale]; - const countryFlagSubgroups = new Set( - messages.subgroups - .filter( - (subgroup) => - subgroup.key === "country-flag" || - subgroup.key === "subdivision-flag", - ) - .map((subgroup) => subgroup.order), - ); - - const filteredGroups = messages.groups.filter((g) => g.key !== "component"); - const filteredEmojis = emojis.filter( - (e) => "group" in e && !EXCLUDED_GROUPS.has(e.group), - ); - - const categories = filteredGroups.map((g) => ({ - index: g.order, - label: - labelOverrides?.categories?.[g.order] ?? - capitalize(decodeHtmlEntities(g.message), locale), - })); - - const skinTones = {}; - for (const key of Object.values(TONE_MAP)) { - const found = messages.skinTones.find((skinTone) => skinTone.key === key); - if (found) { - skinTones[key] = - labelOverrides?.skinTones?.[key] ?? capitalize(found.message, locale); - } - } - - const formattedEmojis = filteredEmojis.map((emoji) => { - const entry = { - emoji: emoji.emoji, - category: emoji.group, - version: emoji.version, - label: capitalize(normalizeSearchText(emoji.label), locale), - tags: (emoji.tags ?? []).map(normalizeSearchText), - }; - if (countryFlagSubgroups.has(emoji.subgroup)) { - entry.countryFlag = true; - } - const skins = getSkinToneVariations(emoji); - if (skins) { - entry.skins = skins; - } - return entry; - }); - - return { - locale, - emojis: formattedEmojis, - categories, - skinTones, - }; -} - -const FIELD_SEPARATOR = "\t"; -const LIST_SEPARATOR = "|"; - -function assertEncodable(value, context) { - if ( - value.includes(FIELD_SEPARATOR) || - value.includes(LIST_SEPARATOR) || - value.includes("\n") || - value.includes("\r") - ) { - throw new Error(`Cannot encode ${context}: ${JSON.stringify(value)}`); - } -} - -function encodeList(values, context) { - return values - .map((value) => { - value = normalizeSearchText(value); - assertEncodable(value, context); - return value; - }) - .join(LIST_SEPARATOR); -} - -function compactSearchTags(label, tags) { - const normalizedLabel = normalizeSearchText(label).toLocaleLowerCase(); - const seen = new Set(); - - return tags.filter((tag) => { - const normalizedTag = normalizeSearchText(tag).toLocaleLowerCase(); - if ( - !normalizedTag || - seen.has(normalizedTag) || - normalizedLabel.includes(normalizedTag) - ) { - return false; - } - seen.add(normalizedTag); - return true; - }); -} - function writeFrimousseCommonData(frimousseDir, data) { - const categoryIndices = data.categories.map(({ index }) => index).join(","); - const emojis = data.emojis - .map((emoji) => { - assertEncodable(emoji.emoji, `emoji ${emoji.emoji}`); - // Store one template instead of all five nearly identical variants. - // Verify every upstream variation so a future exception fails generation - // rather than silently changing the emoji rendered by the picker. - const skins = emoji.skins?.light.replaceAll("🏻", "|") ?? ""; - if (emoji.skins) { - Object.values(emoji.skins).forEach((skin, index) => { - if ( - skins.replaceAll("|", String.fromCodePoint(0x1f3fb + index)) !== - skin - ) { - throw new Error(`Cannot encode skin tones for ${emoji.emoji}`); - } - }); - } - return [ - emoji.emoji, - emoji.category, - emoji.version, - emoji.countryFlag ? "1" : "", - skins, - ].join(FIELD_SEPARATOR); - }) - .join("\n"); - + const { categoryIndices, emojis } = encodeFrimousseCommonData(data); writeFileSync( resolve(frimousseDir, "common.ts"), `// THIS FILE IS AUTO-GENERATED. DO NOT EDIT DIRECTLY.\n// Regenerate with: pnpm --filter @blocknote/core generate-emoji-data\n\nexport const frimousseCategoryIndices: string = ${JSON.stringify(categoryIndices)};\nexport const frimousseCommonData: string = ${JSON.stringify(emojis)};\n`, ); } -function writeFrimousseData( - frimousseDir, - locale, - data, - expectedCategoryIndices, - expectedEmojis, -) { - const categoryIndices = data.categories.map(({ index }) => index).join(","); - if (categoryIndices !== expectedCategoryIndices) { - throw new Error(`Category indices differ for locale ${locale}`); - } - if ( - data.emojis.length !== expectedEmojis.length || - data.emojis.some( - (emoji, index) => emoji.emoji !== expectedEmojis[index].emoji, - ) - ) { - throw new Error(`Emoji order differs for locale ${locale}`); - } - const categoryLabels = encodeList( - data.categories.map(({ label }) => label), - `category labels for ${locale}`, - ); - const skinTones = encodeList( - Object.values(data.skinTones), - `skin tones for ${locale}`, - ); - const emojis = data.emojis - .map((emoji) => { - assertEncodable(emoji.label, `label for ${emoji.emoji} in ${locale}`); - const tags = encodeList( - compactSearchTags(emoji.label, emoji.tags), - `tags for ${emoji.emoji} in ${locale}`, - ); - return tags ? `${emoji.label}${FIELD_SEPARATOR}${tags}` : emoji.label; - }) - .join("\n"); - const encoded = [locale, categoryLabels, skinTones, emojis].join("\n"); - const varName = toVarName(locale) + "FrimousseData"; +function writeFrimousseData(frimousseDir, data, commonData) { + const { locale } = data; + const encoded = encodeFrimousseData(data, commonData); + const varName = + locale.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + "FrimousseData"; // A type annotation prevents declaration emit from copying the entire corpus // into a string literal type in each .d.ts file. writeFileSync( @@ -497,32 +275,20 @@ function generateFrimousseData() { const enEmojis = require("emojibase-data/en/data.json"); const enMessages = require("emojibase-data/en/messages.json"); const commonData = buildFrimousseData(enEmojis, enMessages, "en"); - const commonCategoryIndices = commonData.categories - .map(({ index }) => index) - .join(","); writeFrimousseCommonData(frimousseDir, commonData); - const englishEmojisByEmoji = new Map( - enEmojis.map((emoji) => [emoji.emoji, emoji]), - ); - // Generate for standard emojibase locales for (const locale of EMOJIBASE_LOCALES) { const emojis = require(`emojibase-data/${locale}/data.json`); const messages = require(`emojibase-data/${locale}/messages.json`); const data = buildFrimousseData(emojis, messages, locale); - writeFrimousseData( - frimousseDir, - locale, - data, - commonCategoryIndices, - commonData.emojis, - ); + writeFrimousseData(frimousseDir, data, commonData); } // Generate for extra locales using English base + search overlays - for (const locale of EXTRA_LOCALES) { + const extraLocales = Object.entries(EXTRA_LOCALE_METADATA); + for (const [locale, metadata] of extraLocales) { const getCldrAnnotation = loadCldrAnnotations(locale); const overlay = JSON.parse( readFileSync(resolve(SEARCH_OVERLAYS_DIR, `${locale}.json`), "utf-8"), @@ -530,56 +296,25 @@ function generateFrimousseData() { // Start from English base data const data = buildFrimousseData(enEmojis, enMessages, locale); - const metadata = EXTRA_LOCALE_METADATA[locale]; data.categories = metadata.categories; data.skinTones = metadata.skinTones; - // Apply localized labels/tags from search overlay - for (const emoji of data.emojis) { - const enEmoji = englishEmojisByEmoji.get(emoji.emoji); - if (!enEmoji) { - continue; - } - const loc = overlay[emoji.emoji]; - if (loc) { - if (loc.name) { - emoji.label = capitalize(loc.name, locale); - } - if (loc.keywords.length > 0) { - emoji.tags = [ - ...new Set([...loc.keywords, ...emoji.tags, ...enEmoji.tags]), - ]; - } - } - - const annotation = getCldrAnnotation(emoji.emoji); - if (annotation?.tts?.[0]) { - emoji.label = capitalize(annotation.tts[0], locale); - } - if (annotation?.default) { - emoji.tags = [ - ...new Set([...annotation.default, ...emoji.tags, ...enEmoji.tags]), - ]; - } - } - - writeFrimousseData( - frimousseDir, - locale, - data, - commonCategoryIndices, - commonData.emojis, + data.emojis = data.emojis.map((emoji) => + localizeEmoji( + emoji, + overlay[emoji.emoji], + getCldrAnnotation(emoji.emoji), + locale, + ), ); + + writeFrimousseData(frimousseDir, data, commonData); } console.log( - ` Generated ${EMOJIBASE_LOCALES.length + EXTRA_LOCALES.length} locale files + shared data`, + ` Generated ${EMOJIBASE_LOCALES.length + extraLocales.length} locale files + shared data`, ); } -function main() { - generateFrimousseData(); - console.log("Done!"); -} - -main(); +generateFrimousseData(); +console.log("Done!"); diff --git a/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts b/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts index f1e37eb25f..47ca609b95 100644 --- a/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts +++ b/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts @@ -37,16 +37,20 @@ const LOCALE_ALIASES: Record = { const cache = new Map>(); -const skinToneKeys = [ - "light", - "medium-light", - "medium", - "medium-dark", - "dark", -] as const; +function mapSkinTones( + valueAt: (index: number) => string, +): FrimousseEmojiData["skinTones"] { + return { + light: valueAt(0), + "medium-light": valueAt(1), + medium: valueAt(2), + "medium-dark": valueAt(3), + dark: valueAt(4), + }; +} function decodeFrimousseData(encoded: string): FrimousseEmojiData { - const [locale, categoryLabels, skinToneLabels, ...localizedEmojis] = + const [locale, categoryLabels, encodedSkinToneLabels, ...localizedEmojis] = encoded.split("\n"); const categoryIndices = frimousseCategoryIndices.split(",").map(Number); const commonEmojis = frimousseCommonData.split("\n"); @@ -55,9 +59,7 @@ function decodeFrimousseData(encoded: string): FrimousseEmojiData { throw new Error(`Invalid Frimousse data for locale ${locale}`); } - const skinTones = Object.fromEntries( - skinToneKeys.map((key, index) => [key, skinToneLabels.split("|")[index]]), - ) as FrimousseEmojiData["skinTones"]; + const skinToneLabels = encodedSkinToneLabels.split("|"); return { locale, @@ -65,21 +67,18 @@ function decodeFrimousseData(encoded: string): FrimousseEmojiData { index: categoryIndices[index], label, })), - skinTones, + skinTones: mapSkinTones((index) => skinToneLabels[index]), emojis: commonEmojis.map((common, index) => { const [emoji, category, version, countryFlag, encodedSkins] = common.split("\t"); const [label, encodedTags] = localizedEmojis[index].split("\t"); const skins = encodedSkins - ? (Object.fromEntries( - skinToneKeys.map((key, skinIndex) => [ - key, - encodedSkins.replaceAll( - "|", - String.fromCodePoint(0x1f3fb + skinIndex), - ), - ]), - ) as NonNullable) + ? mapSkinTones((skinIndex) => + encodedSkins.replaceAll( + "|", + String.fromCodePoint(0x1f3fb + skinIndex), + ), + ) : undefined; return { diff --git a/packages/core/src/emoji-data/frimousse/types.ts b/packages/core/src/emoji-data/frimousse/types.ts index 4a95be7f9a..e69df1ab5b 100644 --- a/packages/core/src/emoji-data/frimousse/types.ts +++ b/packages/core/src/emoji-data/frimousse/types.ts @@ -1,3 +1,8 @@ +type SkinTones = Record< + "light" | "medium-light" | "medium" | "medium-dark" | "dark", + string +>; + export interface FrimousseEmoji { emoji: string; category: number; @@ -5,10 +10,7 @@ export interface FrimousseEmoji { label: string; tags: string[]; countryFlag?: true; - skins?: Record< - "light" | "medium-light" | "medium" | "medium-dark" | "dark", - string - >; + skins?: SkinTones; } export interface FrimousseCategory { @@ -20,8 +22,5 @@ export interface FrimousseEmojiData { locale: string; emojis: FrimousseEmoji[]; categories: FrimousseCategory[]; - skinTones: Record< - "light" | "medium-light" | "medium" | "medium-dark" | "dark", - string - >; + skinTones: SkinTones; } diff --git a/packages/core/src/emoji-data/searchEmojis.test.ts b/packages/core/src/emoji-data/searchEmojis.test.ts new file mode 100644 index 0000000000..70285cfee7 --- /dev/null +++ b/packages/core/src/emoji-data/searchEmojis.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { FrimousseEmoji } from "./frimousse/types.js"; +import { searchEmojis } from "./searchEmojis.js"; + +describe("accent-insensitive emoji search", () => { + it("keeps stable ties, duplicate references, and tag-weighted ranking without mutating input", () => { + const first: FrimousseEmoji = { + emoji: "😀", + category: 0, + version: 1, + label: "Match", + tags: [], + }; + const tie = { ...first, emoji: "😃" }; + const strongest = { ...first, emoji: "😄", tags: ["match", "matching"] }; + const tagsOnly = { + ...first, + emoji: "😁", + label: "Other", + tags: ["match", "matching", "matcher"], + }; + const noMatch = { ...first, emoji: "😆", label: "Unrelated" }; + const emojis = [first, tagsOnly, tie, strongest, noMatch, first]; + for (const emoji of emojis) { + Object.freeze(emoji.tags); + Object.freeze(emoji); + } + Object.freeze(emojis); + + const results = searchEmojis(emojis, "match"); + const expected = [strongest, first, tie, first, tagsOnly]; + expect(results).toHaveLength(expected.length); + results.forEach((emoji, index) => expect(emoji).toBe(expected[index])); + expect(searchEmojis(emojis, " \t ")).toBe(emojis); + expect(searchEmojis(emojis, "absent")).toEqual([]); + }); + + it.each([ + ["café", "cafe"], + ["corazón", "CORAZON"], + ["cafe", "café"], + ["café", "cafe\u0301"], + ["cafe\u0301", "café"], + ["gülümseme", "gulumseme"], + ])("matches %s using %s in labels and tags", (text, query) => { + const labelMatch: FrimousseEmoji = { + emoji: "😀", + category: 1, + version: 1, + label: text, + tags: [], + }; + const tagMatch: FrimousseEmoji = { + ...labelMatch, + emoji: "😃", + label: "unrelated", + tags: [text], + }; + + // Label matches retain their higher rank, and display text is unchanged. + expect(searchEmojis([tagMatch, labelMatch], query)).toEqual([ + labelMatch, + tagMatch, + ]); + expect(labelMatch.label).toBe(text); + expect(tagMatch.tags).toEqual([text]); + }); +}); diff --git a/packages/core/src/emoji-data/searchEmojis.ts b/packages/core/src/emoji-data/searchEmojis.ts index b05104b1ca..6a06540be4 100644 --- a/packages/core/src/emoji-data/searchEmojis.ts +++ b/packages/core/src/emoji-data/searchEmojis.ts @@ -1,11 +1,17 @@ import type { FrimousseEmoji } from "./frimousse/types.js"; function foldSearchText(value: string, locale: string): string { - return value.toLocaleLowerCase(locale).replace(/\u0307/g, ""); + return value + .toLocaleLowerCase(locale) + .normalize("NFD") + .replace(/\p{Diacritic}/gu, ""); } /** - * Pass the data's locale for locale-aware casing. Defaults to English. + * Matches without accents or diacritics. Pass the data's locale for + * locale-aware casing. Defaults to English. + * Ranking counts matching stored tags; it does not reconstruct tags removed + * during data generation. */ export function searchEmojis( emojis: FrimousseEmoji[], @@ -18,28 +24,20 @@ export function searchEmojis( return emojis; } - const scores = new WeakMap(); - - return emojis - .filter((emoji) => { - let score = 0; - - if (foldSearchText(emoji.label, casingLocale).includes(searchText)) { - score += 10; - } - - for (const tag of emoji.tags) { - if (foldSearchText(tag, casingLocale).includes(searchText)) { - score += 1; - } - } - - if (score > 0) { - scores.set(emoji, score); - return true; + const matches: { emoji: FrimousseEmoji; score: number }[] = []; + for (const emoji of emojis) { + let score = foldSearchText(emoji.label, casingLocale).includes(searchText) + ? 10 + : 0; + for (const tag of emoji.tags) { + if (foldSearchText(tag, casingLocale).includes(searchText)) { + score += 1; } + } + if (score > 0) { + matches.push({ emoji, score }); + } + } - return false; - }) - .sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0)); + return matches.sort((a, b) => b.score - a.score).map(({ emoji }) => emoji); } diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.test.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.test.ts new file mode 100644 index 0000000000..86b2b40aac --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import { expect, it, vi } from "vite-plus/test"; + +import type { FrimousseEmojiData } from "../../emoji-data/frimousse/types.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { loadFrimousseData } from "../../emoji-data/index.js"; +import { getDefaultEmojiPickerItems } from "./getDefaultEmojiPickerItems.js"; + +vi.mock("../../emoji-data/index.js", async () => ({ + loadFrimousseData: vi.fn(), + ...(await import("../../emoji-data/searchEmojis.js")), +})); + +it("retries emoji data after a failed load for the same editor locale", async () => { + const data: FrimousseEmojiData = { + locale: "en", + emojis: [ + { + emoji: "😀", + category: 0, + version: 1, + label: "Grinning face", + tags: [], + }, + ], + categories: [], + skinTones: { + light: "Light", + "medium-light": "Medium light", + medium: "Medium", + "medium-dark": "Medium dark", + dark: "Dark", + }, + }; + const failure = new Error("Chunk load failed"); + vi.mocked(loadFrimousseData) + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(data); + const editor = BlockNoteEditor.create(); + try { + await expect(getDefaultEmojiPickerItems(editor, "")).rejects.toBe(failure); + await expect(getDefaultEmojiPickerItems(editor, "")).resolves.toEqual([ + { id: "😀", onItemClick: expect.any(Function) }, + ]); + expect(loadFrimousseData).toHaveBeenCalledTimes(2); + } finally { + editor._tiptapEditor.destroy(); + } +}); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts index 85ab84ded2..504c9741ec 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts @@ -1,5 +1,3 @@ -import type { Emoji, EmojiMartData } from "@emoji-mart/data"; - import { defaultInlineContentSchema } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { @@ -9,43 +7,6 @@ import { } from "../../schema/index.js"; import { DefaultGridSuggestionItem } from "./DefaultGridSuggestionItem.js"; -// Temporary fix for https://github.com/missive/emoji-mart/pull/929 -let emojiLoadingPromise: - | Promise<{ - emojiMart: typeof import("emoji-mart"); - emojiData: EmojiMartData; - }> - | undefined; - -async function loadEmojiMart() { - if (emojiLoadingPromise) { - return emojiLoadingPromise; - } - - emojiLoadingPromise = (async () => { - // load dynamically because emoji-mart doesn't specify type: module and breaks in nodejs - const [emojiMartModule, emojiDataModule] = await Promise.all([ - import("emoji-mart"), - // use a dynamic import to encourage bundle-splitting - // and a smaller initial client bundle size - import("@emoji-mart/data"), - ]); - - const emojiMart = - "default" in emojiMartModule ? emojiMartModule.default : emojiMartModule; - const emojiData = - "default" in emojiDataModule - ? (emojiDataModule.default as EmojiMartData) - : (emojiDataModule as EmojiMartData); - - await emojiMart.init({ data: emojiData }); - - return { emojiMart, emojiData }; - })(); - - return emojiLoadingPromise; -} - export async function getDefaultEmojiPickerItems< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -62,15 +23,13 @@ export async function getDefaultEmojiPickerItems< return []; } - const { emojiData, emojiMart } = await loadEmojiMart(); - - const emojisToShow = - query.trim() === "" - ? Object.values(emojiData.emojis) - : ((await emojiMart!.SearchIndex.search(query)) as Emoji[]); + const { loadFrimousseData, searchEmojis } = + await import("../../emoji-data/index.js"); + // The loader owns canonical-locale caching and evicts rejected loads for retry. + const data = await loadFrimousseData(editor.dictionary.locale ?? "en"); - return emojisToShow.map((emoji) => ({ - id: emoji.skins[0].native, - onItemClick: () => editor.insertInlineContent(emoji.skins[0].native + " "), + return searchEmojis(data.emojis, query, data.locale).map((emoji) => ({ + id: emoji.emoji, + onItemClick: () => editor.insertInlineContent(emoji.emoji + " "), })); } diff --git a/packages/react/package.json b/packages/react/package.json index 7e11ea2d10..7428dbe58a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -79,13 +79,12 @@ }, "dependencies": { "@blocknote/core": "workspace:^", - "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.18", "@tiptap/core": "^3.31.3", "@tiptap/pm": "^3.31.3", "@tiptap/react": "^3.31.3", - "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", + "frimousse": "^0.4.0", "use-sync-external-store": "1.6.0" }, "devDependencies": { diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 527cb739b4..0e80820d43 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -79,9 +79,7 @@ const CommentEditorActionsComponent = memo( ))} {canAddReaction && ( - onReactionSelect(emoji.native) - } + onEmojiSelect={onReactionSelect} onOpenChange={onEmojiPickerOpenChange} > {canAddReaction && ( - onReactionSelect(emoji.native) - } + onEmojiSelect={onReactionSelect} onOpenChange={setEmojiPickerOpen} > - | undefined; - -async function loadEmojiMart() { - if (emojiLoadingPromise) { - return emojiLoadingPromise; - } - - emojiLoadingPromise = (async () => { - // load dynamically because emoji-mart doesn't specify type: module and breaks in nodejs - const [emojiMartModule, emojiDataModule] = await Promise.all([ - import("emoji-mart"), - // use a dynamic import to encourage bundle-splitting - // and a smaller initial client bundle size - import("@emoji-mart/data"), - ]); - - const emojiMart = - "default" in emojiMartModule ? emojiMartModule.default : emojiMartModule; - const emojiData = - "default" in emojiDataModule - ? (emojiDataModule.default as EmojiMartData) - : (emojiDataModule as EmojiMartData); - - await emojiMart.init({ data: emojiData }); - - return { emojiMart, emojiData }; - })(); - - return emojiLoadingPromise; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default function EmojiPicker(props: any) { - const ref = useRef(null); - const instance = useRef(null) as any; - - if (instance.current) { - instance.current.update(props); - } - - useEffect(() => { - void (async () => { - const { emojiMart } = await loadEmojiMart(); - - instance.current = new emojiMart.Picker({ ...props, ref }); - })(); - - return () => { - instance.current = null; - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - return React.createElement("div", { ref }); -} diff --git a/packages/react/src/components/Comments/EmojiPicker.tsx b/packages/react/src/components/Comments/EmojiPicker.tsx index 06c20bc4e3..58a81cac62 100644 --- a/packages/react/src/components/Comments/EmojiPicker.tsx +++ b/packages/react/src/components/Comments/EmojiPicker.tsx @@ -1,33 +1,39 @@ import { ReactNode, useState } from "react"; -import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { usePortalElement } from "../../editor/PortalElementOverride.js"; -import Picker from "./EmojiMartPicker.js"; +import { useDictionary } from "../../i18n/dictionary.js"; +import FrimoussePicker from "./FrimoussePicker.js"; export const EmojiPicker = (props: { - onEmojiSelect: (emoji: { native: string }) => void; + onEmojiSelect: (emoji: string) => void; onOpenChange?: (open: boolean) => void; children: ReactNode; }) => { const [open, setOpen] = useState(false); const Components = useComponentsContext()!; - const blockNoteContext = useBlockNoteContext()!; const portalElement = usePortalElement(); + const dict = useDictionary(); + const locale = dict.locale ?? "en"; + + function handleOpenChange(nextOpen: boolean) { + setOpen(nextOpen); + props.onOpenChange?.(nextOpen); + } return ( - +
{ - // Needed as the Picker component's onClickOutside handler - // fires immediately after otherwise, preventing the popover - // from opening. event.preventDefault(); event.stopPropagation(); - setOpen(!open); - props.onOpenChange?.(!open); + handleOpenChange(!open); }} style={{ display: "flex", @@ -42,19 +48,17 @@ export const EmojiPicker = (props: { className={"bn-emoji-picker-popover"} variant={"panel-popover"} > - { - setOpen(false); - props.onOpenChange?.(false); - }} - onEmojiSelect={(emoji: { native: string }) => { - props.onEmojiSelect(emoji); - setOpen(false); - props.onOpenChange?.(false); - }} - theme={blockNoteContext?.colorSchemePreference} - /> + {open && ( + { + props.onEmojiSelect(emoji); + handleOpenChange(false); + }} + onEscape={() => handleOpenChange(false)} + locale={locale} + dictionary={dict.emoji_picker} + /> + )} ); diff --git a/packages/react/src/components/Comments/FrimoussePicker.browser.test.tsx b/packages/react/src/components/Comments/FrimoussePicker.browser.test.tsx new file mode 100644 index 0000000000..61f1607319 --- /dev/null +++ b/packages/react/src/components/Comments/FrimoussePicker.browser.test.tsx @@ -0,0 +1,94 @@ +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import "../../editor/styles.css"; +import FrimoussePicker from "./FrimoussePicker.js"; + +let host: HTMLFormElement; +let root: Root; + +afterEach(() => { + flushSync(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +function renderPicker( + customTheme = false, + onEmojiSelect: (emoji: string) => void = () => {}, +) { + host = document.createElement("form"); + host.className = "bn-root"; + if (customTheme) { + host.style.setProperty("--bn-colors-menu-background", "#182030"); + host.style.setProperty("--bn-colors-menu-text", "#ffffff"); + host.style.setProperty("--bn-colors-editor-text", "#000000"); + host.style.setProperty("--bn-colors-hovered-background", "#273244"); + host.style.setProperty("--bn-colors-hovered-text", "#eeeeee"); + } + host.style.color = "var(--bn-colors-menu-text)"; + document.body.append(host); + root = createRoot(host); + flushSync(() => { + root.render(); + }); +} + +function visibleElement(selector: string): HTMLElement { + const element = Array.from(host.querySelectorAll(selector)).find( + (element) => !element.closest("[aria-hidden]"), + ); + expect(element).toBeDefined(); + return element!; +} + +it("loads bundled emojis without fetching and selects a reaction without submitting its form", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const onEmojiSelect = vi.fn(); + renderPicker(false, onEmojiSelect); + const onSubmit = vi.fn((event: Event) => event.preventDefault()); + host.addEventListener("submit", onSubmit); + + const emoji = await vi.waitFor(() => visibleElement("[frimousse-emoji]")); + emoji.click(); + + expect(onEmojiSelect).toHaveBeenCalledWith(emoji.textContent); + expect(onSubmit).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); +}); + +it("does not paint padded category headers over the first emoji row without a CSS reset", async () => { + renderPicker(); + + await vi.waitFor(() => { + const header = visibleElement("[frimousse-category-header]"); + const row = visibleElement("[frimousse-row]"); + expect(row.querySelector("[frimousse-emoji]")).not.toBeNull(); + expect(header.getBoundingClientRect().height).toBeGreaterThan(0); + expect(header.getBoundingClientRect().bottom).toBeLessThanOrEqual( + row.getBoundingClientRect().top + 0.5, + ); + }); +}); + +it.each([false, true])( + "uses readable menu foregrounds with customTheme=%s", + async (customTheme) => { + renderPicker(customTheme); + + await vi.waitFor(() => { + const menuText = getComputedStyle(host).color; + expect( + getComputedStyle(visibleElement("[frimousse-category-header]")).color, + ).toBe(menuText); + expect(getComputedStyle(visibleElement("[frimousse-search]")).color).toBe( + customTheme ? "rgb(238, 238, 238)" : menuText, + ); + expect( + getComputedStyle(visibleElement(".bn-frimousse-active-emoji-label")) + .color, + ).toBe(menuText); + }); + }, +); diff --git a/packages/react/src/components/Comments/FrimoussePicker.tsx b/packages/react/src/components/Comments/FrimoussePicker.tsx new file mode 100644 index 0000000000..8fe5aec772 --- /dev/null +++ b/packages/react/src/components/Comments/FrimoussePicker.tsx @@ -0,0 +1,81 @@ +import type { Dictionary } from "@blocknote/core"; +import { EmojiPicker } from "frimousse"; +import { resolveBlockNoteEmojiData } from "../../util/emojiPickerData.js"; + +export function ActiveEmojiDisplay({ + emoji, + label, + placeholder, +}: { + emoji?: string; + label?: string; + placeholder: string; +}) { + return ( +
+ {emoji && ( + {emoji} + )} + + {emoji ? label : placeholder} + +
+ ); +} + +type Props = { + onEmojiSelect: (emoji: string) => void; + onEscape?: () => void; + locale: string; + dictionary?: Dictionary["emoji_picker"]; +}; + +export default function FrimoussePicker({ + onEmojiSelect, + onEscape, + locale, + dictionary, +}: Props) { + const placeholder = `${dictionary?.search ?? "Search"}…`; + + return ( + onEmojiSelect(emoji)} + onKeyDownCapture={(event) => { + if (event.key === "Escape" && onEscape) { + event.stopPropagation(); + onEscape(); + } + }} + > + + + + {dictionary?.loading ?? "Loading…"} + + + {dictionary?.search_no_results ?? "No emoji found."} + + + +
+ + {({ emoji }) => ( + + )} + + +
+
+ ); +} diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/EmojiPickerController.tsx b/packages/react/src/components/SuggestionMenu/EmojiPicker/EmojiPickerController.tsx new file mode 100644 index 0000000000..0d86c854aa --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/EmojiPickerController.tsx @@ -0,0 +1,48 @@ +import { defaultInlineContentSchema } from "@blocknote/core"; +import { type SuggestionMenuOptions } from "@blocknote/core/extensions"; +import { autoPlacement, offset, shift, size } from "@floating-ui/react"; + +import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; +import { SuggestionMenuPopover } from "../SuggestionMenuPopover.js"; +import { InlineEmojiPicker } from "./InlineEmojiPicker.js"; + +const middleware = [ + offset(10), + autoPlacement({ + allowedPlacements: ["bottom-start", "top-start"], + padding: 10, + }), + shift(), + size({ + apply({ elements, availableHeight }) { + elements.floating.style.maxHeight = `${Math.max(0, availableHeight)}px`; + }, + padding: 10, + }), +]; + +export function EmojiPickerController(props: { + triggerCharacter: string; + shouldOpen?: SuggestionMenuOptions["shouldOpen"]; + minQueryLength?: number; + floatingUIOptions?: FloatingUIOptions; + portalElement?: HTMLElement | null; +}) { + const editor = useBlockNoteEditor(); + const hasTextInlineContent = + "text" in editor.schema.inlineContentSchema && + editor.schema.inlineContentSchema.text === defaultInlineContentSchema.text; + + return ( + + {(menuProps) => } + + ); +} diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx b/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx new file mode 100644 index 0000000000..fecc310b52 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx @@ -0,0 +1,365 @@ +import { type FrimousseEmojiData } from "@blocknote/core/emoji-data"; +import { + EmojiPicker, + type EmojiDataResolver, + type EmojiPickerListEmojiProps, + type SkinTone, + useSkinTone, +} from "frimousse"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { useBlockNoteContext } from "../../../editor/BlockNoteContext.js"; +import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; +import { useDictionary } from "../../../i18n/dictionary.js"; +import { resolveBlockNoteEmojiData } from "../../../util/emojiPickerData.js"; +import { ActiveEmojiDisplay } from "../../Comments/FrimoussePicker.js"; +import { getSuggestionMenuItemId } from "../getSuggestionMenuItemId.js"; +import { + EMOJI_PICKER_COLUMNS, + filterEmojiData, + getEmojiPickerRows, + moveEmojiPosition, + type EmojiPosition, +} from "./emojiPickerModel.js"; + +const SelectedEmojiContext = createContext(undefined); + +function InlineEmojiPickerEmoji({ + emoji, + ...props +}: EmojiPickerListEmojiProps) { + const selectedChar = useContext(SelectedEmojiContext); + const selected = emoji.emoji === selectedChar; + + return ( + + ); +} + +const inlineEmojiPickerComponents = { + Emoji: InlineEmojiPickerEmoji, +}; + +type ResolvedEmojiData = { + locale: string; + data: FrimousseEmojiData; +}; + +function findButtonAtPosition( + root: HTMLElement, + position: EmojiPosition, + emoji: string, +): HTMLButtonElement | null { + const row = root.querySelector( + `[aria-rowindex="${position.row}"]`, + ); + const button = row?.querySelectorAll( + ".bn-frimousse-emoji", + )[position.column]; + return button?.textContent === emoji ? button : null; +} + +function markButtonSelected( + root: HTMLElement, + button: HTMLButtonElement, + selectedIndex: number, +) { + root + .querySelectorAll("[id^='bn-suggestion-menu-item-']") + .forEach((element) => element.removeAttribute("id")); + button.id = getSuggestionMenuItemId(selectedIndex)!; +} + +function scrollViewportTo( + root: HTMLElement, + position: EmojiPosition, + categoryIndex: number, +) { + const viewport = root.querySelector("[frimousse-viewport]"); + if (!viewport) { + return; + } + + const style = getComputedStyle(root); + const rowHeight = + Number.parseFloat(style.getPropertyValue("--frimousse-row-height")) || 32; + const headerHeight = + Number.parseFloat( + style.getPropertyValue("--frimousse-category-header-height"), + ) || 30; + const estimatedY = + position.row * rowHeight + (categoryIndex + 1) * headerHeight; + const viewportHeight = viewport.clientHeight; + + if ( + estimatedY < viewport.scrollTop || + estimatedY + rowHeight > viewport.scrollTop + viewportHeight - headerHeight + ) { + viewport.scrollTo({ + top: Math.max(0, estimatedY - viewportHeight / 3), + behavior: "instant", + }); + } +} + +function InlineEmojiPickerContent(props: { + insertEmoji: (emoji: string) => void; + rootRef: React.RefObject; + data: FrimousseEmojiData | undefined; + onSkinToneChange: (skinTone: SkinTone) => void; +}) { + const { insertEmoji, rootRef, data, onSkinToneChange } = props; + const editorDOMElement = useEditorDOMElement(); + const setContentEditableProps = + useBlockNoteContext()!.setContentEditableProps!; + const dict = useDictionary(); + const [skinTone] = useSkinTone(); + + const rows = useMemo(() => (data ? getEmojiPickerRows(data) : []), [data]); + const [selectedPosition, setSelectedPosition] = useState({ + row: 0, + column: 0, + }); + + useEffect(() => { + onSkinToneChange(skinTone); + }, [onSkinToneChange, skinTone]); + + const selectedRow = rows[selectedPosition.row]; + const selectedEmoji = selectedRow?.emojis[selectedPosition.column]; + const selectedChar = + selectedEmoji && + (skinTone === "none" + ? selectedEmoji.emoji + : (selectedEmoji.skins?.[skinTone] ?? selectedEmoji.emoji)); + const selectedIndex = + rows + .slice(0, selectedPosition.row) + .reduce((count, row) => count + row.emojis.length, 0) + + selectedPosition.column; + + useEffect(() => { + setContentEditableProps((props) => ({ + ...props, + "aria-expanded": true, + "aria-controls": "bn-suggestion-menu", + })); + return () => { + setContentEditableProps((props) => ({ + ...props, + "aria-expanded": false, + "aria-controls": undefined, + })); + }; + }, [setContentEditableProps]); + + // Frimousse publishes its imperative Root ref after child layout effects. + // Wait until passive effects to synchronize the mounted button and observe + // asynchronous loading/virtualization, including on a keyed Root's first render. + useEffect(() => { + function setActiveDescendant(id: string | undefined) { + setContentEditableProps((props) => + props?.["aria-activedescendant"] === id + ? props + : { ...props, "aria-activedescendant": id }, + ); + } + + const root = rootRef.current; + if (!root || !selectedChar || !selectedRow) { + setActiveDescendant(undefined); + return; + } + // Scroll only once per selection change. Subsequent virtualization updates + // must keep accessibility in sync without undoing manual mouse scrolling. + let scrollPending = true; + const pickerRoot = root; + const selectedCharValue = selectedChar; + const selectedIndexValue = selectedIndex; + + function updateSelectedButton() { + const btn = findButtonAtPosition( + pickerRoot, + selectedPosition, + selectedCharValue, + ); + if (!btn) { + setActiveDescendant(undefined); + return; + } + + if (scrollPending) { + scrollPending = false; + btn.scrollIntoView({ block: "nearest", behavior: "instant" }); + } + markButtonSelected(pickerRoot, btn, selectedIndexValue); + setActiveDescendant(btn.id); + } + + const observer = new MutationObserver(updateSelectedButton); + + observer.observe(root, { + attributeFilter: ["aria-label"], + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + if (!findButtonAtPosition(root, selectedPosition, selectedChar)) { + scrollViewportTo(root, selectedPosition, selectedRow.categoryIndex); + } + updateSelectedButton(); + return () => { + observer.disconnect(); + setActiveDescendant(undefined); + }; + }, [ + rootRef, + selectedChar, + selectedIndex, + selectedPosition, + selectedRow, + setContentEditableProps, + ]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) { + return; + } + + const key = event.key; + if ( + key === "ArrowRight" || + key === "ArrowLeft" || + key === "ArrowDown" || + key === "ArrowUp" + ) { + const nextPosition = moveEmojiPosition(selectedPosition, key, rows); + if (nextPosition === selectedPosition) { + return; + } + event.preventDefault(); + setSelectedPosition(nextPosition); + } else if (event.key === "Enter" && selectedChar) { + event.preventDefault(); + event.stopPropagation(); + insertEmoji(selectedChar); + } + }; + + editorDOMElement?.addEventListener("keydown", handleKeyDown, true); + return () => { + editorDOMElement?.removeEventListener("keydown", handleKeyDown, true); + }; + }, [rows, editorDOMElement, insertEmoji, selectedChar, selectedPosition]); + + const placeholder = `${dict.emoji_picker.search}…`; + + return ( + <> + + + {dict.emoji_picker.loading} + + + {dict.emoji_picker.search_no_results} + + + + + +
+ + +
+ + ); +} + +export function InlineEmojiPicker(props: { + query: string; + closeMenu: () => void; + clearQuery: () => void; +}) { + const editor = useBlockNoteEditor(); + const locale = useDictionary().locale ?? "en"; + const [resolvedData, setResolvedData] = useState(); + const [skinTone, setSkinTone] = useState("none"); + const rootRef = useRef(null); + + // Keep the resolved corpus above the keyed Root so typing only filters it; + // it must not decode data or repeat platform-support detection per keystroke. + const data = useMemo( + () => + resolvedData?.locale === locale + ? filterEmojiData(resolvedData.data, props.query) + : undefined, + [resolvedData, locale, props.query], + ); + const resolveEmojiData = useMemo( + () => async (resolvedLocale, options) => { + if (data) { + return data; + } + const resolved = await resolveBlockNoteEmojiData(resolvedLocale, options); + if (!options.signal?.aborted) { + setResolvedData({ locale: resolvedLocale, data: resolved }); + } + return filterEmojiData(resolved, props.query); + }, + [data, props.query], + ); + + function insertEmoji(emoji: string) { + props.clearQuery(); + props.closeMenu(); + editor.insertInlineContent(emoji + " "); + } + + return ( + insertEmoji(emoji)} + > + + + ); +} diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts new file mode 100644 index 0000000000..692a3dd6cc --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +import type { + FrimousseEmoji, + FrimousseEmojiData, +} from "@blocknote/core/emoji-data"; +import { describe, expect, it } from "vite-plus/test"; + +import { + EMOJI_PICKER_COLUMNS, + filterEmojiData, + getEmojiPickerRows, + moveEmojiPosition, +} from "./emojiPickerModel.js"; + +function emoji(index: number, category = 7): FrimousseEmoji { + return { + emoji: `emoji-${index}`, + category, + version: 1, + label: `Emoji ${index}`, + tags: [], + }; +} + +function data(emojis: FrimousseEmoji[]): FrimousseEmojiData { + return { + locale: "fr", + emojis, + categories: [ + { index: 2, label: "Empty category" }, + { index: 7, label: "First category" }, + { index: 4, label: "Second category" }, + ], + skinTones: { + light: "Light", + "medium-light": "Medium light", + medium: "Medium", + "medium-dark": "Medium dark", + dark: "Dark", + }, + }; +} + +describe("emoji picker model", () => { + it("preserves category and within-category order, skipping empty categories and keeping partial rows", () => { + const first = Array.from({ length: EMOJI_PICKER_COLUMNS + 2 }, (_, index) => + emoji(index), + ); + const second = [emoji(50, 4), emoji(51, 4)]; + const rows = getEmojiPickerRows(data([second[0], ...first, second[1]])); + + expect(rows).toEqual([ + { categoryIndex: 0, emojis: first.slice(0, EMOJI_PICKER_COLUMNS) }, + { categoryIndex: 0, emojis: first.slice(EMOJI_PICKER_COLUMNS) }, + { categoryIndex: 1, emojis: second }, + ]); + expect(getEmojiPickerRows(data([]))).toEqual([]); + }); + + it("filters and ranks without changing display labels or the source corpus", () => { + const tagMatch = { ...emoji(0), tags: ["café"] }; + const labelMatch = { ...emoji(1), label: "Café" }; + const unrelated = emoji(2, 4); + const source = data([tagMatch, unrelated, labelMatch]); + const filtered = filterEmojiData(source, "CAFE"); + + expect(filtered.emojis).toEqual([labelMatch, tagMatch]); + expect(filtered.emojis[0]).toBe(labelMatch); + expect(filtered.emojis[0].label).toBe("Café"); + expect(filtered.categories).toBe(source.categories); + expect(filtered.skinTones).toBe(source.skinTones); + expect(source.emojis).toEqual([tagMatch, unrelated, labelMatch]); + expect(filterEmojiData(source, " ").emojis).toBe(source.emojis); + expect(filterEmojiData(source, "no match").emojis).toEqual([]); + }); + + it("uses the corpus locale for casing", () => { + const dotless = { ...emoji(0), label: "ırmak" }; + const dotted = { ...emoji(1), label: "incir" }; + const source = { ...data([dotless, dotted]), locale: "tr" }; + + expect(filterEmojiData(source, "I").emojis).toEqual([dotless]); + expect(filterEmojiData(source, "i").emojis).toEqual([dotted]); + }); + + const rows = getEmojiPickerRows( + data( + Array.from({ length: EMOJI_PICKER_COLUMNS + 2 }, (_, index) => + emoji(index), + ), + ), + ); + + it.each([ + ["ArrowRight", { row: 0, column: 0 }, { row: 0, column: 1 }], + ["ArrowLeft", { row: 0, column: 1 }, { row: 0, column: 0 }], + ["ArrowRight", { row: 0, column: 8 }, { row: 1, column: 0 }], + ["ArrowLeft", { row: 1, column: 0 }, { row: 0, column: 8 }], + ["ArrowDown", { row: 0, column: 8 }, { row: 1, column: 1 }], + ["ArrowUp", { row: 1, column: 1 }, { row: 0, column: 1 }], + ] as const)("moves %s from %j to %j", (key, from, to) => { + expect(moveEmojiPosition(from, key, rows)).toEqual(to); + }); + + it.each([ + ["ArrowLeft", { row: 0, column: 0 }], + ["ArrowUp", { row: 0, column: 0 }], + ["ArrowRight", { row: 1, column: 1 }], + ["ArrowDown", { row: 1, column: 1 }], + ] as const)( + "preserves position identity at the %s boundary", + (key, position) => { + // The editor keeps the key only when navigation returns the same object. + expect(moveEmojiPosition(position, key, rows)).toBe(position); + }, + ); + + it.each(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] as const)( + "preserves position identity for %s when there is no row", + (key) => { + const position = { row: 0, column: 0 }; + expect(moveEmojiPosition(position, key, [])).toBe(position); + }, + ); +}); diff --git a/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts new file mode 100644 index 0000000000..76869e38ab --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/emojiPickerModel.ts @@ -0,0 +1,104 @@ +import { + searchEmojis, + type FrimousseEmoji, + type FrimousseEmojiData, +} from "@blocknote/core/emoji-data"; + +export const EMOJI_PICKER_COLUMNS = 9; + +export type EmojiPickerRow = { + categoryIndex: number; + emojis: FrimousseEmoji[]; +}; + +export type EmojiPosition = { + row: number; + column: number; +}; + +// Match Frimousse's category order and partial rows after filtering. Keeping this +// model independent of React/DOM lets keyboard navigation use the same results. +export function getEmojiPickerRows(data: FrimousseEmojiData): EmojiPickerRow[] { + const emojisByCategory = new Map(); + + for (const emoji of data.emojis) { + const categoryEmojis = emojisByCategory.get(emoji.category); + if (categoryEmojis) { + categoryEmojis.push(emoji); + } else { + emojisByCategory.set(emoji.category, [emoji]); + } + } + + const rows: EmojiPickerRow[] = []; + let categoryIndex = 0; + for (const category of data.categories) { + const categoryEmojis = emojisByCategory.get(category.index); + if (!categoryEmojis) { + continue; + } + + for ( + let index = 0; + index < categoryEmojis.length; + index += EMOJI_PICKER_COLUMNS + ) { + rows.push({ + categoryIndex, + emojis: categoryEmojis.slice(index, index + EMOJI_PICKER_COLUMNS), + }); + } + categoryIndex += 1; + } + + return rows; +} + +export function filterEmojiData(data: FrimousseEmojiData, query: string) { + return { + ...data, + emojis: searchEmojis(data.emojis, query, data.locale), + }; +} + +export function moveEmojiPosition( + position: EmojiPosition, + key: "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp", + rows: EmojiPickerRow[], +): EmojiPosition { + const row = rows[position.row]; + if (!row) { + return position; + } + + switch (key) { + case "ArrowLeft": { + if (position.column > 0) { + return { ...position, column: position.column - 1 }; + } + const previousRow = rows[position.row - 1]; + return previousRow + ? { row: position.row - 1, column: previousRow.emojis.length - 1 } + : position; + } + case "ArrowRight": { + if (position.column < row.emojis.length - 1) { + return { ...position, column: position.column + 1 }; + } + return rows[position.row + 1] + ? { row: position.row + 1, column: 0 } + : position; + } + case "ArrowUp": + case "ArrowDown": { + const rowIndex = position.row + (key === "ArrowUp" ? -1 : 1); + const nextRow = rows[rowIndex]; + return nextRow + ? { + row: rowIndex, + column: Math.min(position.column, nextRow.emojis.length - 1), + } + : position; + } + } +} diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx index 8111d00e99..e2e0433296 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.tsx @@ -1,4 +1,4 @@ -import { JSX, useMemo } from "react"; +import { useMemo } from "react"; import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -23,24 +23,9 @@ export function GridSuggestionMenu( /> ) : null; - const renderedItems = useMemo(() => { - // let currentGroup: string | undefined = undefined; - const renderedItems = []; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - // if (item.group !== currentGroup) { - // currentGroup = item.group; - // renderedItems.push( - // - // {currentGroup} - // - // ); - // } - - renderedItems.push( + const renderedItems = useMemo( + () => + items.map((item, i) => ( ( isSelected={i === selectedIndex} key={item.id} onClick={() => onItemClick?.(item)} - />, - ); - } - - return renderedItems; - }, [Components, items, onItemClick, selectedIndex]); + /> + )), + [Components, items, onItemClick, selectedIndex], + ); return ( (); - const editorDOMElement = useEditorDOMElement(); const { triggerCharacter, gridSuggestionMenuComponent, columns, - shouldOpen, - minQueryLength, onItemClick, getItems, } = props; @@ -104,79 +88,12 @@ export function GridSuggestionMenuController< ); }, [editor, getItems])!; - const suggestionMenu = useExtension(SuggestionMenu); - - useEffect(() => { - suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); - }, [suggestionMenu, triggerCharacter, shouldOpen]); - - const state = useExtensionState(SuggestionMenu); - const reference = useExtensionState(SuggestionMenu, { - selector: (state) => - ({ - // Use first child as the editor DOM element may itself be scrollable. - // For FloatingUI to auto-update the position during scrolling, the - // `contextElement` must be a descendant of the scroll container. - element: (editorDOMElement?.firstChild || undefined) as - | Element - | undefined, - getBoundingClientRect: () => state?.referencePos || new DOMRect(), - }) satisfies GenericPopoverReference, - }); - - const floatingUIOptions = useMemo( - () => ({ - ...props.floatingUIOptions, - useFloatingOptions: { - open: state?.show && state?.triggerCharacter === triggerCharacter, - onOpenChange: (open) => { - if (!open) { - suggestionMenu.closeMenu(); - } - }, - placement: "bottom-start", - middleware: suggestionMenuMiddleware, - ...props.floatingUIOptions?.useFloatingOptions, - }, - focusManagerProps: { - disabled: true, - ...props.floatingUIOptions?.focusManagerProps, - }, - elementProps: { - // Prevents editor blurring when clicking the scroll bar. - onMouseDownCapture: (event) => event.preventDefault(), - style: { - zIndex: 70, - }, - ...props.floatingUIOptions?.elementProps, - }, - }), - [ - props.floatingUIOptions, - state?.show, - state?.triggerCharacter, - suggestionMenu, - triggerCharacter, - ], - ); - - if ( - !state || - (!state.ignoreQueryLength && - minQueryLength && - (state.query.startsWith(" ") || state.query.length < minQueryLength)) - ) { - return null; - } - return ( - - - {triggerCharacter && ( + + {(menuProps) => + triggerCharacter && ( - )} - - + ) + } + ); } diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 6df8677288..e69918997c 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -1,22 +1,14 @@ import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; import { - SuggestionMenu as SuggestionMenuExtension, SuggestionMenuOptions, filterSuggestionItems, } from "@blocknote/core/extensions"; -import { FC, useEffect, useMemo } from "react"; +import { FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; -import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; -import { suggestionMenuMiddleware } from "./suggestionMenuMiddleware.js"; -import { - GenericPopover, - GenericPopoverReference, -} from "../Popovers/GenericPopover.js"; import { SuggestionMenu } from "./SuggestionMenu.js"; +import { SuggestionMenuPopover } from "./SuggestionMenuPopover.js"; import { SuggestionMenuWrapper } from "./SuggestionMenuWrapper.js"; import { getDefaultReactSlashMenuItems } from "./getDefaultReactSlashMenuItems.js"; import { DefaultReactSuggestionItem, SuggestionMenuProps } from "./types.js"; @@ -66,16 +58,9 @@ export function SuggestionMenuController< InlineContentSchema, StyleSchema >(); - const editorDOMElement = useEditorDOMElement(); - const { - triggerCharacter, - suggestionMenuComponent, - shouldOpen, - minQueryLength, - onItemClick, - getItems, - } = props; + const { triggerCharacter, suggestionMenuComponent, onItemClick, getItems } = + props; const onItemClickOrDefault = useMemo(() => { return ( @@ -97,87 +82,20 @@ export function SuggestionMenuController< ); }, [editor, getItems])!; - const suggestionMenu = useExtension(SuggestionMenuExtension); - - useEffect(() => { - suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); - }, [suggestionMenu, triggerCharacter, shouldOpen]); - - const state = useExtensionState(SuggestionMenuExtension); - const reference = useExtensionState(SuggestionMenuExtension, { - selector: (state) => - ({ - // Use first child as the editor DOM element may itself be scrollable. - // For FloatingUI to auto-update the position during scrolling, the - // `contextElement` must be a descendant of the scroll container. - element: (editorDOMElement?.firstChild || undefined) as - | Element - | undefined, - getBoundingClientRect: () => state?.referencePos || new DOMRect(), - }) satisfies GenericPopoverReference, - }); - - const floatingUIOptions = useMemo( - () => ({ - ...props.floatingUIOptions, - useFloatingOptions: { - open: state?.show && state?.triggerCharacter === triggerCharacter, - onOpenChange: (open) => { - if (!open) { - suggestionMenu.closeMenu(); - } - }, - placement: "bottom-start", - middleware: suggestionMenuMiddleware, - ...props.floatingUIOptions?.useFloatingOptions, - }, - focusManagerProps: { - disabled: true, - ...props.floatingUIOptions?.focusManagerProps, - }, - elementProps: { - // Prevents editor blurring when clicking the scroll bar. - onMouseDownCapture: (event) => event.preventDefault(), - style: { - zIndex: 80, - }, - ...props.floatingUIOptions?.elementProps, - }, - }), - [ - props.floatingUIOptions, - state?.show, - state?.triggerCharacter, - suggestionMenu, - triggerCharacter, - ], - ); - - if ( - !state || - (!state.ignoreQueryLength && - minQueryLength && - (state.query.startsWith(" ") || state.query.length < minQueryLength)) - ) { - return null; - } - return ( - - - {triggerCharacter && ( + + {(menuProps) => + triggerCharacter && ( > } onItemClick={onItemClickOrDefault} /> - )} - - + ) + } + ); } diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuPopover.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuPopover.tsx new file mode 100644 index 0000000000..f359ad37e6 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuPopover.tsx @@ -0,0 +1,117 @@ +import { + SuggestionMenu, + type SuggestionMenuOptions, +} from "@blocknote/core/extensions"; +import { type Middleware } from "@floating-ui/react"; +import { type ReactNode, useEffect, useMemo } from "react"; + +import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; +import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; +import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; +import { + GenericPopover, + GenericPopoverReference, +} from "../Popovers/GenericPopover.js"; +import { suggestionMenuMiddleware } from "./suggestionMenuMiddleware.js"; + +/** Shared positioning and registration for the public suggestion controllers. */ +export function SuggestionMenuPopover(props: { + triggerCharacter: string; + shouldOpen?: SuggestionMenuOptions["shouldOpen"]; + minQueryLength?: number; + floatingUIOptions?: FloatingUIOptions; + portalElement?: HTMLElement | null; + enabled?: boolean; + middleware?: Middleware[]; + zIndex?: number; + children: (props: { + query: string; + closeMenu: () => void; + clearQuery: () => void; + }) => ReactNode; +}) { + const { + triggerCharacter, + shouldOpen, + minQueryLength, + enabled = true, + middleware = suggestionMenuMiddleware, + zIndex = 80, + } = props; + const editorDOMElement = useEditorDOMElement(); + const suggestionMenu = useExtension(SuggestionMenu); + + useEffect(() => { + if (enabled) { + suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); + } + }, [enabled, suggestionMenu, triggerCharacter, shouldOpen]); + + const state = useExtensionState(SuggestionMenu); + const reference = useExtensionState(SuggestionMenu, { + selector: (state) => + ({ + // A descendant lets Floating UI observe scrolling of the editor itself. + element: editorDOMElement?.firstElementChild ?? undefined, + getBoundingClientRect: () => state?.referencePos || new DOMRect(), + }) satisfies GenericPopoverReference, + }); + const floatingUIOptions = useMemo( + () => ({ + ...props.floatingUIOptions, + useFloatingOptions: { + open: state?.show && state.triggerCharacter === triggerCharacter, + onOpenChange: (open) => { + if (!open) { + suggestionMenu.closeMenu(); + } + }, + placement: "bottom-start", + middleware, + ...props.floatingUIOptions?.useFloatingOptions, + }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, + elementProps: { + // Prevent editor blur when clicking the scrollbar. + onMouseDownCapture: (event) => event.preventDefault(), + style: { zIndex }, + ...props.floatingUIOptions?.elementProps, + }, + }), + [ + props.floatingUIOptions, + state?.show, + state?.triggerCharacter, + suggestionMenu, + triggerCharacter, + middleware, + zIndex, + ], + ); + + if ( + !enabled || + !state || + (!state.ignoreQueryLength && + minQueryLength && + (state.query.startsWith(" ") || state.query.length < minQueryLength)) + ) { + return null; + } + + return ( + + + {props.children({ + query: state.query, + closeMenu: suggestionMenu.closeMenu, + clearQuery: suggestionMenu.clearQuery, + })} + + + ); +} diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index b5b1d156dd..0a13337dc0 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -14,7 +14,7 @@ import { FormattingToolbarController } from "../components/FormattingToolbar/For import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarController.js"; import { SideMenuController } from "../components/SideMenu/SideMenuController.js"; import { AttributionTooltipController } from "../components/AttributionTooltip/AttributionTooltipController.js"; -import { GridSuggestionMenuController } from "../components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.js"; +import { EmojiPickerController } from "../components/SuggestionMenu/EmojiPicker/EmojiPickerController.js"; import { SuggestionMenuController } from "../components/SuggestionMenu/SuggestionMenuController.js"; import { TableHandlesController } from "../components/TableHandles/TableHandlesController.js"; import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js"; @@ -137,10 +137,8 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { /> )} {editor.getExtension(SuggestionMenu) && props.emojiPicker !== false && ( - )} diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 12bd1791ca..477b9c8a58 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -199,10 +199,6 @@ export type ComponentProps = { onClick: () => void; item: DefaultReactGridSuggestionItem; }; - // Label: { - // className?: string; - // children?: ReactNode; - // }; Loader: { columns: number; className?: string; diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 8b406bd720..db84ae6bd8 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -348,6 +348,149 @@ SideMenuController offsets its position to keep it centered on the line. */ padding-bottom: env(safe-area-inset-bottom, 0); } +/* Frimousse Emoji Picker styling */ +.bn-frimousse-picker { + display: flex; + flex-direction: column; + width: fit-content; + height: min(352px, calc(100dvh - 20px)); + max-height: inherit; + background-color: var(--bn-colors-menu-background); + border: 1px solid var(--bn-colors-hovered-background); + border-radius: 8px; + overflow: hidden; + isolation: isolate; + font-family: var(--bn-font-family); +} + +.bn-frimousse-picker [frimousse-search] { + position: relative; + z-index: 10; + appearance: none; + margin: 8px 8px 0; + padding: 8px 10px; + border: none; + border-radius: 6px; + background-color: var(--bn-colors-hovered-background); + color: var(--bn-colors-hovered-text); + font-size: 14px; + outline: none; +} + +.bn-frimousse-picker [frimousse-viewport] { + flex: 1; + outline: none; +} + +.bn-frimousse-loading, +.bn-frimousse-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--bn-colors-menu-text); + font-size: 14px; +} + +.bn-frimousse-picker [frimousse-list] { + padding-bottom: 12px; + user-select: none; +} + +.bn-frimousse-picker [frimousse-category-header] { + /* Frimousse assigns the measured, padded sizer height to the real header. */ + box-sizing: border-box; + padding: 12px 12px 6px; + background-color: var(--bn-colors-menu-background); + color: var(--bn-colors-menu-text); + font-size: 12px; + font-weight: 500; +} + +.bn-frimousse-picker [frimousse-row] { + padding-inline: 6px; + scroll-margin-block: 6px; +} + +.bn-frimousse-picker [frimousse-emoji] { + all: unset; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; + background: transparent; + font-size: 18px; + cursor: pointer; +} + +.bn-frimousse-picker [frimousse-emoji]:hover, +.bn-frimousse-picker [frimousse-emoji][data-active], +.bn-frimousse-picker [frimousse-emoji][data-selected] { + background-color: var(--bn-colors-hovered-background); +} + +.bn-frimousse-picker [frimousse-emoji]:focus-visible, +.bn-frimousse-skin-tone:focus-visible { + outline: 2px solid var(--bn-colors-menu-text); + outline-offset: -2px; +} + +.bn-frimousse-picker [frimousse-search]:focus-visible { + outline: 2px solid var(--bn-colors-hovered-text); + outline-offset: -2px; +} + +.bn-frimousse-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + border-top: 1px solid var(--bn-colors-hovered-background); + min-height: 32px; +} + +.bn-frimousse-active-emoji { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; +} + +.bn-frimousse-active-emoji-glyph { + font-size: 18px; + line-height: 1; + flex-shrink: 0; +} + +.bn-frimousse-active-emoji-label { + font-size: 12px; + color: var(--bn-colors-menu-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bn-frimousse-skin-tone { + all: unset; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + font-size: 16px; + border-radius: 6px; + cursor: pointer; + flex-shrink: 0; +} + +.bn-frimousse-skin-tone:hover { + background-color: var(--bn-colors-hovered-background); +} + /* iOS Safari with the keyboard open answers a vertical drag on the toolbar with a viewport pan or pull-to-refresh, and the toolbar pinned to that viewport moves under the finger. On the strip, not the wrapper: WebKit reads @@ -392,11 +535,6 @@ SideMenuController offsets its position to keep it centered on the line. */ overscroll-behavior-y: auto; } -/* Emoji Picker styling */ -.bn-root em-emoji-picker { - max-height: 100%; -} - /* Form.Root's
is a semantic wrapper, never a layout box: the skins' * containers (popover contents, the file panel's tab column) lay out the * fields and the embed tab's submit button as their own flex children, and diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d17381ac88..417b1226c6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -84,6 +84,7 @@ export * from "./components/SideMenu/DragHandleMenu/DefaultItems/TableHeadersIte export * from "./components/SideMenu/DragHandleMenu/DragHandleMenu.js"; export * from "./components/SuggestionMenu/SuggestionMenuController.js"; +export * from "./components/SuggestionMenu/EmojiPicker/EmojiPickerController.js"; export * from "./components/SuggestionMenu/SuggestionMenuWrapper.js"; export * from "./components/SuggestionMenu/getDefaultReactSlashMenuItems.js"; export * from "./components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.js"; diff --git a/packages/react/src/util/emojiPickerData.test.ts b/packages/react/src/util/emojiPickerData.test.ts new file mode 100644 index 0000000000..0af946bc75 --- /dev/null +++ b/packages/react/src/util/emojiPickerData.test.ts @@ -0,0 +1,104 @@ +import type { EmojiData } from "frimousse"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + filterEmojiDataForPlatform, + resolveBlockNoteEmojiData, +} from "./emojiPickerData.js"; + +vi.mock("@blocknote/core/emoji-data", () => ({ + loadFrimousseData: async () => data, +})); + +afterEach(() => vi.unstubAllGlobals()); + +const data: EmojiData = { + locale: "en", + emojis: [ + { + emoji: "😀", + category: 0, + version: 1, + label: "Old emoji", + tags: [], + }, + { + emoji: "🫩", + category: 0, + version: 16, + label: "New emoji", + tags: [], + }, + { + emoji: "🇪🇺", + category: 0, + version: 1, + label: "Flag", + tags: [], + countryFlag: true, + }, + ], + categories: [{ index: 0, label: "Smileys" }], + skinTones: { + light: "Light", + "medium-light": "Medium light", + medium: "Medium", + "medium-dark": "Medium dark", + dark: "Dark", + }, +}; + +describe("filterEmojiDataForPlatform", () => { + it("filters unsupported versions and country flags", () => { + const filtered = filterEmojiDataForPlatform(data, { + emojiVersion: 15, + countryFlags: false, + }); + + expect(filtered.emojis.map((emoji) => emoji.emoji)).toEqual(["😀"]); + expect(data.emojis).toHaveLength(3); + }); + + it("honors a version override without restoring unsupported flags", () => { + const filtered = filterEmojiDataForPlatform( + data, + { emojiVersion: 15, countryFlags: false }, + 16, + ); + + expect(filtered.emojis.map((emoji) => emoji.emoji)).toEqual(["😀", "🫩"]); + }); +}); + +describe("platform detection", () => { + it("reuses one canvas for version and flag probes", async () => { + const context = { + canvas: { width: 0, height: 0 }, + measureText: vi.fn((emoji: string) => ({ + width: emoji === "😀" ? 2 : 4, + })), + fillText: vi.fn(), + clearRect: vi.fn(), + getImageData: () => ({ data: new Uint8ClampedArray(16).fill(255) }), + }; + const getContext = vi.fn(() => context); + vi.stubGlobal("document", { + createElement: () => ({ getContext }), + }); + + const resolved = await resolveBlockNoteEmojiData("en"); + + expect(resolved.emojis.map((emoji) => emoji.emoji)).toEqual(["😀"]); + expect(context.measureText.mock.calls.flat()).toEqual(["🇪🇺", "🫩", "😀"]); + expect(getContext).toHaveBeenCalledOnce(); + }); + + it("preserves the highest-version fallback without flags when canvas is unavailable", async () => { + vi.stubGlobal("document", { + createElement: () => ({ getContext: () => null }), + }); + + const resolved = await resolveBlockNoteEmojiData("en"); + + expect(resolved.emojis.map((emoji) => emoji.emoji)).toEqual(["😀", "🫩"]); + }); +}); diff --git a/packages/react/src/util/emojiPickerData.ts b/packages/react/src/util/emojiPickerData.ts new file mode 100644 index 0000000000..6fe2e6993b --- /dev/null +++ b/packages/react/src/util/emojiPickerData.ts @@ -0,0 +1,111 @@ +import type { EmojiData, EmojiDataResolver } from "frimousse"; + +type EmojiSupport = { + emojiVersion: number; + countryFlags: boolean; +}; + +const CANVAS_SIZE = 2; +const EMOJI_FONT_FAMILY = + "'Apple Color Emoji', 'Noto Color Emoji', 'Twemoji Mozilla', 'Android Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', EmojiSymbols, sans-serif"; + +// Keep this in sync with Frimousse's private platform-support detector. Custom +// resolvers otherwise bypass the filtering applied by its default resolver. +function isEmojiSupported( + context: CanvasRenderingContext2D | null, + emoji: string, +): boolean { + if (!context) { + return false; + } + + context.canvas.width = CANVAS_SIZE; + context.canvas.height = CANVAS_SIZE; + context.font = `2px ${EMOJI_FONT_FAMILY}`; + context.textBaseline = "middle"; + + if (context.measureText(emoji).width >= CANVAS_SIZE * 2) { + return false; + } + + context.fillStyle = "#00f"; + context.fillText(emoji, 0, 0); + const blue = context.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE).data; + + context.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); + context.fillStyle = "#f00"; + context.fillText(emoji, 0, 0); + const red = context.getImageData(0, 0, CANVAS_SIZE, CANVAS_SIZE).data; + + for (let index = 0; index < CANVAS_SIZE * CANVAS_SIZE * 4; index += 4) { + if ( + blue[index] !== red[index] || + blue[index + 1] !== red[index + 1] || + blue[index + 2] !== red[index + 2] + ) { + return false; + } + } + + return true; +} + +function getEmojiSupport(data: EmojiData): EmojiSupport { + // Reuse one canvas for this synchronous detection pass, then let it go. + let context: CanvasRenderingContext2D | null = null; + try { + context = document + .createElement("canvas") + .getContext("2d", { willReadFrequently: true }); + } catch {} + + const versionEmojis = new Map(); + + for (const emoji of data.emojis) { + if (!versionEmojis.has(emoji.version)) { + versionEmojis.set(emoji.version, emoji.emoji); + } + } + + const descendingVersions = [...versionEmojis.keys()].sort( + (first, second) => second - first, + ); + const highestVersion = descendingVersions[0] ?? 0; + const countryFlags = isEmojiSupported(context, "🇪🇺"); + + for (const version of descendingVersions) { + if (isEmojiSupported(context, versionEmojis.get(version)!)) { + return { emojiVersion: version, countryFlags }; + } + } + + return { emojiVersion: highestVersion, countryFlags }; +} + +export function filterEmojiDataForPlatform( + data: EmojiData, + support: EmojiSupport, + emojiVersion?: number, +): EmojiData { + return { + ...data, + emojis: data.emojis.filter((emoji) => { + const isSupportedVersion = + emoji.version <= (emojiVersion ?? support.emojiVersion); + + return isSupportedVersion && (!emoji.countryFlag || support.countryFlags); + }), + }; +} + +export async function resolveBlockNoteEmojiData( + locale: Parameters[0], + { emojiVersion, signal }: Parameters[1] = {}, +): Promise { + const { loadFrimousseData } = await import("@blocknote/core/emoji-data"); + signal?.throwIfAborted(); + const data = await loadFrimousseData(locale); + signal?.throwIfAborted(); + + return filterEmojiDataForPlatform(data, getEmojiSupport(data), emojiVersion); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 980953a1ed..c68146a04e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5438,9 +5438,6 @@ importers: packages/core: dependencies: - '@emoji-mart/data': - specifier: ^1.2.1 - version: 1.2.1 '@handlewithcare/prosemirror-inputrules': specifier: ^0.1.4 version: 0.1.4(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5) @@ -5483,9 +5480,6 @@ importers: '@y/y': specifier: 14.0.0-rc.26 version: 14.0.0-rc.26 - emoji-mart: - specifier: ^5.6.0 - version: 5.6.0 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -5790,9 +5784,6 @@ importers: '@blocknote/core': specifier: workspace:^ version: link:../core - '@emoji-mart/data': - specifier: ^1.2.1 - version: 1.2.1 '@floating-ui/react': specifier: ^0.27.18 version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -5805,12 +5796,12 @@ importers: '@tiptap/react': specifier: ^3.31.3 version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - emoji-mart: - specifier: ^5.6.0 - version: 5.6.0 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 + frimousse: + specifier: ^0.4.0 + version: 0.4.0(react@19.2.5)(typescript@7.0.2) use-sync-external-store: specifier: 1.6.0 version: 1.6.0(react@19.2.5) @@ -7532,9 +7523,6 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@emoji-mart/data@1.2.1': - resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} - '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -12979,9 +12967,6 @@ packages: electron-to-chromium@1.5.464: resolution: {integrity: sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==} - emoji-mart@5.6.0: - resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} - emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -13392,6 +13377,15 @@ packages: peerDependencies: react: ^18 || ^19 + frimousse@0.4.0: + resolution: {integrity: sha512-wA/1Xt9UTk51wYNpy+EvHKPUA4jdm4NuEFzSNNZxqT0Nxrj4RFwYByjRMuaq4qSLOco/ikDCpLKesB8W1w2q+Q==} + peerDependencies: + react: ^18 || ^19 + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -17985,8 +17979,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emoji-mart/data@1.2.1': {} - '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.29.7 @@ -23296,8 +23288,6 @@ snapshots: electron-to-chromium@1.5.464: {} - emoji-mart@5.6.0: {} - emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -23843,6 +23833,12 @@ snapshots: dependencies: react: 19.2.5 + frimousse@0.4.0(react@19.2.5)(typescript@7.0.2): + dependencies: + react: 19.2.5 + optionalDependencies: + typescript: 7.0.2 + fs-constants@1.0.0: {} fsevents@2.3.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5db7781869..cebc42ef63 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -83,6 +83,7 @@ catalog: vite: ^8.0.0 vite-plus: ^1.4.0 minimumReleaseAgeExclude: + - frimousse - vite-plus - "@vitest/*" - vitest diff --git a/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-chromium-linux.png b/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-chromium-linux.png index 4f2a43c799..0331cab427 100644 Binary files a/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-firefox-linux.png b/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-firefox-linux.png index b160f7c899..655c443ee4 100644 Binary files a/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-firefox-linux.png and b/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-firefox-linux.png differ diff --git a/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-webkit-linux.png b/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-webkit-linux.png index 5f4944f5d2..b6582eee00 100644 Binary files a/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/ariakit/__screenshots__/ariakit.test.tsx/ariakit-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/ariakit/ariakit.test.tsx b/tests/src/end-to-end/ariakit/ariakit.test.tsx index e3ee76fe38..4f9ed3526d 100644 --- a/tests/src/end-to-end/ariakit/ariakit.test.tsx +++ b/tests/src/end-to-end/ariakit/ariakit.test.tsx @@ -16,6 +16,7 @@ import { } from "../../utils/editor.js"; import { mouseSequence, moveMouseOverElement } from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +import { waitForEmojiPickerLayout } from "../../utils/emojipicker.js"; beforeEach(async () => { await render(); @@ -69,7 +70,7 @@ describe("Check Ariakit UI", () => { await userEvent.keyboard(":"); await userEvent.keyboard("sm"); - await sleep(500); + await waitForEmojiPickerLayout(); await expectElement(document.body).toMatchScreenshot( "ariakit-emoji-picker", ); diff --git a/tests/src/end-to-end/comments/comments.test.tsx b/tests/src/end-to-end/comments/comments.test.tsx index 299c777525..0eddade9a3 100644 --- a/tests/src/end-to-end/comments/comments.test.tsx +++ b/tests/src/end-to-end/comments/comments.test.tsx @@ -43,19 +43,12 @@ function expectSelectorCount(selector: string, count: number) { }); } -// emoji-mart renders `` as a custom element whose emoji -// buttons live inside its shadow root, so a plain `document.querySelector` -// can't reach them. These helpers pierce the shadow root. +// Frimousse renders regular DOM elements (no shadow DOM), so standard +// selectors work directly. function emojiButtons(): HTMLButtonElement[] { - const picker = document.querySelector("em-emoji-picker"); - if (!picker?.shadowRoot) { - return []; - } return Array.from( - picker.shadowRoot.querySelectorAll( - "button[aria-posinset]", - ), - ); + document.querySelectorAll("[frimousse-emoji]"), + ).filter((btn) => !btn.closest("[aria-hidden]")); } /** Resolves once the emoji picker has rendered at least `min` emoji buttons. */ @@ -165,10 +158,37 @@ describe("Check Comments functionality", () => { // Add a reaction via the action toolbar's add-reaction button. await userEvent.click(await waitForSelector('[data-test="addreaction"]')); - const firstPickerButtons = await waitForEmojiButtons(); - await userEvent.click(firstPickerButtons[0]); - await waitForSelectorDetached("em-emoji-picker"); + await waitForEmojiButtons(); + await userEvent.click(await waitForSelector("[frimousse-search]")); + await userEvent.keyboard("{Escape}"); + await expectSelectorCount("[frimousse-root]", 0); + + // Exercise the UI library's outside-click dismissal too: Escape is handled + // directly by the picker, so it does not cover the popover callback. + await userEvent.click(await waitForSelector('[data-test="addreaction"]')); + await waitForEmojiButtons(); + // The picker overlaps the comment's centre. Click its exposed top-left + // corner instead of asking Playwright to click through the popover. + const commentRect = getRect(await waitForSelector(".bn-thread-comment")); + await clickAt(commentRect.x + 2, commentRect.y + 2); + await expectSelectorCount("[frimousse-root]", 0); + + // Both dismissal paths must leave the owner ready to reopen and select. + await moveMouseOverElement(await waitForSelector(".bn-thread-comment")); + await userEvent.click(await waitForSelector('[data-test="addreaction"]')); + await waitForEmojiButtons(); + await userEvent.fill(await waitForSelector("[frimousse-search]"), "frog"); + await vi.waitFor(() => { + expect(emojiButtons().map((button) => button.textContent)).toEqual([ + "🐸", + ]); + }); + await userEvent.keyboard("{Enter}"); + await waitForSelectorDetached("[frimousse-root]"); await expectSelectorCount(".bn-comment-reaction", 1); + expect( + document.querySelector(".bn-comment-reaction")?.textContent, + ).toContain("🐸"); // Add a second reaction via the add-reaction badge. await moveMouseOverElement(await waitForSelector(".bn-thread-comment")); @@ -178,7 +198,7 @@ describe("Check Comments functionality", () => { // toggling the first one off. const secondPickerButtons = await waitForEmojiButtons(6); await userEvent.click(secondPickerButtons[5]); - await waitForSelectorDetached("em-emoji-picker"); + await waitForSelectorDetached("[frimousse-root]"); await expectSelectorCount(".bn-comment-reaction", 2); }); diff --git a/tests/src/end-to-end/emojipicker/emoji-controller.test.tsx b/tests/src/end-to-end/emojipicker/emoji-controller.test.tsx new file mode 100644 index 0000000000..2e41105e34 --- /dev/null +++ b/tests/src/end-to-end/emojipicker/emoji-controller.test.tsx @@ -0,0 +1,153 @@ +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + EmojiPickerController, + GridSuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; +import type { ComponentProps } from "react"; +import { expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR, EMOJI_PICKER_SELECTOR } from "../../utils/const.js"; +import { + focusOnEditor, + waitForSelector, + waitForSelectorDetached, + waitForTextInEditor, +} from "../../utils/editor.js"; +import { waitForSelectedEmoji } from "../../utils/emojipicker.js"; + +function ConfiguredEditor(props: ComponentProps) { + const editor = useCreateBlockNote(); + return ( + + + + ); +} + +function LegacyGridEditor(props: { onPick: (id: string) => void }) { + const editor = useCreateBlockNote(); + return ( + + + ["first", "second"].map((id) => ({ + id, + icon: {id}, + onItemClick: () => props.onPick(id), + })) + } + /> + + ); +} + +test("legacy grid supports custom items selected by keyboard and click", async () => { + const onPick = vi.fn(); + await render(); + await focusOnEditor(); + await userEvent.keyboard(":custom"); + await waitForSelector('.bn-grid-suggestion-menu-item[aria-selected="true"]'); + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => { + expect( + document.querySelector( + '.bn-grid-suggestion-menu-item[aria-selected="true"]', + )?.textContent, + ).toBe("second"); + }); + await userEvent.keyboard("{Enter}"); + expect(onPick).toHaveBeenCalledExactlyOnceWith("second"); + await waitForSelectorDetached(".bn-grid-suggestion-menu"); + + await userEvent.keyboard(":custom"); + await userEvent.click( + await waitForSelector("#bn-grid-suggestion-menu-item-0"), + ); + expect(onPick.mock.calls).toEqual([["second"], ["first"]]); + await waitForSelectorDetached(".bn-grid-suggestion-menu"); + expect(document.querySelector(EDITOR_SELECTOR)?.textContent).toBe(""); +}); + +test("forwards floating configuration and nested overrides", async () => { + const placement = vi.fn(); + await render( + , + ); + await focusOnEditor(); + await userEvent.keyboard(":sm"); + await waitForSelector(EMOJI_PICKER_SELECTOR); + const popover = await waitForSelector(".configured-emoji-popover"); + await vi.waitFor(() => { + expect(placement).toHaveBeenCalledWith("top-end"); + expect(getComputedStyle(popover).zIndex).toBe("123"); + expect( + document.querySelector("[data-floating-ui-focus-guard]"), + ).not.toBeNull(); + }); + await userEvent.keyboard("{Escape}"); + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).not.toBeNull(); +}); + +test("supports a custom minimum query length", async () => { + await render(); + await focusOnEditor(); + await userEvent.keyboard(":sm"); + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(); + await userEvent.keyboard("i"); + await waitForSelector(EMOJI_PICKER_SELECTOR); +}); + +test("allows explicitly disabling the minimum query length", async () => { + await render(); + await focusOnEditor(); + await userEvent.keyboard(":"); + const selected = await waitForSelectedEmoji(); + const emoji = selected.textContent; + expect(emoji).toBeTruthy(); + await userEvent.keyboard("{Enter}"); + await waitForTextInEditor(`${emoji} `); + expect(document.querySelector(EDITOR_SELECTOR)?.textContent).not.toContain( + ":", + ); + await vi.waitFor(() => + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(), + ); +}); + +test("respects shouldOpen", async () => { + const shouldOpen = vi.fn(() => false); + await render( + , + ); + await focusOnEditor(); + await userEvent.keyboard(":sm"); + expect(shouldOpen).toHaveBeenCalled(); + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(); +}); diff --git a/tests/src/end-to-end/emojipicker/emojipicker.test.tsx b/tests/src/end-to-end/emojipicker/emojipicker.test.tsx index 2cb96c9528..5945a75d6f 100644 --- a/tests/src/end-to-end/emojipicker/emojipicker.test.tsx +++ b/tests/src/end-to-end/emojipicker/emojipicker.test.tsx @@ -1,58 +1,538 @@ import App from "@examples/01-basic/testing/src/App"; -import { beforeEach, describe, expect, test } from "vite-plus/test"; +import { beforeEach, describe, expect, test, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; import { userEvent } from "../../utils/context.js"; import { EDITOR_SELECTOR, EMOJI_PICKER_SELECTOR } from "../../utils/const.js"; import { focusOnEditor, - sleep, waitForSelector, waitForTextInEditor, } from "../../utils/editor.js"; -import { - executeEmojiCommand, - openEmojiPicker, -} from "../../utils/emojipicker.js"; +import { waitForSelectedEmoji } from "../../utils/emojipicker.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +let submitCount: number; + +function editorCaretOffset(editor: HTMLElement) { + const selection = window.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(editor); + range.setEnd(selection.anchorNode!, selection.anchorOffset); + return range.toString().length; +} + +async function expectPickerToLeaveKeyUnhandled( + editor: HTMLElement, + key: string, +) { + // Observe after the picker's capture listener, before ProseMirror bubbles. + const observed: boolean[] = []; + function observe(event: KeyboardEvent) { + if (event.key === key) { + observed.push(event.defaultPrevented); + } + } + editor.addEventListener("keydown", observe, true); + try { + await userEvent.keyboard(`{${key}}`); + expect(observed).toEqual([false]); + expect(document.activeElement).toBe(editor); + } finally { + editor.removeEventListener("keydown", observe, true); + } +} + beforeEach(async () => { - await render(); + submitCount = 0; + await render( + { + event.preventDefault(); + submitCount += 1; + }} + > + + , + ); await waitForSelector(EDITOR_SELECTOR); }); describe("Check Emoji Picker Functionality", () => { - test("should not show emoji picker when : is typed", async () => { + test("should open only after two characters and select the top-left emoji", async () => { await focusOnEditor(); - await openEmojiPicker(); - // Give the editor a chance to (incorrectly) open the picker before - // asserting its absence, so the negative check isn't won by a race. - await sleep(500); - expect(document.querySelectorAll(EMOJI_PICKER_SELECTOR).length).toBe(0); + await userEvent.keyboard(":"); + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(); + await userEvent.keyboard("s"); + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(); + await userEvent.keyboard("m"); + const selected = await waitForSelectedEmoji(); + expect(selected.getAttribute("aria-colindex")).toBe("0"); + expect( + selected.closest("[aria-rowindex]")?.getAttribute("aria-rowindex"), + ).toBe("0"); + await userEvent.keyboard("{Backspace}"); + await vi.waitFor(() => + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(), + ); }); - test("should show emoji picker when : and query is typed", async () => { + test("should leave Enter to the editor for a bare colon", async () => { await focusOnEditor(); - await openEmojiPicker(); - await userEvent.keyboard("sm"); - await waitForSelector(EMOJI_PICKER_SELECTOR); + await userEvent.keyboard(":"); + + await userEvent.keyboard("{Enter}"); + + await vi.waitFor(() => { + const editor = document.querySelector(EDITOR_SELECTOR); + expect(editor?.querySelectorAll(".bn-block-content").length).toBe(2); + expect(editor?.textContent).toContain(":"); + }); }); - test("Should be able to insert emoji", async () => { + test("should navigate and reveal emoji rows without moving the editor caret", async () => { await focusOnEditor(); - await executeEmojiCommand("sm"); - await waitForTextInEditor("🛩️ "); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(":an"); + const selector = ".bn-frimousse-emoji[data-selected]"; + const first = await waitForSelector(selector); + const firstEmoji = first.textContent; + const selection = window.getSelection()!; + const anchorNode = selection.anchorNode; + const anchorOffset = selection.anchorOffset; + + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => { + const selected = document.querySelector(selector); + expect(selected?.textContent).not.toBe(firstEmoji); + expect(selected?.getAttribute("aria-colindex")).toBe("1"); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + expect(selected?.id).toBe(editor.getAttribute("aria-activedescendant")); + }); + + await userEvent.keyboard("{ArrowDown}".repeat(12)); + const viewport = await waitForSelector("[frimousse-viewport]"); + await vi.waitFor(() => { + const selected = document.querySelector(selector); + expect( + selected?.closest("[aria-rowindex]")?.getAttribute("aria-rowindex"), + ).toBe("12"); + expect(selected?.textContent).not.toBe(firstEmoji); + expect(selected?.id).toBe(editor.getAttribute("aria-activedescendant")); + const rect = selected!.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + expect(rect.top).toBeGreaterThanOrEqual(viewportRect.top); + expect(rect.bottom).toBeLessThanOrEqual(viewportRect.bottom); + expect(viewport.scrollTop).toBeGreaterThan(0); + }); + + await userEvent.keyboard("{ArrowUp}"); + await vi.waitFor(() => { + const selected = document.querySelector(selector); + expect( + selected?.closest("[aria-rowindex]")?.getAttribute("aria-rowindex"), + ).toBe("11"); + expect(selected?.id).toBe(editor.getAttribute("aria-activedescendant")); + expect(document.activeElement).toBe(editor); + expect(selection.anchorNode).toBe(anchorNode); + expect(selection.anchorOffset).toBe(anchorOffset); + expect(editor.textContent).toBe(":an"); + }); }); - test("Should be able to open emoji picker from slash menu", async () => { + test("should preserve the selected skin tone when the query changes and insert with Enter", async () => { await focusOnEditor(); - await executeSlashCommand("emoji"); - await userEvent.keyboard("sm"); + await userEvent.keyboard(":waving hand"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + + await userEvent.click( + await waitForSelector("[frimousse-skin-tone-selector]"), + ); + await vi.waitFor(() => { + expect( + document.querySelector(".bn-frimousse-emoji[data-selected]") + ?.textContent, + ).toBe("👋🏻"); + }); + + document.querySelector(EDITOR_SELECTOR)?.focus(); + // A query change remounts the filtered Frimousse view, not its skin tone. + await userEvent.keyboard(" "); + await vi.waitFor(async () => { + expect((await waitForSelectedEmoji()).textContent).toBe("👋🏻"); + }); + await userEvent.keyboard("{Enter}"); + await waitForTextInEditor("👋🏻 "); + }); + test("should insert the selected skin tone when clicked without submitting a form", async () => { + await focusOnEditor(); + await userEvent.keyboard(":waving hand"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + + await userEvent.click( + await waitForSelector("[frimousse-skin-tone-selector]"), + ); + const tonedEmoji = await vi.waitFor(() => { + const selected = document.querySelector( + ".bn-frimousse-emoji[data-selected]", + ); + expect(selected?.textContent).toBe("👋🏻"); + return selected!; + }); + await userEvent.click(tonedEmoji); + + await waitForTextInEditor("👋🏻 "); + expect(submitCount).toBe(0); + expect(document.querySelector(EDITOR_SELECTOR)?.textContent).not.toContain( + "👋🏻 👋🏻", + ); + }); + test("should navigate emojis while focus remains in the editor", async () => { + await focusOnEditor(); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(":sm"); await waitForSelector(EMOJI_PICKER_SELECTOR); + + const selectedSelector = ".bn-frimousse-emoji[data-selected]"; + const first = await waitForSelector(selectedSelector); + const firstEmoji = first.textContent; + await userEvent.keyboard("{ArrowRight}"); + + await vi.waitFor(() => { + const selected = + document.querySelector(selectedSelector); + if (!selected?.textContent || selected.textContent === firstEmoji) { + throw new Error("Emoji selection did not move"); + } + }); + + await userEvent.keyboard("{Backspace}{Backspace}heart"); + const selectedAfterQuery = await vi.waitFor(() => { + const selected = + document.querySelector(selectedSelector); + const first = Array.from( + document.querySelectorAll(".bn-frimousse-emoji"), + ).find((button) => !button.closest("[aria-hidden]")); + const activeDescendant = editor.getAttribute("aria-activedescendant"); + if ( + !selected || + !selected.textContent || + selected !== first || + !activeDescendant || + selected.id !== activeDescendant + ) { + throw new Error("Selection did not reset to the new first result"); + } + expect(document.activeElement).toBe(editor); + return selected.textContent; + }); + + await userEvent.keyboard("{Enter}"); + await waitForTextInEditor(`${selectedAfterQuery} `); + }); + test("should clamp navigation to the last item in a partial row", async () => { + await focusOnEditor(); + await userEvent.keyboard(":keycap"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + + for (let index = 0; index < 8; index++) { + await userEvent.keyboard("{ArrowRight}"); + } + await userEvent.keyboard("{ArrowDown}"); + + const lastEmoji = await vi.waitFor(() => { + const selected = document.querySelector( + ".bn-frimousse-emoji[data-selected]", + ); + if (selected?.textContent !== "🔟") { + throw new Error("Selection did not clamp to the partial row"); + } + return selected; + }); + + expect(lastEmoji.getAttribute("aria-selected")).toBe("true"); + }); + test("should wrap horizontally between neighboring rows", async () => { + await focusOnEditor(); + await userEvent.keyboard(":keycap"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + const selection = window.getSelection()!; + const offset = selection.anchorOffset; + await userEvent.keyboard("{ArrowRight}".repeat(9)); + await vi.waitFor(() => { + const selected = document.querySelector( + ".bn-frimousse-emoji[data-selected]", + ); + expect( + selected?.closest("[aria-rowindex]")?.getAttribute("aria-rowindex"), + ).toBe("1"); + expect(selected?.getAttribute("aria-colindex")).toBe("0"); + }); + await userEvent.keyboard("{ArrowLeft}"); + await vi.waitFor(() => { + const selected = document.querySelector( + ".bn-frimousse-emoji[data-selected]", + ); + expect( + selected?.closest("[aria-rowindex]")?.getAttribute("aria-rowindex"), + ).toBe("0"); + expect(selected?.getAttribute("aria-colindex")).toBe("8"); + expect(selected?.id).toBe( + document + .querySelector(EDITOR_SELECTOR) + ?.getAttribute("aria-activedescendant"), + ); + expect(selection.anchorOffset).toBe(offset); + }); + }); + test.each(["ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown"])( + "should leave %s at a grid boundary to the editor", + async (key) => { + await focusOnEditor(); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(" tail"); + window + .getSelection()! + .collapse(editor.querySelector(".bn-inline-content")!.firstChild, 0); + // A synthetic caret change must reach ProseMirror before typing; + // merely waiting one animation frame can leave its selection stale. + document.dispatchEvent(new Event("selectionchange")); + expect(editorCaretOffset(editor)).toBe(0); + await userEvent.keyboard(":keycap"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + expect(editor.textContent).toBe(":keycap tail"); + if (key === "ArrowRight" || key === "ArrowDown") { + await userEvent.keyboard("{ArrowRight}".repeat(8) + "{ArrowDown}"); + await waitForSelector( + '.bn-frimousse-emoji[data-selected][aria-label="Keycap: 10"]', + ); + } + const offset = editorCaretOffset(editor); + await expectPickerToLeaveKeyUnhandled(editor, key); + if (key === "ArrowLeft" || key === "ArrowRight") { + await vi.waitFor(() => + expect(editorCaretOffset(editor)).toBe( + offset + (key === "ArrowLeft" ? -1 : 1), + ), + ); + } + }, + ); + test.each(["ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown"])( + "should not consume %s when there are no results", + async (key) => { + await focusOnEditor(); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(":zzzzzz"); + expect((await waitForSelector(".bn-frimousse-empty")).textContent).toBe( + "No emoji found", + ); + await expectPickerToLeaveKeyUnhandled(editor, key); + }, + ); + test("should dismiss with Escape and continue typing at the same caret", async () => { + await focusOnEditor(); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(":keycap"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + const offset = editorCaretOffset(editor); + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => { + expect(document.querySelector(EMOJI_PICKER_SELECTOR)).toBeNull(); + expect(document.activeElement).toBe(editor); + expect(editorCaretOffset(editor)).toBe(offset); + expect(editor.getAttribute("aria-activedescendant")).toBeNull(); + }); + await userEvent.keyboard("s"); + expect(editor.textContent).toBe(":keycaps"); + }); + test("should expose the editor selection rather than the hovered emoji", async () => { + await focusOnEditor(); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(":keycap"); + await waitForSelector( + '.bn-frimousse-emoji[data-selected][aria-label="Keycap: #"]', + ); + await userEvent.keyboard("{ArrowRight}"); + + await vi.waitFor(() => { + const selected = document.querySelector( + ".bn-frimousse-emoji[data-selected]", + ); + expect(selected?.getAttribute("aria-label")).toBe("Keycap: *"); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + expect( + document.querySelectorAll('.bn-frimousse-emoji[aria-selected="true"]'), + ).toHaveLength(1); + expect(selected?.id).toBe(editor.getAttribute("aria-activedescendant")); + }); + + // Query again after Frimousse's asynchronous filtering/virtualization. + const firstSelector = '.bn-frimousse-emoji[aria-label="Keycap: #"]'; + const first = await waitForSelector(firstSelector); + first.dispatchEvent(new PointerEvent("pointerover", { bubbles: true })); + await new Promise(requestAnimationFrame); + await vi.waitFor(() => { + const hovered = document.querySelector(firstSelector); + expect(hovered).not.toBeNull(); + expect(hovered?.hasAttribute("data-active")).toBe(false); + expect(hovered?.getAttribute("aria-selected")).not.toBe("true"); + expect( + document.querySelector('.bn-frimousse-emoji[aria-selected="true"]')?.id, + ).toBe(editor.getAttribute("aria-activedescendant")); + expect(document.activeElement).toBe(editor); + }); + }); + test("should leave composing navigation keys to the editor", async () => { + await focusOnEditor(); + const editor = await waitForSelector(EDITOR_SELECTOR); + await userEvent.keyboard(":frog"); + const selected = await waitForSelector( + '.bn-frimousse-emoji[data-selected][aria-label="Frog"]', + ); + const emoji = selected.textContent; + + // Synthetic composition flags exercise the bridge, not an OS IME's UI. + editor.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ); + for (const key of [ + "ArrowRight", + "ArrowDown", + "ArrowLeft", + "ArrowUp", + "Enter", + ]) { + const event = new KeyboardEvent("keydown", { + key, + isComposing: true, + bubbles: true, + cancelable: true, + }); + editor.dispatchEvent(event); + expect(event.defaultPrevented, key).toBe(false); + await new Promise(requestAnimationFrame); + expect( + document.querySelector(".bn-frimousse-emoji[data-selected]") + ?.textContent, + ).toBe(emoji); + } + expect(editor.textContent).toContain(":frog"); + expect(document.activeElement).toBe(editor); + editor.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true }), + ); + }); + test.each(["an", "in"])( + "should allow mouse scrolling and picking with query '%s'", + async (query) => { + await focusOnEditor(); + await userEvent.keyboard(`:${query}`); + const viewport = await waitForSelector("[frimousse-viewport]"); + await waitForSelectedEmoji(); + + // Native scrolling, like a wheel/scrollbar, must not cause the DOM + // observer to pull the unchanged keyboard selection back into view. + async function scrollTo(top: number) { + viewport.scrollTop = top; + // Allow scroll events, virtualization, and its MutationObserver to run. + for (let frame = 0; frame < 3; frame++) { + await new Promise(requestAnimationFrame); + } + expect(viewport.scrollTop).toBe(top); + } + + await scrollTo(96); + await scrollTo(32); + await scrollTo(800); + + for (const [top, key] of [ + [800, "ArrowDown"], + [0, "ArrowUp"], + ] as const) { + if (top === 0) { + await userEvent.keyboard("{ArrowDown}".repeat(12)); + await scrollTo(0); + } + await vi.waitFor(() => { + expect( + document + .querySelector(EDITOR_SELECTOR) + ?.getAttribute("aria-activedescendant"), + ).toBeNull(); + }); + // Returning to keyboard navigation reveals the new selection, even + // though the previously selected row has been virtualized away. + await userEvent.keyboard(`{${key}}`); + await vi.waitFor(() => { + const selected = document.querySelector( + ".bn-frimousse-emoji[data-selected]", + ); + expect(selected).not.toBeNull(); + const rect = selected!.getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); + expect(rect.top).toBeGreaterThanOrEqual(viewportRect.top); + expect(rect.bottom).toBeLessThanOrEqual(viewportRect.bottom); + expect(selected?.id).toBe( + document + .querySelector(EDITOR_SELECTOR) + ?.getAttribute("aria-activedescendant"), + ); + }); + } + // Mouse scrolling remains free after keyboard navigation too. + await scrollTo(800); + + const viewportRect = viewport.getBoundingClientRect(); + const button = Array.from( + viewport.querySelectorAll(".bn-frimousse-emoji"), + ).find((button) => { + const rect = button.getBoundingClientRect(); + return ( + rect.top >= viewportRect.top + 30 && + rect.bottom <= viewportRect.bottom + ); + }); + if (!button?.textContent) { + throw new Error("Expected a visible emoji after scrolling"); + } + const emoji = button.textContent; + await userEvent.click(button); + await waitForTextInEditor(`${emoji} `); + }, + ); + test("should scroll to a mounted emoji without estimating its position", async () => { + await focusOnEditor(); + await userEvent.keyboard(":an"); + await waitForSelector(".bn-frimousse-emoji[data-selected]"); + const viewport = await waitForSelector("[frimousse-viewport]"); + viewport.scrollTop = 96; + for (let frame = 0; frame < 3; frame++) { + await new Promise(requestAnimationFrame); + } + expect(viewport.scrollTop).toBe(96); + const button = await waitForSelector( + '[aria-rowindex="0"] [aria-colindex="1"]', + ); + const scrollTo = vi.spyOn(viewport, "scrollTo"); + const scrollIntoView = vi.spyOn(button, "scrollIntoView"); + try { + await userEvent.keyboard("{ArrowRight}"); + await vi.waitFor(() => { + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "nearest", + behavior: "instant", + }); + expect(button.getAttribute("aria-selected")).toBe("true"); + }); + expect(scrollTo).not.toHaveBeenCalled(); + } finally { + scrollTo.mockRestore(); + scrollIntoView.mockRestore(); + } }); test("Should be able to insert emoji after slash command", async () => { await focusOnEditor(); await executeSlashCommand("emoji"); - await userEvent.keyboard("sm"); - await waitForSelector(EMOJI_PICKER_SELECTOR); - await sleep(500); + await userEvent.keyboard("frog"); + expect((await waitForSelectedEmoji()).textContent).toBe("🐸"); await userEvent.keyboard("{Enter}"); - await waitForTextInEditor("🛩️ "); + await waitForTextInEditor("🐸 "); }); }); diff --git a/tests/src/end-to-end/emojipicker/localized-emojipicker.test.tsx b/tests/src/end-to-end/emojipicker/localized-emojipicker.test.tsx new file mode 100644 index 0000000000..b388cd8373 --- /dev/null +++ b/tests/src/end-to-end/emojipicker/localized-emojipicker.test.tsx @@ -0,0 +1,83 @@ +import App from "@examples/01-basic/10-localization/src/App"; +import { expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR } from "../../utils/const.js"; +import { + focusOnEditor, + waitForSelector, + waitForTextInEditor, +} from "../../utils/editor.js"; +import { waitForSelectedEmoji } from "../../utils/emojipicker.js"; + +test("searches and inserts from localized emoji data without a CDN", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + await render(); + + await userEvent.selectOptions(await waitForSelector("select"), "fr"); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await userEvent.keyboard(":zzzzzz"); + expect((await waitForSelector(".bn-frimousse-empty")).textContent).toBe( + "Aucun emoji trouvé", + ); + await userEvent.keyboard( + "{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}", + ); + await userEvent.keyboard("soleil"); + await userEvent.click( + await waitForSelector('.bn-frimousse-emoji[aria-label="Soleil"]'), + ); + + await waitForTextInEditor("☀️ "); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } +}); + +test.each([ + { language: "fr", query: "soléil" }, + { language: "tr", query: "IŞIK" }, +])( + "keeps displayed results and Enter selection consistent for $language query $query", + async ({ language, query }) => { + await render(); + await userEvent.selectOptions(await waitForSelector("select"), language); + await focusOnEditor(); + await userEvent.keyboard(`:${query}`); + + // These queries require accent folding / Turkish dotless-I casing. Testing + // only the search helper misses Frimousse independently filtering the UI. + const selected = await waitForSelectedEmoji(); + const visibleButtons = Array.from( + document.querySelectorAll(".bn-frimousse-emoji"), + ).filter((button) => !button.closest("[aria-hidden]")); + expect(visibleButtons[0]).toBe(selected); + expect(selected.getAttribute("aria-selected")).toBe("true"); + const emoji = selected.textContent; + expect(emoji).toBeTruthy(); + + await userEvent.keyboard("{Enter}"); + await waitForTextInEditor(`${emoji} `); + expect(document.querySelector(EDITOR_SELECTOR)?.textContent).toBe( + `${emoji} `, + ); + }, +); + +test("preserves edits when switching languages repeatedly", async () => { + await render(); + await focusOnEditor(); + await userEvent.keyboard("Keep my document"); + await waitForTextInEditor("Keep my document"); + + for (const language of ["fr", "tr", "en"]) { + await userEvent.selectOptions(await waitForSelector("select"), language); + await waitForTextInEditor("Keep my document"); + expect(document.querySelector(EDITOR_SELECTOR)?.textContent).toBe( + "Keep my document", + ); + } +}); diff --git a/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-chromium-linux.png b/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-chromium-linux.png index cf69e4171f..4c0c8366d9 100644 Binary files a/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-firefox-linux.png b/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-firefox-linux.png index 51b8da8359..82427407c5 100644 Binary files a/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-firefox-linux.png and b/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-firefox-linux.png differ diff --git a/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-webkit-linux.png b/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-webkit-linux.png index 7e4157d442..d72dfebf17 100644 Binary files a/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/shadcn/__screenshots__/shadcn.test.tsx/shadcn-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/shadcn/shadcn.test.tsx b/tests/src/end-to-end/shadcn/shadcn.test.tsx index 06b02a8490..d5b60ae103 100644 --- a/tests/src/end-to-end/shadcn/shadcn.test.tsx +++ b/tests/src/end-to-end/shadcn/shadcn.test.tsx @@ -25,6 +25,7 @@ import { moveMouseOverElement, } from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +import { waitForEmojiPickerLayout } from "../../utils/emojipicker.js"; beforeEach(async () => { await render(); @@ -76,7 +77,7 @@ describe("Check ShadCN UI", () => { await userEvent.keyboard(":"); await userEvent.keyboard("sm"); - await sleep(500); + await waitForEmojiPickerLayout(); await expectElement(document.body).toMatchScreenshot("shadcn-emoji-picker"); }); test("Check side menu", async () => { diff --git a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-chromium-linux.png b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-chromium-linux.png index 67b1bfa3c9..3ebf8ed65b 100644 Binary files a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-chromium-linux.png differ diff --git a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-firefox-linux.png b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-firefox-linux.png index cf489c2c64..2f37634867 100644 Binary files a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-firefox-linux.png and b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-firefox-linux.png differ diff --git a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-webkit-linux.png b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-webkit-linux.png index bfd83dc8fc..ec82c0d204 100644 Binary files a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-emoji-picker-webkit-linux.png differ diff --git a/tests/src/end-to-end/theming/theming.test.tsx b/tests/src/end-to-end/theming/theming.test.tsx index 5ca3b26eaa..ef1fad98d8 100644 --- a/tests/src/end-to-end/theming/theming.test.tsx +++ b/tests/src/end-to-end/theming/theming.test.tsx @@ -16,6 +16,7 @@ import { } from "../../utils/editor.js"; import { moveMouseOverElement, mouseSequence } from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; +import { waitForEmojiPickerLayout } from "../../utils/emojipicker.js"; // Vitest browser mode has no per-test `colorScheme` knob (the playwright // provider only takes it on the instance level via contextOptions). The editor @@ -95,7 +96,7 @@ describe("Check Dark Theme is Automatically Applied", () => { await userEvent.keyboard(":"); await userEvent.keyboard("sm"); - await sleep(500); + await waitForEmojiPickerLayout(); await expectElement(document.body).toMatchScreenshot("dark-emoji-picker"); }); test("Should show dark side menu", async () => { diff --git a/tests/src/utils/const.ts b/tests/src/utils/const.ts index 54d8165968..52fd448449 100644 --- a/tests/src/utils/const.ts +++ b/tests/src/utils/const.ts @@ -22,7 +22,7 @@ export const DRAG_HANDLE_ADD_SELECTOR = `[data-test="dragHandleAdd"]`; // of the side menu that opens it; match it by its own class. export const DRAG_HANDLE_MENU_SELECTOR = `.bn-drag-handle-menu`; export const SLASH_MENU_SELECTOR = `.bn-suggestion-menu`; -export const EMOJI_PICKER_SELECTOR = `.bn-grid-suggestion-menu`; +export const EMOJI_PICKER_SELECTOR = `.bn-frimousse-picker`; export const ITALIC_BUTTON_SELECTOR = `[data-test="italic"]`; export const COLORS_BUTTON_SELECTOR = `[data-test="colors"]`; diff --git a/tests/src/utils/emojipicker.ts b/tests/src/utils/emojipicker.ts index 240a664884..399d2949d2 100644 --- a/tests/src/utils/emojipicker.ts +++ b/tests/src/utils/emojipicker.ts @@ -1,17 +1,48 @@ -import { userEvent } from "./context.js"; -import { EMOJI_PICKER_SELECTOR } from "./const.js"; -import { sleep, waitForSelector } from "./editor.js"; +import { vi } from "vite-plus/test"; +import { EDITOR_SELECTOR, EMOJI_PICKER_SELECTOR } from "./const.js"; -export async function openEmojiPicker() { - await userEvent.keyboard(":"); +/** Wait for both the rendered result and the editor's keyboard bridge. */ +export async function waitForSelectedEmoji() { + return vi.waitFor( + () => { + const btn = document.querySelector( + `${EMOJI_PICKER_SELECTOR} .bn-frimousse-emoji[data-selected]`, + ); + const activeDescendant = document + .querySelector(EDITOR_SELECTOR) + ?.getAttribute("aria-activedescendant"); + if (!btn || !activeDescendant || btn.id !== activeDescendant) { + throw new Error("Emoji keyboard selection is not ready"); + } + return btn; + }, + { timeout: 5000 }, + ); } -export async function executeEmojiCommand(command: string) { - await openEmojiPicker(); - await sleep(100); - await userEvent.keyboard(command); - await waitForSelector(EMOJI_PICKER_SELECTOR); - await sleep(500); - await userEvent.keyboard("{Enter}"); - await sleep(500); +/** Screenshots must include the first category, not a partially scrolled mount. */ +export async function waitForEmojiPickerLayout() { + await waitForSelectedEmoji(); + await vi.waitFor(() => { + const viewport = document.querySelector( + `${EMOJI_PICKER_SELECTOR} [frimousse-viewport]`, + ); + const header = Array.from( + document.querySelectorAll( + `${EMOJI_PICKER_SELECTOR} [frimousse-category-header]`, + ), + ).find((element) => !element.closest("[aria-hidden]")); + if (!viewport || !header) { + throw new Error("Emoji category header has not rendered"); + } + const viewportRect = viewport.getBoundingClientRect(); + const headerRect = header.getBoundingClientRect(); + if ( + headerRect.height === 0 || + headerRect.top < viewportRect.top || + headerRect.bottom > viewportRect.bottom + ) { + throw new Error("First emoji category header is not fully visible"); + } + }); }