fix: insert emoji shortcode suggestions
This commit is contained in:
@@ -408,6 +408,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
DeleteLinkButton: () => null,
|
||||
SideMenuController: () => null,
|
||||
SuggestionMenuController: () => null,
|
||||
GridSuggestionMenuController: () => null,
|
||||
useComponentsContext: () => ({
|
||||
LinkToolbar: {
|
||||
Button: ({
|
||||
|
||||
@@ -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<string, (query: string) => Promise<any[]>> = {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
|
||||
type SuggestionControllerProps = {
|
||||
triggerCharacter: string
|
||||
getItems: (query: string) => Promise<unknown[]>
|
||||
}
|
||||
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<unknown[]>> = {}
|
||||
let capturedGetItems: ((query: string) => Promise<unknown[]>) | 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: ({
|
||||
|
||||
@@ -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 <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
|
||||
},
|
||||
GridSuggestionMenuController: (props: Record<string, unknown>) => {
|
||||
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
|
||||
return <div data-testid={`grid-suggestion-${String(props.triggerCharacter)}`} />
|
||||
},
|
||||
useComponentsContext: () => ({
|
||||
LinkToolbar: {
|
||||
Button: ({
|
||||
@@ -693,6 +700,34 @@ describe('SingleEditorView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('inserts the selected emoji from shortcode suggestions', async () => {
|
||||
const editor = createEditor()
|
||||
|
||||
render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const getEmojiItems = state.capturedSuggestionProps[':'].getItems as (
|
||||
query: string
|
||||
) => Promise<Array<{ id: string; name: string; onItemClick: () => 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')
|
||||
|
||||
@@ -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<EmojiSuggestionItem[]> => {
|
||||
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: <span title={entry.name}>{entry.emoji}</span>,
|
||||
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<typeof useSuggestionMenuItem
|
||||
}
|
||||
|
||||
function EditorInteractionControllers({
|
||||
getEmojiItems,
|
||||
getPersonMentionItems,
|
||||
getSlashMenuItems,
|
||||
getWikilinkItems,
|
||||
@@ -1192,6 +1236,12 @@ function EditorInteractionControllers({
|
||||
triggerCharacter="/"
|
||||
getItems={getSlashMenuItems}
|
||||
/>
|
||||
<GridSuggestionMenuController
|
||||
triggerCharacter=":"
|
||||
columns={10}
|
||||
minQueryLength={1}
|
||||
getItems={getEmojiItems}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="[["
|
||||
getItems={getWikilinkItems}
|
||||
@@ -1381,6 +1431,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
theme={themeMode}
|
||||
onChange={handleEditorChange}
|
||||
editable={editable}
|
||||
emojiPicker={false}
|
||||
formattingToolbar={false}
|
||||
linkToolbar={false}
|
||||
slashMenu={false}
|
||||
|
||||
@@ -13,11 +13,15 @@ test.afterEach(() => {
|
||||
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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user