From 6f749fde5c9bb0b2fefd140c8905f46e01c3f276 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 10 Jan 2024 02:45:24 +0100 Subject: [PATCH 1/7] Added async slash menu items POC --- packages/core/src/editor/BlockNoteEditor.ts | 6 +- .../suggestion/SuggestionPlugin.ts | 173 ++++-------------- .../extensions/SlashMenu/SlashMenuPlugin.ts | 23 ++- .../SlashMenu/defaultSlashMenuItems.ts | 17 +- .../components/SlashMenu/DefaultSlashMenu.tsx | 155 +++++++++++++--- .../SlashMenu/SlashMenuPositioner.tsx | 32 ++-- packages/react/src/hooks/useBlockNote.ts | 8 +- .../defaultReactSlashMenuItems.tsx | 7 +- tests/src/utils/components/Editor.tsx | 5 +- 9 files changed, 216 insertions(+), 210 deletions(-) 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..db667b02ad 100644 --- a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts @@ -10,10 +10,8 @@ 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; + // The items that should be shown in the menu. + items: Promise; }; class SuggestionsMenuView< @@ -93,8 +91,7 @@ class SuggestionsMenuView< this.suggestionsMenuState = { show: true, referencePos: decorationNode!.getBoundingClientRect(), - filteredItems: this.pluginState.items, - keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!, + items: this.pluginState.items, }; this.updateSuggestionsMenu(); @@ -116,12 +113,7 @@ type SuggestionPluginState = { // 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; + items: Promise; decorationId: string | undefined; }; @@ -132,9 +124,7 @@ function getDefaultPluginState< active: false, triggerCharacter: undefined, queryStartPos: undefined, - items: [] as T[], - keyboardHoveredItemIndex: undefined, - notFoundCount: 0, + items: new Promise((resolve) => resolve([])), decorationId: undefined, }; } @@ -162,7 +152,8 @@ export const setupSuggestionsMenu = < pluginKey: PluginKey, defaultTriggerCharacter: string, - items: (query: string) => T[] = () => [], + getItems: (query: string) => Promise = () => + new Promise((resolve) => resolve([])), onSelectItem: (props: { item: T; editor: BlockNoteEditor; @@ -202,7 +193,12 @@ export const setupSuggestionsMenu = < }, // 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 +211,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, + items: getItems(""), decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, }; } @@ -229,31 +221,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 +233,21 @@ 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(); } - // 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 which menu items to show by checking which items the current query (the text between the trigger + // character and caret) matches with. + next.items = getItems( + newState.doc.textBetween( + prev.queryStartPos!, + newState.selection.from + ) + ); return next; }, @@ -317,69 +272,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 +318,11 @@ export const setupSuggestionsMenu = < }, }, }), - itemCallback: (item: T) => { - deactivate(editor._tiptapEditor.view); + executeItem: (item: T) => { + onSelectItem({ item, editor }); + }, + closeMenu: () => deactivate(editor._tiptapEditor.view), + clearQuery: () => editor._tiptapEditor .chain() .focus() @@ -437,12 +332,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..ece9c5eb82 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts @@ -18,9 +18,14 @@ export class SlashMenuProsemirrorPlugin< SlashMenuItem extends BaseSlashMenuItem > extends EventEmitter { public readonly plugin: Plugin; - public readonly itemCallback: (item: SlashMenuItem) => void; + 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,20 +34,14 @@ 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.executeItem = suggestions.executeItem; + this.closeMenu = suggestions.closeMenu; + this.clearQuery = suggestions.clearQuery; } public onUpdate( 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..5be5e5a10b 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -5,37 +5,137 @@ import groupBy from "lodash.groupby"; import { BlockSchema } from "@blocknote/core"; import { SlashMenuItem } from "./SlashMenuItem"; import type { SlashMenuProps } from "./SlashMenuPositioner"; +import { useEffect, useRef, useState } from "react"; +import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; export function DefaultSlashMenu( props: SlashMenuProps ) { - const renderedItems: any[] = []; - let index = 0; + const [orderedItems, setOrderedItems] = useState< + ReactSlashMenuItem[] | undefined + >(undefined); + const [renderedItems, setRenderedItems] = useState( + undefined + ); + const [selectedIndex, setSelectedIndex] = useState(0); + const notFoundCount = useRef(0); + + // Sets the items to render. + useEffect(() => { + props.items.then((items) => { + const orderedItems: ReactSlashMenuItem[] = []; + const renderedItems: JSX.Element[] = []; + let itemIndex = 0; + + const groups = groupBy(items, (item) => item.group); + + foreach(groups, (groupedItems) => { + renderedItems.push( + + {groupedItems[0].group} + + ); + + for (const item of groupedItems) { + orderedItems.push(item); + renderedItems.push( + { + props.closeMenu(); + props.clearQuery(); + props.executeItem(item); + }} + /> + ); + itemIndex++; + } + }); + + setOrderedItems(orderedItems); + setRenderedItems(renderedItems); + + // Closes menu if query does not match any items after 3 tries. + // TODO: Not quite the right behaviour - should actually close if 3 + // characters are added to the first query that does not match any items. + if (orderedItems.length === 0) { + if (notFoundCount.current >= 3) { + props.closeMenu(); + } else { + notFoundCount.current++; + } + } + }); + }, [props, selectedIndex]); + + // Handles keyboard navigation. + useEffect(() => { + const preventMenuNavigationKeys = (event: KeyboardEvent) => { + console.log(event.key); + 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) { + props.closeMenu(); + props.clearQuery(); + props.executeItem(orderedItems[selectedIndex]); + } + + return true; + } + + if (event.key === "Escape") { + event.preventDefault(); + + props.closeMenu(); + + return true; + } - const groups = groupBy(props.filteredItems, (i) => i.group); + return false; + }; - foreach(groups, (groupedItems) => { - renderedItems.push( - - {groupedItems[0].group} - + props.editor.domElement.addEventListener( + "keydown", + preventMenuNavigationKeys, + true ); - for (const item of groupedItems) { - renderedItems.push( - props.itemCallback(item)} - /> + return () => { + props.editor.domElement.removeEventListener( + "keydown", + preventMenuNavigationKeys, + true ); - index++; - } - }); + }; + }, [selectedIndex, orderedItems, props.editor.domElement, props]); return ( ( trigger={"hover"} closeDelay={10000000}> event.preventDefault()} className={"bn-slash-menu"}> - {renderedItems.length > 0 ? ( - renderedItems + {renderedItems ? ( + renderedItems.length > 0 ? ( + renderedItems + ) : ( + No match found + ) ) : ( - No match found + Loading... )} diff --git a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx index c3b29447e5..842248a763 100644 --- a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx +++ b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx @@ -18,11 +18,13 @@ import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; import { DefaultSlashMenu } from "./DefaultSlashMenu"; export type SlashMenuProps = - Pick, "itemCallback"> & - Pick< - SuggestionsMenuState>, - "filteredItems" | "keyboardHoveredItemIndex" - >; + Pick< + SlashMenuProsemirrorPlugin, + "executeItem" | "closeMenu" | "clearQuery" + > & + Pick>, "items"> & { + editor: BlockNoteEditor; + }; export const SlashMenuPositioner = < BSchema extends BlockSchema = DefaultBlockSchema @@ -31,10 +33,9 @@ export const SlashMenuPositioner = < slashMenu?: FC>; }) => { const [show, setShow] = useState(false); - const [filteredItems, setFilteredItems] = - useState[]>(); - const [keyboardHoveredItemIndex, setKeyboardHoveredItemIndex] = - useState(); + const [items, setItems] = useState[]>>( + new Promise((resolve) => resolve([])) + ); const referencePos = useRef(); @@ -61,8 +62,7 @@ export const SlashMenuPositioner = < useEffect(() => { return props.editor.slashMenu.onUpdate((slashMenuState) => { setShow(slashMenuState.show); - setFilteredItems(slashMenuState.filteredItems); - setKeyboardHoveredItemIndex(slashMenuState.keyboardHoveredItemIndex); + setItems(slashMenuState.items); referencePos.current = slashMenuState.referencePos; @@ -76,7 +76,7 @@ export const SlashMenuPositioner = < }); }, [refs]); - if (!isMounted || !filteredItems || keyboardHoveredItemIndex === undefined) { + if (!isMounted || !items === undefined) { return null; } @@ -92,9 +92,11 @@ export const SlashMenuPositioner = < zIndex: 2000, }}> props.editor.slashMenu.itemCallback(item)} - keyboardHoveredItemIndex={keyboardHoveredItemIndex} + editor={props.editor} + items={items} + executeItem={props.editor.slashMenu.executeItem} + closeMenu={props.editor.slashMenu.closeMenu} + clearQuery={props.editor.slashMenu.clearQuery} /> ); 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); From 3d82986ce81f83fb7795ff97fa439ceee0fddbda Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 10 Jan 2024 16:56:15 +0100 Subject: [PATCH 2/7] Fixed vanilla example --- examples/vanilla/src/ui/addSlashMenu.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 936fcbcb75..8a38ad9b3b 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -5,13 +5,12 @@ import { } 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 + onClick: (item: BaseSlashMenuItem) => void ) { element.innerHTML = ""; const domItems = items.map((val, i) => { @@ -19,16 +18,13 @@ export const addSlashMenu = (editor: BlockNoteEditor) => { 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,11 +37,7 @@ export const addSlashMenu = (editor: BlockNoteEditor) => { } if (slashMenuState.show) { - updateItems( - slashMenuState.filteredItems, - editor.slashMenu.itemCallback, - slashMenuState.keyboardHoveredItemIndex - ); + updateItems(await slashMenuState.items, editor.slashMenu.executeItem); element.style.display = "block"; From c30d98e4a66e90cb3e4dce1420934dba42b24fa9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 10 Jan 2024 17:26:17 +0100 Subject: [PATCH 3/7] Small test fix --- tests/src/utils/slashmenu.ts | 1 + 1 file changed, 1 insertion(+) 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); } From 22691e51d3397f7c5cb8834d95f706f270ba5b68 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 12 Jan 2024 21:27:01 +0100 Subject: [PATCH 4/7] Implemented PR feedback --- examples/vanilla/src/ui/addSlashMenu.ts | 37 +++++++++- packages/core/src/editor/BlockNoteEditor.ts | 5 +- .../suggestion/SuggestionPlugin.ts | 73 ++++++++----------- .../extensions/SlashMenu/SlashMenuPlugin.ts | 18 ++++- .../components/SlashMenu/DefaultSlashMenu.tsx | 61 ++++++++++------ .../SlashMenu/SlashMenuPositioner.tsx | 16 ++-- packages/react/src/editor/styles.css | 9 +++ 7 files changed, 135 insertions(+), 84 deletions(-) diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 8a38ad9b3b..90c1bb14ab 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -2,17 +2,42 @@ import { BaseSlashMenuItem, BlockNoteEditor, DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, } from "@blocknote/core"; import { createButton } from "./util"; export const addSlashMenu = async (editor: BlockNoteEditor) => { let element: HTMLElement; - function updateItems( - items: BaseSlashMenuItem[], - onClick: (item: BaseSlashMenuItem) => void + async function updateItems( + query: string, + getItems: ( + query: string, + token: { + cancel: (() => void) | undefined; + } + ) => Promise< + BaseSlashMenuItem< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + >[] + >, + onClick: ( + item: BaseSlashMenuItem< + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema + > + ) => void ) { element.innerHTML = ""; + const items = await getItems(query, { + cancel: () => { + return; + }, + }); const domItems = items.map((val, i) => { const element = createButton(val.name, () => { onClick(val); @@ -37,7 +62,11 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => { } if (slashMenuState.show) { - updateItems(await slashMenuState.items, editor.slashMenu.executeItem); + 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 fd8dcfa553..e5bf72d3fa 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -84,7 +84,10 @@ export type BlockNoteEditorOptions< * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ slashMenuItems: ( - query: string + query: string, + token: { + cancel: (() => void) | undefined; + } ) => Promise[]>; /** diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts index db667b02ad..f267b39ca3 100644 --- a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts @@ -8,33 +8,30 @@ import { SuggestionItem } from "./SuggestionItem"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); -export type SuggestionsMenuState = - BaseUiElementState & { - // The items that should be shown in the menu. - items: Promise; - }; +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) { @@ -91,7 +88,7 @@ class SuggestionsMenuView< this.suggestionsMenuState = { show: true, referencePos: decorationNode!.getBoundingClientRect(), - items: this.pluginState.items, + query: this.pluginState.query, }; this.updateSuggestionsMenu(); @@ -103,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 @@ -112,19 +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: Promise; + query: string; decorationId: string | undefined; }; -function getDefaultPluginState< - T extends SuggestionItem ->(): SuggestionPluginState { +function getDefaultPluginState(): SuggestionPluginState { return { active: false, triggerCharacter: undefined, queryStartPos: undefined, - items: new Promise((resolve) => resolve([])), + query: "", decorationId: undefined, }; } @@ -146,14 +140,16 @@ export const setupSuggestionsMenu = < S extends StyleSchema >( editor: BlockNoteEditor, - updateSuggestionsMenu: ( - suggestionsMenuState: SuggestionsMenuState - ) => void, + updateSuggestionsMenu: (suggestionsMenuState: SuggestionsMenuState) => void, pluginKey: PluginKey, defaultTriggerCharacter: string, - getItems: (query: string) => Promise = () => - new Promise((resolve) => resolve([])), + getItems: ( + query: string, + token: { + cancel: (() => void) | undefined; + } + ) => Promise = () => new Promise((resolve) => resolve([])), onSelectItem: (props: { item: T; editor: BlockNoteEditor; @@ -166,7 +162,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 })); @@ -177,7 +173,7 @@ export const setupSuggestionsMenu = < key: pluginKey, view: () => { - suggestionsPluginView = new SuggestionsMenuView( + suggestionsPluginView = new SuggestionsMenuView( editor, pluginKey, @@ -188,17 +184,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; @@ -211,7 +202,7 @@ export const setupSuggestionsMenu = < triggerCharacter: transaction.getMeta(pluginKey)?.triggerCharacter || "", queryStartPos: newState.selection.from, - items: getItems(""), + query: "", decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, }; } @@ -235,18 +226,15 @@ export const setupSuggestionsMenu = < // Moving the caret before the character which triggered the menu should hide it. (prev.active && newState.selection.from < prev.queryStartPos!) ) { - return getDefaultPluginState(); + return getDefaultPluginState(); } 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 = getItems( - newState.doc.textBetween( - prev.queryStartPos!, - newState.selection.from - ) + // Updates the current query. + next.query = newState.doc.textBetween( + prev.queryStartPos!, + newState.selection.from ); return next; @@ -318,6 +306,7 @@ export const setupSuggestionsMenu = < }, }, }), + getItems: getItems, executeItem: (item: T) => { onSelectItem({ item, editor }); }, diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts index ece9c5eb82..bd5fb27cb9 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts @@ -18,13 +18,24 @@ export class SlashMenuProsemirrorPlugin< SlashMenuItem extends BaseSlashMenuItem > extends EventEmitter { public readonly plugin: Plugin; + public readonly getItems: ( + query: string, + token: { + cancel: (() => void) | undefined; + } + ) => Promise; public readonly executeItem: (item: SlashMenuItem) => void; public readonly closeMenu: () => void; public readonly clearQuery: () => void; constructor( editor: BlockNoteEditor, - getItems: (query: string) => Promise + getItems: ( + query: string, + token: { + cancel: (() => void) | undefined; + } + ) => Promise ) { super(); const suggestions = setupSuggestionsMenu( @@ -39,14 +50,13 @@ export class SlashMenuProsemirrorPlugin< ); this.plugin = suggestions.plugin; + 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/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index 5be5e5a10b..b0795e8186 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -1,4 +1,4 @@ -import { Menu } from "@mantine/core"; +import { Loader, Menu } from "@mantine/core"; import foreach from "lodash.foreach"; import groupBy from "lodash.groupby"; @@ -14,15 +14,37 @@ export function DefaultSlashMenu( const [orderedItems, setOrderedItems] = useState< ReactSlashMenuItem[] | undefined >(undefined); - const [renderedItems, setRenderedItems] = useState( - undefined + const [renderedItems, setRenderedItems] = useState( + null ); + const [loader, setLoader] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); - const notFoundCount = useRef(0); + + // Used to cancel old queries. This is needed in case the time to retrieve + // items varies, and an old query evaluates after a newer one. + const prevQueryToken = useRef<{ cancel: (() => void) | undefined }>({ + cancel: undefined, + }); + // Used to close the menu if the query is >3 characters longer than the last + // query that returned any results. + const lastUsefulQueryLength = useRef(0); // Sets the items to render. useEffect(() => { - props.items.then((items) => { + // TODO: Does this pattern make sense? https://stackoverflow.com/questions/30233302/promise-is-it-possible-to-force-cancel-a-promise + // Cancels the previous query since it has changed. + if (prevQueryToken.current.cancel !== undefined) { + prevQueryToken.current.cancel(); + prevQueryToken.current.cancel = undefined; + } + + setLoader(); + + props.getItems(props.query, prevQueryToken.current).then((items) => { + prevQueryToken.current.cancel = () => { + return; + }; + const orderedItems: ReactSlashMenuItem[] = []; const renderedItems: JSX.Element[] = []; let itemIndex = 0; @@ -59,16 +81,12 @@ export function DefaultSlashMenu( setOrderedItems(orderedItems); setRenderedItems(renderedItems); + setLoader(null); - // Closes menu if query does not match any items after 3 tries. - // TODO: Not quite the right behaviour - should actually close if 3 - // characters are added to the first query that does not match any items. - if (orderedItems.length === 0) { - if (notFoundCount.current >= 3) { - props.closeMenu(); - } else { - notFoundCount.current++; - } + if (orderedItems.length > 0) { + lastUsefulQueryLength.current = props.query.length; + } else if (props.query.length - lastUsefulQueryLength.current > 3) { + props.closeMenu(); } }); }, [props, selectedIndex]); @@ -76,7 +94,6 @@ export function DefaultSlashMenu( // Handles keyboard navigation. useEffect(() => { const preventMenuNavigationKeys = (event: KeyboardEvent) => { - console.log(event.key); if (event.key === "ArrowUp") { event.preventDefault(); @@ -153,15 +170,11 @@ export function DefaultSlashMenu( event.preventDefault()} className={"bn-slash-menu"}> - {renderedItems ? ( - renderedItems.length > 0 ? ( - renderedItems - ) : ( - No match found - ) - ) : ( - Loading... - )} + {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 842248a763..ed7df9423c 100644 --- a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx +++ b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx @@ -14,15 +14,14 @@ 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< SlashMenuProsemirrorPlugin, - "executeItem" | "closeMenu" | "clearQuery" + "getItems" | "executeItem" | "closeMenu" | "clearQuery" > & - Pick>, "items"> & { + Pick & { editor: BlockNoteEditor; }; @@ -33,9 +32,7 @@ export const SlashMenuPositioner = < slashMenu?: FC>; }) => { const [show, setShow] = useState(false); - const [items, setItems] = useState[]>>( - new Promise((resolve) => resolve([])) - ); + const [query, setQuery] = useState(""); const referencePos = useRef(); @@ -62,7 +59,7 @@ export const SlashMenuPositioner = < useEffect(() => { return props.editor.slashMenu.onUpdate((slashMenuState) => { setShow(slashMenuState.show); - setItems(slashMenuState.items); + setQuery(slashMenuState.query); referencePos.current = slashMenuState.referencePos; @@ -76,7 +73,7 @@ export const SlashMenuPositioner = < }); }, [refs]); - if (!isMounted || !items === undefined) { + if (!isMounted || !query === undefined) { return null; } @@ -93,7 +90,8 @@ export const SlashMenuPositioner = < }}> Date: Sat, 13 Jan 2024 00:54:05 +0100 Subject: [PATCH 5/7] Improved performance --- .../components/SlashMenu/DefaultSlashMenu.tsx | 94 +++++++++++-------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index b0795e8186..31d8f37a0c 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -5,7 +5,7 @@ import groupBy from "lodash.groupby"; import { BlockSchema } from "@blocknote/core"; import { SlashMenuItem } from "./SlashMenuItem"; import type { SlashMenuProps } from "./SlashMenuPositioner"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; export function DefaultSlashMenu( @@ -14,12 +14,10 @@ export function DefaultSlashMenu( const [orderedItems, setOrderedItems] = useState< ReactSlashMenuItem[] | undefined >(undefined); - const [renderedItems, setRenderedItems] = useState( - null - ); const [loader, setLoader] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); + const prevQuery = useRef(undefined); // Used to cancel old queries. This is needed in case the time to retrieve // items varies, and an old query evaluates after a newer one. const prevQueryToken = useRef<{ cancel: (() => void) | undefined }>({ @@ -29,8 +27,13 @@ export function DefaultSlashMenu( // query that returned any results. const lastUsefulQueryLength = useRef(0); - // Sets the items to render. + // Gets the items to display and orders them by group. useEffect(() => { + if (props.query === prevQuery.current) { + return; + } + prevQuery.current = props.query; + // TODO: Does this pattern make sense? https://stackoverflow.com/questions/30233302/promise-is-it-possible-to-force-cancel-a-promise // Cancels the previous query since it has changed. if (prevQueryToken.current.cancel !== undefined) { @@ -41,55 +44,72 @@ export function DefaultSlashMenu( setLoader(); props.getItems(props.query, prevQueryToken.current).then((items) => { - prevQueryToken.current.cancel = () => { - return; - }; + prevQueryToken.current.cancel = undefined; const orderedItems: ReactSlashMenuItem[] = []; - const renderedItems: JSX.Element[] = []; - let itemIndex = 0; const groups = groupBy(items, (item) => item.group); foreach(groups, (groupedItems) => { - renderedItems.push( - - {groupedItems[0].group} - - ); - for (const item of groupedItems) { orderedItems.push(item); - renderedItems.push( - { - props.closeMenu(); - props.clearQuery(); - props.executeItem(item); - }} - /> - ); - itemIndex++; } }); - setOrderedItems(orderedItems); - setRenderedItems(renderedItems); - setLoader(null); - if (orderedItems.length > 0) { lastUsefulQueryLength.current = props.query.length; } else if (props.query.length - lastUsefulQueryLength.current > 3) { props.closeMenu(); } + + setOrderedItems(orderedItems); + setLoader(null); }); - }, [props, selectedIndex]); + }, [props]); + + // 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} + ); + } + + renderedItems.push( + { + props.closeMenu(); + props.clearQuery(); + props.executeItem(item); + }} + /> + ); + + itemIndex++; + } + + return renderedItems; + }, [orderedItems, props, selectedIndex]); // Handles keyboard navigation. useEffect(() => { From 7b39927b35b7067ac19a0f7dca8df25f72155a3a Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 15 Jan 2024 13:49:57 +0100 Subject: [PATCH 6/7] Simplified how outdated queries are ignored/cancelled --- examples/vanilla/src/ui/addSlashMenu.ts | 11 ++------ packages/core/src/editor/BlockNoteEditor.ts | 5 +--- .../suggestion/SuggestionPlugin.ts | 8 ++---- .../extensions/SlashMenu/SlashMenuPlugin.ts | 14 ++-------- .../components/SlashMenu/DefaultSlashMenu.tsx | 27 ++++++------------- 5 files changed, 15 insertions(+), 50 deletions(-) diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 90c1bb14ab..332f4a62d4 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -13,10 +13,7 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => { async function updateItems( query: string, getItems: ( - query: string, - token: { - cancel: (() => void) | undefined; - } + query: string ) => Promise< BaseSlashMenuItem< DefaultBlockSchema, @@ -33,11 +30,7 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => { ) => void ) { element.innerHTML = ""; - const items = await getItems(query, { - cancel: () => { - return; - }, - }); + const items = await getItems(query); const domItems = items.map((val, i) => { const element = createButton(val.name, () => { onClick(val); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index e5bf72d3fa..fd8dcfa553 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -84,10 +84,7 @@ export type BlockNoteEditorOptions< * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ slashMenuItems: ( - query: string, - token: { - cancel: (() => void) | undefined; - } + query: string ) => Promise[]>; /** diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts index f267b39ca3..f0ab5a3f7a 100644 --- a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts @@ -144,12 +144,8 @@ export const setupSuggestionsMenu = < pluginKey: PluginKey, defaultTriggerCharacter: string, - getItems: ( - query: string, - token: { - cancel: (() => void) | undefined; - } - ) => Promise = () => new Promise((resolve) => resolve([])), + getItems: (query: string) => Promise = () => + new Promise((resolve) => resolve([])), onSelectItem: (props: { item: T; editor: BlockNoteEditor; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts index bd5fb27cb9..278df7a00b 100644 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts @@ -18,24 +18,14 @@ export class SlashMenuProsemirrorPlugin< SlashMenuItem extends BaseSlashMenuItem > extends EventEmitter { public readonly plugin: Plugin; - public readonly getItems: ( - query: string, - token: { - cancel: (() => void) | undefined; - } - ) => Promise; + public readonly getItems: (query: string) => Promise; public readonly executeItem: (item: SlashMenuItem) => void; public readonly closeMenu: () => void; public readonly clearQuery: () => void; constructor( editor: BlockNoteEditor, - getItems: ( - query: string, - token: { - cancel: (() => void) | undefined; - } - ) => Promise + getItems: (query: string) => Promise ) { super(); const suggestions = setupSuggestionsMenu( diff --git a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index 31d8f37a0c..dcdc739190 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -17,34 +17,23 @@ export function DefaultSlashMenu( const [loader, setLoader] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); - const prevQuery = useRef(undefined); - // Used to cancel old queries. This is needed in case the time to retrieve - // items varies, and an old query evaluates after a newer one. - const prevQueryToken = useRef<{ cancel: (() => void) | undefined }>({ - cancel: undefined, - }); + // Used to ignore the previous query if a new one is made before it returns. + const currentQuery = useRef(undefined); // 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(() => { - if (props.query === prevQuery.current) { - return; - } - prevQuery.current = props.query; - - // TODO: Does this pattern make sense? https://stackoverflow.com/questions/30233302/promise-is-it-possible-to-force-cancel-a-promise - // Cancels the previous query since it has changed. - if (prevQueryToken.current.cancel !== undefined) { - prevQueryToken.current.cancel(); - prevQueryToken.current.cancel = undefined; - } + const thisQuery = props.query; + currentQuery.current = props.query; setLoader(); - props.getItems(props.query, prevQueryToken.current).then((items) => { - prevQueryToken.current.cancel = undefined; + props.getItems(props.query).then((items) => { + if (currentQuery.current !== thisQuery) { + return; + } const orderedItems: ReactSlashMenuItem[] = []; From 570e4c21a0436aa9c9c563c6e220d80756c332a9 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 15 Jan 2024 15:20:54 +0100 Subject: [PATCH 7/7] small fixes --- .../components/SlashMenu/DefaultSlashMenu.tsx | 63 ++++++++++++------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index dcdc739190..15ab09c34e 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -3,35 +3,39 @@ import foreach from "lodash.foreach"; import groupBy from "lodash.groupby"; import { BlockSchema } from "@blocknote/core"; -import { SlashMenuItem } from "./SlashMenuItem"; -import type { SlashMenuProps } from "./SlashMenuPositioner"; 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 { query, getItems, closeMenu, executeItem, clearQuery, editor } = props; + const [orderedItems, setOrderedItems] = useState< ReactSlashMenuItem[] | undefined >(undefined); - const [loader, setLoader] = useState(null); + const [loading, setLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); // Used to ignore the previous query if a new one is made before it returns. - const currentQuery = useRef(undefined); + 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 = props.query; - currentQuery.current = props.query; + const thisQuery = query; + currentQuery.current = query; - setLoader(); + setLoading(true); - props.getItems(props.query).then((items) => { + getItems(query).then((items) => { if (currentQuery.current !== thisQuery) { + // outdated query returned, ignore the result return; } @@ -46,15 +50,15 @@ export function DefaultSlashMenu( }); if (orderedItems.length > 0) { - lastUsefulQueryLength.current = props.query.length; - } else if (props.query.length - lastUsefulQueryLength.current > 3) { - props.closeMenu(); + lastUsefulQueryLength.current = query.length; + } else if (query.length - lastUsefulQueryLength.current > 3) { + closeMenu(); } setOrderedItems(orderedItems); - setLoader(null); + setLoading(false); }); - }, [props]); + }, [query, getItems, closeMenu]); // Creates the JSX elements to render. const renderedItems = useMemo(() => { @@ -87,9 +91,9 @@ export function DefaultSlashMenu( shortcut={item.shortcut} isSelected={selectedIndex === itemIndex} set={() => { - props.closeMenu(); - props.clearQuery(); - props.executeItem(item); + closeMenu(); + clearQuery(); + executeItem(item); }} /> ); @@ -98,7 +102,7 @@ export function DefaultSlashMenu( } return renderedItems; - }, [orderedItems, props, selectedIndex]); + }, [closeMenu, executeItem, clearQuery, orderedItems, selectedIndex]); // Handles keyboard navigation. useEffect(() => { @@ -129,9 +133,9 @@ export function DefaultSlashMenu( event.preventDefault(); if (orderedItems !== undefined) { - props.closeMenu(); - props.clearQuery(); - props.executeItem(orderedItems[selectedIndex]); + closeMenu(); + clearQuery(); + executeItem(orderedItems[selectedIndex]); } return true; @@ -140,7 +144,7 @@ export function DefaultSlashMenu( if (event.key === "Escape") { event.preventDefault(); - props.closeMenu(); + closeMenu(); return true; } @@ -148,20 +152,31 @@ export function DefaultSlashMenu( return false; }; - props.editor.domElement.addEventListener( + editor.domElement.addEventListener( "keydown", preventMenuNavigationKeys, true ); return () => { - props.editor.domElement.removeEventListener( + editor.domElement.removeEventListener( "keydown", preventMenuNavigationKeys, true ); }; - }, [selectedIndex, orderedItems, props.editor.domElement, props]); + }, [ + selectedIndex, + orderedItems, + editor.domElement, + closeMenu, + clearQuery, + executeItem, + ]); + + const loader = loading ? ( + + ) : null; return (