diff --git a/src/App.test.tsx b/src/App.test.tsx index 0ca430fe..18506ce2 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -408,6 +408,7 @@ vi.mock('@blocknote/react', () => ({ DeleteLinkButton: () => null, SideMenuController: () => null, SuggestionMenuController: () => null, + GridSuggestionMenuController: () => null, useComponentsContext: () => ({ LinkToolbar: { Button: ({ diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index a24691d5..48c1a0aa 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -64,16 +64,17 @@ vi.mock('@blocknote/code-block', () => ({ codeBlockOptions: {}, })) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock -const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? []) +const mockFilterSuggestionItems = vi.fn((...args: unknown[]) => args[0] ?? []) vi.mock('@blocknote/core/extensions', () => ({ filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args), })) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock -const capturedGetItemsByTrigger: Record Promise> = {} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock -let capturedGetItems: ((query: string) => Promise) | null = null +type SuggestionControllerProps = { + triggerCharacter: string + getItems: (query: string) => Promise +} +const capturedGetItemsByTrigger: Record Promise> = {} +let capturedGetItems: ((query: string) => Promise) | null = null vi.mock('@blocknote/react', () => ({ AudioBlock: () => null, AudioToExternalHTML: () => null, @@ -114,12 +115,15 @@ vi.mock('@blocknote/react', () => ({ EditLinkButton: () => null, DeleteLinkButton: () => null, SideMenuController: () => null, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock - SuggestionMenuController: (props: any) => { + SuggestionMenuController: (props: SuggestionControllerProps) => { capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems if (props.triggerCharacter === '[[') capturedGetItems = props.getItems return null }, + GridSuggestionMenuController: (props: SuggestionControllerProps) => { + capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems + return null + }, useComponentsContext: () => ({ LinkToolbar: { Button: ({ diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 9714552f..d22203c9 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -30,6 +30,7 @@ vi.mock('@blocknote/react', () => ({ children?: ReactNode editable?: boolean className?: string + emojiPicker?: boolean formattingToolbar?: boolean linkToolbar?: boolean slashMenu?: boolean @@ -46,6 +47,7 @@ vi.mock('@blocknote/react', () => ({ children, editable, className, + emojiPicker, formattingToolbar, linkToolbar, slashMenu, @@ -53,6 +55,7 @@ vi.mock('@blocknote/react', () => ({ ...restProps } = props state.capturedBlockNoteOnChange = props.onChange ?? null + void emojiPicker void formattingToolbar void slashMenu void sideMenu @@ -83,6 +86,10 @@ vi.mock('@blocknote/react', () => ({ state.capturedSuggestionProps[String(props.triggerCharacter)] = props return
}, + GridSuggestionMenuController: (props: Record) => { + state.capturedSuggestionProps[String(props.triggerCharacter)] = props + return
+ }, useComponentsContext: () => ({ LinkToolbar: { Button: ({ @@ -693,6 +700,34 @@ describe('SingleEditorView', () => { } }) + it('inserts the selected emoji from shortcode suggestions', async () => { + const editor = createEditor() + + render( + , + ) + + const getEmojiItems = state.capturedSuggestionProps[':'].getItems as ( + query: string + ) => Promise void }>> + + const italyItems = await getEmojiItems(':it') + expect(italyItems[0]).toMatchObject({ id: '🇮🇹' }) + expect(italyItems[0].name).toMatch(/italy/i) + + const items = await getEmojiItems(':rocket') + const rocketItem = items.find(item => item.id === '🚀') + + expect(rocketItem).toMatchObject({ name: 'rocket' }) + rocketItem!.onItemClick() + + expect(editor.insertInlineContent).toHaveBeenCalledWith('🚀', { updateSelection: true }) + }) + it('guards stale click handlers stored on wikilink suggestion items', async () => { const editor = createEditor() editor.domElement = document.createElement('div') diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 7e680b33..bdfa7053 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -2,6 +2,7 @@ import { ArrowSquareOut as ExternalLink, Copy } from '@phosphor-icons/react' import { Component, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { invoke } from '@tauri-apps/api/core' import { + GridSuggestionMenuController, BlockNoteViewRaw, ComponentsContext, DeleteLinkButton, @@ -13,6 +14,7 @@ import { useComponentsContext, useCreateBlockNote, useDictionary, + type DefaultReactGridSuggestionItem, type LinkToolbarProps, } from '@blocknote/react' import { components } from '@blocknote/mantine' @@ -26,6 +28,7 @@ import { useImageLightbox } from '../hooks/useImageLightbox' import { createTranslator, type AppLocale } from '../lib/i18n' import { isTauri } from '../mock-tauri' import { buildTypeEntryMap } from '../utils/typeColors' +import { searchEmojis, type EmojiEntry } from '../utils/emoji' import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions' import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions' import { attachClickHandlers, enrichSuggestionItems, hasMultipleSuggestionWorkspaces } from '../utils/suggestionEnrichment' @@ -95,6 +98,7 @@ const TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR = [ '[contenteditable="true"]', ].join(', ') const MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES = 1 +const EMOJI_SHORTCODE_RESULT_LIMIT = 80 type TestTableBlock = { type?: string @@ -102,6 +106,10 @@ type TestTableBlock = { } type SuggestionAction = () => void type SuggestionItemWithClick = { onItemClick?: SuggestionAction } +type EmojiSuggestionItem = DefaultReactGridSuggestionItem & { + group: string + name: string +} type BlockNoteRenderRecoveryState = { error: unknown recoveryKey: number @@ -364,6 +372,16 @@ function normalizeSuggestionQuery(query: string, triggerCharacter: string): stri : query } +function emojiSuggestionRank(entry: EmojiEntry, query: string): number { + const normalizedName = entry.name.toLowerCase() + const tokens = normalizedName.split(/[^a-z0-9]+/).filter(Boolean) + if (normalizedName === query) return 0 + if (tokens.some(token => token === query)) return 1 + if (tokens.some(token => token.startsWith(query))) return 2 + if (normalizedName.startsWith(query)) return 3 + return 4 +} + const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]' const CLIPBOARD_INLINE_FORMAT_SELECTOR = 'a, b, code, em, i, s, span, strong, u' const CODE_BLOCK_COPY_RESET_MS = 1200 @@ -1132,6 +1150,30 @@ function useSuggestionMenuItems(options: { buildItems(query, '@') ?? [] ), [buildItems]) + const getEmojiItems = useCallback(async (query: string): Promise => { + const normalizedQuery = normalizeSuggestionQuery(query, ':').trim().toLowerCase() + if (!normalizedQuery) return [] + + return searchEmojis(normalizedQuery) + .sort((left, right) => { + const rankDelta = emojiSuggestionRank(left, normalizedQuery) - emojiSuggestionRank(right, normalizedQuery) + return rankDelta || left.name.localeCompare(right.name) + }) + .slice(0, EMOJI_SHORTCODE_RESULT_LIMIT) + .map((entry) => ({ + id: entry.emoji, + icon: {entry.emoji}, + name: entry.name, + group: entry.group, + onItemClick: () => { + runEditorAction(() => { + editor.insertInlineContent(entry.emoji, { updateSelection: true }) + trackEvent('emoji_shortcode_inserted', { group: entry.group }) + }) + }, + })) + }, [editor, runEditorAction]) + const getSlashMenuItems = useCallback(async (query: string) => { try { return guardSuggestionMenuItems( @@ -1148,6 +1190,7 @@ function useSuggestionMenuItems(options: { return { getWikilinkItems, + getEmojiItems, getPersonMentionItems, getSlashMenuItems, } @@ -1159,6 +1202,7 @@ type EditorInteractionControllersProps = ReturnType + { removeFixtureVaultCopy(tempVaultDir) }) -async function openSlashMenuOnNewLine(page: Page) { +async function focusNewEditorLine(page: Page) { await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click() await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) await page.locator('.bn-block-content').last().click() await page.keyboard.press('Enter') +} + +async function openSlashMenuOnNewLine(page: Page) { + await focusNewEditorLine(page) await page.keyboard.type('/') const menu = page.locator('.bn-suggestion-menu') @@ -34,6 +38,15 @@ async function openFilteredSlashMenuOnNewLine(page: Page) { return menu } +async function openEmojiShortcodeMenuOnNewLine(page: Page, query: string) { + await focusNewEditorLine(page) + await page.keyboard.type(`:${query}`) + + const menu = page.locator('.bn-grid-suggestion-menu') + await expect(menu).toBeVisible({ timeout: 5_000 }) + return menu +} + test('filtered slash-menu commands can be selected with the mouse', async ({ page }) => { await openFilteredSlashMenuOnNewLine(page) @@ -56,3 +69,23 @@ test('plain slash-menu mouse selection opens follow-up pickers', async ({ page } await expect(page.locator('.bn-grid-suggestion-menu')).toBeVisible({ timeout: 5_000 }) await expect(page.locator('.bn-suggestion-menu')).not.toBeVisible() }) + +test('emoji shortcode suggestions insert the selected emoji with the keyboard', async ({ page }) => { + await openEmojiShortcodeMenuOnNewLine(page, 'it') + + await page.keyboard.press('Enter') + + await expect(page.locator('.bn-editor')).toContainText('🇮🇹') + await expect(page.locator('.bn-editor')).not.toContainText(':it') + await expect(page.locator('.bn-grid-suggestion-menu')).not.toBeVisible() +}) + +test('emoji shortcode suggestions insert the selected emoji with the mouse', async ({ page }) => { + const menu = await openEmojiShortcodeMenuOnNewLine(page, 'rocket') + + await menu.locator('.bn-grid-suggestion-menu-item').filter({ hasText: '🚀' }).click() + + await expect(page.locator('.bn-editor')).toContainText('🚀') + await expect(page.locator('.bn-editor')).not.toContainText(':rocket') + await expect(page.locator('.bn-grid-suggestion-menu')).not.toBeVisible() +})