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/docs/content/docs/react/components/emoji-picker.mdx b/docs/content/docs/react/components/emoji-picker.mdx new file mode 100644 index 0000000000..38837afb66 --- /dev/null +++ b/docs/content/docs/react/components/emoji-picker.mdx @@ -0,0 +1,36 @@ +--- +title: Emoji Picker +description: Insert emoji with the built-in localized picker and configure its trigger and visibility. +--- + +# Emoji Picker + +The Emoji Picker is enabled by default. Type `:` followed by at least two non-whitespace characters to search, or select the emoji item in the Slash Menu. + +The picker uses [Frimousse](https://frimousse.liveblocks.io/) and loads emoji labels and search keywords for the editor's [locale](/docs/features/localization). + +## Disabling the Picker + +Pass `emojiPicker={false}` to `BlockNoteView` to disable the default inline picker. + +## Configuring the Picker + +To configure the trigger or minimum query length, disable the default picker and mount `EmojiPickerController` inside `BlockNoteView`: + +```tsx +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { EmojiPickerController, useCreateBlockNote } from "@blocknote/react"; + +export default function Editor() { + const editor = useCreateBlockNote(); + + return ( + + + + ); +} +``` + +The controller also accepts `shouldOpen`, `floatingUIOptions`, and `portalElement` for controlling when and where it opens. Set `minQueryLength={0}` to allow opening immediately after the trigger. diff --git a/docs/content/docs/react/components/grid-suggestion-menus.mdx b/docs/content/docs/react/components/grid-suggestion-menus.mdx deleted file mode 100644 index 8fe53197f8..0000000000 --- a/docs/content/docs/react/components/grid-suggestion-menus.mdx +++ /dev/null @@ -1,52 +0,0 @@ ---- -title: Grid Suggestion Menus -description: In addition to displaying Suggestion Menus as stacks, BlockNote also supports displaying them as grids. ---- - -# Grid Suggestion Menus - -Grid Suggestion Menus appear when the user enters a trigger character, and text after the character is used to filter the menu items. - -Grid Suggestion Menus are similar to regular Suggestion Menus, but results are organized in a grid, and users can use all arrow keys (including left, right) on their keyboard to navigate the results. - -## Emoji Picker - -The Emoji Picker is a Grid Suggestion Menu that opens with the `:` character (or when selecting emoji item in the Slash Menu). - -It only displays once the user types 2 non-whitespace characters a query, to minimize cases where the user only wants to enter the `:` character. - - - -### Changing Emoji Picker Columns - -By default, the Emoji Picker is rendered with 10 columns, but you can change this to any amount. In the demo below, the Emoji Picker is changed to only display 5 columns. - - - -Passing `emojiPicker={false}` to `BlockNoteView` tells BlockNote not to show the default Emoji Picker. Adding the `GridSuggestionMenuController` with `triggerCharacter={":"}` and `columns={5}` tells BlockNote to show one with 5 columns instead. - -### Replacing the Emoji Picker Component - -You can replace the React component used for the Emoji Picker with your own, as you can see in the demo below. - - - -Again, we add a `GridSuggestionMenuController` component with `triggerCharacter={":"}` and set `emojiPicker={false}` to replace the default Emoji Picker. - -Now, we also pass a component to its `gridSuggestionMenuComponent` prop. The `gridSuggestionMenuComponent` we pass is responsible for rendering the filtered items. The `GridSuggestionMenuController` controls its position and visibility (below the trigger character), and it also determines which items should be shown. Since we don't specify which items to show (the `getItems` prop isn't defined), it will use the default items for a grid, which are the emojis. - -## Creating additional Grid Suggestion Menus - -You can add additional Grid Suggestion Menus to the editor, which can use any trigger character. The demo below adds an example Grid Suggestion Menu for mentions, where each item is the first character of the user's name, and opens with the `@` character. - - - -Changing the column count in the new Grid Suggestion Menu, or the component used to render it, is done the same way as for the [Emoji Picker](/docs/react/components/suggestion-menus). For more information about how the mentions elements work, see [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content). 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/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/.bnexample.json b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/.bnexample.json deleted file mode 100644 index 3dfb6cf780..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/.bnexample.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "playground": true, - "docs": true, - "author": "yousefed", - "tags": [ - "Intermediate", - "Blocks", - "UI Components", - "Suggestion Menus", - "Emoji Picker" - ] -} diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/README.md b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/README.md deleted file mode 100644 index 124ab50535..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Changing Emoji Picker Columns - -In this example, we change the Emoji Picker to display 5 columns instead of 10. - -**Try it out:** Press the ":" key to open the Emoji Picker! - -**Relevant Docs:** - -- [Changing Emoji Picker Columns](/docs/react/components/suggestion-menus) -- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/index.html b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/index.html deleted file mode 100644 index e138fc136f..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Changing Emoji Picker Columns - - - -
- - - diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/main.tsx b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/main.tsx deleted file mode 100644 index 1260513388..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./src/App.jsx"; - -const root = createRoot(document.getElementById("root")!); -root.render( - - - , -); diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/package.json b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/package.json deleted file mode 100644 index 5ad89bd485..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@blocknote/example-ui-components-suggestion-menus-emoji-picker-columns", - "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "type": "module", - "private": true, - "version": "0.12.4", - "scripts": { - "start": "vite", - "dev": "vite", - "build:prod": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@blocknote/ariakit": "latest", - "@blocknote/core": "latest", - "@blocknote/mantine": "latest", - "@blocknote/react": "latest", - "@blocknote/shadcn": "latest", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "react": "^19.2.3", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.0" - } -} diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/src/App.tsx b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/src/App.tsx deleted file mode 100644 index fe4f1e4ed4..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/src/App.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import "@blocknote/core/fonts/inter.css"; -import { - GridSuggestionMenuController, - useCreateBlockNote, -} from "@blocknote/react"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; - -export default function App() { - // Creates a new editor instance. - const editor = useCreateBlockNote({ - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - content: "Press the ':' key to open the Emoji Picker", - }, - { - type: "paragraph", - content: "There are now 5 columns instead of 10", - }, - ], - }); - - // Renders the editor instance. - return ( - - - - ); -} diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/src/vite-env.d.ts b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/src/vite-env.d.ts deleted file mode 100644 index bc2d8a36f3..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/tsconfig.json b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/tsconfig.json deleted file mode 100644 index 2aa62c56e6..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "composite": true, - "paths": { - "@shared/*": ["../../../shared/*"] - } - }, - "include": ["."], - "__ADD_FOR_LOCAL_DEV_references": [ - { - "path": "../../../packages/core/" - }, - { - "path": "../../../packages/react/" - } - ] -} diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite-env.d.ts b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts deleted file mode 100644 index cbf6ff2ffc..0000000000 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import react from "@vitejs/plugin-react"; -import * as fs from "fs"; -import * as path from "path"; -import { defineConfig } from "vite"; -// https://vitejs.dev/config/ -export default defineConfig(((conf: { command: string }) => ({ - plugins: [react()], - optimizeDeps: {}, - build: { - sourcemap: true, - }, - resolve: { - alias: - conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) - ? {} - : ({ - // The repo-wide alias for the shared test-utils directory (private, - // so it only resolves inside the monorepo). Harmless for examples - // that don't use it. - "@shared": path.resolve(__dirname, "../../../shared/"), - // Comment out the lines below to load a built version of blocknote - // or, keep as is to load live from sources with live reload working - "@blocknote/core": path.resolve( - __dirname, - "../../../packages/core/src/", - ), - "@blocknote/react": path.resolve( - __dirname, - "../../../packages/react/src/", - ), - } as any), - }, -})) as Parameters[0]); diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/.bnexample.json b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/.bnexample.json deleted file mode 100644 index 7bfc6d29e1..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/.bnexample.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "playground": true, - "docs": true, - "author": "yousefed", - "tags": [ - "Intermediate", - "UI Components", - "Suggestion Menus", - "Emoji Picker", - "Appearance & Styling" - ] -} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/README.md b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/README.md deleted file mode 100644 index 85590bd013..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Replacing Emoji Picker Component - -In this example, we replace the default Emoji Picker component with a basic custom one. - -**Try it out:** Press the ":" key to see the new Emoji Picker! - -**Relevant Docs:** - -- [Replacing the Emoji Picker Component](/docs/react/components/suggestion-menus) -- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/index.html b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/index.html deleted file mode 100644 index 8b5b2ed7e7..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Replacing Emoji Picker Component - - - -
- - - diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/main.tsx b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/main.tsx deleted file mode 100644 index 1260513388..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./src/App.jsx"; - -const root = createRoot(document.getElementById("root")!); -root.render( - - - , -); diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/package.json b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/package.json deleted file mode 100644 index 0cf06466cb..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@blocknote/example-ui-components-suggestion-menus-emoji-picker-component", - "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "type": "module", - "private": true, - "version": "0.12.4", - "scripts": { - "start": "vite", - "dev": "vite", - "build:prod": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@blocknote/ariakit": "latest", - "@blocknote/core": "latest", - "@blocknote/mantine": "latest", - "@blocknote/react": "latest", - "@blocknote/shadcn": "latest", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "react": "^19.2.3", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.0" - } -} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/App.tsx b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/App.tsx deleted file mode 100644 index 452203ce6d..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/App.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import "@blocknote/core/fonts/inter.css"; -import { - DefaultReactGridSuggestionItem, - GridSuggestionMenuController, - GridSuggestionMenuProps, - useCreateBlockNote, -} from "@blocknote/react"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; - -import "./styles.css"; - -// Custom component to replace the default Emoji Picker. -function CustomEmojiPicker( - props: GridSuggestionMenuProps, -) { - return ( -
- {props.items.map((item, index) => ( -
{ - props.onItemClick?.(item); - }} - > - {item.icon} -
- ))} -
- ); -} - -export default function App() { - // Creates a new editor instance. - const editor = useCreateBlockNote({ - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - content: "Press the ':' key to open the Emoji Picker", - }, - { - type: "paragraph", - content: "It's been replaced with a custom component", - }, - ], - }); - - // Renders the editor instance. - return ( - - - - ); -} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/styles.css b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/styles.css deleted file mode 100644 index 1688511e20..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/styles.css +++ /dev/null @@ -1,40 +0,0 @@ -.emoji-picker { - background-color: white; - border: 1px solid lightgray; - border-radius: 2px; - box-shadow: 0 0 8px #dddddd; - - display: grid; - flex-direction: column; - gap: 8px; - height: fit-content; - max-height: inherit; - - overflow: auto; - - padding: 8px; - - top: 8px; -} - -.emoji-picker-item { - background-color: white; - border: 1px solid lightgray; - border-radius: 2px; - box-shadow: 0 0 4px #dddddd; - - cursor: pointer; - - font-size: 16px; - - align-items: center; - display: flex; - flex-direction: row; - - padding: 8px; -} - -.emoji-picker-item:hover, -.emoji-picker-item.selected { - background-color: lightgray; -} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/vite-env.d.ts b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/vite-env.d.ts deleted file mode 100644 index bc2d8a36f3..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/tsconfig.json b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/tsconfig.json deleted file mode 100644 index 2aa62c56e6..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "composite": true, - "paths": { - "@shared/*": ["../../../shared/*"] - } - }, - "include": ["."], - "__ADD_FOR_LOCAL_DEV_references": [ - { - "path": "../../../packages/core/" - }, - { - "path": "../../../packages/react/" - } - ] -} diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite-env.d.ts b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts deleted file mode 100644 index cbf6ff2ffc..0000000000 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import react from "@vitejs/plugin-react"; -import * as fs from "fs"; -import * as path from "path"; -import { defineConfig } from "vite"; -// https://vitejs.dev/config/ -export default defineConfig(((conf: { command: string }) => ({ - plugins: [react()], - optimizeDeps: {}, - build: { - sourcemap: true, - }, - resolve: { - alias: - conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) - ? {} - : ({ - // The repo-wide alias for the shared test-utils directory (private, - // so it only resolves inside the monorepo). Harmless for examples - // that don't use it. - "@shared": path.resolve(__dirname, "../../../shared/"), - // Comment out the lines below to load a built version of blocknote - // or, keep as is to load live from sources with live reload working - "@blocknote/core": path.resolve( - __dirname, - "../../../packages/core/src/", - ), - "@blocknote/react": path.resolve( - __dirname, - "../../../packages/react/src/", - ), - } as any), - }, -})) as Parameters[0]); diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/.bnexample.json b/examples/03-ui-components/10-suggestion-menus-grid-mentions/.bnexample.json deleted file mode 100644 index f347671a56..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/.bnexample.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "playground": true, - "docs": true, - "author": "yousefed", - "tags": [ - "Intermediate", - "Inline Content", - "Custom Schemas", - "Suggestion Menus" - ] -} diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/README.md b/examples/03-ui-components/10-suggestion-menus-grid-mentions/README.md deleted file mode 100644 index f813a631af..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Grid Mentions Menu - -In this example, we create a custom `Mention` inline content type which is used to tag people. In addition, we create a new Suggestion Menu for mentions which opens with the "@" character. This Suggestion Menu is displayed as a grid of 2 columns, where each item is the first letter of the person's name. - -**Try it out:** Press the "@" key to open the mentions menu and insert a mention! - -**Relevant Docs:** - -- [Custom Inline Content Types](/docs/features/custom-schemas/custom-inline-content) -- [Creating Suggestion Menus](/docs/react/components/suggestion-menus) -- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/index.html b/examples/03-ui-components/10-suggestion-menus-grid-mentions/index.html deleted file mode 100644 index c2959cb33a..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Grid Mentions Menu - - - -
- - - diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/main.tsx b/examples/03-ui-components/10-suggestion-menus-grid-mentions/main.tsx deleted file mode 100644 index 1260513388..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import React from "react"; -import { createRoot } from "react-dom/client"; -import App from "./src/App.jsx"; - -const root = createRoot(document.getElementById("root")!); -root.render( - - - , -); diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/package.json b/examples/03-ui-components/10-suggestion-menus-grid-mentions/package.json deleted file mode 100644 index 7009d0a507..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@blocknote/example-ui-components-suggestion-menus-grid-mentions", - "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "type": "module", - "private": true, - "version": "0.12.4", - "scripts": { - "start": "vite", - "dev": "vite", - "build:prod": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@blocknote/ariakit": "latest", - "@blocknote/core": "latest", - "@blocknote/mantine": "latest", - "@blocknote/react": "latest", - "@blocknote/shadcn": "latest", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "react": "^19.2.3", - "react-dom": "^19.2.3" - }, - "devDependencies": { - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.0" - } -} diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/App.tsx b/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/App.tsx deleted file mode 100644 index 8050cadb21..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/App.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { BlockNoteSchema, defaultInlineContentSpecs } from "@blocknote/core"; -import { filterSuggestionItems } from "@blocknote/core/extensions"; -import "@blocknote/core/fonts/inter.css"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; -import { - DefaultReactGridSuggestionItem, - GridSuggestionMenuController, - useCreateBlockNote, -} from "@blocknote/react"; - -import { Mention } from "./Mention"; - -// Our schema with inline content specs, which contain the configs and -// implementations for inline content that we want our editor to use. -const schema = BlockNoteSchema.create({ - inlineContentSpecs: { - // Adds all default inline content. - ...defaultInlineContentSpecs, - // Adds the mention tag. - mention: Mention, - }, -}); - -// Function which gets all users for the mentions menu. -const getMentionMenuItems = ( - editor: typeof schema.BlockNoteEditor, -): DefaultReactGridSuggestionItem[] => { - const users = ["Steve", "Bob", "Joe", "Mike"]; - - return users.map((user) => ({ - id: user, - onItemClick: () => { - editor.insertInlineContent([ - { - type: "mention", - props: { - user, - }, - }, - " ", // add a space after the mention - ]); - }, - icon:

{user.substring(0, 1)}

, - })); -}; - -export function App() { - const editor = useCreateBlockNote({ - schema, - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - content: [ - { - type: "mention", - props: { - user: "Steve", - }, - }, - { - type: "text", - text: " <- This is an example mention", - styles: {}, - }, - ], - }, - { - type: "paragraph", - content: "Press the '@' key to open the mentions menu and add another", - }, - ], - }); - - return ( - - {/* Adds a mentions menu which opens with the "@" key */} - - // Gets the mentions menu items - // TODO: Fix map/type cast - filterSuggestionItems( - getMentionMenuItems(editor).map((item) => ({ - ...item, - title: item.id, - })), - query, - ) as DefaultReactGridSuggestionItem[] - } - columns={2} - minQueryLength={2} - /> - - ); -} - -export default App; diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/Mention.tsx b/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/Mention.tsx deleted file mode 100644 index b19366ff65..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/Mention.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createReactInlineContentSpec } from "@blocknote/react"; - -// The Mention inline content. -export const Mention = createReactInlineContentSpec( - { - type: "mention", - propSchema: { - user: { - default: "Unknown", - }, - }, - content: "none", - }, - { - render: (props) => ( - - @{props.inlineContent.props.user} - - ), - }, -); diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/vite-env.d.ts b/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/vite-env.d.ts deleted file mode 100644 index bc2d8a36f3..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/tsconfig.json b/examples/03-ui-components/10-suggestion-menus-grid-mentions/tsconfig.json deleted file mode 100644 index 2aa62c56e6..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "allowJs": false, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "composite": true, - "paths": { - "@shared/*": ["../../../shared/*"] - } - }, - "include": ["."], - "__ADD_FOR_LOCAL_DEV_references": [ - { - "path": "../../../packages/core/" - }, - { - "path": "../../../packages/react/" - } - ] -} diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite-env.d.ts b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts deleted file mode 100644 index cbf6ff2ffc..0000000000 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import react from "@vitejs/plugin-react"; -import * as fs from "fs"; -import * as path from "path"; -import { defineConfig } from "vite"; -// https://vitejs.dev/config/ -export default defineConfig(((conf: { command: string }) => ({ - plugins: [react()], - optimizeDeps: {}, - build: { - sourcemap: true, - }, - resolve: { - alias: - conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) - ? {} - : ({ - // The repo-wide alias for the shared test-utils directory (private, - // so it only resolves inside the monorepo). Harmless for examples - // that don't use it. - "@shared": path.resolve(__dirname, "../../../shared/"), - // Comment out the lines below to load a built version of blocknote - // or, keep as is to load live from sources with live reload working - "@blocknote/core": path.resolve( - __dirname, - "../../../packages/core/src/", - ), - "@blocknote/react": path.resolve( - __dirname, - "../../../packages/react/src/", - ), - } as any), - }, -})) as Parameters[0]); diff --git a/packages/ariakit/src/components.ts b/packages/ariakit/src/components.ts index ed0c9e28d0..15caaa3a35 100644 --- a/packages/ariakit/src/components.ts +++ b/packages/ariakit/src/components.ts @@ -20,10 +20,6 @@ import { Popover, PopoverContent, PopoverTrigger } from "./popover/Popover.js"; import { SideMenu } from "./sideMenu/SideMenu.js"; import { SideMenuButton } from "./sideMenu/SideMenuButton.js"; import { AttributionTooltip } from "./attributionMarks/AttributionTooltip.js"; -import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.js"; -import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.js"; -import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.js"; -import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.js"; import { SuggestionMenu } from "./suggestionMenu/SuggestionMenu.js"; import { SuggestionMenuEmptyItem } from "./suggestionMenu/SuggestionMenuEmptyItem.js"; import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; @@ -58,12 +54,6 @@ export const components: Components = { TabPanel: PanelTab, TextInput: PanelTextInput, }, - GridSuggestionMenu: { - Root: GridSuggestionMenu, - Item: GridSuggestionMenuItem, - EmptyItem: GridSuggestionMenuEmptyItem, - Loader: GridSuggestionMenuLoader, - }, LinkToolbar: { Root: Toolbar, Button: ToolbarButton, diff --git a/packages/ariakit/src/style.css b/packages/ariakit/src/style.css index 027262c703..2f91a7803f 100644 --- a/packages/ariakit/src/style.css +++ b/packages/ariakit/src/style.css @@ -150,52 +150,6 @@ width: 16px; } -.bn-ariakit .bn-grid-suggestion-menu { - background: var(--bn-colors-menu-background); - border-radius: var(--bn-border-radius-large); - box-shadow: var(--bn-shadow-medium); - display: grid; - gap: 7px; - height: fit-content; - justify-items: center; - max-height: inherit; - overflow-y: auto; - padding: 20px; -} - -.bn-ariakit .bn-grid-suggestion-menu-item { - align-items: center; - border-radius: var(--bn-border-radius-large); - cursor: pointer; - display: flex; - font-size: 20px; - height: 32px; - justify-content: center; - margin: 2px; - padding: 6px; - width: 32px; -} - -.bn-ariakit .bn-grid-suggestion-menu-item[aria-selected="true"], -.bn-ariakit .bn-grid-suggestion-menu-item:hover { - background-color: var(--bn-colors-hovered-background); -} - -.bn-ariakit .bn-grid-suggestion-menu-empty-item, -.bn-ariakit .bn-grid-suggestion-menu-loader { - align-items: center; - color: var(--bn-colors-menu-text); - display: flex; - font-size: 14px; - font-weight: 500; - height: 32px; - justify-content: center; -} - -.bn-ariakit .bn-grid-suggestion-menu-loader span { - background-color: var(--bn-colors-side-menu); -} - .bn-ariakit .bn-side-menu { align-items: center; display: flex; diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx deleted file mode 100644 index 5bfa4e0141..0000000000 --- a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; - -export const GridSuggestionMenu = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["Root"] ->((props, ref) => { - const { className, children, id, columns, ...rest } = props; - - assertEmpty(rest); - - return ( -
- {children} -
- ); -}); diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx deleted file mode 100644 index 2d2c086ac6..0000000000 --- a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { assertEmpty, mergeCSSClasses } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; - -export const GridSuggestionMenuEmptyItem = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["EmptyItem"] ->((props, ref) => { - const { className, children, columns, ...rest } = props; - - assertEmpty(rest); - - return ( -
-
{children}
-
- ); -}); diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx deleted file mode 100644 index 81bbde8a62..0000000000 --- a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, elementOverflow, mergeRefs } from "@blocknote/react"; -import { forwardRef, useEffect, useRef } from "react"; - -export const GridSuggestionMenuItem = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["Item"] ->((props, ref) => { - const { className, isSelected, onClick, item, id, ...rest } = props; - - assertEmpty(rest); - - const itemRef = useRef(null); - - useEffect(() => { - if (!itemRef.current || !isSelected) { - return; - } - - const overflow = elementOverflow( - itemRef.current, - itemRef.current.closest(".bn-grid-suggestion-menu")!, - ); - - if (overflow !== "none") { - itemRef.current.scrollIntoView({ block: "nearest" }); - } - }, [isSelected]); - - return ( -
- {item.icon} -
- ); -}); diff --git a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx b/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx deleted file mode 100644 index ab5d7dcd54..0000000000 --- a/packages/ariakit/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; - -export const GridSuggestionMenuLoader = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["Loader"] ->((props, ref) => { - const { - className, - children, // unused, using "dots" instead - columns, - ...rest - } = props; - - assertEmpty(rest); - - return ( -
- {children} -
- ); -}); diff --git a/packages/core/NOTICE.txt b/packages/core/NOTICE.txt index 84d35c2f6e..dfe2d9611f 100644 --- a/packages/core/NOTICE.txt +++ b/packages/core/NOTICE.txt @@ -30,6 +30,36 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +iamcal emoji-data shortcodes +---------------------------- + +The shared emoji data includes legacy shortcodes from iamcal/emoji-data, +distributed through emojibase-data/en/shortcodes/iamcal.json: + +https://github.com/iamcal/emoji-data + +The MIT License (MIT) + +Copyright (c) 2013 Cal Henderson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + Unicode CLDR annotation data ---------------------------- 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..857d3c7bd7 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. @@ -18,11 +22,45 @@ annotated as `string`: otherwise declaration emit repeats the entire dataset as string literal types in the published `.d.ts` files. Each locale module exposes its encoded string as a default export for the loader, -alongside its existing named export. Generation verifies emoji identity/order +alongside its 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), matching Frimousse's runtime search. + +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 +markers, and optional skin templates. A separate string in the same shared module +stores pipe-separated shortcodes, one row per emoji (including empty rows). +Grouping aliases together helps gzip/Brotli compress repeated shortcode fragments. +Shortcodes come from the `emojibase-data/en/shortcodes/iamcal.json` +build dependency (iamcal/emoji-data). +Generation maps canonical hexcodes, not localized labels. The loader appends +these aliases to every locale's tags, skipping case-insensitive duplicates and +aliases already contained in the label, just like generator tag compaction. +The loader preserves localized labels and tag order. Both picker searches +therefore see the same aliases without repeating them in 24 locale modules. +Added aliases can affect ranking for queries that match them, since both +searches score matching stored tags. +Custom emoji-data URLs are not enriched: their labels/tags remain authoritative. +The preset does not include skin-tone-specific shortcodes; newer emoji without +a preset entry retain their localized label/keyword search. Source licenses and +attribution are included in `packages/core/NOTICE.txt`. + +A skin template uses `|` for each skin-tone modifier, including sequences with two modifiers. The loader substitutes each of the five Unicode modifiers. Generation verifies that all five reconstructed variants exactly match the upstream data and fails if this assumption changes. @@ -38,24 +76,19 @@ both labels and tags, including any English search aliases in localized data. The locale is optional and defaults to English. Every language uses `toLocaleLowerCase`; the locale is normalized to its language once per search. -## Size comparison - -Measured with Node's default `gzipSync` on JavaScript string exports (UTF-8 bytes, -without comments or source maps), against the previous five-variant encoding: +## Shortcode coverage and shared size -| Shared data | Before | After | -| ----------- | -----: | -----: | -| Raw | 60,793 | 40,955 | -| Gzip | 11,364 | 7,367 | -| Brotli | 6,864 | 5,434 | +Shortcodes are stored once in the shared module, not repeated in the 24 locale +modules. The map supplies 1,959 aliases for 1,898 +of the 1,906 base emoji; the eight Unicode 16 additions have no preset entry. +Measured on the formatted `common.ts` module (UTF-8, including comments/type +annotations, Node's default compressors; these are not final bundled sizes): -The decoder adds a small substitution expression. All languages benefit from -this shared-data reduction. Omitting empty tag fields additionally saves 4,474 -raw bytes and 885 gzip bytes across the 24 locale string literals. +| Shared module | Bytes | +| ------------- | -----: | +| Raw | 65,938 | +| Gzip | 17,767 | +| Brotli | 13,595 | -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. +When evaluating encoding changes, measure compressed size and preserve compacted +tag order and content: 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..6ff431ec27 --- /dev/null +++ b/packages/core/scripts/emoji-data/format.mjs @@ -0,0 +1,259 @@ +/** 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, shortcodes = {}) { + 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 aliases = shortcodes[emoji.hexcode]; + const entry = { + emoji: emoji.emoji, + category: emoji.group, + version: emoji.version, + label: capitalize(normalizeSearchText(emoji.label), locale), + tags: (emoji.tags ?? []).map(normalizeSearchText), + // Build-only metadata, encoded once in common.ts rather than per locale. + shortcodes: typeof aliases === "string" ? [aliases] : (aliases ?? []), + }; + 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}`); + } + }); + } + const fields = [ + emoji.emoji, + emoji.category, + emoji.version, + emoji.countryFlag ? "1" : "", + skins, + ]; + return fields.join(FIELD_SEPARATOR); + }) + .join("\n"); + // Group like data together for better gzip/Brotli compression, retaining + // positional alignment (including empty rows) with the shared emoji corpus. + const shortcodes = data.emojis + .map((emoji) => + encodeList( + compactSearchTags("", emoji.shortcodes ?? []), + `shortcodes for ${emoji.emoji}`, + ), + ) + .join("\n"); + return { categoryIndices, emojis, shortcodes }; +} + +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..183606ea75 --- /dev/null +++ b/packages/core/scripts/emoji-data/format.test.mjs @@ -0,0 +1,196 @@ +// @vitest-environment node +import { expect, it } from "vite-plus/test"; + +// Pure transformations: no generator entry point or translation corpora needed. +import { + buildFrimousseData, + compactSearchTags, + encodeFrimousseCommonData, + encodeFrimousseData, + localizeEmoji, +} from "./format.mjs"; + +it("encodes canonical single/multiple shortcodes once, without changing localized tags", () => { + const data = buildFrimousseData( + [ + { + hexcode: "1F60D", + emoji: "😍", + label: "amour", + group: 0, + version: 1, + tags: ["aimer"], + }, + { hexcode: "1F44D", emoji: "👍️", label: "pouce", group: 0, version: 1 }, + { + hexcode: "1FAE9", + emoji: "🫩", + label: "fatigué", + group: 0, + version: 16, + }, + ], + { + groups: [{ order: 0, key: "faces", message: "visages" }], + subgroups: [], + skinTones: [], + }, + "fr", + { "1F60D": "heart_eyes", "1F44D": ["+1", "thumbsup", "THUMBSUP", ""] }, + ); + expect(encodeFrimousseCommonData(data)).toEqual({ + categoryIndices: "0", + emojis: "😍\t0\t1\t\t\n👍️\t0\t1\t\t\n🫩\t0\t16\t\t", + shortcodes: "heart_eyes\n+1|thumbsup\n", + }); + expect(data.emojis.map(({ tags }) => tags)).toEqual([["aimer"], [], []]); + expect(encodeFrimousseData(data, data)).toBe( + "fr\nVisages\n\nAmour\taimer\nPouce\nFatigué", + ); +}); + +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", + shortcodes: "\n", + }); + 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..1fba8442d0 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,20 @@ 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, shortcodes } = + 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`, + `// 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)};\nexport const frimousseShortcodes: string = ${JSON.stringify(shortcodes)};\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( @@ -496,33 +275,24 @@ function generateFrimousseData() { // Load English base data (needed for extra locales) 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(","); + // The same iamcal/emoji-data short names used by legacy Emoji Mart. Keying by + // canonical hexcode works for every locale, including presentation selectors. + const shortcodes = require("emojibase-data/en/shortcodes/iamcal.json"); + const commonData = buildFrimousseData(enEmojis, enMessages, "en", shortcodes); 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 +300,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/editor/BlockNoteEditor.node.test.ts b/packages/core/src/editor/BlockNoteEditor.node.test.ts index 144d0e9c6e..eb70307057 100644 --- a/packages/core/src/editor/BlockNoteEditor.node.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.node.test.ts @@ -2,6 +2,39 @@ import { expect, it, vi } from "vite-plus/test"; import { createExtension } from "./BlockNoteExtension.js"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; +import { en } from "../i18n/locales/en.js"; + +it("reuses complete dictionaries without copying them", () => { + const dictionary = { + ...en, + emoji_picker: { search: "", loading: "Wait", search_no_results: "None" }, + }; + const editor = BlockNoteEditor.create({ dictionary }); + const defaultEditor = BlockNoteEditor.create(); + try { + expect(editor.dictionary).toBe(dictionary); + expect(editor.dictionary.emoji_picker).toBe(dictionary.emoji_picker); + expect(defaultEditor.dictionary).toBe(en); + } finally { + editor._tiptapEditor.destroy(); + defaultEditor._tiptapEditor.destroy(); + } +}); + +it("supplies picker strings for dictionaries that omit the picker section", () => { + const { emoji_picker: _picker, ...dictionary } = en; + const editor = BlockNoteEditor.create({ + // @ts-expect-error Simulate a dictionary authored before emoji_picker existed. + dictionary, + }); + try { + expect(editor.dictionary.emoji_picker).toEqual(en.emoji_picker); + expect(editor.dictionary.slash_menu).toBe(dictionary.slash_menu); + expect(editor.dictionary).toBe(dictionary); + } finally { + editor._tiptapEditor.destroy(); + } +}); it("emits destroy once, including listeners registered during extension initialization", () => { const onDestroy = vi.fn(); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 699dcbbc5b..9c3586cd86 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -466,6 +466,9 @@ export class BlockNoteEditor< super(); this.dictionary = options.dictionary || en; + if (this.dictionary.emoji_picker === undefined) { + this.dictionary.emoji_picker = en.emoji_picker; + } this.settings = { tables: { splitCells: options?.tables?.splitCells ?? false, diff --git a/packages/core/src/emoji-data/frimousse/common.ts b/packages/core/src/emoji-data/frimousse/common.ts index f47a4cf9f1..9e9f952e1b 100644 --- a/packages/core/src/emoji-data/frimousse/common.ts +++ b/packages/core/src/emoji-data/frimousse/common.ts @@ -4,3 +4,5 @@ export const frimousseCategoryIndices: string = "0,1,3,4,5,6,7,8,9"; export const frimousseCommonData: string = "😀\t0\t1\t\t\n😃\t0\t0.6\t\t\n😄\t0\t0.6\t\t\n😁\t0\t0.6\t\t\n😆\t0\t0.6\t\t\n😅\t0\t0.6\t\t\n🤣\t0\t3\t\t\n😂\t0\t0.6\t\t\n🙂\t0\t1\t\t\n🙃\t0\t1\t\t\n🫠\t0\t14\t\t\n😉\t0\t0.6\t\t\n😊\t0\t0.6\t\t\n😇\t0\t1\t\t\n🥰\t0\t11\t\t\n😍\t0\t0.6\t\t\n🤩\t0\t5\t\t\n😘\t0\t0.6\t\t\n😗\t0\t1\t\t\n☺️\t0\t0.6\t\t\n😚\t0\t0.6\t\t\n😙\t0\t1\t\t\n🥲\t0\t13\t\t\n😋\t0\t0.6\t\t\n😛\t0\t1\t\t\n😜\t0\t0.6\t\t\n🤪\t0\t5\t\t\n😝\t0\t0.6\t\t\n🤑\t0\t1\t\t\n🤗\t0\t1\t\t\n🤭\t0\t5\t\t\n🫢\t0\t14\t\t\n🫣\t0\t14\t\t\n🤫\t0\t5\t\t\n🤔\t0\t1\t\t\n🫡\t0\t14\t\t\n🤐\t0\t1\t\t\n🤨\t0\t5\t\t\n😐️\t0\t0.7\t\t\n😑\t0\t1\t\t\n😶\t0\t1\t\t\n🫥\t0\t14\t\t\n😶‍🌫️\t0\t13.1\t\t\n😏\t0\t0.6\t\t\n😒\t0\t0.6\t\t\n🙄\t0\t1\t\t\n😬\t0\t1\t\t\n😮‍💨\t0\t13.1\t\t\n🤥\t0\t3\t\t\n🫨\t0\t15\t\t\n🙂‍↔️\t0\t15.1\t\t\n🙂‍↕️\t0\t15.1\t\t\n😌\t0\t0.6\t\t\n😔\t0\t0.6\t\t\n😪\t0\t0.6\t\t\n🤤\t0\t3\t\t\n😴\t0\t1\t\t\n🫩\t0\t16\t\t\n😷\t0\t0.6\t\t\n🤒\t0\t1\t\t\n🤕\t0\t1\t\t\n🤢\t0\t3\t\t\n🤮\t0\t5\t\t\n🤧\t0\t3\t\t\n🥵\t0\t11\t\t\n🥶\t0\t11\t\t\n🥴\t0\t11\t\t\n😵\t0\t0.6\t\t\n😵‍💫\t0\t13.1\t\t\n🤯\t0\t5\t\t\n🤠\t0\t3\t\t\n🥳\t0\t11\t\t\n🥸\t0\t13\t\t\n😎\t0\t1\t\t\n🤓\t0\t1\t\t\n🧐\t0\t5\t\t\n😕\t0\t1\t\t\n🫤\t0\t14\t\t\n😟\t0\t1\t\t\n🙁\t0\t1\t\t\n☹️\t0\t0.7\t\t\n😮\t0\t1\t\t\n😯\t0\t1\t\t\n😲\t0\t0.6\t\t\n😳\t0\t0.6\t\t\n🥺\t0\t11\t\t\n🥹\t0\t14\t\t\n😦\t0\t1\t\t\n😧\t0\t1\t\t\n😨\t0\t0.6\t\t\n😰\t0\t0.6\t\t\n😥\t0\t0.6\t\t\n😢\t0\t0.6\t\t\n😭\t0\t0.6\t\t\n😱\t0\t0.6\t\t\n😖\t0\t0.6\t\t\n😣\t0\t0.6\t\t\n😞\t0\t0.6\t\t\n😓\t0\t0.6\t\t\n😩\t0\t0.6\t\t\n😫\t0\t0.6\t\t\n🥱\t0\t12\t\t\n😤\t0\t0.6\t\t\n😡\t0\t0.6\t\t\n😠\t0\t0.6\t\t\n🤬\t0\t5\t\t\n😈\t0\t1\t\t\n👿\t0\t0.6\t\t\n💀\t0\t0.6\t\t\n☠️\t0\t1\t\t\n💩\t0\t0.6\t\t\n🤡\t0\t3\t\t\n👹\t0\t0.6\t\t\n👺\t0\t0.6\t\t\n👻\t0\t0.6\t\t\n👽️\t0\t0.6\t\t\n👾\t0\t0.6\t\t\n🤖\t0\t1\t\t\n😺\t0\t0.6\t\t\n😸\t0\t0.6\t\t\n😹\t0\t0.6\t\t\n😻\t0\t0.6\t\t\n😼\t0\t0.6\t\t\n😽\t0\t0.6\t\t\n🙀\t0\t0.6\t\t\n😿\t0\t0.6\t\t\n😾\t0\t0.6\t\t\n🙈\t0\t0.6\t\t\n🙉\t0\t0.6\t\t\n🙊\t0\t0.6\t\t\n💌\t0\t0.6\t\t\n💘\t0\t0.6\t\t\n💝\t0\t0.6\t\t\n💖\t0\t0.6\t\t\n💗\t0\t0.6\t\t\n💓\t0\t0.6\t\t\n💞\t0\t0.6\t\t\n💕\t0\t0.6\t\t\n💟\t0\t0.6\t\t\n❣️\t0\t1\t\t\n💔\t0\t0.6\t\t\n❤️‍🔥\t0\t13.1\t\t\n❤️‍🩹\t0\t13.1\t\t\n❤️\t0\t0.6\t\t\n🩷\t0\t15\t\t\n🧡\t0\t5\t\t\n💛\t0\t0.6\t\t\n💚\t0\t0.6\t\t\n💙\t0\t0.6\t\t\n🩵\t0\t15\t\t\n💜\t0\t0.6\t\t\n🤎\t0\t12\t\t\n🖤\t0\t3\t\t\n🩶\t0\t15\t\t\n🤍\t0\t12\t\t\n💋\t0\t0.6\t\t\n💯\t0\t0.6\t\t\n💢\t0\t0.6\t\t\n💥\t0\t0.6\t\t\n💫\t0\t0.6\t\t\n💦\t0\t0.6\t\t\n💨\t0\t0.6\t\t\n🕳️\t0\t0.7\t\t\n💬\t0\t0.6\t\t\n👁️‍🗨️\t0\t2\t\t\n🗨️\t0\t2\t\t\n🗯️\t0\t0.7\t\t\n💭\t0\t1\t\t\n💤\t0\t0.6\t\t\n👋\t1\t0.6\t\t👋|\n🤚\t1\t3\t\t🤚|\n🖐️\t1\t0.7\t\t🖐|\n✋️\t1\t0.6\t\t✋|\n🖖\t1\t1\t\t🖖|\n🫱\t1\t14\t\t🫱|\n🫲\t1\t14\t\t🫲|\n🫳\t1\t14\t\t🫳|\n🫴\t1\t14\t\t🫴|\n🫷\t1\t15\t\t🫷|\n🫸\t1\t15\t\t🫸|\n👌\t1\t0.6\t\t👌|\n🤌\t1\t13\t\t🤌|\n🤏\t1\t12\t\t🤏|\n✌️\t1\t0.6\t\t✌|\n🤞\t1\t3\t\t🤞|\n🫰\t1\t14\t\t🫰|\n🤟\t1\t5\t\t🤟|\n🤘\t1\t1\t\t🤘|\n🤙\t1\t3\t\t🤙|\n👈️\t1\t0.6\t\t👈|\n👉️\t1\t0.6\t\t👉|\n👆️\t1\t0.6\t\t👆|\n🖕\t1\t1\t\t🖕|\n👇️\t1\t0.6\t\t👇|\n☝️\t1\t0.6\t\t☝|\n🫵\t1\t14\t\t🫵|\n👍️\t1\t0.6\t\t👍|\n👎️\t1\t0.6\t\t👎|\n✊️\t1\t0.6\t\t✊|\n👊\t1\t0.6\t\t👊|\n🤛\t1\t3\t\t🤛|\n🤜\t1\t3\t\t🤜|\n👏\t1\t0.6\t\t👏|\n🙌\t1\t0.6\t\t🙌|\n🫶\t1\t14\t\t🫶|\n👐\t1\t0.6\t\t👐|\n🤲\t1\t5\t\t🤲|\n🤝\t1\t3\t\t🤝|\n🙏\t1\t0.6\t\t🙏|\n✍️\t1\t0.7\t\t✍|\n💅\t1\t0.6\t\t💅|\n🤳\t1\t3\t\t🤳|\n💪\t1\t0.6\t\t💪|\n🦾\t1\t12\t\t\n🦿\t1\t12\t\t\n🦵\t1\t11\t\t🦵|\n🦶\t1\t11\t\t🦶|\n👂️\t1\t0.6\t\t👂|\n🦻\t1\t12\t\t🦻|\n👃\t1\t0.6\t\t👃|\n🧠\t1\t5\t\t\n🫀\t1\t13\t\t\n🫁\t1\t13\t\t\n🦷\t1\t11\t\t\n🦴\t1\t11\t\t\n👀\t1\t0.6\t\t\n👁️\t1\t0.7\t\t\n👅\t1\t0.6\t\t\n👄\t1\t0.6\t\t\n🫦\t1\t14\t\t\n👶\t1\t0.6\t\t👶|\n🧒\t1\t5\t\t🧒|\n👦\t1\t0.6\t\t👦|\n👧\t1\t0.6\t\t👧|\n🧑\t1\t5\t\t🧑|\n👱\t1\t0.6\t\t👱|\n👨\t1\t0.6\t\t👨|\n🧔\t1\t5\t\t🧔|\n🧔‍♂️\t1\t13.1\t\t🧔|‍♂️\n🧔‍♀️\t1\t13.1\t\t🧔|‍♀️\n👨‍🦰\t1\t11\t\t👨|‍🦰\n👨‍🦱\t1\t11\t\t👨|‍🦱\n👨‍🦳\t1\t11\t\t👨|‍🦳\n👨‍🦲\t1\t11\t\t👨|‍🦲\n👩\t1\t0.6\t\t👩|\n👩‍🦰\t1\t11\t\t👩|‍🦰\n🧑‍🦰\t1\t12.1\t\t🧑|‍🦰\n👩‍🦱\t1\t11\t\t👩|‍🦱\n🧑‍🦱\t1\t12.1\t\t🧑|‍🦱\n👩‍🦳\t1\t11\t\t👩|‍🦳\n🧑‍🦳\t1\t12.1\t\t🧑|‍🦳\n👩‍🦲\t1\t11\t\t👩|‍🦲\n🧑‍🦲\t1\t12.1\t\t🧑|‍🦲\n👱‍♀️\t1\t4\t\t👱|‍♀️\n👱‍♂️\t1\t4\t\t👱|‍♂️\n🧓\t1\t5\t\t🧓|\n👴\t1\t0.6\t\t👴|\n👵\t1\t0.6\t\t👵|\n🙍\t1\t0.6\t\t🙍|\n🙍‍♂️\t1\t4\t\t🙍|‍♂️\n🙍‍♀️\t1\t4\t\t🙍|‍♀️\n🙎\t1\t0.6\t\t🙎|\n🙎‍♂️\t1\t4\t\t🙎|‍♂️\n🙎‍♀️\t1\t4\t\t🙎|‍♀️\n🙅\t1\t0.6\t\t🙅|\n🙅‍♂️\t1\t4\t\t🙅|‍♂️\n🙅‍♀️\t1\t4\t\t🙅|‍♀️\n🙆\t1\t0.6\t\t🙆|\n🙆‍♂️\t1\t4\t\t🙆|‍♂️\n🙆‍♀️\t1\t4\t\t🙆|‍♀️\n💁\t1\t0.6\t\t💁|\n💁‍♂️\t1\t4\t\t💁|‍♂️\n💁‍♀️\t1\t4\t\t💁|‍♀️\n🙋\t1\t0.6\t\t🙋|\n🙋‍♂️\t1\t4\t\t🙋|‍♂️\n🙋‍♀️\t1\t4\t\t🙋|‍♀️\n🧏\t1\t12\t\t🧏|\n🧏‍♂️\t1\t12\t\t🧏|‍♂️\n🧏‍♀️\t1\t12\t\t🧏|‍♀️\n🙇\t1\t0.6\t\t🙇|\n🙇‍♂️\t1\t4\t\t🙇|‍♂️\n🙇‍♀️\t1\t4\t\t🙇|‍♀️\n🤦\t1\t3\t\t🤦|\n🤦‍♂️\t1\t4\t\t🤦|‍♂️\n🤦‍♀️\t1\t4\t\t🤦|‍♀️\n🤷\t1\t3\t\t🤷|\n🤷‍♂️\t1\t4\t\t🤷|‍♂️\n🤷‍♀️\t1\t4\t\t🤷|‍♀️\n🧑‍⚕️\t1\t12.1\t\t🧑|‍⚕️\n👨‍⚕️\t1\t4\t\t👨|‍⚕️\n👩‍⚕️\t1\t4\t\t👩|‍⚕️\n🧑‍🎓\t1\t12.1\t\t🧑|‍🎓\n👨‍🎓\t1\t4\t\t👨|‍🎓\n👩‍🎓\t1\t4\t\t👩|‍🎓\n🧑‍🏫\t1\t12.1\t\t🧑|‍🏫\n👨‍🏫\t1\t4\t\t👨|‍🏫\n👩‍🏫\t1\t4\t\t👩|‍🏫\n🧑‍⚖️\t1\t12.1\t\t🧑|‍⚖️\n👨‍⚖️\t1\t4\t\t👨|‍⚖️\n👩‍⚖️\t1\t4\t\t👩|‍⚖️\n🧑‍🌾\t1\t12.1\t\t🧑|‍🌾\n👨‍🌾\t1\t4\t\t👨|‍🌾\n👩‍🌾\t1\t4\t\t👩|‍🌾\n🧑‍🍳\t1\t12.1\t\t🧑|‍🍳\n👨‍🍳\t1\t4\t\t👨|‍🍳\n👩‍🍳\t1\t4\t\t👩|‍🍳\n🧑‍🔧\t1\t12.1\t\t🧑|‍🔧\n👨‍🔧\t1\t4\t\t👨|‍🔧\n👩‍🔧\t1\t4\t\t👩|‍🔧\n🧑‍🏭\t1\t12.1\t\t🧑|‍🏭\n👨‍🏭\t1\t4\t\t👨|‍🏭\n👩‍🏭\t1\t4\t\t👩|‍🏭\n🧑‍💼\t1\t12.1\t\t🧑|‍💼\n👨‍💼\t1\t4\t\t👨|‍💼\n👩‍💼\t1\t4\t\t👩|‍💼\n🧑‍🔬\t1\t12.1\t\t🧑|‍🔬\n👨‍🔬\t1\t4\t\t👨|‍🔬\n👩‍🔬\t1\t4\t\t👩|‍🔬\n🧑‍💻\t1\t12.1\t\t🧑|‍💻\n👨‍💻\t1\t4\t\t👨|‍💻\n👩‍💻\t1\t4\t\t👩|‍💻\n🧑‍🎤\t1\t12.1\t\t🧑|‍🎤\n👨‍🎤\t1\t4\t\t👨|‍🎤\n👩‍🎤\t1\t4\t\t👩|‍🎤\n🧑‍🎨\t1\t12.1\t\t🧑|‍🎨\n👨‍🎨\t1\t4\t\t👨|‍🎨\n👩‍🎨\t1\t4\t\t👩|‍🎨\n🧑‍✈️\t1\t12.1\t\t🧑|‍✈️\n👨‍✈️\t1\t4\t\t👨|‍✈️\n👩‍✈️\t1\t4\t\t👩|‍✈️\n🧑‍🚀\t1\t12.1\t\t🧑|‍🚀\n👨‍🚀\t1\t4\t\t👨|‍🚀\n👩‍🚀\t1\t4\t\t👩|‍🚀\n🧑‍🚒\t1\t12.1\t\t🧑|‍🚒\n👨‍🚒\t1\t4\t\t👨|‍🚒\n👩‍🚒\t1\t4\t\t👩|‍🚒\n👮\t1\t0.6\t\t👮|\n👮‍♂️\t1\t4\t\t👮|‍♂️\n👮‍♀️\t1\t4\t\t👮|‍♀️\n🕵️\t1\t0.7\t\t🕵|\n🕵️‍♂️\t1\t4\t\t🕵|‍♂️\n🕵️‍♀️\t1\t4\t\t🕵|‍♀️\n💂\t1\t0.6\t\t💂|\n💂‍♂️\t1\t4\t\t💂|‍♂️\n💂‍♀️\t1\t4\t\t💂|‍♀️\n🥷\t1\t13\t\t🥷|\n👷\t1\t0.6\t\t👷|\n👷‍♂️\t1\t4\t\t👷|‍♂️\n👷‍♀️\t1\t4\t\t👷|‍♀️\n🫅\t1\t14\t\t🫅|\n🤴\t1\t3\t\t🤴|\n👸\t1\t0.6\t\t👸|\n👳\t1\t0.6\t\t👳|\n👳‍♂️\t1\t4\t\t👳|‍♂️\n👳‍♀️\t1\t4\t\t👳|‍♀️\n👲\t1\t0.6\t\t👲|\n🧕\t1\t5\t\t🧕|\n🤵\t1\t3\t\t🤵|\n🤵‍♂️\t1\t13\t\t🤵|‍♂️\n🤵‍♀️\t1\t13\t\t🤵|‍♀️\n👰\t1\t0.6\t\t👰|\n👰‍♂️\t1\t13\t\t👰|‍♂️\n👰‍♀️\t1\t13\t\t👰|‍♀️\n🤰\t1\t3\t\t🤰|\n🫃\t1\t14\t\t🫃|\n🫄\t1\t14\t\t🫄|\n🤱\t1\t5\t\t🤱|\n👩‍🍼\t1\t13\t\t👩|‍🍼\n👨‍🍼\t1\t13\t\t👨|‍🍼\n🧑‍🍼\t1\t13\t\t🧑|‍🍼\n👼\t1\t0.6\t\t👼|\n🎅\t1\t0.6\t\t🎅|\n🤶\t1\t3\t\t🤶|\n🧑‍🎄\t1\t13\t\t🧑|‍🎄\n🦸\t1\t11\t\t🦸|\n🦸‍♂️\t1\t11\t\t🦸|‍♂️\n🦸‍♀️\t1\t11\t\t🦸|‍♀️\n🦹\t1\t11\t\t🦹|\n🦹‍♂️\t1\t11\t\t🦹|‍♂️\n🦹‍♀️\t1\t11\t\t🦹|‍♀️\n🧙\t1\t5\t\t🧙|\n🧙‍♂️\t1\t5\t\t🧙|‍♂️\n🧙‍♀️\t1\t5\t\t🧙|‍♀️\n🧚\t1\t5\t\t🧚|\n🧚‍♂️\t1\t5\t\t🧚|‍♂️\n🧚‍♀️\t1\t5\t\t🧚|‍♀️\n🧛\t1\t5\t\t🧛|\n🧛‍♂️\t1\t5\t\t🧛|‍♂️\n🧛‍♀️\t1\t5\t\t🧛|‍♀️\n🧜\t1\t5\t\t🧜|\n🧜‍♂️\t1\t5\t\t🧜|‍♂️\n🧜‍♀️\t1\t5\t\t🧜|‍♀️\n🧝\t1\t5\t\t🧝|\n🧝‍♂️\t1\t5\t\t🧝|‍♂️\n🧝‍♀️\t1\t5\t\t🧝|‍♀️\n🧞\t1\t5\t\t\n🧞‍♂️\t1\t5\t\t\n🧞‍♀️\t1\t5\t\t\n🧟\t1\t5\t\t\n🧟‍♂️\t1\t5\t\t\n🧟‍♀️\t1\t5\t\t\n🧌\t1\t14\t\t\n💆\t1\t0.6\t\t💆|\n💆‍♂️\t1\t4\t\t💆|‍♂️\n💆‍♀️\t1\t4\t\t💆|‍♀️\n💇\t1\t0.6\t\t💇|\n💇‍♂️\t1\t4\t\t💇|‍♂️\n💇‍♀️\t1\t4\t\t💇|‍♀️\n🚶\t1\t0.6\t\t🚶|\n🚶‍♂️\t1\t4\t\t🚶|‍♂️\n🚶‍♀️\t1\t4\t\t🚶|‍♀️\n🚶‍➡️\t1\t15.1\t\t🚶|‍➡️\n🚶‍♀️‍➡️\t1\t15.1\t\t🚶|‍♀️‍➡️\n🚶‍♂️‍➡️\t1\t15.1\t\t🚶|‍♂️‍➡️\n🧍\t1\t12\t\t🧍|\n🧍‍♂️\t1\t12\t\t🧍|‍♂️\n🧍‍♀️\t1\t12\t\t🧍|‍♀️\n🧎\t1\t12\t\t🧎|\n🧎‍♂️\t1\t12\t\t🧎|‍♂️\n🧎‍♀️\t1\t12\t\t🧎|‍♀️\n🧎‍➡️\t1\t15.1\t\t🧎|‍➡️\n🧎‍♀️‍➡️\t1\t15.1\t\t🧎|‍♀️‍➡️\n🧎‍♂️‍➡️\t1\t15.1\t\t🧎|‍♂️‍➡️\n🧑‍🦯\t1\t12.1\t\t🧑|‍🦯\n🧑‍🦯‍➡️\t1\t15.1\t\t🧑|‍🦯‍➡️\n👨‍🦯\t1\t12\t\t👨|‍🦯\n👨‍🦯‍➡️\t1\t15.1\t\t👨|‍🦯‍➡️\n👩‍🦯\t1\t12\t\t👩|‍🦯\n👩‍🦯‍➡️\t1\t15.1\t\t👩|‍🦯‍➡️\n🧑‍🦼\t1\t12.1\t\t🧑|‍🦼\n🧑‍🦼‍➡️\t1\t15.1\t\t🧑|‍🦼‍➡️\n👨‍🦼\t1\t12\t\t👨|‍🦼\n👨‍🦼‍➡️\t1\t15.1\t\t👨|‍🦼‍➡️\n👩‍🦼\t1\t12\t\t👩|‍🦼\n👩‍🦼‍➡️\t1\t15.1\t\t👩|‍🦼‍➡️\n🧑‍🦽\t1\t12.1\t\t🧑|‍🦽\n🧑‍🦽‍➡️\t1\t15.1\t\t🧑|‍🦽‍➡️\n👨‍🦽\t1\t12\t\t👨|‍🦽\n👨‍🦽‍➡️\t1\t15.1\t\t👨|‍🦽‍➡️\n👩‍🦽\t1\t12\t\t👩|‍🦽\n👩‍🦽‍➡️\t1\t15.1\t\t👩|‍🦽‍➡️\n🏃\t1\t0.6\t\t🏃|\n🏃‍♂️\t1\t4\t\t🏃|‍♂️\n🏃‍♀️\t1\t4\t\t🏃|‍♀️\n🏃‍➡️\t1\t15.1\t\t🏃|‍➡️\n🏃‍♀️‍➡️\t1\t15.1\t\t🏃|‍♀️‍➡️\n🏃‍♂️‍➡️\t1\t15.1\t\t🏃|‍♂️‍➡️\n💃\t1\t0.6\t\t💃|\n🕺\t1\t3\t\t🕺|\n🕴️\t1\t0.7\t\t🕴|\n👯\t1\t0.6\t\t\n👯‍♂️\t1\t4\t\t\n👯‍♀️\t1\t4\t\t\n🧖\t1\t5\t\t🧖|\n🧖‍♂️\t1\t5\t\t🧖|‍♂️\n🧖‍♀️\t1\t5\t\t🧖|‍♀️\n🧗\t1\t5\t\t🧗|\n🧗‍♂️\t1\t5\t\t🧗|‍♂️\n🧗‍♀️\t1\t5\t\t🧗|‍♀️\n🤺\t1\t3\t\t\n🏇\t1\t1\t\t🏇|\n⛷️\t1\t0.7\t\t\n🏂️\t1\t0.6\t\t🏂|\n🏌️\t1\t0.7\t\t🏌|\n🏌️‍♂️\t1\t4\t\t🏌|‍♂️\n🏌️‍♀️\t1\t4\t\t🏌|‍♀️\n🏄️\t1\t0.6\t\t🏄|\n🏄‍♂️\t1\t4\t\t🏄|‍♂️\n🏄‍♀️\t1\t4\t\t🏄|‍♀️\n🚣\t1\t1\t\t🚣|\n🚣‍♂️\t1\t4\t\t🚣|‍♂️\n🚣‍♀️\t1\t4\t\t🚣|‍♀️\n🏊️\t1\t0.6\t\t🏊|\n🏊‍♂️\t1\t4\t\t🏊|‍♂️\n🏊‍♀️\t1\t4\t\t🏊|‍♀️\n⛹️\t1\t0.7\t\t⛹|\n⛹️‍♂️\t1\t4\t\t⛹|‍♂️\n⛹️‍♀️\t1\t4\t\t⛹|‍♀️\n🏋️\t1\t0.7\t\t🏋|\n🏋️‍♂️\t1\t4\t\t🏋|‍♂️\n🏋️‍♀️\t1\t4\t\t🏋|‍♀️\n🚴\t1\t1\t\t🚴|\n🚴‍♂️\t1\t4\t\t🚴|‍♂️\n🚴‍♀️\t1\t4\t\t🚴|‍♀️\n🚵\t1\t1\t\t🚵|\n🚵‍♂️\t1\t4\t\t🚵|‍♂️\n🚵‍♀️\t1\t4\t\t🚵|‍♀️\n🤸\t1\t3\t\t🤸|\n🤸‍♂️\t1\t4\t\t🤸|‍♂️\n🤸‍♀️\t1\t4\t\t🤸|‍♀️\n🤼\t1\t3\t\t\n🤼‍♂️\t1\t4\t\t\n🤼‍♀️\t1\t4\t\t\n🤽\t1\t3\t\t🤽|\n🤽‍♂️\t1\t4\t\t🤽|‍♂️\n🤽‍♀️\t1\t4\t\t🤽|‍♀️\n🤾\t1\t3\t\t🤾|\n🤾‍♂️\t1\t4\t\t🤾|‍♂️\n🤾‍♀️\t1\t4\t\t🤾|‍♀️\n🤹\t1\t3\t\t🤹|\n🤹‍♂️\t1\t4\t\t🤹|‍♂️\n🤹‍♀️\t1\t4\t\t🤹|‍♀️\n🧘\t1\t5\t\t🧘|\n🧘‍♂️\t1\t5\t\t🧘|‍♂️\n🧘‍♀️\t1\t5\t\t🧘|‍♀️\n🛀\t1\t0.6\t\t🛀|\n🛌\t1\t1\t\t🛌|\n🧑‍🤝‍🧑\t1\t12\t\t🧑|‍🤝‍🧑|\n👭\t1\t1\t\t👭|\n👫\t1\t0.6\t\t👫|\n👬\t1\t1\t\t👬|\n💏\t1\t0.6\t\t💏|\n👩‍❤️‍💋‍👨\t1\t2\t\t👩|‍❤️‍💋‍👨|\n👨‍❤️‍💋‍👨\t1\t2\t\t👨|‍❤️‍💋‍👨|\n👩‍❤️‍💋‍👩\t1\t2\t\t👩|‍❤️‍💋‍👩|\n💑\t1\t0.6\t\t💑|\n👩‍❤️‍👨\t1\t2\t\t👩|‍❤️‍👨|\n👨‍❤️‍👨\t1\t2\t\t👨|‍❤️‍👨|\n👩‍❤️‍👩\t1\t2\t\t👩|‍❤️‍👩|\n👨‍👩‍👦\t1\t2\t\t\n👨‍👩‍👧\t1\t2\t\t\n👨‍👩‍👧‍👦\t1\t2\t\t\n👨‍👩‍👦‍👦\t1\t2\t\t\n👨‍👩‍👧‍👧\t1\t2\t\t\n👨‍👨‍👦\t1\t2\t\t\n👨‍👨‍👧\t1\t2\t\t\n👨‍👨‍👧‍👦\t1\t2\t\t\n👨‍👨‍👦‍👦\t1\t2\t\t\n👨‍👨‍👧‍👧\t1\t2\t\t\n👩‍👩‍👦\t1\t2\t\t\n👩‍👩‍👧\t1\t2\t\t\n👩‍👩‍👧‍👦\t1\t2\t\t\n👩‍👩‍👦‍👦\t1\t2\t\t\n👩‍👩‍👧‍👧\t1\t2\t\t\n👨‍👦\t1\t4\t\t\n👨‍👦‍👦\t1\t4\t\t\n👨‍👧\t1\t4\t\t\n👨‍👧‍👦\t1\t4\t\t\n👨‍👧‍👧\t1\t4\t\t\n👩‍👦\t1\t4\t\t\n👩‍👦‍👦\t1\t4\t\t\n👩‍👧\t1\t4\t\t\n👩‍👧‍👦\t1\t4\t\t\n👩‍👧‍👧\t1\t4\t\t\n🗣️\t1\t0.7\t\t\n👤\t1\t0.6\t\t\n👥\t1\t1\t\t\n🫂\t1\t13\t\t\n👪️\t1\t0.6\t\t\n🧑‍🧑‍🧒\t1\t15.1\t\t\n🧑‍🧑‍🧒‍🧒\t1\t15.1\t\t\n🧑‍🧒\t1\t15.1\t\t\n🧑‍🧒‍🧒\t1\t15.1\t\t\n👣\t1\t0.6\t\t\n🫆\t1\t16\t\t\n🐵\t3\t0.6\t\t\n🐒\t3\t0.6\t\t\n🦍\t3\t3\t\t\n🦧\t3\t12\t\t\n🐶\t3\t0.6\t\t\n🐕️\t3\t0.7\t\t\n🦮\t3\t12\t\t\n🐕‍🦺\t3\t12\t\t\n🐩\t3\t0.6\t\t\n🐺\t3\t0.6\t\t\n🦊\t3\t3\t\t\n🦝\t3\t11\t\t\n🐱\t3\t0.6\t\t\n🐈️\t3\t0.7\t\t\n🐈‍⬛\t3\t13\t\t\n🦁\t3\t1\t\t\n🐯\t3\t0.6\t\t\n🐅\t3\t1\t\t\n🐆\t3\t1\t\t\n🐴\t3\t0.6\t\t\n🫎\t3\t15\t\t\n🫏\t3\t15\t\t\n🐎\t3\t0.6\t\t\n🦄\t3\t1\t\t\n🦓\t3\t5\t\t\n🦌\t3\t3\t\t\n🦬\t3\t13\t\t\n🐮\t3\t0.6\t\t\n🐂\t3\t1\t\t\n🐃\t3\t1\t\t\n🐄\t3\t1\t\t\n🐷\t3\t0.6\t\t\n🐖\t3\t1\t\t\n🐗\t3\t0.6\t\t\n🐽\t3\t0.6\t\t\n🐏\t3\t1\t\t\n🐑\t3\t0.6\t\t\n🐐\t3\t1\t\t\n🐪\t3\t1\t\t\n🐫\t3\t0.6\t\t\n🦙\t3\t11\t\t\n🦒\t3\t5\t\t\n🐘\t3\t0.6\t\t\n🦣\t3\t13\t\t\n🦏\t3\t3\t\t\n🦛\t3\t11\t\t\n🐭\t3\t0.6\t\t\n🐁\t3\t1\t\t\n🐀\t3\t1\t\t\n🐹\t3\t0.6\t\t\n🐰\t3\t0.6\t\t\n🐇\t3\t1\t\t\n🐿️\t3\t0.7\t\t\n🦫\t3\t13\t\t\n🦔\t3\t5\t\t\n🦇\t3\t3\t\t\n🐻\t3\t0.6\t\t\n🐻‍❄️\t3\t13\t\t\n🐨\t3\t0.6\t\t\n🐼\t3\t0.6\t\t\n🦥\t3\t12\t\t\n🦦\t3\t12\t\t\n🦨\t3\t12\t\t\n🦘\t3\t11\t\t\n🦡\t3\t11\t\t\n🐾\t3\t0.6\t\t\n🦃\t3\t1\t\t\n🐔\t3\t0.6\t\t\n🐓\t3\t1\t\t\n🐣\t3\t0.6\t\t\n🐤\t3\t0.6\t\t\n🐥\t3\t0.6\t\t\n🐦️\t3\t0.6\t\t\n🐧\t3\t0.6\t\t\n🕊️\t3\t0.7\t\t\n🦅\t3\t3\t\t\n🦆\t3\t3\t\t\n🦢\t3\t11\t\t\n🦉\t3\t3\t\t\n🦤\t3\t13\t\t\n🪶\t3\t13\t\t\n🦩\t3\t12\t\t\n🦚\t3\t11\t\t\n🦜\t3\t11\t\t\n🪽\t3\t15\t\t\n🐦‍⬛\t3\t15\t\t\n🪿\t3\t15\t\t\n🐦‍🔥\t3\t15.1\t\t\n🐸\t3\t0.6\t\t\n🐊\t3\t1\t\t\n🐢\t3\t0.6\t\t\n🦎\t3\t3\t\t\n🐍\t3\t0.6\t\t\n🐲\t3\t0.6\t\t\n🐉\t3\t1\t\t\n🦕\t3\t5\t\t\n🦖\t3\t5\t\t\n🐳\t3\t0.6\t\t\n🐋\t3\t1\t\t\n🐬\t3\t0.6\t\t\n🦭\t3\t13\t\t\n🐟️\t3\t0.6\t\t\n🐠\t3\t0.6\t\t\n🐡\t3\t0.6\t\t\n🦈\t3\t3\t\t\n🐙\t3\t0.6\t\t\n🐚\t3\t0.6\t\t\n🪸\t3\t14\t\t\n🪼\t3\t15\t\t\n🦀\t3\t1\t\t\n🦞\t3\t11\t\t\n🦐\t3\t3\t\t\n🦑\t3\t3\t\t\n🦪\t3\t12\t\t\n🐌\t3\t0.6\t\t\n🦋\t3\t3\t\t\n🐛\t3\t0.6\t\t\n🐜\t3\t0.6\t\t\n🐝\t3\t0.6\t\t\n🪲\t3\t13\t\t\n🐞\t3\t0.6\t\t\n🦗\t3\t5\t\t\n🪳\t3\t13\t\t\n🕷️\t3\t0.7\t\t\n🕸️\t3\t0.7\t\t\n🦂\t3\t1\t\t\n🦟\t3\t11\t\t\n🪰\t3\t13\t\t\n🪱\t3\t13\t\t\n🦠\t3\t11\t\t\n💐\t3\t0.6\t\t\n🌸\t3\t0.6\t\t\n💮\t3\t0.6\t\t\n🪷\t3\t14\t\t\n🏵️\t3\t0.7\t\t\n🌹\t3\t0.6\t\t\n🥀\t3\t3\t\t\n🌺\t3\t0.6\t\t\n🌻\t3\t0.6\t\t\n🌼\t3\t0.6\t\t\n🌷\t3\t0.6\t\t\n🪻\t3\t15\t\t\n🌱\t3\t0.6\t\t\n🪴\t3\t13\t\t\n🌲\t3\t1\t\t\n🌳\t3\t1\t\t\n🌴\t3\t0.6\t\t\n🌵\t3\t0.6\t\t\n🌾\t3\t0.6\t\t\n🌿\t3\t0.6\t\t\n☘️\t3\t1\t\t\n🍀\t3\t0.6\t\t\n🍁\t3\t0.6\t\t\n🍂\t3\t0.6\t\t\n🍃\t3\t0.6\t\t\n🪹\t3\t14\t\t\n🪺\t3\t14\t\t\n🍄\t3\t0.6\t\t\n🪾\t3\t16\t\t\n🍇\t4\t0.6\t\t\n🍈\t4\t0.6\t\t\n🍉\t4\t0.6\t\t\n🍊\t4\t0.6\t\t\n🍋\t4\t1\t\t\n🍋‍🟩\t4\t15.1\t\t\n🍌\t4\t0.6\t\t\n🍍\t4\t0.6\t\t\n🥭\t4\t11\t\t\n🍎\t4\t0.6\t\t\n🍏\t4\t0.6\t\t\n🍐\t4\t1\t\t\n🍑\t4\t0.6\t\t\n🍒\t4\t0.6\t\t\n🍓\t4\t0.6\t\t\n🫐\t4\t13\t\t\n🥝\t4\t3\t\t\n🍅\t4\t0.6\t\t\n🫒\t4\t13\t\t\n🥥\t4\t5\t\t\n🥑\t4\t3\t\t\n🍆\t4\t0.6\t\t\n🥔\t4\t3\t\t\n🥕\t4\t3\t\t\n🌽\t4\t0.6\t\t\n🌶️\t4\t0.7\t\t\n🫑\t4\t13\t\t\n🥒\t4\t3\t\t\n🥬\t4\t11\t\t\n🥦\t4\t5\t\t\n🧄\t4\t12\t\t\n🧅\t4\t12\t\t\n🥜\t4\t3\t\t\n🫘\t4\t14\t\t\n🌰\t4\t0.6\t\t\n🫚\t4\t15\t\t\n🫛\t4\t15\t\t\n🍄‍🟫\t4\t15.1\t\t\n🫜\t4\t16\t\t\n🍞\t4\t0.6\t\t\n🥐\t4\t3\t\t\n🥖\t4\t3\t\t\n🫓\t4\t13\t\t\n🥨\t4\t5\t\t\n🥯\t4\t11\t\t\n🥞\t4\t3\t\t\n🧇\t4\t12\t\t\n🧀\t4\t1\t\t\n🍖\t4\t0.6\t\t\n🍗\t4\t0.6\t\t\n🥩\t4\t5\t\t\n🥓\t4\t3\t\t\n🍔\t4\t0.6\t\t\n🍟\t4\t0.6\t\t\n🍕\t4\t0.6\t\t\n🌭\t4\t1\t\t\n🥪\t4\t5\t\t\n🌮\t4\t1\t\t\n🌯\t4\t1\t\t\n🫔\t4\t13\t\t\n🥙\t4\t3\t\t\n🧆\t4\t12\t\t\n🥚\t4\t3\t\t\n🍳\t4\t0.6\t\t\n🥘\t4\t3\t\t\n🍲\t4\t0.6\t\t\n🫕\t4\t13\t\t\n🥣\t4\t5\t\t\n🥗\t4\t3\t\t\n🍿\t4\t1\t\t\n🧈\t4\t12\t\t\n🧂\t4\t11\t\t\n🥫\t4\t5\t\t\n🍱\t4\t0.6\t\t\n🍘\t4\t0.6\t\t\n🍙\t4\t0.6\t\t\n🍚\t4\t0.6\t\t\n🍛\t4\t0.6\t\t\n🍜\t4\t0.6\t\t\n🍝\t4\t0.6\t\t\n🍠\t4\t0.6\t\t\n🍢\t4\t0.6\t\t\n🍣\t4\t0.6\t\t\n🍤\t4\t0.6\t\t\n🍥\t4\t0.6\t\t\n🥮\t4\t11\t\t\n🍡\t4\t0.6\t\t\n🥟\t4\t5\t\t\n🥠\t4\t5\t\t\n🥡\t4\t5\t\t\n🍦\t4\t0.6\t\t\n🍧\t4\t0.6\t\t\n🍨\t4\t0.6\t\t\n🍩\t4\t0.6\t\t\n🍪\t4\t0.6\t\t\n🎂\t4\t0.6\t\t\n🍰\t4\t0.6\t\t\n🧁\t4\t11\t\t\n🥧\t4\t5\t\t\n🍫\t4\t0.6\t\t\n🍬\t4\t0.6\t\t\n🍭\t4\t0.6\t\t\n🍮\t4\t0.6\t\t\n🍯\t4\t0.6\t\t\n🍼\t4\t1\t\t\n🥛\t4\t3\t\t\n☕️\t4\t0.6\t\t\n🫖\t4\t13\t\t\n🍵\t4\t0.6\t\t\n🍶\t4\t0.6\t\t\n🍾\t4\t1\t\t\n🍷\t4\t0.6\t\t\n🍸️\t4\t0.6\t\t\n🍹\t4\t0.6\t\t\n🍺\t4\t0.6\t\t\n🍻\t4\t0.6\t\t\n🥂\t4\t3\t\t\n🥃\t4\t3\t\t\n🫗\t4\t14\t\t\n🥤\t4\t5\t\t\n🧋\t4\t13\t\t\n🧃\t4\t12\t\t\n🧉\t4\t12\t\t\n🧊\t4\t12\t\t\n🥢\t4\t5\t\t\n🍽️\t4\t0.7\t\t\n🍴\t4\t0.6\t\t\n🥄\t4\t3\t\t\n🔪\t4\t0.6\t\t\n🫙\t4\t14\t\t\n🏺\t4\t1\t\t\n🌍️\t5\t0.7\t\t\n🌎️\t5\t0.7\t\t\n🌏️\t5\t0.6\t\t\n🌐\t5\t1\t\t\n🗺️\t5\t0.7\t\t\n🗾\t5\t0.6\t\t\n🧭\t5\t11\t\t\n🏔️\t5\t0.7\t\t\n⛰️\t5\t0.7\t\t\n🌋\t5\t0.6\t\t\n🗻\t5\t0.6\t\t\n🏕️\t5\t0.7\t\t\n🏖️\t5\t0.7\t\t\n🏜️\t5\t0.7\t\t\n🏝️\t5\t0.7\t\t\n🏞️\t5\t0.7\t\t\n🏟️\t5\t0.7\t\t\n🏛️\t5\t0.7\t\t\n🏗️\t5\t0.7\t\t\n🧱\t5\t11\t\t\n🪨\t5\t13\t\t\n🪵\t5\t13\t\t\n🛖\t5\t13\t\t\n🏘️\t5\t0.7\t\t\n🏚️\t5\t0.7\t\t\n🏠️\t5\t0.6\t\t\n🏡\t5\t0.6\t\t\n🏢\t5\t0.6\t\t\n🏣\t5\t0.6\t\t\n🏤\t5\t1\t\t\n🏥\t5\t0.6\t\t\n🏦\t5\t0.6\t\t\n🏨\t5\t0.6\t\t\n🏩\t5\t0.6\t\t\n🏪\t5\t0.6\t\t\n🏫\t5\t0.6\t\t\n🏬\t5\t0.6\t\t\n🏭️\t5\t0.6\t\t\n🏯\t5\t0.6\t\t\n🏰\t5\t0.6\t\t\n💒\t5\t0.6\t\t\n🗼\t5\t0.6\t\t\n🗽\t5\t0.6\t\t\n⛪️\t5\t0.6\t\t\n🕌\t5\t1\t\t\n🛕\t5\t12\t\t\n🕍\t5\t1\t\t\n⛩️\t5\t0.7\t\t\n🕋\t5\t1\t\t\n⛲️\t5\t0.6\t\t\n⛺️\t5\t0.6\t\t\n🌁\t5\t0.6\t\t\n🌃\t5\t0.6\t\t\n🏙️\t5\t0.7\t\t\n🌄\t5\t0.6\t\t\n🌅\t5\t0.6\t\t\n🌆\t5\t0.6\t\t\n🌇\t5\t0.6\t\t\n🌉\t5\t0.6\t\t\n♨️\t5\t0.6\t\t\n🎠\t5\t0.6\t\t\n🛝\t5\t14\t\t\n🎡\t5\t0.6\t\t\n🎢\t5\t0.6\t\t\n💈\t5\t0.6\t\t\n🎪\t5\t0.6\t\t\n🚂\t5\t1\t\t\n🚃\t5\t0.6\t\t\n🚄\t5\t0.6\t\t\n🚅\t5\t0.6\t\t\n🚆\t5\t1\t\t\n🚇️\t5\t0.6\t\t\n🚈\t5\t1\t\t\n🚉\t5\t0.6\t\t\n🚊\t5\t1\t\t\n🚝\t5\t1\t\t\n🚞\t5\t1\t\t\n🚋\t5\t1\t\t\n🚌\t5\t0.6\t\t\n🚍️\t5\t0.7\t\t\n🚎\t5\t1\t\t\n🚐\t5\t1\t\t\n🚑️\t5\t0.6\t\t\n🚒\t5\t0.6\t\t\n🚓\t5\t0.6\t\t\n🚔️\t5\t0.7\t\t\n🚕\t5\t0.6\t\t\n🚖\t5\t1\t\t\n🚗\t5\t0.6\t\t\n🚘️\t5\t0.7\t\t\n🚙\t5\t0.6\t\t\n🛻\t5\t13\t\t\n🚚\t5\t0.6\t\t\n🚛\t5\t1\t\t\n🚜\t5\t1\t\t\n🏎️\t5\t0.7\t\t\n🏍️\t5\t0.7\t\t\n🛵\t5\t3\t\t\n🦽\t5\t12\t\t\n🦼\t5\t12\t\t\n🛺\t5\t12\t\t\n🚲️\t5\t0.6\t\t\n🛴\t5\t3\t\t\n🛹\t5\t11\t\t\n🛼\t5\t13\t\t\n🚏\t5\t0.6\t\t\n🛣️\t5\t0.7\t\t\n🛤️\t5\t0.7\t\t\n🛢️\t5\t0.7\t\t\n⛽️\t5\t0.6\t\t\n🛞\t5\t14\t\t\n🚨\t5\t0.6\t\t\n🚥\t5\t0.6\t\t\n🚦\t5\t1\t\t\n🛑\t5\t3\t\t\n🚧\t5\t0.6\t\t\n⚓️\t5\t0.6\t\t\n🛟\t5\t14\t\t\n⛵️\t5\t0.6\t\t\n🛶\t5\t3\t\t\n🚤\t5\t0.6\t\t\n🛳️\t5\t0.7\t\t\n⛴️\t5\t0.7\t\t\n🛥️\t5\t0.7\t\t\n🚢\t5\t0.6\t\t\n✈️\t5\t0.6\t\t\n🛩️\t5\t0.7\t\t\n🛫\t5\t1\t\t\n🛬\t5\t1\t\t\n🪂\t5\t12\t\t\n💺\t5\t0.6\t\t\n🚁\t5\t1\t\t\n🚟\t5\t1\t\t\n🚠\t5\t1\t\t\n🚡\t5\t1\t\t\n🛰️\t5\t0.7\t\t\n🚀\t5\t0.6\t\t\n🛸\t5\t5\t\t\n🛎️\t5\t0.7\t\t\n🧳\t5\t11\t\t\n⌛️\t5\t0.6\t\t\n⏳️\t5\t0.6\t\t\n⌚️\t5\t0.6\t\t\n⏰️\t5\t0.6\t\t\n⏱️\t5\t1\t\t\n⏲️\t5\t1\t\t\n🕰️\t5\t0.7\t\t\n🕛️\t5\t0.6\t\t\n🕧️\t5\t0.7\t\t\n🕐️\t5\t0.6\t\t\n🕜️\t5\t0.7\t\t\n🕑️\t5\t0.6\t\t\n🕝️\t5\t0.7\t\t\n🕒️\t5\t0.6\t\t\n🕞️\t5\t0.7\t\t\n🕓️\t5\t0.6\t\t\n🕟️\t5\t0.7\t\t\n🕔️\t5\t0.6\t\t\n🕠️\t5\t0.7\t\t\n🕕️\t5\t0.6\t\t\n🕡️\t5\t0.7\t\t\n🕖️\t5\t0.6\t\t\n🕢️\t5\t0.7\t\t\n🕗️\t5\t0.6\t\t\n🕣️\t5\t0.7\t\t\n🕘️\t5\t0.6\t\t\n🕤️\t5\t0.7\t\t\n🕙️\t5\t0.6\t\t\n🕥️\t5\t0.7\t\t\n🕚️\t5\t0.6\t\t\n🕦️\t5\t0.7\t\t\n🌑\t5\t0.6\t\t\n🌒\t5\t1\t\t\n🌓\t5\t0.6\t\t\n🌔\t5\t0.6\t\t\n🌕️\t5\t0.6\t\t\n🌖\t5\t1\t\t\n🌗\t5\t1\t\t\n🌘\t5\t1\t\t\n🌙\t5\t0.6\t\t\n🌚\t5\t1\t\t\n🌛\t5\t0.6\t\t\n🌜️\t5\t0.7\t\t\n🌡️\t5\t0.7\t\t\n☀️\t5\t0.6\t\t\n🌝\t5\t1\t\t\n🌞\t5\t1\t\t\n🪐\t5\t12\t\t\n⭐️\t5\t0.6\t\t\n🌟\t5\t0.6\t\t\n🌠\t5\t0.6\t\t\n🌌\t5\t0.6\t\t\n☁️\t5\t0.6\t\t\n⛅️\t5\t0.6\t\t\n⛈️\t5\t0.7\t\t\n🌤️\t5\t0.7\t\t\n🌥️\t5\t0.7\t\t\n🌦️\t5\t0.7\t\t\n🌧️\t5\t0.7\t\t\n🌨️\t5\t0.7\t\t\n🌩️\t5\t0.7\t\t\n🌪️\t5\t0.7\t\t\n🌫️\t5\t0.7\t\t\n🌬️\t5\t0.7\t\t\n🌀\t5\t0.6\t\t\n🌈\t5\t0.6\t\t\n🌂\t5\t0.6\t\t\n☂️\t5\t0.7\t\t\n☔️\t5\t0.6\t\t\n⛱️\t5\t0.7\t\t\n⚡️\t5\t0.6\t\t\n❄️\t5\t0.6\t\t\n☃️\t5\t0.7\t\t\n⛄️\t5\t0.6\t\t\n☄️\t5\t1\t\t\n🔥\t5\t0.6\t\t\n💧\t5\t0.6\t\t\n🌊\t5\t0.6\t\t\n🎃\t6\t0.6\t\t\n🎄\t6\t0.6\t\t\n🎆\t6\t0.6\t\t\n🎇\t6\t0.6\t\t\n🧨\t6\t11\t\t\n✨️\t6\t0.6\t\t\n🎈\t6\t0.6\t\t\n🎉\t6\t0.6\t\t\n🎊\t6\t0.6\t\t\n🎋\t6\t0.6\t\t\n🎍\t6\t0.6\t\t\n🎎\t6\t0.6\t\t\n🎏\t6\t0.6\t\t\n🎐\t6\t0.6\t\t\n🎑\t6\t0.6\t\t\n🧧\t6\t11\t\t\n🎀\t6\t0.6\t\t\n🎁\t6\t0.6\t\t\n🎗️\t6\t0.7\t\t\n🎟️\t6\t0.7\t\t\n🎫\t6\t0.6\t\t\n🎖️\t6\t0.7\t\t\n🏆️\t6\t0.6\t\t\n🏅\t6\t1\t\t\n🥇\t6\t3\t\t\n🥈\t6\t3\t\t\n🥉\t6\t3\t\t\n⚽️\t6\t0.6\t\t\n⚾️\t6\t0.6\t\t\n🥎\t6\t11\t\t\n🏀\t6\t0.6\t\t\n🏐\t6\t1\t\t\n🏈\t6\t0.6\t\t\n🏉\t6\t1\t\t\n🎾\t6\t0.6\t\t\n🥏\t6\t11\t\t\n🎳\t6\t0.6\t\t\n🏏\t6\t1\t\t\n🏑\t6\t1\t\t\n🏒\t6\t1\t\t\n🥍\t6\t11\t\t\n🏓\t6\t1\t\t\n🏸\t6\t1\t\t\n🥊\t6\t3\t\t\n🥋\t6\t3\t\t\n🥅\t6\t3\t\t\n⛳️\t6\t0.6\t\t\n⛸️\t6\t0.7\t\t\n🎣\t6\t0.6\t\t\n🤿\t6\t12\t\t\n🎽\t6\t0.6\t\t\n🎿\t6\t0.6\t\t\n🛷\t6\t5\t\t\n🥌\t6\t5\t\t\n🎯\t6\t0.6\t\t\n🪀\t6\t12\t\t\n🪁\t6\t12\t\t\n🔫\t6\t0.6\t\t\n🎱\t6\t0.6\t\t\n🔮\t6\t0.6\t\t\n🪄\t6\t13\t\t\n🎮️\t6\t0.6\t\t\n🕹️\t6\t0.7\t\t\n🎰\t6\t0.6\t\t\n🎲\t6\t0.6\t\t\n🧩\t6\t11\t\t\n🧸\t6\t11\t\t\n🪅\t6\t13\t\t\n🪩\t6\t14\t\t\n🪆\t6\t13\t\t\n♠️\t6\t0.6\t\t\n♥️\t6\t0.6\t\t\n♦️\t6\t0.6\t\t\n♣️\t6\t0.6\t\t\n♟️\t6\t11\t\t\n🃏\t6\t0.6\t\t\n🀄️\t6\t0.6\t\t\n🎴\t6\t0.6\t\t\n🎭️\t6\t0.6\t\t\n🖼️\t6\t0.7\t\t\n🎨\t6\t0.6\t\t\n🧵\t6\t11\t\t\n🪡\t6\t13\t\t\n🧶\t6\t11\t\t\n🪢\t6\t13\t\t\n👓️\t7\t0.6\t\t\n🕶️\t7\t0.7\t\t\n🥽\t7\t11\t\t\n🥼\t7\t11\t\t\n🦺\t7\t12\t\t\n👔\t7\t0.6\t\t\n👕\t7\t0.6\t\t\n👖\t7\t0.6\t\t\n🧣\t7\t5\t\t\n🧤\t7\t5\t\t\n🧥\t7\t5\t\t\n🧦\t7\t5\t\t\n👗\t7\t0.6\t\t\n👘\t7\t0.6\t\t\n🥻\t7\t12\t\t\n🩱\t7\t12\t\t\n🩲\t7\t12\t\t\n🩳\t7\t12\t\t\n👙\t7\t0.6\t\t\n👚\t7\t0.6\t\t\n🪭\t7\t15\t\t\n👛\t7\t0.6\t\t\n👜\t7\t0.6\t\t\n👝\t7\t0.6\t\t\n🛍️\t7\t0.7\t\t\n🎒\t7\t0.6\t\t\n🩴\t7\t13\t\t\n👞\t7\t0.6\t\t\n👟\t7\t0.6\t\t\n🥾\t7\t11\t\t\n🥿\t7\t11\t\t\n👠\t7\t0.6\t\t\n👡\t7\t0.6\t\t\n🩰\t7\t12\t\t\n👢\t7\t0.6\t\t\n🪮\t7\t15\t\t\n👑\t7\t0.6\t\t\n👒\t7\t0.6\t\t\n🎩\t7\t0.6\t\t\n🎓️\t7\t0.6\t\t\n🧢\t7\t5\t\t\n🪖\t7\t13\t\t\n⛑️\t7\t0.7\t\t\n📿\t7\t1\t\t\n💄\t7\t0.6\t\t\n💍\t7\t0.6\t\t\n💎\t7\t0.6\t\t\n🔇\t7\t1\t\t\n🔈️\t7\t0.7\t\t\n🔉\t7\t1\t\t\n🔊\t7\t0.6\t\t\n📢\t7\t0.6\t\t\n📣\t7\t0.6\t\t\n📯\t7\t1\t\t\n🔔\t7\t0.6\t\t\n🔕\t7\t1\t\t\n🎼\t7\t0.6\t\t\n🎵\t7\t0.6\t\t\n🎶\t7\t0.6\t\t\n🎙️\t7\t0.7\t\t\n🎚️\t7\t0.7\t\t\n🎛️\t7\t0.7\t\t\n🎤\t7\t0.6\t\t\n🎧️\t7\t0.6\t\t\n📻️\t7\t0.6\t\t\n🎷\t7\t0.6\t\t\n🪗\t7\t13\t\t\n🎸\t7\t0.6\t\t\n🎹\t7\t0.6\t\t\n🎺\t7\t0.6\t\t\n🎻\t7\t0.6\t\t\n🪕\t7\t12\t\t\n🥁\t7\t3\t\t\n🪘\t7\t13\t\t\n🪇\t7\t15\t\t\n🪈\t7\t15\t\t\n🪉\t7\t16\t\t\n📱\t7\t0.6\t\t\n📲\t7\t0.6\t\t\n☎️\t7\t0.6\t\t\n📞\t7\t0.6\t\t\n📟️\t7\t0.6\t\t\n📠\t7\t0.6\t\t\n🔋\t7\t0.6\t\t\n🪫\t7\t14\t\t\n🔌\t7\t0.6\t\t\n💻️\t7\t0.6\t\t\n🖥️\t7\t0.7\t\t\n🖨️\t7\t0.7\t\t\n⌨️\t7\t1\t\t\n🖱️\t7\t0.7\t\t\n🖲️\t7\t0.7\t\t\n💽\t7\t0.6\t\t\n💾\t7\t0.6\t\t\n💿️\t7\t0.6\t\t\n📀\t7\t0.6\t\t\n🧮\t7\t11\t\t\n🎥\t7\t0.6\t\t\n🎞️\t7\t0.7\t\t\n📽️\t7\t0.7\t\t\n🎬️\t7\t0.6\t\t\n📺️\t7\t0.6\t\t\n📷️\t7\t0.6\t\t\n📸\t7\t1\t\t\n📹️\t7\t0.6\t\t\n📼\t7\t0.6\t\t\n🔍️\t7\t0.6\t\t\n🔎\t7\t0.6\t\t\n🕯️\t7\t0.7\t\t\n💡\t7\t0.6\t\t\n🔦\t7\t0.6\t\t\n🏮\t7\t0.6\t\t\n🪔\t7\t12\t\t\n📔\t7\t0.6\t\t\n📕\t7\t0.6\t\t\n📖\t7\t0.6\t\t\n📗\t7\t0.6\t\t\n📘\t7\t0.6\t\t\n📙\t7\t0.6\t\t\n📚️\t7\t0.6\t\t\n📓\t7\t0.6\t\t\n📒\t7\t0.6\t\t\n📃\t7\t0.6\t\t\n📜\t7\t0.6\t\t\n📄\t7\t0.6\t\t\n📰\t7\t0.6\t\t\n🗞️\t7\t0.7\t\t\n📑\t7\t0.6\t\t\n🔖\t7\t0.6\t\t\n🏷️\t7\t0.7\t\t\n💰️\t7\t0.6\t\t\n🪙\t7\t13\t\t\n💴\t7\t0.6\t\t\n💵\t7\t0.6\t\t\n💶\t7\t1\t\t\n💷\t7\t1\t\t\n💸\t7\t0.6\t\t\n💳️\t7\t0.6\t\t\n🧾\t7\t11\t\t\n💹\t7\t0.6\t\t\n✉️\t7\t0.6\t\t\n📧\t7\t0.6\t\t\n📨\t7\t0.6\t\t\n📩\t7\t0.6\t\t\n📤️\t7\t0.6\t\t\n📥️\t7\t0.6\t\t\n📦️\t7\t0.6\t\t\n📫️\t7\t0.6\t\t\n📪️\t7\t0.6\t\t\n📬️\t7\t0.7\t\t\n📭️\t7\t0.7\t\t\n📮\t7\t0.6\t\t\n🗳️\t7\t0.7\t\t\n✏️\t7\t0.6\t\t\n✒️\t7\t0.6\t\t\n🖋️\t7\t0.7\t\t\n🖊️\t7\t0.7\t\t\n🖌️\t7\t0.7\t\t\n🖍️\t7\t0.7\t\t\n📝\t7\t0.6\t\t\n💼\t7\t0.6\t\t\n📁\t7\t0.6\t\t\n📂\t7\t0.6\t\t\n🗂️\t7\t0.7\t\t\n📅\t7\t0.6\t\t\n📆\t7\t0.6\t\t\n🗒️\t7\t0.7\t\t\n🗓️\t7\t0.7\t\t\n📇\t7\t0.6\t\t\n📈\t7\t0.6\t\t\n📉\t7\t0.6\t\t\n📊\t7\t0.6\t\t\n📋️\t7\t0.6\t\t\n📌\t7\t0.6\t\t\n📍\t7\t0.6\t\t\n📎\t7\t0.6\t\t\n🖇️\t7\t0.7\t\t\n📏\t7\t0.6\t\t\n📐\t7\t0.6\t\t\n✂️\t7\t0.6\t\t\n🗃️\t7\t0.7\t\t\n🗄️\t7\t0.7\t\t\n🗑️\t7\t0.7\t\t\n🔒️\t7\t0.6\t\t\n🔓️\t7\t0.6\t\t\n🔏\t7\t0.6\t\t\n🔐\t7\t0.6\t\t\n🔑\t7\t0.6\t\t\n🗝️\t7\t0.7\t\t\n🔨\t7\t0.6\t\t\n🪓\t7\t12\t\t\n⛏️\t7\t0.7\t\t\n⚒️\t7\t1\t\t\n🛠️\t7\t0.7\t\t\n🗡️\t7\t0.7\t\t\n⚔️\t7\t1\t\t\n💣️\t7\t0.6\t\t\n🪃\t7\t13\t\t\n🏹\t7\t1\t\t\n🛡️\t7\t0.7\t\t\n🪚\t7\t13\t\t\n🔧\t7\t0.6\t\t\n🪛\t7\t13\t\t\n🔩\t7\t0.6\t\t\n⚙️\t7\t1\t\t\n🗜️\t7\t0.7\t\t\n⚖️\t7\t1\t\t\n🦯\t7\t12\t\t\n🔗\t7\t0.6\t\t\n⛓️‍💥\t7\t15.1\t\t\n⛓️\t7\t0.7\t\t\n🪝\t7\t13\t\t\n🧰\t7\t11\t\t\n🧲\t7\t11\t\t\n🪜\t7\t13\t\t\n🪏\t7\t16\t\t\n⚗️\t7\t1\t\t\n🧪\t7\t11\t\t\n🧫\t7\t11\t\t\n🧬\t7\t11\t\t\n🔬\t7\t1\t\t\n🔭\t7\t1\t\t\n📡\t7\t0.6\t\t\n💉\t7\t0.6\t\t\n🩸\t7\t12\t\t\n💊\t7\t0.6\t\t\n🩹\t7\t12\t\t\n🩼\t7\t14\t\t\n🩺\t7\t12\t\t\n🩻\t7\t14\t\t\n🚪\t7\t0.6\t\t\n🛗\t7\t13\t\t\n🪞\t7\t13\t\t\n🪟\t7\t13\t\t\n🛏️\t7\t0.7\t\t\n🛋️\t7\t0.7\t\t\n🪑\t7\t12\t\t\n🚽\t7\t0.6\t\t\n🪠\t7\t13\t\t\n🚿\t7\t1\t\t\n🛁\t7\t1\t\t\n🪤\t7\t13\t\t\n🪒\t7\t12\t\t\n🧴\t7\t11\t\t\n🧷\t7\t11\t\t\n🧹\t7\t11\t\t\n🧺\t7\t11\t\t\n🧻\t7\t11\t\t\n🪣\t7\t13\t\t\n🧼\t7\t11\t\t\n🫧\t7\t14\t\t\n🪥\t7\t13\t\t\n🧽\t7\t11\t\t\n🧯\t7\t11\t\t\n🛒\t7\t3\t\t\n🚬\t7\t0.6\t\t\n⚰️\t7\t1\t\t\n🪦\t7\t13\t\t\n⚱️\t7\t1\t\t\n🧿\t7\t11\t\t\n🪬\t7\t14\t\t\n🗿\t7\t0.6\t\t\n🪧\t7\t13\t\t\n🪪\t7\t14\t\t\n🏧\t8\t0.6\t\t\n🚮\t8\t1\t\t\n🚰\t8\t1\t\t\n♿️\t8\t0.6\t\t\n🚹️\t8\t0.6\t\t\n🚺️\t8\t0.6\t\t\n🚻\t8\t0.6\t\t\n🚼️\t8\t0.6\t\t\n🚾\t8\t0.6\t\t\n🛂\t8\t1\t\t\n🛃\t8\t1\t\t\n🛄\t8\t1\t\t\n🛅\t8\t1\t\t\n⚠️\t8\t0.6\t\t\n🚸\t8\t1\t\t\n⛔️\t8\t0.6\t\t\n🚫\t8\t0.6\t\t\n🚳\t8\t1\t\t\n🚭️\t8\t0.6\t\t\n🚯\t8\t1\t\t\n🚱\t8\t1\t\t\n🚷\t8\t1\t\t\n📵\t8\t1\t\t\n🔞\t8\t0.6\t\t\n☢️\t8\t1\t\t\n☣️\t8\t1\t\t\n⬆️\t8\t0.6\t\t\n↗️\t8\t0.6\t\t\n➡️\t8\t0.6\t\t\n↘️\t8\t0.6\t\t\n⬇️\t8\t0.6\t\t\n↙️\t8\t0.6\t\t\n⬅️\t8\t0.6\t\t\n↖️\t8\t0.6\t\t\n↕️\t8\t0.6\t\t\n↔️\t8\t0.6\t\t\n↩️\t8\t0.6\t\t\n↪️\t8\t0.6\t\t\n⤴️\t8\t0.6\t\t\n⤵️\t8\t0.6\t\t\n🔃\t8\t0.6\t\t\n🔄\t8\t1\t\t\n🔙\t8\t0.6\t\t\n🔚\t8\t0.6\t\t\n🔛\t8\t0.6\t\t\n🔜\t8\t0.6\t\t\n🔝\t8\t0.6\t\t\n🛐\t8\t1\t\t\n⚛️\t8\t1\t\t\n🕉️\t8\t0.7\t\t\n✡️\t8\t0.7\t\t\n☸️\t8\t0.7\t\t\n☯️\t8\t0.7\t\t\n✝️\t8\t0.7\t\t\n☦️\t8\t1\t\t\n☪️\t8\t0.7\t\t\n☮️\t8\t1\t\t\n🕎\t8\t1\t\t\n🔯\t8\t0.6\t\t\n🪯\t8\t15\t\t\n♈️\t8\t0.6\t\t\n♉️\t8\t0.6\t\t\n♊️\t8\t0.6\t\t\n♋️\t8\t0.6\t\t\n♌️\t8\t0.6\t\t\n♍️\t8\t0.6\t\t\n♎️\t8\t0.6\t\t\n♏️\t8\t0.6\t\t\n♐️\t8\t0.6\t\t\n♑️\t8\t0.6\t\t\n♒️\t8\t0.6\t\t\n♓️\t8\t0.6\t\t\n⛎️\t8\t0.6\t\t\n🔀\t8\t1\t\t\n🔁\t8\t1\t\t\n🔂\t8\t1\t\t\n▶️\t8\t0.6\t\t\n⏩️\t8\t0.6\t\t\n⏭️\t8\t0.7\t\t\n⏯️\t8\t1\t\t\n◀️\t8\t0.6\t\t\n⏪️\t8\t0.6\t\t\n⏮️\t8\t0.7\t\t\n🔼\t8\t0.6\t\t\n⏫️\t8\t0.6\t\t\n🔽\t8\t0.6\t\t\n⏬️\t8\t0.6\t\t\n⏸️\t8\t0.7\t\t\n⏹️\t8\t0.7\t\t\n⏺️\t8\t0.7\t\t\n⏏️\t8\t1\t\t\n🎦\t8\t0.6\t\t\n🔅\t8\t1\t\t\n🔆\t8\t1\t\t\n📶\t8\t0.6\t\t\n🛜\t8\t15\t\t\n📳\t8\t0.6\t\t\n📴\t8\t0.6\t\t\n♀️\t8\t4\t\t\n♂️\t8\t4\t\t\n⚧️\t8\t13\t\t\n✖️\t8\t0.6\t\t\n➕️\t8\t0.6\t\t\n➖️\t8\t0.6\t\t\n➗️\t8\t0.6\t\t\n🟰\t8\t14\t\t\n♾️\t8\t11\t\t\n‼️\t8\t0.6\t\t\n⁉️\t8\t0.6\t\t\n❓️\t8\t0.6\t\t\n❔️\t8\t0.6\t\t\n❕️\t8\t0.6\t\t\n❗️\t8\t0.6\t\t\n〰️\t8\t0.6\t\t\n💱\t8\t0.6\t\t\n💲\t8\t0.6\t\t\n⚕️\t8\t4\t\t\n♻️\t8\t0.6\t\t\n⚜️\t8\t1\t\t\n🔱\t8\t0.6\t\t\n📛\t8\t0.6\t\t\n🔰\t8\t0.6\t\t\n⭕️\t8\t0.6\t\t\n✅️\t8\t0.6\t\t\n☑️\t8\t0.6\t\t\n✔️\t8\t0.6\t\t\n❌️\t8\t0.6\t\t\n❎️\t8\t0.6\t\t\n➰️\t8\t0.6\t\t\n➿️\t8\t1\t\t\n〽️\t8\t0.6\t\t\n✳️\t8\t0.6\t\t\n✴️\t8\t0.6\t\t\n❇️\t8\t0.6\t\t\n©️\t8\t0.6\t\t\n®️\t8\t0.6\t\t\n™️\t8\t0.6\t\t\n🫟\t8\t16\t\t\n#️⃣\t8\t0.6\t\t\n*️⃣\t8\t2\t\t\n0️⃣\t8\t0.6\t\t\n1️⃣\t8\t0.6\t\t\n2️⃣\t8\t0.6\t\t\n3️⃣\t8\t0.6\t\t\n4️⃣\t8\t0.6\t\t\n5️⃣\t8\t0.6\t\t\n6️⃣\t8\t0.6\t\t\n7️⃣\t8\t0.6\t\t\n8️⃣\t8\t0.6\t\t\n9️⃣\t8\t0.6\t\t\n🔟\t8\t0.6\t\t\n🔠\t8\t0.6\t\t\n🔡\t8\t0.6\t\t\n🔢\t8\t0.6\t\t\n🔣\t8\t0.6\t\t\n🔤\t8\t0.6\t\t\n🅰️\t8\t0.6\t\t\n🆎\t8\t0.6\t\t\n🅱️\t8\t0.6\t\t\n🆑\t8\t0.6\t\t\n🆒\t8\t0.6\t\t\n🆓\t8\t0.6\t\t\nℹ️\t8\t0.6\t\t\n🆔\t8\t0.6\t\t\nⓂ️\t8\t0.6\t\t\n🆕\t8\t0.6\t\t\n🆖\t8\t0.6\t\t\n🅾️\t8\t0.6\t\t\n🆗\t8\t0.6\t\t\n🅿️\t8\t0.6\t\t\n🆘\t8\t0.6\t\t\n🆙\t8\t0.6\t\t\n🆚\t8\t0.6\t\t\n🈁\t8\t0.6\t\t\n🈂️\t8\t0.6\t\t\n🈷️\t8\t0.6\t\t\n🈶\t8\t0.6\t\t\n🈯️\t8\t0.6\t\t\n🉐\t8\t0.6\t\t\n🈹\t8\t0.6\t\t\n🈚️\t8\t0.6\t\t\n🈲\t8\t0.6\t\t\n🉑\t8\t0.6\t\t\n🈸\t8\t0.6\t\t\n🈴\t8\t0.6\t\t\n🈳\t8\t0.6\t\t\n㊗️\t8\t0.6\t\t\n㊙️\t8\t0.6\t\t\n🈺\t8\t0.6\t\t\n🈵\t8\t0.6\t\t\n🔴\t8\t0.6\t\t\n🟠\t8\t12\t\t\n🟡\t8\t12\t\t\n🟢\t8\t12\t\t\n🔵\t8\t0.6\t\t\n🟣\t8\t12\t\t\n🟤\t8\t12\t\t\n⚫️\t8\t0.6\t\t\n⚪️\t8\t0.6\t\t\n🟥\t8\t12\t\t\n🟧\t8\t12\t\t\n🟨\t8\t12\t\t\n🟩\t8\t12\t\t\n🟦\t8\t12\t\t\n🟪\t8\t12\t\t\n🟫\t8\t12\t\t\n⬛️\t8\t0.6\t\t\n⬜️\t8\t0.6\t\t\n◼️\t8\t0.6\t\t\n◻️\t8\t0.6\t\t\n◾️\t8\t0.6\t\t\n◽️\t8\t0.6\t\t\n▪️\t8\t0.6\t\t\n▫️\t8\t0.6\t\t\n🔶\t8\t0.6\t\t\n🔷\t8\t0.6\t\t\n🔸\t8\t0.6\t\t\n🔹\t8\t0.6\t\t\n🔺\t8\t0.6\t\t\n🔻\t8\t0.6\t\t\n💠\t8\t0.6\t\t\n🔘\t8\t0.6\t\t\n🔳\t8\t0.6\t\t\n🔲\t8\t0.6\t\t\n🏁\t9\t0.6\t\t\n🚩\t9\t0.6\t\t\n🎌\t9\t0.6\t\t\n🏴\t9\t1\t\t\n🏳️\t9\t0.7\t\t\n🏳️‍🌈\t9\t4\t\t\n🏳️‍⚧️\t9\t13\t\t\n🏴‍☠️\t9\t11\t\t\n🇦🇨\t9\t2\t1\t\n🇦🇩\t9\t2\t1\t\n🇦🇪\t9\t2\t1\t\n🇦🇫\t9\t2\t1\t\n🇦🇬\t9\t2\t1\t\n🇦🇮\t9\t2\t1\t\n🇦🇱\t9\t2\t1\t\n🇦🇲\t9\t2\t1\t\n🇦🇴\t9\t2\t1\t\n🇦🇶\t9\t2\t1\t\n🇦🇷\t9\t2\t1\t\n🇦🇸\t9\t2\t1\t\n🇦🇹\t9\t2\t1\t\n🇦🇺\t9\t2\t1\t\n🇦🇼\t9\t2\t1\t\n🇦🇽\t9\t2\t1\t\n🇦🇿\t9\t2\t1\t\n🇧🇦\t9\t2\t1\t\n🇧🇧\t9\t2\t1\t\n🇧🇩\t9\t2\t1\t\n🇧🇪\t9\t2\t1\t\n🇧🇫\t9\t2\t1\t\n🇧🇬\t9\t2\t1\t\n🇧🇭\t9\t2\t1\t\n🇧🇮\t9\t2\t1\t\n🇧🇯\t9\t2\t1\t\n🇧🇱\t9\t2\t1\t\n🇧🇲\t9\t2\t1\t\n🇧🇳\t9\t2\t1\t\n🇧🇴\t9\t2\t1\t\n🇧🇶\t9\t2\t1\t\n🇧🇷\t9\t2\t1\t\n🇧🇸\t9\t2\t1\t\n🇧🇹\t9\t2\t1\t\n🇧🇻\t9\t2\t1\t\n🇧🇼\t9\t2\t1\t\n🇧🇾\t9\t2\t1\t\n🇧🇿\t9\t2\t1\t\n🇨🇦\t9\t2\t1\t\n🇨🇨\t9\t2\t1\t\n🇨🇩\t9\t2\t1\t\n🇨🇫\t9\t2\t1\t\n🇨🇬\t9\t2\t1\t\n🇨🇭\t9\t2\t1\t\n🇨🇮\t9\t2\t1\t\n🇨🇰\t9\t2\t1\t\n🇨🇱\t9\t2\t1\t\n🇨🇲\t9\t2\t1\t\n🇨🇳\t9\t0.6\t1\t\n🇨🇴\t9\t2\t1\t\n🇨🇵\t9\t2\t1\t\n🇨🇶\t9\t16\t1\t\n🇨🇷\t9\t2\t1\t\n🇨🇺\t9\t2\t1\t\n🇨🇻\t9\t2\t1\t\n🇨🇼\t9\t2\t1\t\n🇨🇽\t9\t2\t1\t\n🇨🇾\t9\t2\t1\t\n🇨🇿\t9\t2\t1\t\n🇩🇪\t9\t0.6\t1\t\n🇩🇬\t9\t2\t1\t\n🇩🇯\t9\t2\t1\t\n🇩🇰\t9\t2\t1\t\n🇩🇲\t9\t2\t1\t\n🇩🇴\t9\t2\t1\t\n🇩🇿\t9\t2\t1\t\n🇪🇦\t9\t2\t1\t\n🇪🇨\t9\t2\t1\t\n🇪🇪\t9\t2\t1\t\n🇪🇬\t9\t2\t1\t\n🇪🇭\t9\t2\t1\t\n🇪🇷\t9\t2\t1\t\n🇪🇸\t9\t0.6\t1\t\n🇪🇹\t9\t2\t1\t\n🇪🇺\t9\t2\t1\t\n🇫🇮\t9\t2\t1\t\n🇫🇯\t9\t2\t1\t\n🇫🇰\t9\t2\t1\t\n🇫🇲\t9\t2\t1\t\n🇫🇴\t9\t2\t1\t\n🇫🇷\t9\t0.6\t1\t\n🇬🇦\t9\t2\t1\t\n🇬🇧\t9\t0.6\t1\t\n🇬🇩\t9\t2\t1\t\n🇬🇪\t9\t2\t1\t\n🇬🇫\t9\t2\t1\t\n🇬🇬\t9\t2\t1\t\n🇬🇭\t9\t2\t1\t\n🇬🇮\t9\t2\t1\t\n🇬🇱\t9\t2\t1\t\n🇬🇲\t9\t2\t1\t\n🇬🇳\t9\t2\t1\t\n🇬🇵\t9\t2\t1\t\n🇬🇶\t9\t2\t1\t\n🇬🇷\t9\t2\t1\t\n🇬🇸\t9\t2\t1\t\n🇬🇹\t9\t2\t1\t\n🇬🇺\t9\t2\t1\t\n🇬🇼\t9\t2\t1\t\n🇬🇾\t9\t2\t1\t\n🇭🇰\t9\t2\t1\t\n🇭🇲\t9\t2\t1\t\n🇭🇳\t9\t2\t1\t\n🇭🇷\t9\t2\t1\t\n🇭🇹\t9\t2\t1\t\n🇭🇺\t9\t2\t1\t\n🇮🇨\t9\t2\t1\t\n🇮🇩\t9\t2\t1\t\n🇮🇪\t9\t2\t1\t\n🇮🇱\t9\t2\t1\t\n🇮🇲\t9\t2\t1\t\n🇮🇳\t9\t2\t1\t\n🇮🇴\t9\t2\t1\t\n🇮🇶\t9\t2\t1\t\n🇮🇷\t9\t2\t1\t\n🇮🇸\t9\t2\t1\t\n🇮🇹\t9\t0.6\t1\t\n🇯🇪\t9\t2\t1\t\n🇯🇲\t9\t2\t1\t\n🇯🇴\t9\t2\t1\t\n🇯🇵\t9\t0.6\t1\t\n🇰🇪\t9\t2\t1\t\n🇰🇬\t9\t2\t1\t\n🇰🇭\t9\t2\t1\t\n🇰🇮\t9\t2\t1\t\n🇰🇲\t9\t2\t1\t\n🇰🇳\t9\t2\t1\t\n🇰🇵\t9\t2\t1\t\n🇰🇷\t9\t0.6\t1\t\n🇰🇼\t9\t2\t1\t\n🇰🇾\t9\t2\t1\t\n🇰🇿\t9\t2\t1\t\n🇱🇦\t9\t2\t1\t\n🇱🇧\t9\t2\t1\t\n🇱🇨\t9\t2\t1\t\n🇱🇮\t9\t2\t1\t\n🇱🇰\t9\t2\t1\t\n🇱🇷\t9\t2\t1\t\n🇱🇸\t9\t2\t1\t\n🇱🇹\t9\t2\t1\t\n🇱🇺\t9\t2\t1\t\n🇱🇻\t9\t2\t1\t\n🇱🇾\t9\t2\t1\t\n🇲🇦\t9\t2\t1\t\n🇲🇨\t9\t2\t1\t\n🇲🇩\t9\t2\t1\t\n🇲🇪\t9\t2\t1\t\n🇲🇫\t9\t2\t1\t\n🇲🇬\t9\t2\t1\t\n🇲🇭\t9\t2\t1\t\n🇲🇰\t9\t2\t1\t\n🇲🇱\t9\t2\t1\t\n🇲🇲\t9\t2\t1\t\n🇲🇳\t9\t2\t1\t\n🇲🇴\t9\t2\t1\t\n🇲🇵\t9\t2\t1\t\n🇲🇶\t9\t2\t1\t\n🇲🇷\t9\t2\t1\t\n🇲🇸\t9\t2\t1\t\n🇲🇹\t9\t2\t1\t\n🇲🇺\t9\t2\t1\t\n🇲🇻\t9\t2\t1\t\n🇲🇼\t9\t2\t1\t\n🇲🇽\t9\t2\t1\t\n🇲🇾\t9\t2\t1\t\n🇲🇿\t9\t2\t1\t\n🇳🇦\t9\t2\t1\t\n🇳🇨\t9\t2\t1\t\n🇳🇪\t9\t2\t1\t\n🇳🇫\t9\t2\t1\t\n🇳🇬\t9\t2\t1\t\n🇳🇮\t9\t2\t1\t\n🇳🇱\t9\t2\t1\t\n🇳🇴\t9\t2\t1\t\n🇳🇵\t9\t2\t1\t\n🇳🇷\t9\t2\t1\t\n🇳🇺\t9\t2\t1\t\n🇳🇿\t9\t2\t1\t\n🇴🇲\t9\t2\t1\t\n🇵🇦\t9\t2\t1\t\n🇵🇪\t9\t2\t1\t\n🇵🇫\t9\t2\t1\t\n🇵🇬\t9\t2\t1\t\n🇵🇭\t9\t2\t1\t\n🇵🇰\t9\t2\t1\t\n🇵🇱\t9\t2\t1\t\n🇵🇲\t9\t2\t1\t\n🇵🇳\t9\t2\t1\t\n🇵🇷\t9\t2\t1\t\n🇵🇸\t9\t2\t1\t\n🇵🇹\t9\t2\t1\t\n🇵🇼\t9\t2\t1\t\n🇵🇾\t9\t2\t1\t\n🇶🇦\t9\t2\t1\t\n🇷🇪\t9\t2\t1\t\n🇷🇴\t9\t2\t1\t\n🇷🇸\t9\t2\t1\t\n🇷🇺\t9\t0.6\t1\t\n🇷🇼\t9\t2\t1\t\n🇸🇦\t9\t2\t1\t\n🇸🇧\t9\t2\t1\t\n🇸🇨\t9\t2\t1\t\n🇸🇩\t9\t2\t1\t\n🇸🇪\t9\t2\t1\t\n🇸🇬\t9\t2\t1\t\n🇸🇭\t9\t2\t1\t\n🇸🇮\t9\t2\t1\t\n🇸🇯\t9\t2\t1\t\n🇸🇰\t9\t2\t1\t\n🇸🇱\t9\t2\t1\t\n🇸🇲\t9\t2\t1\t\n🇸🇳\t9\t2\t1\t\n🇸🇴\t9\t2\t1\t\n🇸🇷\t9\t2\t1\t\n🇸🇸\t9\t2\t1\t\n🇸🇹\t9\t2\t1\t\n🇸🇻\t9\t2\t1\t\n🇸🇽\t9\t2\t1\t\n🇸🇾\t9\t2\t1\t\n🇸🇿\t9\t2\t1\t\n🇹🇦\t9\t2\t1\t\n🇹🇨\t9\t2\t1\t\n🇹🇩\t9\t2\t1\t\n🇹🇫\t9\t2\t1\t\n🇹🇬\t9\t2\t1\t\n🇹🇭\t9\t2\t1\t\n🇹🇯\t9\t2\t1\t\n🇹🇰\t9\t2\t1\t\n🇹🇱\t9\t2\t1\t\n🇹🇲\t9\t2\t1\t\n🇹🇳\t9\t2\t1\t\n🇹🇴\t9\t2\t1\t\n🇹🇷\t9\t2\t1\t\n🇹🇹\t9\t2\t1\t\n🇹🇻\t9\t2\t1\t\n🇹🇼\t9\t2\t1\t\n🇹🇿\t9\t2\t1\t\n🇺🇦\t9\t2\t1\t\n🇺🇬\t9\t2\t1\t\n🇺🇲\t9\t2\t1\t\n🇺🇳\t9\t4\t1\t\n🇺🇸\t9\t0.6\t1\t\n🇺🇾\t9\t2\t1\t\n🇺🇿\t9\t2\t1\t\n🇻🇦\t9\t2\t1\t\n🇻🇨\t9\t2\t1\t\n🇻🇪\t9\t2\t1\t\n🇻🇬\t9\t2\t1\t\n🇻🇮\t9\t2\t1\t\n🇻🇳\t9\t2\t1\t\n🇻🇺\t9\t2\t1\t\n🇼🇫\t9\t2\t1\t\n🇼🇸\t9\t2\t1\t\n🇽🇰\t9\t2\t1\t\n🇾🇪\t9\t2\t1\t\n🇾🇹\t9\t2\t1\t\n🇿🇦\t9\t2\t1\t\n🇿🇲\t9\t2\t1\t\n🇿🇼\t9\t2\t1\t\n🏴󠁧󠁢󠁥󠁮󠁧󠁿\t9\t5\t1\t\n🏴󠁧󠁢󠁳󠁣󠁴󠁿\t9\t5\t1\t\n🏴󠁧󠁢󠁷󠁬󠁳󠁿\t9\t5\t1\t"; +export const frimousseShortcodes: string = + "grinning\nsmiley\nsmile\ngrin\nlaughing|satisfied\nsweat_smile\nrolling_on_the_floor_laughing\njoy\nslightly_smiling_face\nupside_down_face\nmelting_face\nwink\nblush\ninnocent\nsmiling_face_with_3_hearts\nheart_eyes\nstar-struck|grinning_face_with_star_eyes\nkissing_heart\nkissing\nrelaxed\nkissing_closed_eyes\nkissing_smiling_eyes\nsmiling_face_with_tear\nyum\nstuck_out_tongue\nstuck_out_tongue_winking_eye\nzany_face|grinning_face_with_one_large_and_one_small_eye\nstuck_out_tongue_closed_eyes\nmoney_mouth_face\nhugging_face\nface_with_hand_over_mouth|smiling_face_with_smiling_eyes_and_hand_covering_mouth\nface_with_open_eyes_and_hand_over_mouth\nface_with_peeking_eye\nshushing_face|face_with_finger_covering_closed_lips\nthinking_face\nsaluting_face\nzipper_mouth_face\nface_with_raised_eyebrow|face_with_one_eyebrow_raised\nneutral_face\nexpressionless\nno_mouth\ndotted_line_face\nface_in_clouds\nsmirk\nunamused\nface_with_rolling_eyes\ngrimacing\nface_exhaling\nlying_face\nshaking_face\nhead_shaking_horizontally\nhead_shaking_vertically\nrelieved\npensive\nsleepy\ndrooling_face\nsleeping\n\nmask\nface_with_thermometer\nface_with_head_bandage\nnauseated_face\nface_vomiting|face_with_open_mouth_vomiting\nsneezing_face\nhot_face\ncold_face\nwoozy_face\ndizzy_face\nface_with_spiral_eyes\nexploding_head|shocked_face_with_exploding_head\nface_with_cowboy_hat\npartying_face\ndisguised_face\nsunglasses\nnerd_face\nface_with_monocle\nconfused\nface_with_diagonal_mouth\nworried\nslightly_frowning_face\nwhite_frowning_face\nopen_mouth\nhushed\nastonished\nflushed\npleading_face\nface_holding_back_tears\nfrowning\nanguished\nfearful\ncold_sweat\ndisappointed_relieved\ncry\nsob\nscream\nconfounded\npersevere\ndisappointed\nsweat\nweary\ntired_face\nyawning_face\ntriumph\nrage\nangry\nface_with_symbols_on_mouth|serious_face_with_symbols_covering_mouth\nsmiling_imp\nimp\nskull\nskull_and_crossbones\nhankey|poop|shit\nclown_face\njapanese_ogre\njapanese_goblin\nghost\nalien\nspace_invader\nrobot_face\nsmiley_cat\nsmile_cat\njoy_cat\nheart_eyes_cat\nsmirk_cat\nkissing_cat\nscream_cat\ncrying_cat_face\npouting_cat\nsee_no_evil\nhear_no_evil\nspeak_no_evil\nlove_letter\ncupid\ngift_heart\nsparkling_heart\nheartpulse\nheartbeat\nrevolving_hearts\ntwo_hearts\nheart_decoration\nheavy_heart_exclamation_mark_ornament\nbroken_heart\nheart_on_fire\nmending_heart\nheart\npink_heart\norange_heart\nyellow_heart\ngreen_heart\nblue_heart\nlight_blue_heart\npurple_heart\nbrown_heart\nblack_heart\ngrey_heart\nwhite_heart\nkiss\n100\nanger\nboom|collision\ndizzy\nsweat_drops\ndash\nhole\nspeech_balloon\neye-in-speech-bubble\nleft_speech_bubble\nright_anger_bubble\nthought_balloon\nzzz\nwave\nraised_back_of_hand\nraised_hand_with_fingers_splayed\nhand|raised_hand\nspock-hand\nrightwards_hand\nleftwards_hand\npalm_down_hand\npalm_up_hand\nleftwards_pushing_hand\nrightwards_pushing_hand\nok_hand\npinched_fingers\npinching_hand\nv\ncrossed_fingers|hand_with_index_and_middle_fingers_crossed\nhand_with_index_finger_and_thumb_crossed\ni_love_you_hand_sign\nthe_horns|sign_of_the_horns\ncall_me_hand\npoint_left\npoint_right\npoint_up_2\nmiddle_finger|reversed_hand_with_middle_finger_extended\npoint_down\npoint_up\nindex_pointing_at_the_viewer\n+1|thumbsup\n-1|thumbsdown\nfist\nfacepunch|punch\nleft-facing_fist\nright-facing_fist\nclap\nraised_hands\nheart_hands\nopen_hands\npalms_up_together\nhandshake\npray\nwriting_hand\nnail_care\nselfie\nmuscle\nmechanical_arm\nmechanical_leg\nleg\nfoot\near\near_with_hearing_aid\nnose\nbrain\nanatomical_heart\nlungs\ntooth\nbone\neyes\neye\ntongue\nlips\nbiting_lip\nbaby\nchild\nboy\ngirl\nadult\nperson_with_blond_hair\nman\nbearded_person\nman_with_beard\nwoman_with_beard\nred_haired_man\ncurly_haired_man\nwhite_haired_man\nbald_man\nwoman\nred_haired_woman\nred_haired_person\ncurly_haired_woman\ncurly_haired_person\nwhite_haired_woman\nwhite_haired_person\nbald_woman\nbald_person\nblond-haired-woman\nblond-haired-man\nolder_adult\nolder_man\nolder_woman\nperson_frowning\nman-frowning\nwoman-frowning\nperson_with_pouting_face\nman-pouting\nwoman-pouting\nno_good\nman-gesturing-no\nwoman-gesturing-no\nok_woman\nman-gesturing-ok\nwoman-gesturing-ok\ninformation_desk_person\nman-tipping-hand\nwoman-tipping-hand\nraising_hand\nman-raising-hand\nwoman-raising-hand\ndeaf_person\ndeaf_man\ndeaf_woman\nbow\nman-bowing\nwoman-bowing\nface_palm\nman-facepalming\nwoman-facepalming\nshrug\nman-shrugging\nwoman-shrugging\nhealth_worker\nmale-doctor\nfemale-doctor\nstudent\nmale-student\nfemale-student\nteacher\nmale-teacher\nfemale-teacher\njudge\nmale-judge\nfemale-judge\nfarmer\nmale-farmer\nfemale-farmer\ncook\nmale-cook\nfemale-cook\nmechanic\nmale-mechanic\nfemale-mechanic\nfactory_worker\nmale-factory-worker\nfemale-factory-worker\noffice_worker\nmale-office-worker\nfemale-office-worker\nscientist\nmale-scientist\nfemale-scientist\ntechnologist\nmale-technologist\nfemale-technologist\nsinger\nmale-singer\nfemale-singer\nartist\nmale-artist\nfemale-artist\npilot\nmale-pilot\nfemale-pilot\nastronaut\nmale-astronaut\nfemale-astronaut\nfirefighter\nmale-firefighter\nfemale-firefighter\ncop\nmale-police-officer\nfemale-police-officer\nsleuth_or_spy\nmale-detective\nfemale-detective\nguardsman\nmale-guard\nfemale-guard\nninja\nconstruction_worker\nmale-construction-worker\nfemale-construction-worker\nperson_with_crown\nprince\nprincess\nman_with_turban\nman-wearing-turban\nwoman-wearing-turban\nman_with_gua_pi_mao\nperson_with_headscarf\nperson_in_tuxedo\nman_in_tuxedo\nwoman_in_tuxedo\nbride_with_veil\nman_with_veil\nwoman_with_veil\npregnant_woman\npregnant_man\npregnant_person\nbreast-feeding\nwoman_feeding_baby\nman_feeding_baby\nperson_feeding_baby\nangel\nsanta\nmrs_claus|mother_christmas\nmx_claus\nsuperhero\nmale_superhero\nfemale_superhero\nsupervillain\nmale_supervillain\nfemale_supervillain\nmage\nmale_mage\nfemale_mage\nfairy\nmale_fairy\nfemale_fairy\nvampire\nmale_vampire\nfemale_vampire\nmerperson\nmerman\nmermaid\nelf\nmale_elf\nfemale_elf\ngenie\nmale_genie\nfemale_genie\nzombie\nmale_zombie\nfemale_zombie\ntroll\nmassage\nman-getting-massage\nwoman-getting-massage\nhaircut\nman-getting-haircut\nwoman-getting-haircut\nwalking\nman-walking\nwoman-walking\nperson_walking_facing_right\nwoman_walking_facing_right\nman_walking_facing_right\nstanding_person\nman_standing\nwoman_standing\nkneeling_person\nman_kneeling\nwoman_kneeling\nperson_kneeling_facing_right\nwoman_kneeling_facing_right\nman_kneeling_facing_right\nperson_with_probing_cane\nperson_with_white_cane_facing_right\nman_with_probing_cane\nman_with_white_cane_facing_right\nwoman_with_probing_cane\nwoman_with_white_cane_facing_right\nperson_in_motorized_wheelchair\nperson_in_motorized_wheelchair_facing_right\nman_in_motorized_wheelchair\nman_in_motorized_wheelchair_facing_right\nwoman_in_motorized_wheelchair\nwoman_in_motorized_wheelchair_facing_right\nperson_in_manual_wheelchair\nperson_in_manual_wheelchair_facing_right\nman_in_manual_wheelchair\nman_in_manual_wheelchair_facing_right\nwoman_in_manual_wheelchair\nwoman_in_manual_wheelchair_facing_right\nrunner|running\nman-running\nwoman-running\nperson_running_facing_right\nwoman_running_facing_right\nman_running_facing_right\ndancer\nman_dancing\nman_in_business_suit_levitating\ndancers\nmen-with-bunny-ears-partying|man-with-bunny-ears-partying\nwomen-with-bunny-ears-partying|woman-with-bunny-ears-partying\nperson_in_steamy_room\nman_in_steamy_room\nwoman_in_steamy_room\nperson_climbing\nman_climbing\nwoman_climbing\nfencer\nhorse_racing\nskier\nsnowboarder\ngolfer\nman-golfing\nwoman-golfing\nsurfer\nman-surfing\nwoman-surfing\nrowboat\nman-rowing-boat\nwoman-rowing-boat\nswimmer\nman-swimming\nwoman-swimming\nperson_with_ball\nman-bouncing-ball\nwoman-bouncing-ball\nweight_lifter\nman-lifting-weights\nwoman-lifting-weights\nbicyclist\nman-biking\nwoman-biking\nmountain_bicyclist\nman-mountain-biking\nwoman-mountain-biking\nperson_doing_cartwheel\nman-cartwheeling\nwoman-cartwheeling\nwrestlers\nman-wrestling\nwoman-wrestling\nwater_polo\nman-playing-water-polo\nwoman-playing-water-polo\nhandball\nman-playing-handball\nwoman-playing-handball\njuggling\nman-juggling\nwoman-juggling\nperson_in_lotus_position\nman_in_lotus_position\nwoman_in_lotus_position\nbath\nsleeping_accommodation\npeople_holding_hands\ntwo_women_holding_hands|women_holding_hands\nman_and_woman_holding_hands|woman_and_man_holding_hands|couple\ntwo_men_holding_hands|men_holding_hands\ncouplekiss\nwoman-kiss-man\nman-kiss-man\nwoman-kiss-woman\ncouple_with_heart\nwoman-heart-man\nman-heart-man\nwoman-heart-woman\nman-woman-boy\nman-woman-girl\nman-woman-girl-boy\nman-woman-boy-boy\nman-woman-girl-girl\nman-man-boy\nman-man-girl\nman-man-girl-boy\nman-man-boy-boy\nman-man-girl-girl\nwoman-woman-boy\nwoman-woman-girl\nwoman-woman-girl-boy\nwoman-woman-boy-boy\nwoman-woman-girl-girl\nman-boy\nman-boy-boy\nman-girl\nman-girl-boy\nman-girl-girl\nwoman-boy\nwoman-boy-boy\nwoman-girl\nwoman-girl-boy\nwoman-girl-girl\nspeaking_head_in_silhouette\nbust_in_silhouette\nbusts_in_silhouette\npeople_hugging\nfamily\nfamily_adult_adult_child\nfamily_adult_adult_child_child\nfamily_adult_child\nfamily_adult_child_child\nfootprints\n\nmonkey_face\nmonkey\ngorilla\norangutan\ndog\ndog2\nguide_dog\nservice_dog\npoodle\nwolf\nfox_face\nraccoon\ncat\ncat2\nblack_cat\nlion_face\ntiger\ntiger2\nleopard\nhorse\nmoose\ndonkey\nracehorse\nunicorn_face\nzebra_face\ndeer\nbison\ncow\nox\nwater_buffalo\ncow2\npig\npig2\nboar\npig_nose\nram\nsheep\ngoat\ndromedary_camel\ncamel\nllama\ngiraffe_face\nelephant\nmammoth\nrhinoceros\nhippopotamus\nmouse\nmouse2\nrat\nhamster\nrabbit\nrabbit2\nchipmunk\nbeaver\nhedgehog\nbat\nbear\npolar_bear\nkoala\npanda_face\nsloth\notter\nskunk\nkangaroo\nbadger\nfeet|paw_prints\nturkey\nchicken\nrooster\nhatching_chick\nbaby_chick\nhatched_chick\nbird\npenguin\ndove_of_peace\neagle\nduck\nswan\nowl\ndodo\nfeather\nflamingo\npeacock\nparrot\nwing\nblack_bird\ngoose\nphoenix\nfrog\ncrocodile\nturtle\nlizard\nsnake\ndragon_face\ndragon\nsauropod\nt-rex\nwhale\nwhale2\ndolphin|flipper\nseal\nfish\ntropical_fish\nblowfish\nshark\noctopus\nshell\ncoral\njellyfish\ncrab\nlobster\nshrimp\nsquid\noyster\nsnail\nbutterfly\nbug\nant\nbee|honeybee\nbeetle\nladybug|lady_beetle\ncricket\ncockroach\nspider\nspider_web\nscorpion\nmosquito\nfly\nworm\nmicrobe\nbouquet\ncherry_blossom\nwhite_flower\nlotus\nrosette\nrose\nwilted_flower\nhibiscus\nsunflower\nblossom\ntulip\nhyacinth\nseedling\npotted_plant\nevergreen_tree\ndeciduous_tree\npalm_tree\ncactus\near_of_rice\nherb\nshamrock\nfour_leaf_clover\nmaple_leaf\nfallen_leaf\nleaves\nempty_nest\nnest_with_eggs\nmushroom\n\ngrapes\nmelon\nwatermelon\ntangerine\nlemon\nlime\nbanana\npineapple\nmango\napple\ngreen_apple\npear\npeach\ncherries\nstrawberry\nblueberries\nkiwifruit\ntomato\nolive\ncoconut\navocado\neggplant\npotato\ncarrot\ncorn\nhot_pepper\nbell_pepper\ncucumber\nleafy_green\nbroccoli\ngarlic\nonion\npeanuts\nbeans\nchestnut\nginger_root\npea_pod\nbrown_mushroom\n\nbread\ncroissant\nbaguette_bread\nflatbread\npretzel\nbagel\npancakes\nwaffle\ncheese_wedge\nmeat_on_bone\npoultry_leg\ncut_of_meat\nbacon\nhamburger\nfries\npizza\nhotdog\nsandwich\ntaco\nburrito\ntamale\nstuffed_flatbread\nfalafel\negg\nfried_egg|cooking\nshallow_pan_of_food\nstew\nfondue\nbowl_with_spoon\ngreen_salad\npopcorn\nbutter\nsalt\ncanned_food\nbento\nrice_cracker\nrice_ball\nrice\ncurry\nramen\nspaghetti\nsweet_potato\noden\nsushi\nfried_shrimp\nfish_cake\nmoon_cake\ndango\ndumpling\nfortune_cookie\ntakeout_box\nicecream\nshaved_ice\nice_cream\ndoughnut\ncookie\nbirthday\ncake\ncupcake\npie\nchocolate_bar\ncandy\nlollipop\ncustard\nhoney_pot\nbaby_bottle\nglass_of_milk\ncoffee\nteapot\ntea\nsake\nchampagne\nwine_glass\ncocktail\ntropical_drink\nbeer\nbeers\nclinking_glasses\ntumbler_glass\npouring_liquid\ncup_with_straw\nbubble_tea\nbeverage_box\nmate_drink\nice_cube\nchopsticks\nknife_fork_plate\nfork_and_knife\nspoon\nhocho|knife\njar\namphora\nearth_africa\nearth_americas\nearth_asia\nglobe_with_meridians\nworld_map\njapan\ncompass\nsnow_capped_mountain\nmountain\nvolcano\nmount_fuji\ncamping\nbeach_with_umbrella\ndesert\ndesert_island\nnational_park\nstadium\nclassical_building\nbuilding_construction\nbricks\nrock\nwood\nhut\nhouse_buildings\nderelict_house_building\nhouse\nhouse_with_garden\noffice\npost_office\neuropean_post_office\nhospital\nbank\nhotel\nlove_hotel\nconvenience_store\nschool\ndepartment_store\nfactory\njapanese_castle\neuropean_castle\nwedding\ntokyo_tower\nstatue_of_liberty\nchurch\nmosque\nhindu_temple\nsynagogue\nshinto_shrine\nkaaba\nfountain\ntent\nfoggy\nnight_with_stars\ncityscape\nsunrise_over_mountains\nsunrise\ncity_sunset\ncity_sunrise\nbridge_at_night\nhotsprings\ncarousel_horse\nplayground_slide\nferris_wheel\nroller_coaster\nbarber\ncircus_tent\nsteam_locomotive\nrailway_car\nbullettrain_side\nbullettrain_front\ntrain2\nmetro\nlight_rail\nstation\ntram\nmonorail\nmountain_railway\ntrain\nbus\noncoming_bus\ntrolleybus\nminibus\nambulance\nfire_engine\npolice_car\noncoming_police_car\ntaxi\noncoming_taxi\ncar|red_car\noncoming_automobile\nblue_car\npickup_truck\ntruck\narticulated_lorry\ntractor\nracing_car\nracing_motorcycle\nmotor_scooter\nmanual_wheelchair\nmotorized_wheelchair\nauto_rickshaw\nbike\nscooter\nskateboard\nroller_skate\nbusstop\nmotorway\nrailway_track\noil_drum\nfuelpump\nwheel\nrotating_light\ntraffic_light\nvertical_traffic_light\noctagonal_sign\nconstruction\nanchor\nring_buoy\nboat|sailboat\ncanoe\nspeedboat\npassenger_ship\nferry\nmotor_boat\nship\nairplane\nsmall_airplane\nairplane_departure\nairplane_arriving\nparachute\nseat\nhelicopter\nsuspension_railway\nmountain_cableway\naerial_tramway\nsatellite\nrocket\nflying_saucer\nbellhop_bell\nluggage\nhourglass\nhourglass_flowing_sand\nwatch\nalarm_clock\nstopwatch\ntimer_clock\nmantelpiece_clock\nclock12\nclock1230\nclock1\nclock130\nclock2\nclock230\nclock3\nclock330\nclock4\nclock430\nclock5\nclock530\nclock6\nclock630\nclock7\nclock730\nclock8\nclock830\nclock9\nclock930\nclock10\nclock1030\nclock11\nclock1130\nnew_moon\nwaxing_crescent_moon\nfirst_quarter_moon\nmoon|waxing_gibbous_moon\nfull_moon\nwaning_gibbous_moon\nlast_quarter_moon\nwaning_crescent_moon\ncrescent_moon\nnew_moon_with_face\nfirst_quarter_moon_with_face\nlast_quarter_moon_with_face\nthermometer\nsunny\nfull_moon_with_face\nsun_with_face\nringed_planet\nstar\nstar2\nstars\nmilky_way\ncloud\npartly_sunny\nthunder_cloud_and_rain\nmostly_sunny|sun_small_cloud\nbarely_sunny|sun_behind_cloud\npartly_sunny_rain|sun_behind_rain_cloud\nrain_cloud\nsnow_cloud\nlightning|lightning_cloud\ntornado|tornado_cloud\nfog\nwind_blowing_face\ncyclone\nrainbow\nclosed_umbrella\numbrella\numbrella_with_rain_drops\numbrella_on_ground\nzap\nsnowflake\nsnowman\nsnowman_without_snow\ncomet\nfire\ndroplet\nocean\njack_o_lantern\nchristmas_tree\nfireworks\nsparkler\nfirecracker\nsparkles\nballoon\ntada\nconfetti_ball\ntanabata_tree\nbamboo\ndolls\nflags\nwind_chime\nrice_scene\nred_envelope\nribbon\ngift\nreminder_ribbon\nadmission_tickets\nticket\nmedal\ntrophy\nsports_medal\nfirst_place_medal\nsecond_place_medal\nthird_place_medal\nsoccer\nbaseball\nsoftball\nbasketball\nvolleyball\nfootball\nrugby_football\ntennis\nflying_disc\nbowling\ncricket_bat_and_ball\nfield_hockey_stick_and_ball\nice_hockey_stick_and_puck\nlacrosse\ntable_tennis_paddle_and_ball\nbadminton_racquet_and_shuttlecock\nboxing_glove\nmartial_arts_uniform\ngoal_net\ngolf\nice_skate\nfishing_pole_and_fish\ndiving_mask\nrunning_shirt_with_sash\nski\nsled\ncurling_stone\ndart\nyo-yo\nkite\ngun\n8ball\ncrystal_ball\nmagic_wand\nvideo_game\njoystick\nslot_machine\ngame_die\njigsaw\nteddy_bear\npinata\nmirror_ball\nnesting_dolls\nspades\nhearts\ndiamonds\nclubs\nchess_pawn\nblack_joker\nmahjong\nflower_playing_cards\nperforming_arts\nframe_with_picture\nart\nthread\nsewing_needle\nyarn\nknot\neyeglasses\ndark_sunglasses\ngoggles\nlab_coat\nsafety_vest\nnecktie\nshirt|tshirt\njeans\nscarf\ngloves\ncoat\nsocks\ndress\nkimono\nsari\none-piece_swimsuit\nbriefs\nshorts\nbikini\nwomans_clothes\nfolding_hand_fan\npurse\nhandbag\npouch\nshopping_bags\nschool_satchel\nthong_sandal\nmans_shoe|shoe\nathletic_shoe\nhiking_boot\nwomans_flat_shoe\nhigh_heel\nsandal\nballet_shoes\nboot\nhair_pick\ncrown\nwomans_hat\ntophat\nmortar_board\nbilled_cap\nmilitary_helmet\nhelmet_with_white_cross\nprayer_beads\nlipstick\nring\ngem\nmute\nspeaker\nsound\nloud_sound\nloudspeaker\nmega\npostal_horn\nbell\nno_bell\nmusical_score\nmusical_note\nnotes\nstudio_microphone\nlevel_slider\ncontrol_knobs\nmicrophone\nheadphones\nradio\nsaxophone\naccordion\nguitar\nmusical_keyboard\ntrumpet\nviolin\nbanjo\ndrum_with_drumsticks\nlong_drum\nmaracas\nflute\n\niphone\ncalling\nphone|telephone\ntelephone_receiver\npager\nfax\nbattery\nlow_battery\nelectric_plug\ncomputer\ndesktop_computer\nprinter\nkeyboard\nthree_button_mouse\ntrackball\nminidisc\nfloppy_disk\ncd\ndvd\nabacus\nmovie_camera\nfilm_frames\nfilm_projector\nclapper\ntv\ncamera\ncamera_with_flash\nvideo_camera\nvhs\nmag\nmag_right\ncandle\nbulb\nflashlight\nizakaya_lantern|lantern\ndiya_lamp\nnotebook_with_decorative_cover\nclosed_book\nbook|open_book\ngreen_book\nblue_book\norange_book\nbooks\nnotebook\nledger\npage_with_curl\nscroll\npage_facing_up\nnewspaper\nrolled_up_newspaper\nbookmark_tabs\nbookmark\nlabel\nmoneybag\ncoin\nyen\ndollar\neuro\npound\nmoney_with_wings\ncredit_card\nreceipt\nchart\nemail|envelope\ne-mail\nincoming_envelope\nenvelope_with_arrow\noutbox_tray\ninbox_tray\npackage\nmailbox\nmailbox_closed\nmailbox_with_mail\nmailbox_with_no_mail\npostbox\nballot_box_with_ballot\npencil2\nblack_nib\nlower_left_fountain_pen\nlower_left_ballpoint_pen\nlower_left_paintbrush\nlower_left_crayon\nmemo|pencil\nbriefcase\nfile_folder\nopen_file_folder\ncard_index_dividers\ndate\ncalendar\nspiral_note_pad\nspiral_calendar_pad\ncard_index\nchart_with_upwards_trend\nchart_with_downwards_trend\nbar_chart\nclipboard\npushpin\nround_pushpin\npaperclip\nlinked_paperclips\nstraight_ruler\ntriangular_ruler\nscissors\ncard_file_box\nfile_cabinet\nwastebasket\nlock\nunlock\nlock_with_ink_pen\nclosed_lock_with_key\nkey\nold_key\nhammer\naxe\npick\nhammer_and_pick\nhammer_and_wrench\ndagger_knife\ncrossed_swords\nbomb\nboomerang\nbow_and_arrow\nshield\ncarpentry_saw\nwrench\nscrewdriver\nnut_and_bolt\ngear\ncompression\nscales\nprobing_cane\nlink\nbroken_chain\nchains\nhook\ntoolbox\nmagnet\nladder\n\nalembic\ntest_tube\npetri_dish\ndna\nmicroscope\ntelescope\nsatellite_antenna\nsyringe\ndrop_of_blood\npill\nadhesive_bandage\ncrutch\nstethoscope\nx-ray\ndoor\nelevator\nmirror\nwindow\nbed\ncouch_and_lamp\nchair\ntoilet\nplunger\nshower\nbathtub\nmouse_trap\nrazor\nlotion_bottle\nsafety_pin\nbroom\nbasket\nroll_of_paper\nbucket\nsoap\nbubbles\ntoothbrush\nsponge\nfire_extinguisher\nshopping_trolley\nsmoking\ncoffin\nheadstone\nfuneral_urn\nnazar_amulet\nhamsa\nmoyai\nplacard\nidentification_card\natm\nput_litter_in_its_place\npotable_water\nwheelchair\nmens\nwomens\nrestroom\nbaby_symbol\nwc\npassport_control\ncustoms\nbaggage_claim\nleft_luggage\nwarning\nchildren_crossing\nno_entry\nno_entry_sign\nno_bicycles\nno_smoking\ndo_not_litter\nnon-potable_water\nno_pedestrians\nno_mobile_phones\nunderage\nradioactive_sign\nbiohazard_sign\narrow_up\narrow_upper_right\narrow_right\narrow_lower_right\narrow_down\narrow_lower_left\narrow_left\narrow_upper_left\narrow_up_down\nleft_right_arrow\nleftwards_arrow_with_hook\narrow_right_hook\narrow_heading_up\narrow_heading_down\narrows_clockwise\narrows_counterclockwise\nback\nend\non\nsoon\ntop\nplace_of_worship\natom_symbol\nom_symbol\nstar_of_david\nwheel_of_dharma\nyin_yang\nlatin_cross\northodox_cross\nstar_and_crescent\npeace_symbol\nmenorah_with_nine_branches\nsix_pointed_star\nkhanda\naries\ntaurus\ngemini\ncancer\nleo\nvirgo\nlibra\nscorpius\nsagittarius\ncapricorn\naquarius\npisces\nophiuchus\ntwisted_rightwards_arrows\nrepeat\nrepeat_one\narrow_forward\nfast_forward\nblack_right_pointing_double_triangle_with_vertical_bar\nblack_right_pointing_triangle_with_double_vertical_bar\narrow_backward\nrewind\nblack_left_pointing_double_triangle_with_vertical_bar\narrow_up_small\narrow_double_up\narrow_down_small\narrow_double_down\ndouble_vertical_bar\nblack_square_for_stop\nblack_circle_for_record\neject\ncinema\nlow_brightness\nhigh_brightness\nsignal_strength\nwireless\nvibration_mode\nmobile_phone_off\nfemale_sign\nmale_sign\ntransgender_symbol\nheavy_multiplication_x\nheavy_plus_sign\nheavy_minus_sign\nheavy_division_sign\nheavy_equals_sign\ninfinity\nbangbang\ninterrobang\nquestion\ngrey_question\ngrey_exclamation\nexclamation|heavy_exclamation_mark\nwavy_dash\ncurrency_exchange\nheavy_dollar_sign\nmedical_symbol|staff_of_aesculapius\nrecycle\nfleur_de_lis\ntrident\nname_badge\nbeginner\no\nwhite_check_mark\nballot_box_with_check\nheavy_check_mark\nx\nnegative_squared_cross_mark\ncurly_loop\nloop\npart_alternation_mark\neight_spoked_asterisk\neight_pointed_black_star\nsparkle\ncopyright\nregistered\ntm\n\nhash\nkeycap_star\nzero\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nkeycap_ten\ncapital_abcd\nabcd\n1234\nsymbols\nabc\na\nab\nb\ncl\ncool\nfree\ninformation_source\nid\nm\nnew\nng\no2\nok\nparking\nsos\nup\nvs\nkoko\nsa\nu6708\nu6709\nu6307\nideograph_advantage\nu5272\nu7121\nu7981\naccept\nu7533\nu5408\nu7a7a\ncongratulations\nsecret\nu55b6\nu6e80\nred_circle\nlarge_orange_circle\nlarge_yellow_circle\nlarge_green_circle\nlarge_blue_circle\nlarge_purple_circle\nlarge_brown_circle\nblack_circle\nwhite_circle\nlarge_red_square\nlarge_orange_square\nlarge_yellow_square\nlarge_green_square\nlarge_blue_square\nlarge_purple_square\nlarge_brown_square\nblack_large_square\nwhite_large_square\nblack_medium_square\nwhite_medium_square\nblack_medium_small_square\nwhite_medium_small_square\nblack_small_square\nwhite_small_square\nlarge_orange_diamond\nlarge_blue_diamond\nsmall_orange_diamond\nsmall_blue_diamond\nsmall_red_triangle\nsmall_red_triangle_down\ndiamond_shape_with_a_dot_inside\nradio_button\nwhite_square_button\nblack_square_button\ncheckered_flag\ntriangular_flag_on_post\ncrossed_flags\nwaving_black_flag\nwaving_white_flag\nrainbow-flag\ntransgender_flag\npirate_flag\nflag-ac\nflag-ad\nflag-ae\nflag-af\nflag-ag\nflag-ai\nflag-al\nflag-am\nflag-ao\nflag-aq\nflag-ar\nflag-as\nflag-at\nflag-au\nflag-aw\nflag-ax\nflag-az\nflag-ba\nflag-bb\nflag-bd\nflag-be\nflag-bf\nflag-bg\nflag-bh\nflag-bi\nflag-bj\nflag-bl\nflag-bm\nflag-bn\nflag-bo\nflag-bq\nflag-br\nflag-bs\nflag-bt\nflag-bv\nflag-bw\nflag-by\nflag-bz\nflag-ca\nflag-cc\nflag-cd\nflag-cf\nflag-cg\nflag-ch\nflag-ci\nflag-ck\nflag-cl\nflag-cm\ncn|flag-cn\nflag-co\nflag-cp\n\nflag-cr\nflag-cu\nflag-cv\nflag-cw\nflag-cx\nflag-cy\nflag-cz\nde|flag-de\nflag-dg\nflag-dj\nflag-dk\nflag-dm\nflag-do\nflag-dz\nflag-ea\nflag-ec\nflag-ee\nflag-eg\nflag-eh\nflag-er\nes|flag-es\nflag-et\nflag-eu\nflag-fi\nflag-fj\nflag-fk\nflag-fm\nflag-fo\nfr|flag-fr\nflag-ga\ngb|uk|flag-gb\nflag-gd\nflag-ge\nflag-gf\nflag-gg\nflag-gh\nflag-gi\nflag-gl\nflag-gm\nflag-gn\nflag-gp\nflag-gq\nflag-gr\nflag-gs\nflag-gt\nflag-gu\nflag-gw\nflag-gy\nflag-hk\nflag-hm\nflag-hn\nflag-hr\nflag-ht\nflag-hu\nflag-ic\nflag-id\nflag-ie\nflag-il\nflag-im\nflag-in\nflag-io\nflag-iq\nflag-ir\nflag-is\nit|flag-it\nflag-je\nflag-jm\nflag-jo\njp|flag-jp\nflag-ke\nflag-kg\nflag-kh\nflag-ki\nflag-km\nflag-kn\nflag-kp\nkr|flag-kr\nflag-kw\nflag-ky\nflag-kz\nflag-la\nflag-lb\nflag-lc\nflag-li\nflag-lk\nflag-lr\nflag-ls\nflag-lt\nflag-lu\nflag-lv\nflag-ly\nflag-ma\nflag-mc\nflag-md\nflag-me\nflag-mf\nflag-mg\nflag-mh\nflag-mk\nflag-ml\nflag-mm\nflag-mn\nflag-mo\nflag-mp\nflag-mq\nflag-mr\nflag-ms\nflag-mt\nflag-mu\nflag-mv\nflag-mw\nflag-mx\nflag-my\nflag-mz\nflag-na\nflag-nc\nflag-ne\nflag-nf\nflag-ng\nflag-ni\nflag-nl\nflag-no\nflag-np\nflag-nr\nflag-nu\nflag-nz\nflag-om\nflag-pa\nflag-pe\nflag-pf\nflag-pg\nflag-ph\nflag-pk\nflag-pl\nflag-pm\nflag-pn\nflag-pr\nflag-ps\nflag-pt\nflag-pw\nflag-py\nflag-qa\nflag-re\nflag-ro\nflag-rs\nru|flag-ru\nflag-rw\nflag-sa\nflag-sb\nflag-sc\nflag-sd\nflag-se\nflag-sg\nflag-sh\nflag-si\nflag-sj\nflag-sk\nflag-sl\nflag-sm\nflag-sn\nflag-so\nflag-sr\nflag-ss\nflag-st\nflag-sv\nflag-sx\nflag-sy\nflag-sz\nflag-ta\nflag-tc\nflag-td\nflag-tf\nflag-tg\nflag-th\nflag-tj\nflag-tk\nflag-tl\nflag-tm\nflag-tn\nflag-to\nflag-tr\nflag-tt\nflag-tv\nflag-tw\nflag-tz\nflag-ua\nflag-ug\nflag-um\nflag-un\nus|flag-us\nflag-uy\nflag-uz\nflag-va\nflag-vc\nflag-ve\nflag-vg\nflag-vi\nflag-vn\nflag-vu\nflag-wf\nflag-ws\nflag-xk\nflag-ye\nflag-yt\nflag-za\nflag-zm\nflag-zw\nflag-england\nflag-scotland\nflag-wales"; diff --git a/packages/core/src/emoji-data/frimousse/loadFrimousseData.test.ts b/packages/core/src/emoji-data/frimousse/loadFrimousseData.test.ts index f8321b2ce9..3b4b74b746 100644 --- a/packages/core/src/emoji-data/frimousse/loadFrimousseData.test.ts +++ b/packages/core/src/emoji-data/frimousse/loadFrimousseData.test.ts @@ -5,6 +5,60 @@ import { searchEmojis } from "../searchEmojis.js"; import { loadFrimousseData } from "./loadFrimousseData.js"; describe("localized emoji data", () => { + it.each([ + "ar", + "de", + "en", + "es", + "fa", + "fr", + "he", + "hr", + "is", + "it", + "ja", + "ko", + "nb", + "nl", + "pl", + "pt", + "ru", + "sk", + "tr", + "uk", + "uz", + "vi", + "zh", + "zh-hant", + ])("keeps legacy Emoji Mart shortcodes searchable in %s", async (locale) => { + const { emojis } = await loadFrimousseData(locale); + // Both inline search and Frimousse's comment picker search labels and tags. + // Include single-name mappings, multiple aliases, and presentation selectors. + for (const [query, identity] of [ + ["heart_eyes", "😍"], + ["thumbsup", "👍"], + ["+1", "👍"], + ["slightly_smiling_face", "🙂"], + ["hankey", "💩"], + ["shit", "💩"], + ["staff_of_aesculapius", "⚕"], + ]) { + const matches = emojis.filter((emoji) => + [emoji.label, ...emoji.tags].some((value) => + value.toLowerCase().includes(query), + ), + ); + expect( + matches.map(({ emoji }) => emoji.replaceAll("\uFE0F", "")), + ).toContain(identity); + } + for (const { tags } of emojis) { + expect(new Set(tags.map((tag) => tag.toLowerCase())).size).toBe( + tags.length, + ); + } + }); + it("reuses decoded data for concurrent loads, aliases, and fallbacks", async () => { const [english, concurrent, regional, fallback, anotherFallback] = await Promise.all( diff --git a/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts b/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts index f1e37eb25f..fdc5e5add5 100644 --- a/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts +++ b/packages/core/src/emoji-data/frimousse/loadFrimousseData.ts @@ -1,5 +1,9 @@ import type { FrimousseEmojiData } from "./types.js"; -import { frimousseCategoryIndices, frimousseCommonData } from "./common.js"; +import { + frimousseCategoryIndices, + frimousseCommonData, + frimousseShortcodes, +} from "./common.js"; type DataModule = { default: string }; @@ -37,27 +41,33 @@ 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"); + const shortcodes = frimousseShortcodes.split("\n"); - if (localizedEmojis.length !== commonEmojis.length) { + if ( + localizedEmojis.length !== commonEmojis.length || + shortcodes.length !== commonEmojis.length + ) { 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 +75,30 @@ 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 tags = encodedTags ? encodedTags.split("|") : []; + // Keep localized keyword order; compact appended aliases just like the + // generator. English casing matches Frimousse's search and compaction. + const seen = new Set(tags.map((tag) => tag.toLowerCase())); + const normalizedLabel = label.toLowerCase(); + for (const shortcode of shortcodes[index].split("|")) { + const normalized = shortcode.toLowerCase(); + if (!seen.has(normalized) && !normalizedLabel.includes(normalized)) { + tags.push(shortcode); + seen.add(normalized); + } + } 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 { @@ -87,7 +106,7 @@ function decodeFrimousseData(encoded: string): FrimousseEmojiData { category: Number(category), version: Number(version), label, - tags: encodedTags ? encodedTags.split("|") : [], + tags, ...(countryFlag ? { countryFlag: true as const } : {}), ...(skins ? { skins } : {}), }; 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/extensions/SuggestionMenu/DefaultGridSuggestionItem.ts b/packages/core/src/extensions/SuggestionMenu/DefaultGridSuggestionItem.ts deleted file mode 100644 index 01d5aea707..0000000000 --- a/packages/core/src/extensions/SuggestionMenu/DefaultGridSuggestionItem.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type DefaultGridSuggestionItem = { - id: string; - onItemClick: () => void; -}; diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts deleted file mode 100644 index 85ab84ded2..0000000000 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultEmojiPickerItems.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Emoji, EmojiMartData } from "@emoji-mart/data"; - -import { defaultInlineContentSchema } from "../../blocks/defaultBlocks.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { - BlockSchema, - InlineContentSchema, - StyleSchema, -} 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, - S extends StyleSchema, ->( - editor: BlockNoteEditor, - query: string, -): Promise { - if ( - !("text" in editor.schema.inlineContentSchema) || - editor.schema.inlineContentSchema["text"] !== - defaultInlineContentSchema["text"] - ) { - return []; - } - - const { emojiData, emojiMart } = await loadEmojiMart(); - - const emojisToShow = - query.trim() === "" - ? Object.values(emojiData.emojis) - : ((await emojiMart!.SearchIndex.search(query)) as Emoji[]); - - return emojisToShow.map((emoji) => ({ - id: emoji.skins[0].native, - onItemClick: () => editor.insertInlineContent(emoji.skins[0].native + " "), - })); -} diff --git a/packages/core/src/extensions/index.ts b/packages/core/src/extensions/index.ts index cbb5946e7b..78a5b26fae 100644 --- a/packages/core/src/extensions/index.ts +++ b/packages/core/src/extensions/index.ts @@ -16,10 +16,8 @@ export * from "./ShowSelection/ShowSelection.js"; export * from "./SideMenu/SideMenu.js"; export * from "./SourceBlockWithPreview/SourceBlockWithPreview.js"; export * from "./SourceInlineContentWithPreview/SourceInlineContentWithPreview.js"; -export * from "./SuggestionMenu/DefaultGridSuggestionItem.js"; export * from "./SyntaxHighlighting/SyntaxHighlighting.js"; export * from "./SuggestionMenu/DefaultSuggestionItem.js"; -export * from "./SuggestionMenu/getDefaultEmojiPickerItems.js"; export * from "./SuggestionMenu/getDefaultSlashMenuItems.js"; export * from "./SuggestionMenu/SuggestionMenu.js"; export * from "./TableHandles/TableHandles.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a1fda3424..cffeff63fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,7 +23,6 @@ export * from "./editor/defaultColors.js"; export * from "./editor/selectionTypes.js"; export * from "./exporter/index.js"; export * from "./extensions-shared/UiElementPosition.js"; -export { getDefaultEmojiPickerItems } from "./extensions/SuggestionMenu/getDefaultEmojiPickerItems.js"; export { filterSuggestionItems, getDefaultSlashMenuItems, diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css index 14b9dd8211..5a7a165fd9 100644 --- a/packages/mantine/src/blocknoteStyles.css +++ b/packages/mantine/src/blocknoteStyles.css @@ -456,52 +456,6 @@ on touch devices (e.g. the mobile formatting toolbar). */ background-color: var(--bn-colors-side-menu); } -.bn-mantine .bn-grid-suggestion-menu { - background: var(--bn-colors-menu-background); - border-radius: var(--bn-border-radius-large); - box-shadow: var(--bn-shadow-medium); - display: grid; - gap: 7px; - height: fit-content; - justify-items: center; - max-height: inherit; - overflow-y: auto; - padding: 20px; -} - -.bn-mantine .bn-grid-suggestion-menu-item { - align-items: center; - border-radius: var(--bn-border-radius-large); - cursor: pointer; - display: flex; - font-size: 20px; - height: 32px; - justify-content: center; - margin: 2px; - padding: 6px; - width: 32px; -} - -.bn-mantine .bn-grid-suggestion-menu-item[aria-selected="true"], -.bn-mantine .bn-grid-suggestion-menu-item:hover { - background-color: var(--bn-colors-hovered-background); -} - -.bn-mantine .bn-grid-suggestion-menu-empty-item, -.bn-mantine .bn-grid-suggestion-menu-loader { - align-items: center; - color: var(--bn-colors-menu-text); - display: flex; - font-size: 14px; - font-weight: 500; - height: 32px; - justify-content: center; -} - -.bn-mantine .bn-grid-suggestion-menu-loader span { - background-color: var(--bn-colors-side-menu); -} - /* Side Menu styling */ .bn-mantine .bn-side-menu { background-color: transparent; diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index 7676489a66..abbb4f3523 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -28,10 +28,6 @@ import { SuggestionMenuEmptyItem } from "./suggestionMenu/SuggestionMenuEmptyIte import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel.js"; import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader.js"; -import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.js"; -import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.js"; -import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.js"; -import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.js"; import { ExtendButton } from "./tableHandle/ExtendButton.js"; import { TableHandle } from "./tableHandle/TableHandle.js"; import { Toolbar } from "./toolbar/Toolbar.js"; @@ -57,12 +53,6 @@ export const components: Components = { TabPanel: PanelTab, TextInput: PanelTextInput, }, - GridSuggestionMenu: { - Root: GridSuggestionMenu, - Item: GridSuggestionMenuItem, - EmptyItem: GridSuggestionMenuEmptyItem, - Loader: GridSuggestionMenuLoader, - }, LinkToolbar: { Root: Toolbar, Button: ToolbarButton, diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx deleted file mode 100644 index 5bfa4e0141..0000000000 --- a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; - -export const GridSuggestionMenu = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["Root"] ->((props, ref) => { - const { className, children, id, columns, ...rest } = props; - - assertEmpty(rest); - - return ( -
- {children} -
- ); -}); diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx deleted file mode 100644 index 43ec88511b..0000000000 --- a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Group as MantineGroup } from "@mantine/core"; - -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; - -export const GridSuggestionMenuEmptyItem = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["EmptyItem"] ->((props, ref) => { - const { className, children, columns, ...rest } = props; - - assertEmpty(rest); - - return ( - - - {children} - - - ); -}); diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx deleted file mode 100644 index 6d545d7b56..0000000000 --- a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { mergeRefs } from "@mantine/hooks"; - -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps, elementOverflow } from "@blocknote/react"; -import { forwardRef, useEffect, useRef } from "react"; - -export const GridSuggestionMenuItem = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["Item"] ->((props, ref) => { - const { className, isSelected, onClick, item, id, ...rest } = props; - - assertEmpty(rest); - - const itemRef = useRef(null); - - useEffect(() => { - if (!itemRef.current || !isSelected) { - return; - } - - const overflow = elementOverflow( - itemRef.current, - itemRef.current.closest(".bn-grid-suggestion-menu")!, - ); - - if (overflow !== "none") { - itemRef.current.scrollIntoView({ block: "nearest" }); - } - }, [isSelected]); - - return ( -
- {item.icon} -
- ); -}); diff --git a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx b/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx deleted file mode 100644 index cf6d7d03e1..0000000000 --- a/packages/mantine/src/suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Loader as MantineLoader } from "@mantine/core"; - -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; - -export const GridSuggestionMenuLoader = forwardRef< - HTMLDivElement, - ComponentProps["GridSuggestionMenu"]["Loader"] ->((props, ref) => { - const { - className, - children: _children, // unused, using "dots" instead - columns, - ...rest - } = props; - - assertEmpty(rest); - - return ( -
- -
- ); -}); 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..2e2e47276d --- /dev/null +++ b/packages/react/src/components/Comments/FrimoussePicker.browser.test.tsx @@ -0,0 +1,116 @@ +import { en } from "@blocknote/core/locales"; +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.setProperty("--bn-colors-side-menu", "#8899aa"); + } + 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); + }); + }, +); + +it("uses a theme-colored focus indicator instead of a text-colored search border", async () => { + renderPicker(true); + + await vi.waitFor(() => { + const search = visibleElement("[frimousse-search]"); + search.focus(); + expect(search.matches(":focus-visible")).toBe(true); + const style = getComputedStyle(search); + expect(style.outlineStyle).toBe("solid"); + expect(style.outlineWidth).toBe("2px"); + expect(style.outlineColor).toBe("rgb(136, 153, 170)"); + }); +}); diff --git a/packages/react/src/components/Comments/FrimoussePicker.tsx b/packages/react/src/components/Comments/FrimoussePicker.tsx new file mode 100644 index 0000000000..c1035dbf1f --- /dev/null +++ b/packages/react/src/components/Comments/FrimoussePicker.tsx @@ -0,0 +1,78 @@ +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}…`; + + return ( + onEmojiSelect(emoji)} + onKeyDownCapture={(event) => { + if (event.key === "Escape" && onEscape) { + event.stopPropagation(); + onEscape(); + } + }} + > + + + + {dictionary.loading} + + + {dictionary.search_no_results} + + + +
+ + {({ 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..802aa30503 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx @@ -0,0 +1,406 @@ +import { type FrimousseEmojiData } from "@blocknote/core/emoji-data"; +import { + EmojiPicker, + type EmojiDataResolver, + type EmojiPickerListEmojiProps, + useSkinTone, +} from "frimousse"; +import { + createContext, + useCallback, + 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 { useMergeRefs } from "../../../util/mergeRefs.js"; +import { ActiveEmojiDisplay } from "../../Comments/FrimoussePicker.js"; +import { getSuggestionMenuItemId } from "../getSuggestionMenuItemId.js"; +import { useCloseSuggestionMenuNoItems } from "../hooks/useCloseSuggestionMenuNoItems.js"; +import { + EMOJI_PICKER_COLUMNS, + filterEmojiData, + getEmojiPickerRows, + moveEmojiPosition, + type EmojiPosition, +} from "./emojiPickerModel.js"; + +const SelectedEmojiContext = createContext(undefined); +const RegisterEmojiContext = createContext< + ((emoji: string, button: HTMLButtonElement) => () => void) | undefined +>(undefined); +const INITIAL_POSITION: EmojiPosition = { row: 0, column: 0 }; + +function InlineEmojiPickerEmoji({ + emoji, + ref, + ...props +}: EmojiPickerListEmojiProps) { + const selectedChar = useContext(SelectedEmojiContext); + const selected = emoji.emoji === selectedChar; + const registerEmoji = useContext(RegisterEmojiContext); + const buttonRef = useRef(null); + const mergedRef = useMergeRefs([ref, buttonRef]); + const columnIndex = props["aria-colindex"]; + + useEffect(() => { + const button = buttonRef.current; + // Frimousse also renders these components in a hidden sizing row. + if (button?.closest("[aria-rowindex]")) { + return registerEmoji?.(emoji.emoji, button); + } + return undefined; + }, [emoji.emoji, columnIndex, registerEmoji]); + + return ( + + ); +} + +const inlineEmojiPickerComponents = { + Emoji: InlineEmojiPickerEmoji, +}; + +type ResolvedEmojiData = { + locale: string; + data: FrimousseEmojiData; +}; + +function findButtonAtPosition( + buttons: Map, + position: EmojiPosition, + emoji: string, +): HTMLButtonElement | null { + const button = buttons.get(emoji); + return button?.closest("[aria-rowindex]")?.getAttribute("aria-rowindex") === + String(position.row) && + button.getAttribute("aria-colindex") === String(position.column) && + button.textContent === emoji + ? button + : null; +} + +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: { + query: string; + insertEmoji: (emoji: string) => void; + rootRef: React.RefObject; + data: FrimousseEmojiData | undefined; +}) { + const { query, insertEmoji, rootRef, data } = props; + const editorDOMElement = useEditorDOMElement(); + const setContentEditableProps = + useBlockNoteContext()!.setContentEditableProps!; + const dict = useDictionary(); + const [skinTone] = useSkinTone(); + const buttons = useRef(new Map()); + const syncSelectedButton = useRef<(() => void) | undefined>(undefined); + const registerEmoji = useCallback( + (emoji: string, button: HTMLButtonElement) => { + buttons.current.set(emoji, button); + syncSelectedButton.current?.(); + return () => { + if (buttons.current.get(emoji) === button) { + buttons.current.delete(emoji); + syncSelectedButton.current?.(); + } + }; + }, + [], + ); + + const rows = useMemo(() => (data ? getEmojiPickerRows(data) : []), [data]); + const [selection, setSelection] = useState({ + query, + position: INITIAL_POSITION, + }); + if (selection.query !== query) { + setSelection({ query, position: INITIAL_POSITION }); + } + const selectedPosition = + selection.query === query ? selection.position : INITIAL_POSITION; + + 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 before scrolling to an unmounted selection. + 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 selectedCharValue = selectedChar; + const selectedIndexValue = selectedIndex; + let activeButton: HTMLButtonElement | null = null; + + function updateSelectedButton() { + const btn = findButtonAtPosition( + buttons.current, + selectedPosition, + selectedCharValue, + ); + if (activeButton !== btn) { + activeButton?.removeAttribute("id"); + activeButton = btn; + } + if (!btn) { + setActiveDescendant(undefined); + return; + } + + if (scrollPending) { + scrollPending = false; + btn.scrollIntoView({ block: "nearest", behavior: "instant" }); + } + btn.id = getSuggestionMenuItemId(selectedIndexValue)!; + setActiveDescendant(btn.id); + } + + // Button effects report async filtering, virtualization and skin-tone changes. + syncSelectedButton.current = updateSelectedButton; + if ( + !findButtonAtPosition(buttons.current, selectedPosition, selectedChar) + ) { + scrollViewportTo(root, selectedPosition, selectedRow.categoryIndex); + } + updateSelectedButton(); + return () => { + syncSelectedButton.current = undefined; + activeButton?.removeAttribute("id"); + 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(); + setSelection({ query, position: nextPosition }); + } else if (event.key === "Enter" && selectedChar) { + event.preventDefault(); + event.stopPropagation(); + // Frimousse schedules filtering asynchronously. Only insert once its + // rendered grid has caught up with the keyboard model's selection. + if ( + findButtonAtPosition(buttons.current, selectedPosition, selectedChar) + ) { + insertEmoji(selectedChar); + } + } + }; + + editorDOMElement?.addEventListener("keydown", handleKeyDown, true); + return () => { + editorDOMElement?.removeEventListener("keydown", handleKeyDown, true); + }; + }, [ + rows, + editorDOMElement, + insertEmoji, + query, + selectedChar, + selectedPosition, + ]); + + const placeholder = `${dict.emoji_picker.search}…`; + + return ( + <> +