From 6f749fde5c9bb0b2fefd140c8905f46e01c3f276 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 10 Jan 2024 02:45:24 +0100 Subject: [PATCH 001/130] 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 002/130] 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 003/130] 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 004/130] 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 005/130] 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 006/130] 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 007/130] 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 ( Date: Mon, 15 Jan 2024 20:12:49 +0100 Subject: [PATCH 008/130] Added custom suggestion menus PoC --- examples/editor/examples/basic/App.tsx | 39 +++++- examples/vanilla/src/ui/addSlashMenu.ts | 12 +- packages/core/src/editor/BlockNoteEditor.ts | 64 ++++++--- .../core/src/editor/BlockNoteExtensions.ts | 1 + .../suggestion/SuggestionItem.ts | 11 +- .../suggestion/SuggestionPlugin.ts | 90 +++++++++++-- .../Placeholder/PlaceholderExtension.ts | 9 +- .../src/extensions/SideMenu/SideMenuPlugin.ts | 13 +- .../extensions/SlashMenu/BaseSlashMenuItem.ts | 12 -- .../extensions/SlashMenu/SlashMenuPlugin.ts | 52 -------- .../SlashMenu/defaultSlashMenuItems.ts | 4 +- packages/core/src/index.ts | 3 +- .../SuggestionMenuPositioner.tsx | 125 ++++++++++++++++++ .../components/SlashMenu/DefaultSlashMenu.tsx | 18 ++- .../SlashMenu/SlashMenuPositioner.tsx | 107 +++------------ packages/react/src/index.ts | 1 + .../src/slashMenuItems/ReactSlashMenuItem.ts | 4 +- .../defaultReactSlashMenuItems.tsx | 9 +- 18 files changed, 363 insertions(+), 211 deletions(-) delete mode 100644 packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts delete mode 100644 packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts create mode 100644 packages/react/src/components-shared/SuggestionMenu/SuggestionMenuPositioner.tsx diff --git a/examples/editor/examples/basic/App.tsx b/examples/editor/examples/basic/App.tsx index 5eec46aee6..b923b73acf 100644 --- a/examples/editor/examples/basic/App.tsx +++ b/examples/editor/examples/basic/App.tsx @@ -1,5 +1,17 @@ import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; -import { BlockNoteView, useBlockNote } from "@blocknote/react"; +import { + BlockNoteView, + DefaultSlashMenu, + FormattingToolbarPositioner, + getDefaultReactSlashMenuItems, + HyperlinkToolbarPositioner, + ImageToolbarPositioner, + SideMenuPositioner, + SlashMenuPositioner, + SuggestionMenuPositioner, + TableHandlesPositioner, + useBlockNote, +} from "@blocknote/react"; import "@blocknote/react/style.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; @@ -13,12 +25,35 @@ export function App() { }, }, uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, + extraSuggestionMenus: [ + { + name: "mentions", + triggerCharacter: "@", + getItems: getDefaultReactSlashMenuItems, + }, + ], }); // Give tests a way to get prosemirror instance (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; - return ; + return ( + + + + + + + {editor.blockSchema.table && ( + + )} + + + ); } export default App; diff --git a/examples/vanilla/src/ui/addSlashMenu.ts b/examples/vanilla/src/ui/addSlashMenu.ts index 332f4a62d4..f0a79d5d75 100644 --- a/examples/vanilla/src/ui/addSlashMenu.ts +++ b/examples/vanilla/src/ui/addSlashMenu.ts @@ -1,9 +1,9 @@ import { - BaseSlashMenuItem, BlockNoteEditor, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, + SuggestionItem, } from "@blocknote/core"; import { createButton } from "./util"; @@ -15,14 +15,14 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => { getItems: ( query: string ) => Promise< - BaseSlashMenuItem< + SuggestionItem< DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema >[] >, onClick: ( - item: BaseSlashMenuItem< + item: SuggestionItem< DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema @@ -42,7 +42,7 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => { return domItems; } - editor.slashMenu.onUpdate(async (slashMenuState) => { + editor.suggestionMenus.slashMenu.onUpdate(async (slashMenuState) => { if (!element) { element = document.createElement("div"); element.style.background = "gray"; @@ -57,8 +57,8 @@ export const addSlashMenu = async (editor: BlockNoteEditor) => { if (slashMenuState.show) { await updateItems( slashMenuState.query, - editor.slashMenu.getItems, - editor.slashMenu.executeItem + editor.suggestionMenus.slashMenu.getItems, + editor.suggestionMenus.slashMenu.executeItem ); element.style.display = "block"; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index fd8dcfa553..48446a6491 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -32,8 +32,6 @@ import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingTool import { HyperlinkToolbarProsemirrorPlugin } from "../extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; import { ImageToolbarProsemirrorPlugin } from "../extensions/ImageToolbar/ImageToolbarPlugin"; import { SideMenuProsemirrorPlugin } from "../extensions/SideMenu/SideMenuPlugin"; -import { BaseSlashMenuItem } from "../extensions/SlashMenu/BaseSlashMenuItem"; -import { SlashMenuProsemirrorPlugin } from "../extensions/SlashMenu/SlashMenuPlugin"; import { getDefaultSlashMenuItems } from "../extensions/SlashMenu/defaultSlashMenuItems"; import { TableHandlesProsemirrorPlugin } from "../extensions/TableHandles/TableHandlesPlugin"; import { UniqueID } from "../extensions/UniqueID/UniqueID"; @@ -69,6 +67,11 @@ import { transformPasted } from "./transformPasted"; // CSS import "./Block.css"; import "./editor.css"; +import { + createSuggestionMenu, + SuggestionMenuProseMirrorPlugin, +} from "../extensions-shared/suggestion/SuggestionPlugin"; +import { SuggestionItem } from "../extensions-shared/suggestion/SuggestionItem"; export type BlockNoteEditorOptions< BSpecs extends BlockSpecs, @@ -83,9 +86,15 @@ export type BlockNoteEditorOptions< * * @default defaultSlashMenuItems from `./extensions/SlashMenu` */ - slashMenuItems: ( - query: string - ) => Promise[]>; + slashMenuItems: (query: string) => Promise[]>; + + extraSuggestionMenus: { + name: string; + triggerCharacter: string; + getItems: >( + query: string + ) => Promise; + }[]; /** * The HTML element that should be used as the parent element for the editor. @@ -222,12 +231,6 @@ export class BlockNoteEditor< SSchema >; public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; - public readonly slashMenu: SlashMenuProsemirrorPlugin< - BSchema, - ISchema, - SSchema, - any - >; public readonly hyperlinkToolbar: HyperlinkToolbarProsemirrorPlugin< BSchema, ISchema, @@ -251,6 +254,16 @@ export class BlockNoteEditor< > | undefined; + public readonly suggestionMenus: Record< + string | "slashMenu", + SuggestionMenuProseMirrorPlugin< + SuggestionItem, + BSchema, + ISchema, + SSchema + > + > = {}; + public readonly uploadFile: ((file: File) => Promise) | undefined; public static create< @@ -289,11 +302,32 @@ export class BlockNoteEditor< this.sideMenu = new SideMenuProsemirrorPlugin(this); this.formattingToolbar = new FormattingToolbarProsemirrorPlugin(this); - this.slashMenu = new SlashMenuProsemirrorPlugin( - this, + + this.suggestionMenus.slashMenu = createSuggestionMenu< + BSchema, + ISchema, + SSchema, + SuggestionItem + >( + "slashMenu", + "/", newOptions.slashMenuItems || ((query) => getDefaultSlashMenuItems(query, this.blockSchema) as any) - ); + )(this); + + for (const extraSuggestionMenu of newOptions.extraSuggestionMenus || []) { + this.suggestionMenus[extraSuggestionMenu.name] = createSuggestionMenu< + BSchema, + ISchema, + SSchema, + SuggestionItem + >( + extraSuggestionMenu.name, + extraSuggestionMenu.triggerCharacter, + extraSuggestionMenu.getItems + )(this); + } + this.hyperlinkToolbar = new HyperlinkToolbarProsemirrorPlugin(this); this.imageToolbar = new ImageToolbarProsemirrorPlugin(this); @@ -318,10 +352,10 @@ export class BlockNoteEditor< return [ this.sideMenu.plugin, this.formattingToolbar.plugin, - this.slashMenu.plugin, this.hyperlinkToolbar.plugin, this.imageToolbar.plugin, ...(this.tableHandles ? [this.tableHandles.plugin] : []), + ...Object.values(this.suggestionMenus).map((menu) => menu.plugin), ]; }, }); diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index ec2277e5b9..902ed5d498 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -66,6 +66,7 @@ export const getBlockNoteExtensions = < // DropCursor, Placeholder.configure({ + editor: opts.editor, includeChildren: true, showOnlyCurrent: false, }), diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts b/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts index aebd7be2cd..f19c7581d4 100644 --- a/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionItem.ts @@ -1,3 +1,12 @@ -export type SuggestionItem = { +import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor"; + +export type SuggestionItem< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> = { name: string; + execute: (editor: BlockNoteEditor) => void; + aliases?: string[]; }; diff --git a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts index f0ab5a3f7a..591159dfdc 100644 --- a/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts +++ b/packages/core/src/extensions-shared/suggestion/SuggestionPlugin.ts @@ -5,6 +5,7 @@ import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; import { BaseUiElementState } from "../BaseUiElementTypes"; import { SuggestionItem } from "./SuggestionItem"; +import { EventEmitter } from "../../util/EventEmitter"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); @@ -12,7 +13,7 @@ export type SuggestionsMenuState = BaseUiElementState & { query: string; }; -class SuggestionsMenuView< +class SuggestionMenuView< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema @@ -133,14 +134,14 @@ function getDefaultPluginState(): SuggestionPluginState { * - This version hides some unnecessary complexity from the user of the plugin. * - This version handles key events differently */ -export const setupSuggestionsMenu = < - T extends SuggestionItem, +export const setupSuggestionMenu = < BSchema extends BlockSchema, I extends InlineContentSchema, - S extends StyleSchema + S extends StyleSchema, + T extends SuggestionItem >( editor: BlockNoteEditor, - updateSuggestionsMenu: (suggestionsMenuState: SuggestionsMenuState) => void, + updateSuggestionMenu: (suggestionMenuState: SuggestionsMenuState) => void, pluginKey: PluginKey, defaultTriggerCharacter: string, @@ -158,7 +159,7 @@ export const setupSuggestionsMenu = < throw new Error("'char' should be a single character"); } - let suggestionsPluginView: SuggestionsMenuView; + let suggestionPluginView: SuggestionMenuView; const deactivate = (view: EditorView) => { view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true })); @@ -169,13 +170,13 @@ export const setupSuggestionsMenu = < key: pluginKey, view: () => { - suggestionsPluginView = new SuggestionsMenuView( + suggestionPluginView = new SuggestionMenuView( editor, pluginKey, - updateSuggestionsMenu + updateSuggestionMenu ); - return suggestionsPluginView; + return suggestionPluginView; }, state: { @@ -313,10 +314,77 @@ export const setupSuggestionsMenu = < .focus() .deleteRange({ from: - suggestionsPluginView.pluginState.queryStartPos! - - suggestionsPluginView.pluginState.triggerCharacter!.length, + suggestionPluginView.pluginState.queryStartPos! - + suggestionPluginView.pluginState.triggerCharacter!.length, to: editor._tiptapEditor.state.selection.from, }) .run(), }; }; + +export class SuggestionMenuProseMirrorPlugin< + Item extends SuggestionItem, + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +> extends EventEmitter { + public readonly plugin: Plugin; + public readonly getItems: (query: string) => Promise; + public readonly executeItem: (item: Item) => void; + public readonly closeMenu: () => void; + public readonly clearQuery: () => void; + + constructor( + editor: BlockNoteEditor, + name: string, + triggerCharacter: string, + getItems: (query: string) => Promise + ) { + if (triggerCharacter.length !== 1) { + throw new Error( + `The trigger character must be a single character, but received ${triggerCharacter}` + ); + } + + super(); + const suggestionMenu = setupSuggestionMenu( + editor, + (state) => { + this.emit("update", state); + }, + new PluginKey(name), + triggerCharacter, + getItems, + ({ item, editor }) => item.execute(editor) + ); + + this.plugin = suggestionMenu.plugin; + this.getItems = getItems; + this.executeItem = suggestionMenu.executeItem; + this.closeMenu = suggestionMenu.closeMenu; + this.clearQuery = suggestionMenu.clearQuery; + } + + public onUpdate(callback: (state: SuggestionsMenuState) => void) { + return this.on("update", callback); + } +} + +export function createSuggestionMenu< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, + SuggestionMenuItem extends SuggestionItem +>( + name: string, + triggerCharacter: string, + getItems: (query: string) => Promise +) { + return (editor: BlockNoteEditor) => + new SuggestionMenuProseMirrorPlugin( + editor, + name, + triggerCharacter, + getItems + ); +} diff --git a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts index 4bfa6ec41f..bed6d7f964 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderExtension.ts @@ -2,7 +2,7 @@ import { Editor, Extension } from "@tiptap/core"; import { Node as ProsemirrorNode } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; -import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); @@ -14,6 +14,7 @@ const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); * */ export interface PlaceholderOptions { + editor: BlockNoteEditor | undefined; emptyEditorClass: string; emptyNodeClass: string; isFilterClass: string; @@ -36,6 +37,7 @@ export const Placeholder = Extension.create({ addOptions() { return { + editor: undefined, emptyEditorClass: "bn-is-editor-empty", emptyNodeClass: "bn-is-empty", isFilterClass: "bn-is-filter", @@ -55,7 +57,10 @@ export const Placeholder = Extension.create({ decorations: (state) => { const { doc, selection } = state; // Get state of slash menu - const menuState = slashMenuPluginKey.getState(state); + const menuState = + this.options.editor!.suggestionMenus.slashMenu.plugin.getState( + state + ); const active = this.editor.isEditable || !this.options.showOnlyWhenEditable; const { anchor } = selection; diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index e1592f9a17..eca6052d96 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -15,7 +15,6 @@ import { StyleSchema, } from "../../schema"; import { EventEmitter } from "../../util/EventEmitter"; -import { slashMenuPluginKey } from "../SlashMenu/SlashMenuPlugin"; import { MultipleNodeSelection } from "./MultipleNodeSelection"; let dragImageElement: Element | undefined; @@ -567,11 +566,13 @@ export class SideMenuView< // Focuses and activates the suggestion menu. this.pmView.focus(); this.pmView.dispatch( - this.pmView.state.tr.scrollIntoView().setMeta(slashMenuPluginKey, { - // TODO import suggestion plugin key - activate: true, - type: "drag", - }) + this.pmView.state.tr + .scrollIntoView() + .setMeta(this.editor.suggestionMenus.slashMenu.plugin.spec.key!, { + // TODO import suggestion plugin key + activate: true, + type: "drag", + }) ); } } diff --git a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts b/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts deleted file mode 100644 index 42d42bebbd..0000000000 --- a/packages/core/src/extensions/SlashMenu/BaseSlashMenuItem.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { SuggestionItem } from "../../extensions-shared/suggestion/SuggestionItem"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; - -export type BaseSlashMenuItem< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema -> = SuggestionItem & { - execute: (editor: BlockNoteEditor) => void; - aliases?: string[]; -}; diff --git a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts b/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts deleted file mode 100644 index 278df7a00b..0000000000 --- a/packages/core/src/extensions/SlashMenu/SlashMenuPlugin.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Plugin, PluginKey } from "prosemirror-state"; - -import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; -import { - SuggestionsMenuState, - setupSuggestionsMenu, -} from "../../extensions-shared/suggestion/SuggestionPlugin"; -import { BlockSchema, InlineContentSchema, StyleSchema } from "../../schema"; -import { EventEmitter } from "../../util/EventEmitter"; -import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; - -export const slashMenuPluginKey = new PluginKey("SlashMenuPlugin"); - -export class SlashMenuProsemirrorPlugin< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema, - SlashMenuItem extends BaseSlashMenuItem -> extends EventEmitter { - public readonly plugin: Plugin; - 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) => Promise - ) { - super(); - const suggestions = setupSuggestionsMenu( - editor, - (state) => { - this.emit("update", state); - }, - slashMenuPluginKey, - "/", - getItems, - ({ item, editor }) => item.execute(editor) - ); - - 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) { - return this.on("update", callback); - } -} diff --git a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts index 57429ddd9c..4803603146 100644 --- a/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SlashMenu/defaultSlashMenuItems.ts @@ -9,7 +9,7 @@ import { isStyledTextInlineContent, } from "../../schema"; import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; -import { BaseSlashMenuItem } from "./BaseSlashMenuItem"; +import { SuggestionItem } from "../../extensions-shared/suggestion/SuggestionItem"; // Sets the editor's text cursor position to the next content editable block, // so either a block with inline content or a table. The last block is always a @@ -79,7 +79,7 @@ export async function getDefaultSlashMenuItems< I extends InlineContentSchema, S extends StyleSchema >(query: string, schema: BSchema = defaultBlockSchema as unknown as BSchema) { - const slashMenuItems: BaseSlashMenuItem[] = []; + const slashMenuItems: SuggestionItem[] = []; if ("heading" in schema && "level" in schema.heading.propSchema) { // Command for creating a level 1 heading diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9678ae5a55..6db1e47352 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,13 +9,12 @@ export * from "./editor/BlockNoteExtensions"; export * from "./editor/selectionTypes"; export * from "./extensions-shared/BaseUiElementTypes"; export type { SuggestionItem } from "./extensions-shared/suggestion/SuggestionItem"; +export * from "./extensions-shared/suggestion/SuggestionItem"; export * from "./extensions-shared/suggestion/SuggestionPlugin"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; export * from "./extensions/ImageToolbar/ImageToolbarPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; -export * from "./extensions/SlashMenu/BaseSlashMenuItem"; -export * from "./extensions/SlashMenu/SlashMenuPlugin"; export { getDefaultSlashMenuItems } from "./extensions/SlashMenu/defaultSlashMenuItems"; export * from "./extensions/TableHandles/TableHandlesPlugin"; export * from "./schema"; diff --git a/packages/react/src/components-shared/SuggestionMenu/SuggestionMenuPositioner.tsx b/packages/react/src/components-shared/SuggestionMenu/SuggestionMenuPositioner.tsx new file mode 100644 index 0000000000..147a218747 --- /dev/null +++ b/packages/react/src/components-shared/SuggestionMenu/SuggestionMenuPositioner.tsx @@ -0,0 +1,125 @@ +import { + BlockNoteEditor, + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, + SuggestionItem, + SuggestionMenuProseMirrorPlugin, + SuggestionsMenuState, +} from "@blocknote/core"; +import { + flip, + offset, + size, + useFloating, + useTransitionStyles, +} from "@floating-ui/react"; +import { FC, useEffect, useRef, useState } from "react"; + +export type SuggestionMenuProps< + Item extends SuggestionItem, + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +> = Pick< + SuggestionMenuProseMirrorPlugin, + "getItems" | "executeItem" | "closeMenu" | "clearQuery" +> & + Pick & { + editor: BlockNoteEditor; + }; + +export const SuggestionMenuPositioner = < + Item extends SuggestionItem, + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + editor: BlockNoteEditor; + suggestionsMenuName: string; + suggestionsMenuComponent: FC>; +}) => { + const [show, setShow] = useState(false); + const [query, setQuery] = useState(""); + + const referencePos = useRef(); + + const { refs, update, context, floatingStyles } = useFloating({ + open: show, + placement: "bottom-start", + middleware: [ + offset(10), + // Flips the menu placement to maximize the space available, and prevents + // the menu from being cut off by the confines of the screen. + flip(), + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight - 10}px`, + }); + }, + }), + ], + }); + + const { isMounted, styles } = useTransitionStyles(context); + + useEffect(() => { + return props.editor.suggestionMenus[props.suggestionsMenuName].onUpdate( + (suggestionsMenuState) => { + setShow(suggestionsMenuState.show); + setQuery(suggestionsMenuState.query); + + referencePos.current = suggestionsMenuState.referencePos; + + update(); + } + ); + }, [props.editor, props.suggestionsMenuName, show, update]); + + useEffect(() => { + refs.setReference({ + getBoundingClientRect: () => referencePos.current!, + }); + }, [refs]); + + if (!isMounted || !query === undefined) { + return null; + } + + const SuggestionsMenu = props.suggestionsMenuComponent; + + return ( +
+ +
+ ); +}; diff --git a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx index 15ab09c34e..db1c39f137 100644 --- a/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx +++ b/packages/react/src/components/SlashMenu/DefaultSlashMenu.tsx @@ -2,19 +2,23 @@ import { Loader, Menu } from "@mantine/core"; import foreach from "lodash.foreach"; import groupBy from "lodash.groupby"; -import { BlockSchema } from "@blocknote/core"; +import { BlockSchema, InlineContentSchema, StyleSchema } 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 +import { SuggestionMenuProps } from "../../components-shared/SuggestionMenu/SuggestionMenuPositioner"; + +export function DefaultSlashMenu< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + props: SuggestionMenuProps, BSchema, I, S> ) { const { query, getItems, closeMenu, executeItem, clearQuery, editor } = props; const [orderedItems, setOrderedItems] = useState< - ReactSlashMenuItem[] | undefined + ReactSlashMenuItem[] | undefined >(undefined); const [loading, setLoading] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -39,7 +43,7 @@ export function DefaultSlashMenu( return; } - const orderedItems: ReactSlashMenuItem[] = []; + const orderedItems: ReactSlashMenuItem[] = []; const groups = groupBy(items, (item) => item.group); diff --git a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx index ed7df9423c..f0fe96f8e9 100644 --- a/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx +++ b/packages/react/src/components/SlashMenu/SlashMenuPositioner.tsx @@ -2,100 +2,37 @@ import { BlockNoteEditor, BlockSchema, DefaultBlockSchema, - SlashMenuProsemirrorPlugin, - SuggestionsMenuState, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, } from "@blocknote/core"; -import { - flip, - offset, - size, - useFloating, - useTransitionStyles, -} from "@floating-ui/react"; -import { FC, useEffect, useRef, useState } from "react"; +import { FC } from "react"; import { DefaultSlashMenu } from "./DefaultSlashMenu"; - -export type SlashMenuProps = - Pick< - SlashMenuProsemirrorPlugin, - "getItems" | "executeItem" | "closeMenu" | "clearQuery" - > & - Pick & { - editor: BlockNoteEditor; - }; +import { + SuggestionMenuPositioner, + SuggestionMenuProps, +} from "../../components-shared/SuggestionMenu/SuggestionMenuPositioner"; +import { ReactSlashMenuItem } from "../../slashMenuItems/ReactSlashMenuItem"; export const SlashMenuPositioner = < - BSchema extends BlockSchema = DefaultBlockSchema + BSchema extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema >(props: { - editor: BlockNoteEditor; - slashMenu?: FC>; + editor: BlockNoteEditor; + slashMenu?: FC< + SuggestionMenuProps, BSchema, I, S> + >; }) => { - const [show, setShow] = useState(false); - const [query, setQuery] = useState(""); - - const referencePos = useRef(); - - const { refs, update, context, floatingStyles } = useFloating({ - open: show, - placement: "bottom-start", - middleware: [ - offset(10), - // Flips the slash menu placement to maximize the space available, and - // prevents the menu from being cut off by the confines of the screen. - flip(), - size({ - apply({ availableHeight, elements }) { - Object.assign(elements.floating.style, { - maxHeight: `${availableHeight - 10}px`, - }); - }, - }), - ], - }); - - const { isMounted, styles } = useTransitionStyles(context); - - useEffect(() => { - return props.editor.slashMenu.onUpdate((slashMenuState) => { - setShow(slashMenuState.show); - setQuery(slashMenuState.query); - - referencePos.current = slashMenuState.referencePos; - - update(); - }); - }, [props.editor, show, update]); - - useEffect(() => { - refs.setReference({ - getBoundingClientRect: () => referencePos.current!, - }); - }, [refs]); - - if (!isMounted || !query === undefined) { - return null; - } - const SlashMenu = props.slashMenu || DefaultSlashMenu; return ( -
- -
+ ); }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 258765b26b..c09a2880fd 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -41,6 +41,7 @@ export * from "./components/ImageToolbar/ImageToolbarPositioner"; export * from "./components/TableHandles/DefaultTableHandle"; export * from "./components/TableHandles/TableHandlePositioner"; +export * from "./components-shared/SuggestionMenu/SuggestionMenuPositioner"; export * from "./components-shared/Toolbar/Toolbar"; export * from "./components-shared/Toolbar/ToolbarButton"; export * from "./components-shared/Toolbar/ToolbarDropdown"; diff --git a/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts b/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts index 65ceb044f7..59830fced8 100644 --- a/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts +++ b/packages/react/src/slashMenuItems/ReactSlashMenuItem.ts @@ -1,18 +1,18 @@ import { - BaseSlashMenuItem, BlockSchema, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, InlineContentSchema, StyleSchema, + SuggestionItem, } from "@blocknote/core"; export type ReactSlashMenuItem< BSchema extends BlockSchema = DefaultBlockSchema, I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema -> = BaseSlashMenuItem & { +> = SuggestionItem & { group: string; icon: JSX.Element; hint?: string; diff --git a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx index b471abaa88..e531c2c85d 100644 --- a/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/slashMenuItems/defaultReactSlashMenuItems.tsx @@ -1,11 +1,11 @@ import { - BaseSlashMenuItem, BlockSchema, defaultBlockSchema, DefaultBlockSchema, getDefaultSlashMenuItems, InlineContentSchema, StyleSchema, + SuggestionItem, } from "@blocknote/core"; import { RiH1, @@ -22,10 +22,7 @@ import { ReactSlashMenuItem } from "./ReactSlashMenuItem"; const extraFields: Record< string, - Omit< - ReactSlashMenuItem, - keyof BaseSlashMenuItem - > + Omit> > = { Heading: { group: "Headings", @@ -88,7 +85,7 @@ export async function getDefaultReactSlashMenuItems< // infer to DefaultBlockSchema if it is not defined. schema: BSchema = defaultBlockSchema as any as BSchema ): Promise[]> { - const slashMenuItems: BaseSlashMenuItem[] = + const slashMenuItems: SuggestionItem[] = await getDefaultSlashMenuItems(query, schema); return slashMenuItems.map((item) => ({ From 03126c5c3511195dc80d462847f4903bc30a0a48 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 15 Jan 2024 20:51:36 +0100 Subject: [PATCH 009/130] Updated custom mentions example --- examples/editor/examples/basic/App.tsx | 75 ++++++++++++++++++++- packages/core/src/editor/BlockNoteEditor.ts | 4 +- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/examples/editor/examples/basic/App.tsx b/examples/editor/examples/basic/App.tsx index b923b73acf..b703b065c5 100644 --- a/examples/editor/examples/basic/App.tsx +++ b/examples/editor/examples/basic/App.tsx @@ -1,9 +1,18 @@ -import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; +import { + BlockNoteEditor, + DefaultBlockSchema, + defaultInlineContentSchema, + defaultInlineContentSpecs, + DefaultStyleSchema, + InlineContentSchema, + InlineContentSpecs, + uploadToTmpFilesDotOrg_DEV_ONLY, +} from "@blocknote/core"; import { BlockNoteView, + createReactInlineContentSpec, DefaultSlashMenu, FormattingToolbarPositioner, - getDefaultReactSlashMenuItems, HyperlinkToolbarPositioner, ImageToolbarPositioner, SideMenuPositioner, @@ -16,8 +25,68 @@ import "@blocknote/react/style.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; +const MentionInlineContent = createReactInlineContentSpec( + { + type: "mention", + propSchema: { + user: { + default: "Unknown", + }, + }, + content: "none", + }, + { + render: (props) => ( + + @{props.inlineContent.props.user} + + ), + } +); + +const customInlineContentSpecs = { + ...defaultInlineContentSpecs, + mention: MentionInlineContent, +} satisfies InlineContentSpecs; +const customInlineContentSchema = { + ...defaultInlineContentSchema, + mention: MentionInlineContent.config, +} satisfies InlineContentSchema; + +async function getMentionMenuItems(query: string) { + const users = ["Steve", "Bob", "Joe", "Mike"]; + const items = users.map((user) => ({ + name: user, + execute: ( + editor: BlockNoteEditor< + DefaultBlockSchema, + typeof customInlineContentSchema, + DefaultStyleSchema + > + ) => { + editor._tiptapEditor.commands.insertContent({ + type: "mention", + attrs: { + user: user, + }, + }); + }, + aliases: [] as string[], + })); + + return items.filter( + ({ name, aliases }) => + name.toLowerCase().startsWith(query.toLowerCase()) || + (aliases && + aliases.filter((alias) => + alias.toLowerCase().startsWith(query.toLowerCase()) + ).length !== 0) + ); +} + export function App() { const editor = useBlockNote({ + inlineContentSpecs: customInlineContentSpecs, domAttributes: { editor: { class: "editor", @@ -29,7 +98,7 @@ export function App() { { name: "mentions", triggerCharacter: "@", - getItems: getDefaultReactSlashMenuItems, + getItems: getMentionMenuItems, }, ], }); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 48446a6491..adf8c85954 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -91,9 +91,7 @@ export type BlockNoteEditorOptions< extraSuggestionMenus: { name: string; triggerCharacter: string; - getItems: >( - query: string - ) => Promise; + getItems: (query: string) => Promise[]>; }[]; /** From b6c5ce26ab74e8b64759abff570bdcc9eccddd19 Mon Sep 17 00:00:00 2001 From: Chris Nicholas Date: Tue, 30 Jan 2024 11:56:26 +0000 Subject: [PATCH 010/130] Add Liveblocks info and BlockNote download command (#540) * Update real-time-collaboration.md * feat: Example image * feat: Replace image with video * Fixed video --------- Co-authored-by: Matthew Lipski --- .../docs/docs/real-time-collaboration.md | 13 +++++++++++++ .../liveblocks_blocknote_example.mp4 | Bin 0 -> 946276 bytes 2 files changed, 13 insertions(+) create mode 100644 packages/website/docs/public/img/screenshots/liveblocks_blocknote_example.mp4 diff --git a/packages/website/docs/docs/real-time-collaboration.md b/packages/website/docs/docs/real-time-collaboration.md index b3d6661b78..7ea30e00b5 100644 --- a/packages/website/docs/docs/real-time-collaboration.md +++ b/packages/website/docs/docs/real-time-collaboration.md @@ -44,6 +44,7 @@ const editor = useBlockNote({ When a user edits the document, an incremental change (or "update") is captured and can be shared between users of your app. You can share these updates by setting up a _Yjs Provider_. In the snipped above, we use [y-webrtc](https://github.com/yjs/y-webrtc) which shares updates over WebRTC (and BroadcastChannel), but you might be interested in different providers for production-ready use cases. +- [Liveblocks](https://liveblocks.io/yjs) A fully hosted WebSocket infrastructure and persisted data store for Yjs documents. Includes webhooks, REST API, and browser DevTools, all for Yjs - [PartyKit](https://www.partykit.io/) A serverless provider that runs on Cloudflare - [Hocuspocus](https://www.hocuspocus.dev/) open source and extensible Node.js server with pluggable storage (scales with Redis) - [y-websocket](https://github.com/yjs/y-websocket) provider that you can connect to your own websocket server @@ -52,6 +53,18 @@ When a user edits the document, an incremental change (or "update") is captured - [Matrix-CRDT](https://github.com/yousefED/matrix-crdt) syncs updates over Matrix (experimental) - [Nostr-CRDT](https://github.com/yousefED/nostr-crdt) syncs updates over Nostr (experimental) +## Liveblocks + +Liveblocks provides a hosted back-end for Yjs which allows you to download and set up a real-time multiplayer BlockNote example with one command. + +```shell +npx create-liveblocks-app@latest --example nextjs-yjs-blocknote-advanced +``` + +