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 (
-
+