diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 936fcbcb75..332f4a62d4 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -2,33 +2,47 @@ import { BaseSlashMenuItem, BlockNoteEditor, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, } from "@blocknote/core"; import { createButton } from "./util"; -export const addSlashMenu = (editor: BlockNoteEditor) => { +export const addSlashMenu = async (editor: BlockNoteEditor) => { let element: HTMLElement; - function updateItems( - items: BaseSlashMenuItem[], - onClick: (item: BaseSlashMenuItem) => void, - selected: number + async function updateItems( + query: string, + getItems: ( + query: string + ) => Promise< + BaseSlashMenuItem< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + >[] + >, + onClick: ( + item: BaseSlashMenuItem< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + > + ) => void ) { element.innerHTML = ""; + const items = await getItems(query); const domItems = items.map((val, i) => { const element = createButton(val.name, () => { onClick(val); }); element.style.display = "block"; - if (selected === i) { - element.style.fontWeight = "bold"; - } return element; }); element.append(...domItems); return domItems; } - editor.slashMenu.onUpdate((slashMenuState) => { + editor.slashMenu.onUpdate(async (slashMenuState) => { if (!element) { element = document.createElement("div"); element.style.background = "gray"; @@ -41,10 +55,10 @@ export const addSlashMenu = (editor: BlockNoteEditor) => { } if (slashMenuState.show) { - updateItems( - slashMenuState.filteredItems, - editor.slashMenu.itemCallback, - slashMenuState.keyboardHoveredItemIndex + await updateItems( + slashMenuState.query, + editor.slashMenu.getItems, + editor.slashMenu.executeItem ); element.style.display = "block"; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 38d2a85641..fd8dcfa553 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -83,7 +83,9 @@ export type BlockNoteEditorOptions< * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashMenuItems: BaseSlashMenuItem[]; + slashMenuItems: ( + query: string + ) => Promise[]>; /** * The HTML element that should be used as the parent element for the editor. @@ -290,7 +292,7 @@ export class BlockNoteEditor< this.slashMenu = new SlashMenuProsemirrorPlugin( this, newOptions.slashMenuItems || - (getDefaultSlashMenuItems(this.blockSchema) as any) + ((query) => getDefaultSlashMenuItems(query, this.blockSchema) as any) ); this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts index 23ff2ad1b6..f0ab5a3f7a 100644 --- a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts @@ -8,35 +8,30 @@ import { SuggestionItem } from "./SuggestionItem"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); -export type SuggestionsMenuState = - BaseUiElementState & { - // The suggested items to display. - filteredItems: T[]; - // The index of the suggested item that's currently hovered by the keyboard. - keyboardHoveredItemIndex: number; - }; +export type SuggestionsMenuState = BaseUiElementState & { + query: string; +}; class SuggestionsMenuView< - T extends SuggestionItem, BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema > { - private suggestionsMenuState?: SuggestionsMenuState; + private suggestionsMenuState?: SuggestionsMenuState; public updateSuggestionsMenu: () => void; - pluginState: SuggestionPluginState; + pluginState: SuggestionPluginState; constructor( private readonly editor: BlockNoteEditor, private readonly pluginKey: PluginKey, updateSuggestionsMenu: ( - suggestionsMenuState: SuggestionsMenuState + suggestionsMenuState: SuggestionsMenuState ) => void = () => { // noop } ) { - this.pluginState = getDefaultPluginState(); + this.pluginState = getDefaultPluginState(); this.updateSuggestionsMenu = () => { if (!this.suggestionsMenuState) { @@ -93,8 +88,7 @@ class SuggestionsMenuView< this.suggestionsMenuState = { show: true, referencePos: decorationNode!.getBoundingClientRect(), - filteredItems: this.pluginState.items, - keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!, + query: this.pluginState.query, }; this.updateSuggestionsMenu(); @@ -106,7 +100,7 @@ class SuggestionsMenuView< } } -type SuggestionPluginState = { +type SuggestionPluginState = { // True when the menu is shown, false when hidden. active: boolean; // The character that triggered the menu being shown. Allowing the trigger to be different to the default @@ -115,26 +109,16 @@ type SuggestionPluginState = { // The editor position just after the trigger character, i.e. where the user query begins. Used to figure out // which menu items to show and can also be used to delete the trigger character. queryStartPos: number | undefined; - // The items that should be shown in the menu. - items: T[]; - // The index of the item in the menu that's currently hovered using the keyboard. - keyboardHoveredItemIndex: number | undefined; - // The number of characters typed after the last query that matched with at least 1 item. Used to close the - // menu if the user keeps entering queries that don't return any results. - notFoundCount: number | undefined; + query: string; decorationId: string | undefined; }; -function getDefaultPluginState< - T extends SuggestionItem ->(): SuggestionPluginState { +function getDefaultPluginState(): SuggestionPluginState { return { active: false, triggerCharacter: undefined, queryStartPos: undefined, - items: [] as T[], - keyboardHoveredItemIndex: undefined, - notFoundCount: 0, + query: "", decorationId: undefined, }; } @@ -156,13 +140,12 @@ export const setupSuggestionsMenu = < S extends StyleSchema >( editor: BlockNoteEditor, - updateSuggestionsMenu: ( - suggestionsMenuState: SuggestionsMenuState - ) => void, + updateSuggestionsMenu: (suggestionsMenuState: SuggestionsMenuState) => void, pluginKey: PluginKey, defaultTriggerCharacter: string, - items: (query: string) => T[] = () => [], + getItems: (query: string) => Promise = () => + new Promise((resolve) => resolve([])), onSelectItem: (props: { item: T; editor: BlockNoteEditor; @@ -175,7 +158,7 @@ export const setupSuggestionsMenu = < throw new Error("'char' should be a single character"); } - let suggestionsPluginView: SuggestionsMenuView; + let suggestionsPluginView: SuggestionsMenuView; const deactivate = (view: EditorView) => { view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true })); @@ -186,7 +169,7 @@ export const setupSuggestionsMenu = < key: pluginKey, view: () => { - suggestionsPluginView = new SuggestionsMenuView( + suggestionsPluginView = new SuggestionsMenuView( editor, pluginKey, @@ -197,12 +180,12 @@ export const setupSuggestionsMenu = < state: { // Initialize the plugin's internal state. - init(): SuggestionPluginState { - return getDefaultPluginState(); + init(): SuggestionPluginState { + return getDefaultPluginState(); }, // Apply changes to the plugin state from an editor transaction. - apply(transaction, prev, oldState, newState): SuggestionPluginState { + apply(transaction, prev, _oldState, newState): SuggestionPluginState { // TODO: More clearly define which transactions should be ignored. if (transaction.getMeta("orderedListIndexing") !== undefined) { return prev; @@ -215,11 +198,7 @@ export const setupSuggestionsMenu = < triggerCharacter: transaction.getMeta(pluginKey)?.triggerCharacter || "", queryStartPos: newState.selection.from, - items: items(""), - keyboardHoveredItemIndex: 0, - // TODO: Maybe should be 1 if the menu has no possible items? Probably redundant since a menu with no items - // is useless in practice. - notFoundCount: 0, + query: "", decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, }; } @@ -229,31 +208,7 @@ export const setupSuggestionsMenu = < return prev; } - const next = { ...prev }; - - // Updates which menu items to show by checking which items the current query (the text between the trigger - // character and caret) matches with. - next.items = items( - newState.doc.textBetween( - prev.queryStartPos!, - newState.selection.from - ) - ); - - // Updates notFoundCount if the query doesn't match any items. - next.notFoundCount = 0; - if (next.items.length === 0) { - // Checks how many characters were typed or deleted since the last transaction, and updates the notFoundCount - // accordingly. Also ensures the notFoundCount does not become negative. - next.notFoundCount = Math.max( - 0, - prev.notFoundCount! + - (newState.selection.from - oldState.selection.from) - ); - } - - // Hides the menu. This is done after items and notFoundCount are already updated as notFoundCount is needed to - // check if the menu should be hidden. + // Checks if the menu should be hidden. if ( // Highlighting text should hide the menu. newState.selection.from !== newState.selection.to || @@ -265,34 +220,18 @@ export const setupSuggestionsMenu = < transaction.getMeta("blur") || transaction.getMeta("pointer") || // Moving the caret before the character which triggered the menu should hide it. - (prev.active && newState.selection.from < prev.queryStartPos!) || - // Entering more than 3 characters, after the last query that matched with at least 1 menu item, should hide - // the menu. - next.notFoundCount > 3 + (prev.active && newState.selection.from < prev.queryStartPos!) ) { - return getDefaultPluginState(); + return getDefaultPluginState(); } - // Updates keyboardHoveredItemIndex if the up or down arrow key was - // pressed, or resets it if the keyboard cursor moved. - if ( - transaction.getMeta(pluginKey)?.selectedItemIndexChanged !== - undefined - ) { - let newIndex = - transaction.getMeta(pluginKey).selectedItemIndexChanged; - - // Allows selection to jump between first and last items. - if (newIndex < 0) { - newIndex = prev.items.length - 1; - } else if (newIndex >= prev.items.length) { - newIndex = 0; - } + const next = { ...prev }; - next.keyboardHoveredItemIndex = newIndex; - } else if (oldState.selection.from !== newState.selection.from) { - next.keyboardHoveredItemIndex = 0; - } + // Updates the current query. + next.query = newState.doc.textBetween( + prev.queryStartPos!, + newState.selection.from + ); return next; }, @@ -317,69 +256,6 @@ export const setupSuggestionsMenu = < return true; } - // Doesn't handle other keystrokes if the menu isn't active. - if (!menuIsActive) { - return false; - } - - // Handles keystrokes for navigating the menu. - const { - triggerCharacter, - queryStartPos, - items, - keyboardHoveredItemIndex, - } = pluginKey.getState(view.state); - - // Moves the keyboard selection to the previous item. - if (event.key === "ArrowUp") { - view.dispatch( - view.state.tr.setMeta(pluginKey, { - selectedItemIndexChanged: keyboardHoveredItemIndex - 1, - }) - ); - return true; - } - - // Moves the keyboard selection to the next item. - if (event.key === "ArrowDown") { - view.dispatch( - view.state.tr.setMeta(pluginKey, { - selectedItemIndexChanged: keyboardHoveredItemIndex + 1, - }) - ); - return true; - } - - // Selects an item and closes the menu. - if (event.key === "Enter") { - if (items.length === 0) { - return true; - } - - deactivate(view); - editor._tiptapEditor - .chain() - .focus() - .deleteRange({ - from: queryStartPos! - triggerCharacter!.length, - to: editor._tiptapEditor.state.selection.from, - }) - .run(); - - onSelectItem({ - item: items[keyboardHoveredItemIndex], - editor: editor, - }); - - return true; - } - - // Closes the menu. - if (event.key === "Escape") { - deactivate(view); - return true; - } - return false; }, @@ -426,8 +302,12 @@ export const setupSuggestionsMenu = < }, }, }), - itemCallback: (item: T) => { - deactivate(editor._tiptapEditor.view); + getItems: getItems, + executeItem: (item: T) => { + onSelectItem({ item, editor }); + }, + closeMenu: () => deactivate(editor._tiptapEditor.view), + clearQuery: () => editor._tiptapEditor .chain() .focus() @@ -437,12 +317,6 @@ export const setupSuggestionsMenu = < suggestionsPluginView.pluginState.triggerCharacter!.length, to: editor._tiptapEditor.state.selection.from, }) - .run(); - - onSelectItem({ - item: item, - editor: editor, - }); - }, + .run(), }; }; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts index c58a32cbce..278df7a00b 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts @@ -18,9 +18,15 @@ export class SlashMenuProsemirrorPlugin< SlashMenuItem extends BaseSlashMenuItem > extends EventEmitter { public readonly plugin: Plugin; - public readonly itemCallback: (item: SlashMenuItem) => void; + public readonly getItems: (query: string) => Promise; + public readonly executeItem: (item: SlashMenuItem) => void; + public readonly closeMenu: () => void; + public readonly clearQuery: () => void; - constructor(editor: BlockNoteEditor, items: SlashMenuItem[]) { + constructor( + editor: BlockNoteEditor, + getItems: (query: string) => Promise + ) { super(); const suggestions = setupSuggestionsMenu( editor, @@ -29,25 +35,18 @@ export class SlashMenuProsemirrorPlugin< }, slashMenuPluginKey, "/", - (query) => - items.filter( - ({ name, aliases }: SlashMenuItem) => - name.toLowerCase().startsWith(query.toLowerCase()) || - (aliases && - aliases.filter((alias) => - alias.toLowerCase().startsWith(query.toLowerCase()) - ).length !== 0) - ), + getItems, ({ item, editor }) => item.execute(editor) ); this.plugin = suggestions.plugin; - this.itemCallback = suggestions.itemCallback; + this.getItems = getItems; + this.executeItem = suggestions.executeItem; + this.closeMenu = suggestions.closeMenu; + this.clearQuery = suggestions.clearQuery; } - public onUpdate( - callback: (state: SuggestionsMenuState) => void - ) { + public onUpdate(callback: (state: SuggestionsMenuState) => void) { return this.on("update", callback); } } diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index b6b3aa0115..57429ddd9c 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -74,13 +74,11 @@ function insertOrUpdateBlock< return insertedBlock; } -export const getDefaultSlashMenuItems = < +export async function getDefaultSlashMenuItems< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema ->( - schema: BSchema = defaultBlockSchema as unknown as BSchema -) => { +>(query: string, schema: BSchema = defaultBlockSchema as unknown as BSchema) { const slashMenuItems: BaseSlashMenuItem[] = []; if ("heading" in schema && "level" in schema.heading.propSchema) { @@ -209,5 +207,12 @@ export const getDefaultSlashMenuItems = < }); } - return slashMenuItems; -}; + return slashMenuItems.filter( + ({ name, aliases }) => + name.toLowerCase().startsWith(query.toLowerCase()) || + (aliases && + aliases.filter((alias) => + alias.toLowerCase().startsWith(query.toLowerCase()) + ).length !== 0) + ); +} diff --git a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index ead8ff2573..15ab09c34e 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -1,27 +1,87 @@ -import { Menu } from "@mantine/core"; +import { Loader, Menu } from "@mantine/core"; import foreach from "lodash.foreach"; import groupBy from "lodash.groupby"; import { BlockSchema } from "@blocknote/core"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; import { SlashMenuItem } from "./SlashMenuItem"; import type { SlashMenuProps } from "./SlashMenuPositioner"; export function DefaultSlashMenu( props: SlashMenuProps ) { - const renderedItems: any[] = []; - let index = 0; + const { query, getItems, closeMenu, executeItem, clearQuery, editor } = props; - const groups = groupBy(props.filteredItems, (i) => i.group); + const [orderedItems, setOrderedItems] = useState< + ReactSlashMenuItem[] | undefined + >(undefined); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); - foreach(groups, (groupedItems) => { - renderedItems.push( - - {groupedItems[0].group} - - ); + // Used to ignore the previous query if a new one is made before it returns. + const currentQuery = useRef(); + + // Used to close the menu if the query is >3 characters longer than the last + // query that returned any results. + const lastUsefulQueryLength = useRef(0); + + // Gets the items to display and orders them by group. + useEffect(() => { + const thisQuery = query; + currentQuery.current = query; + + setLoading(true); + + getItems(query).then((items) => { + if (currentQuery.current !== thisQuery) { + // outdated query returned, ignore the result + return; + } + + const orderedItems: ReactSlashMenuItem[] = []; + + const groups = groupBy(items, (item) => item.group); + + foreach(groups, (groupedItems) => { + for (const item of groupedItems) { + orderedItems.push(item); + } + }); + + if (orderedItems.length > 0) { + lastUsefulQueryLength.current = query.length; + } else if (query.length - lastUsefulQueryLength.current > 3) { + closeMenu(); + } + + setOrderedItems(orderedItems); + setLoading(false); + }); + }, [query, getItems, closeMenu]); + + // Creates the JSX elements to render. + const renderedItems = useMemo(() => { + if (orderedItems === undefined) { + return null; + } + + if (orderedItems.length === 0) { + return []; + } + + const renderedItems: JSX.Element[] = []; + let currentGroup = undefined; + let itemIndex = 0; + + for (const item of orderedItems) { + if (item.group !== currentGroup) { + currentGroup = item.group; + renderedItems.push( + {currentGroup} + ); + } - for (const item of groupedItems) { renderedItems.push( ( icon={item.icon} hint={item.hint} shortcut={item.shortcut} - isSelected={props.keyboardHoveredItemIndex === index} - set={() => props.itemCallback(item)} + isSelected={selectedIndex === itemIndex} + set={() => { + closeMenu(); + clearQuery(); + executeItem(item); + }} /> ); - index++; + + itemIndex++; } - }); + + return renderedItems; + }, [closeMenu, executeItem, clearQuery, orderedItems, selectedIndex]); + + // Handles keyboard navigation. + useEffect(() => { + const preventMenuNavigationKeys = (event: KeyboardEvent) => { + if (event.key === "ArrowUp") { + event.preventDefault(); + + if (orderedItems !== undefined) { + setSelectedIndex( + (selectedIndex - 1 + orderedItems!.length) % orderedItems!.length + ); + } + + return true; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + + if (orderedItems !== undefined) { + setSelectedIndex((selectedIndex + 1) % orderedItems!.length); + } + + return true; + } + + if (event.key === "Enter") { + event.preventDefault(); + + if (orderedItems !== undefined) { + closeMenu(); + clearQuery(); + executeItem(orderedItems[selectedIndex]); + } + + return true; + } + + if (event.key === "Escape") { + event.preventDefault(); + + closeMenu(); + + return true; + } + + return false; + }; + + editor.domElement.addEventListener( + "keydown", + preventMenuNavigationKeys, + true + ); + + return () => { + editor.domElement.removeEventListener( + "keydown", + preventMenuNavigationKeys, + true + ); + }; + }, [ + selectedIndex, + orderedItems, + editor.domElement, + closeMenu, + clearQuery, + executeItem, + ]); + + const loader = loading ? ( + + ) : null; return ( ( trigger={"hover"} closeDelay={10000000}> event.preventDefault()} className={"bn-slash-menu"}> - {renderedItems.length > 0 ? ( - renderedItems - ) : ( - No match found - )} + {renderedItems === null + ? loader + : renderedItems.length > 0 + ? [...renderedItems, loader] + : [No match found, loader]} ); diff --git a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx index c3b29447e5..ed7df9423c 100644 --- a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx +++ b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx @@ -14,15 +14,16 @@ import { } from "@floating-ui/react"; import { FC, useEffect, useRef, useState } from "react"; -import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; import { DefaultSlashMenu } from "./DefaultSlashMenu"; export type SlashMenuProps = - Pick, "itemCallback"> & - Pick< - SuggestionsMenuState>, - "filteredItems" | "keyboardHoveredItemIndex" - >; + Pick< + SlashMenuProsemirrorPlugin, + "getItems" | "executeItem" | "closeMenu" | "clearQuery" + > & + Pick & { + editor: BlockNoteEditor; + }; export const SlashMenuPositioner = < BSchema extends BlockSchema = DefaultBlockSchema @@ -31,10 +32,7 @@ export const SlashMenuPositioner = < slashMenu?: FC>; }) => { const [show, setShow] = useState(false); - const [filteredItems, setFilteredItems] = - useState[]>(); - const [keyboardHoveredItemIndex, setKeyboardHoveredItemIndex] = - useState(); + const [query, setQuery] = useState(""); const referencePos = useRef(); @@ -61,8 +59,7 @@ export const SlashMenuPositioner = < useEffect(() => { return props.editor.slashMenu.onUpdate((slashMenuState) => { setShow(slashMenuState.show); - setFilteredItems(slashMenuState.filteredItems); - setKeyboardHoveredItemIndex(slashMenuState.keyboardHoveredItemIndex); + setQuery(slashMenuState.query); referencePos.current = slashMenuState.referencePos; @@ -76,7 +73,7 @@ export const SlashMenuPositioner = < }); }, [refs]); - if (!isMounted || !filteredItems || keyboardHoveredItemIndex === undefined) { + if (!isMounted || !query === undefined) { return null; } @@ -92,9 +89,12 @@ export const SlashMenuPositioner = < zIndex: 2000, }}> props.editor.slashMenu.itemCallback(item)} - keyboardHoveredItemIndex={keyboardHoveredItemIndex} + editor={props.editor} + query={query} + getItems={props.editor.slashMenu.getItems} + executeItem={props.editor.slashMenu.executeItem} + closeMenu={props.editor.slashMenu.closeMenu} + clearQuery={props.editor.slashMenu.clearQuery} /> ); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index b13438fbad..5458ca61bb 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -461,6 +461,15 @@ gap: 0; } +.bn-container .bn-slash-menu .bn-slash-menu-loader { + height: 20px; + width: 100%; +} + +.bn-container .bn-slash-menu .bn-slash-menu-loader span { + background-color: var(--bn-colors-side-menu); +} + /* Side Menu & Drag Handle styling */ .bn-container .bn-side-menu { background-color: transparent; diff --git a/packages/react/src/hooks/useBlockNote.ts b/packages/react/src/hooks/useBlockNote.ts index 2cf1f213e8..9b1d61f65a 100644 --- a/packages/react/src/hooks/useBlockNote.ts +++ b/packages/react/src/hooks/useBlockNote.ts @@ -23,9 +23,11 @@ const initEditor = < options: Partial> ) => BlockNoteEditor.create({ - slashMenuItems: getDefaultReactSlashMenuItems( - getBlockSchemaFromSpecs(options.blockSpecs || defaultBlockSpecs) - ), + slashMenuItems: (query) => + getDefaultReactSlashMenuItems( + query, + getBlockSchemaFromSpecs(options.blockSpecs || defaultBlockSpecs) + ), ...options, }); diff --git a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx index d252b8b605..b471abaa88 100644 --- a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx @@ -76,19 +76,20 @@ const extraFields: Record< }, }; -export function getDefaultReactSlashMenuItems< +export async function getDefaultReactSlashMenuItems< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema >( + query: string, // This type casting is weird, but it's the best way of doing it, as it allows // the schema type to be automatically inferred if it is defined, or be // inferred as any if it is not defined. I don't think it's possible to make it // infer to DefaultBlockSchema if it is not defined. schema: BSchema = defaultBlockSchema as any as BSchema -): ReactSlashMenuItem[] { +): Promise[]> { const slashMenuItems: BaseSlashMenuItem[] = - getDefaultSlashMenuItems(schema); + await getDefaultSlashMenuItems(query, schema); return slashMenuItems.map((item) => ({ ...item, diff --git a/tests/src/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx index ffc47cc38f..c706a00d70 100644 --- a/tests/src/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -39,7 +39,10 @@ export default function Editor() { editor: { class: styles.editor, "data-test": "editor" }, }, blockSpecs, - slashMenuItems: [...getDefaultReactSlashMenuItems(), ...slashMenuItems], + slashMenuItems: async (query: string) => [ + ...(await getDefaultReactSlashMenuItems(query)), + ...slashMenuItems, + ], }); console.log(editor); diff --git a/tests/src/utils/slashmenu.ts b/tests/src/utils/slashmenu.ts index ec611d71a6..b6a5129b1e 100644 --- a/tests/src/utils/slashmenu.ts +++ b/tests/src/utils/slashmenu.ts @@ -10,4 +10,5 @@ export async function executeSlashCommand(page: Page, command: string) { await openSlashMenu(page); await page.keyboard.type(command); await page.keyboard.press("Enter"); + await page.waitForTimeout(500); }