From 546d255ac41f7ba83f5106513ca7fad2508606b3 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 14 Feb 2024 21:50:39 +0100 Subject: [PATCH 1/8] clean getitems --- examples/editor/examples/basic/App.tsx | 53 ++---- .../SuggestionMenu/DefaultSuggestionItem.tsx | 7 + .../SuggestionMenu/DefaultSuggestionMenu.tsx | 161 ++++++++++-------- .../SuggestionMenu/MantineSuggestionMenu.tsx | 25 +-- .../MantineSuggestionMenuItem.tsx | 19 +-- .../SuggestionMenu/defaultGetItems.tsx | 97 ++++------- .../hooks/useCloseSuggestionMenuNoItems.ts | 8 +- .../useSuggestionMenuKeyboardNavigation.ts | 15 +- .../react/src/editor/BlockNoteDefaultUI.tsx | 27 ++- packages/react/src/schema/ReactBlockSpec.tsx | 6 +- tests/src/utils/components/Editor.tsx | 78 +++------ tests/src/utils/customblocks/Alert.tsx | 69 +++----- tests/src/utils/customblocks/Button.tsx | 53 +++--- tests/src/utils/customblocks/Embed.tsx | 67 +++----- tests/src/utils/customblocks/Image.tsx | 63 +++---- tests/src/utils/customblocks/ReactAlert.tsx | 83 ++++----- tests/src/utils/customblocks/ReactImage.tsx | 87 ++++------ tests/src/utils/customblocks/Separator.tsx | 58 +++---- 18 files changed, 398 insertions(+), 578 deletions(-) create mode 100644 packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx diff --git a/examples/editor/examples/basic/App.tsx b/examples/editor/examples/basic/App.tsx index 99e7379a3c..c57890df87 100644 --- a/examples/editor/examples/basic/App.tsx +++ b/examples/editor/examples/basic/App.tsx @@ -1,19 +1,16 @@ import { - BlockNoteEditor, - DefaultBlockSchema, defaultInlineContentSchema, defaultInlineContentSpecs, - DefaultStyleSchema, InlineContentSchema, InlineContentSpecs, - uploadToTmpFilesDotOrg_DEV_ONLY, + uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core"; import { BlockNoteDefaultUI, BlockNoteView, createReactInlineContentSpec, DefaultPositionedSuggestionMenu, - SuggestionMenuItemProps, + filterSuggestionItems, useBlockNote, } from "@blocknote/react"; import "@blocknote/react/style.css"; @@ -49,39 +46,17 @@ const customInlineContentSchema = { } satisfies InlineContentSchema; async function getMentionMenuItems( - editor: BlockNoteEditor< - DefaultBlockSchema, - typeof customInlineContentSchema, - DefaultStyleSchema - >, - query: string, - closeMenu: () => void, - clearQuery: () => void -): Promise { + + query: string +) { const users = ["Steve", "Bob", "Joe", "Mike"]; - const items: SuggestionMenuItemProps[] = users.map((user) => ({ - name: user, - execute: () => { - closeMenu(); - clearQuery(); + const items = users.map((user) => ({ + title: user, - 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) + return filterSuggestionItems(items, query); ); } @@ -107,9 +82,15 @@ export function App() { - getMentionMenuItems(editor, query, closeMenu, clearQuery) - } + getItems={async (query) => getMentionMenuItems(query)} + onItemClick={(item) => { + editor._tiptapEditor.commands.insertContent({ + type: "mention", + attrs: { + user: item.title, + }, + }); + }} /> ); diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx new file mode 100644 index 0000000000..014090e7b9 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx @@ -0,0 +1,7 @@ +export type DefaultSuggestionItem = { + title: string; + group?: string; + subtext?: string; + icon?: JSX.Element; + badge?: string; +}; diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx index 2212b81bd1..2b2ffb264f 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx @@ -1,115 +1,138 @@ -import { FC, useMemo } from "react"; import { BlockNoteEditor, BlockSchema, InlineContentSchema, StyleSchema, } from "@blocknote/core"; +import { FC, useCallback } from "react"; -import { SuggestionMenuItemProps } from "./MantineSuggestionMenuItem"; -import { useLoadSuggestionMenuItems } from "./hooks/useLoadSuggestionMenuItems"; -import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems"; -import { useSuggestionMenuKeyboardNavigation } from "./hooks/useSuggestionMenuKeyboardNavigation"; import { useSuggestionMenu } from "../../hooks/useSuggestionMenu"; -import { defaultGetItems } from "./defaultGetItems"; +import { DefaultSuggestionItem } from "./DefaultSuggestionItem"; import { MantineSuggestionMenu, SuggestionMenuProps, } from "./MantineSuggestionMenu"; +import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems"; +import { useLoadSuggestionMenuItems } from "./hooks/useLoadSuggestionMenuItems"; +import { useSuggestionMenuKeyboardNavigation } from "./hooks/useSuggestionMenuKeyboardNavigation"; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +type ItemType Promise> = + ArrayElement>>; export function DefaultPositionedSuggestionMenu< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, - Item extends { - name: string; - execute: () => void; - } = SuggestionMenuItemProps ->(props: { - editor: BlockNoteEditor; - triggerCharacter?: string; - getItems?: ( - query: string, - closeMenu: () => void, - clearQuery: () => void - ) => Promise; - suggestionMenuComponent?: FC>; -}) { - const { editor, triggerCharacter, getItems, suggestionMenuComponent } = props; + // This is a bit hacky, but only way I found to make types work so the optionality + // of suggestionMenuComponent depends on the return type of getItems + GetItemsType extends (query: string) => Promise +>( + props: { + editor: BlockNoteEditor; + triggerCharacter?: string; + getItems: GetItemsType; + onItemClick?: (item: ItemType) => void; + } & (ArrayElement< + Awaited> + > extends DefaultSuggestionItem + ? { + // can be undefined + suggestionMenuComponent?: FC< + SuggestionMenuProps> + >; + } + : { + // getItems doesn't return DefaultSuggestionItem, so suggestionMenuComponent is required + suggestionMenuComponent: FC< + SuggestionMenuProps> + >; + }) +) { + const { + editor, + triggerCharacter, + onItemClick, + getItems, + suggestionMenuComponent, + } = props; const { isMounted, suggestionMenuProps, positionerProps } = useSuggestionMenu( editor, triggerCharacter || "/" ); + const clickHandler = useCallback( + (item: ItemType) => { + suggestionMenuProps.closeMenu(); + suggestionMenuProps.clearQuery(); + onItemClick?.(item); + }, + [onItemClick, suggestionMenuProps] + ); + if (!isMounted) { return null; } - return ( -
- -
- ); + if (suggestionMenuComponent) { + return ( +
+ +
+ ); + } else { + return ( +
+ +
+ ); + } } -// TODO: The reason these 2 components are split is because the hooks in -// `DefaultSuggestionMenu` assume that the menu is open. Therefore, they should -// only be run when the menu is open (you cannot conditionally run hooks). -// We could add a `show` param to each hook, but I feel like that is less -// "React-ish" than splitting the components. I think that it might be an issue -// that the menu plugins currently send both position and state data in the -// same update, as we can't separate `useSuggestionMenu` into 2 hooks, one for -// state and one for position. -// TODO: Make renderItems accept any item type if getItems also returns an -// arbitrary data type. export function DefaultSuggestionMenu< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, - Item extends { - name: string; - execute: () => void; - } = SuggestionMenuItemProps + Item >(props: { editor: BlockNoteEditor; query: string; closeMenu: () => void; clearQuery: () => void; - getItems?: ( - query: string, - closeMenu: () => void, - clearQuery: () => void - ) => Promise; - suggestionMenuComponent?: FC>; + getItems: (query: string) => Promise; + onItemClick?: (item: Item) => void; + suggestionMenuComponent: FC>; }) { const { editor, query, closeMenu, - clearQuery, getItems, + onItemClick, suggestionMenuComponent, } = props; - const getItemsForLoading = useMemo<(query: string) => Promise>( - () => (query: string) => - getItems !== undefined - ? getItems(query, closeMenu, clearQuery) - : (defaultGetItems(editor, query, closeMenu, clearQuery) as Promise< - Item[] - >), - [clearQuery, closeMenu, editor, getItems] - ); - - const { items, usedQuery, loadingState } = useLoadSuggestionMenuItems( + const { items, usedQuery, loadingState } = useLoadSuggestionMenuItems( query, - getItemsForLoading + getItems ); useCloseSuggestionMenuNoItems(items, usedQuery, closeMenu); @@ -117,15 +140,15 @@ export function DefaultSuggestionMenu< const selectedIndex = useSuggestionMenuKeyboardNavigation( editor, items, - closeMenu + closeMenu, + onItemClick ); - const SuggestionMenu: FC> = - suggestionMenuComponent || MantineSuggestionMenu; - + const Comp = suggestionMenuComponent; return ( - diff --git a/packages/react/src/components/SuggestionMenu/MantineSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/MantineSuggestionMenu.tsx index 434c587948..ebd6f2eadd 100644 --- a/packages/react/src/components/SuggestionMenu/MantineSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/MantineSuggestionMenu.tsx @@ -1,20 +1,19 @@ import { Loader, Menu } from "@mantine/core"; import { Children, useMemo } from "react"; -import { - MantineSuggestionMenuItem, - SuggestionMenuItemProps, -} from "./MantineSuggestionMenuItem"; +import { DefaultSuggestionItem } from "./DefaultSuggestionItem"; +import { MantineSuggestionMenuItem } from "./MantineSuggestionMenuItem"; export type SuggestionMenuProps = { items: T[]; loadingState: "loading-initial" | "loading" | "loaded"; selectedIndex: number; + onItemClick?: (item: T) => void; }; -export function MantineSuggestionMenu( - props: SuggestionMenuProps +export function MantineSuggestionMenu( + props: SuggestionMenuProps ) { - const { items, loadingState, selectedIndex } = props; + const { items, loadingState, selectedIndex, onItemClick } = props; const loader = loadingState === "loading-initial" || loadingState === "loading" ? ( @@ -26,8 +25,9 @@ export function MantineSuggestionMenu( const renderedItems = []; for (let i = 0; i < items.length; i++) { - if (items[i].group !== currentGroup) { - currentGroup = items[i].group; + const item = items[i]; + if (item.group !== currentGroup) { + currentGroup = item.group; renderedItems.push( {currentGroup} ); @@ -35,15 +35,16 @@ export function MantineSuggestionMenu( renderedItems.push( onItemClick?.(item)} /> ); } return renderedItems; - }, [items, selectedIndex]); + }, [items, selectedIndex, onItemClick]); return ( void; +export function MantineSuggestionMenuItem(props: { + title: string; + onClick: () => void; subtext?: string; icon?: JSX.Element; badge?: string; isSelected?: boolean; - - aliases?: string[]; - group?: string; -}; - -export function MantineSuggestionMenuItem(props: SuggestionMenuItemProps) { +}) { const itemRef = useRef(null); function isSelected() { @@ -55,7 +50,7 @@ export function MantineSuggestionMenuItem(props: SuggestionMenuItemProps) { return ( { @@ -69,7 +64,7 @@ export function MantineSuggestionMenuItem(props: SuggestionMenuItemProps) { {/*Might need separate classes.*/} - {props.name} + {props.title} {props.subtext} diff --git a/packages/react/src/components/SuggestionMenu/defaultGetItems.tsx b/packages/react/src/components/SuggestionMenu/defaultGetItems.tsx index 3471ad79f8..cf7a0ecf9a 100644 --- a/packages/react/src/components/SuggestionMenu/defaultGetItems.tsx +++ b/packages/react/src/components/SuggestionMenu/defaultGetItems.tsx @@ -2,9 +2,6 @@ import { Block, BlockNoteEditor, BlockSchema, - DefaultBlockSchema, - DefaultInlineContentSchema, - DefaultStyleSchema, formatKeyboardShortcut, imageToolbarPluginKey, InlineContentSchema, @@ -23,8 +20,6 @@ import { RiText, } from "react-icons/ri"; -import { SuggestionMenuItemProps } from "./MantineSuggestionMenuItem"; - // 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 // paragraph, so this function won't try to set the cursor position past the @@ -92,27 +87,15 @@ export function insertOrUpdateBlock< // can just be plugged as props into SuggestionMenuLabel and SuggestionMenuItem // components, but the labels make the keyboard selection code more complex. // TODO: Also probably want an easier way of customizing the items list. -export async function defaultGetItems< - BSchema extends BlockSchema = DefaultBlockSchema, - I extends InlineContentSchema = DefaultInlineContentSchema, - S extends StyleSchema = DefaultStyleSchema ->( - editor: BlockNoteEditor, - query: string, - closeMenu: () => void, - clearQuery: () => void -): Promise { - const items: SuggestionMenuItemProps[] = [ +export function getDefaultReactSlashMenuItems() { + const items = [ { - name: "Heading 1", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Heading 1", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 1 }, - } as PartialBlock); + }); }, subtext: "Used for a top-level heading", icon: , @@ -121,15 +104,12 @@ export async function defaultGetItems< group: "Headings", }, { - name: "Heading 2", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Heading 2", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 2 }, - } as PartialBlock); + }); }, subtext: "Used for key sections", icon: , @@ -138,15 +118,12 @@ export async function defaultGetItems< group: "Headings", }, { - name: "Heading 3", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Heading 3", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 3 }, - } as PartialBlock); + }); }, subtext: "Used for subsections and group headings", icon: , @@ -155,11 +132,8 @@ export async function defaultGetItems< group: "Headings", }, { - name: "Numbered List", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Numbered List", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "numberedListItem" }); }, subtext: "Used to display a numbered list", @@ -169,11 +143,8 @@ export async function defaultGetItems< group: "Basic blocks", }, { - name: "Bullet List", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Bullet List", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "bulletListItem" }); }, subtext: "Used to display an unordered list", @@ -183,11 +154,8 @@ export async function defaultGetItems< group: "Basic blocks", }, { - name: "Paragraph", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Paragraph", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "paragraph" }); }, subtext: "Used for the body of your document", @@ -197,11 +165,8 @@ export async function defaultGetItems< group: "Basic blocks", }, { - name: "Table", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Table", + onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "table", content: { @@ -215,19 +180,17 @@ export async function defaultGetItems< }, ], }, - } as PartialBlock); + }); }, subtext: "Used for for tables", icon: , aliases: ["table"], group: "Advanced", + badge: undefined, }, { - name: "Image", - execute: () => { - closeMenu(); - clearQuery(); - + title: "Image", + onItemClick: (editor: BlockNoteEditor) => { const insertedBlock = insertOrUpdateBlock(editor, { type: "image", }); @@ -253,8 +216,8 @@ export async function defaultGetItems< "dropbox", ], group: "Media", - } satisfies SuggestionMenuItemProps, - ]; + }, + ]; // satisfies (DefaultSuggestionItem & { onItemClick: any; aliases: any })[]; // For testing async // return new Promise((resolve) => { @@ -273,9 +236,15 @@ export async function defaultGetItems< // }, 1000); // }); + return items; +} + +export function filterSuggestionItems< + T extends { title: string; aliases?: string[] } +>(items: T[], query: string) { return items.filter( - ({ name, aliases }) => - name.toLowerCase().startsWith(query.toLowerCase()) || + ({ title, aliases }) => + title.toLowerCase().startsWith(query.toLowerCase()) || (aliases && aliases.filter((alias) => alias.toLowerCase().startsWith(query.toLowerCase()) diff --git a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts index d8cae06427..1de4f7348c 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems.ts @@ -1,15 +1,9 @@ import { useEffect, useRef } from "react"; -import { SuggestionMenuItemProps } from "../MantineSuggestionMenuItem"; // Hook which closes the suggestion after a certain number of consecutive // invalid queries are made. An invalid query is one which returns no items, and // each invalid query must be longer than the previous one to close the menu -export function useCloseSuggestionMenuNoItems< - Item extends { - name: string; - execute: () => void; - } = SuggestionMenuItemProps ->( +export function useCloseSuggestionMenuNoItems( items: Item[], usedQuery: string | undefined, closeMenu: () => void, diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index edef4b737c..36d020f97d 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -1,19 +1,14 @@ import { BlockNoteEditor } from "@blocknote/core"; import { useEffect, useState } from "react"; -import { SuggestionMenuItemProps } from "../MantineSuggestionMenuItem"; // Hook which handles keyboard navigation of a suggestion menu. Arrow keys are // used to select a menu item, enter to execute it, and escape to close the // menu. -export function useSuggestionMenuKeyboardNavigation< - Item extends { - name: string; - execute: () => void; - } = SuggestionMenuItemProps ->( +export function useSuggestionMenuKeyboardNavigation( editor: BlockNoteEditor, items: Item[], - closeMenu: () => void + closeMenu: () => void, + onItemClick?: (item: Item) => void ) { const [selectedIndex, setSelectedIndex] = useState(0); @@ -43,7 +38,7 @@ export function useSuggestionMenuKeyboardNavigation< event.preventDefault(); if (items.length) { - items[selectedIndex].execute(); + onItemClick?.(items[selectedIndex]); } return true; @@ -73,7 +68,7 @@ export function useSuggestionMenuKeyboardNavigation< true ); }; - }, [closeMenu, editor.domElement, items, selectedIndex]); + }, [closeMenu, editor.domElement, items, selectedIndex, onItemClick]); return selectedIndex; } diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 65f8d1a9e9..f1745f32d8 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,15 +1,19 @@ -import { FormattingToolbarPositioner } from "../components/FormattingToolbar/FormattingToolbarPositioner"; -import { HyperlinkToolbarPositioner } from "../components/HyperlinkToolbar/HyperlinkToolbarPositioner"; -import { DefaultPositionedSuggestionMenu } from "../components/SuggestionMenu/DefaultSuggestionMenu"; -import { SideMenuPositioner } from "../components/SideMenu/SideMenuPositioner"; -import { ImageToolbarPositioner } from "../components/ImageToolbar/ImageToolbarPositioner"; -import { TableHandlesPositioner } from "../components/TableHandles/TableHandlePositioner"; import { BlockNoteEditor, BlockSchema, InlineContentSchema, StyleSchema, } from "@blocknote/core"; +import { FormattingToolbarPositioner } from "../components/FormattingToolbar/FormattingToolbarPositioner"; +import { HyperlinkToolbarPositioner } from "../components/HyperlinkToolbar/HyperlinkToolbarPositioner"; +import { ImageToolbarPositioner } from "../components/ImageToolbar/ImageToolbarPositioner"; +import { SideMenuPositioner } from "../components/SideMenu/SideMenuPositioner"; +import { DefaultPositionedSuggestionMenu } from "../components/SuggestionMenu/DefaultSuggestionMenu"; +import { + filterSuggestionItems, + getDefaultReactSlashMenuItems, +} from "../components/SuggestionMenu/defaultGetItems"; +import { TableHandlesPositioner } from "../components/TableHandles/TableHandlePositioner"; export function BlockNoteDefaultUI< BSchema extends BlockSchema, @@ -33,7 +37,16 @@ export function BlockNoteDefaultUI< )} {props.slashMenu !== false && ( - + + filterSuggestionItems(getDefaultReactSlashMenuItems(), query) + } + // suggestionMenuComponent={MantineSuggestionMenu} + onItemClick={(item) => { + item.onItemClick(props.editor); + }} + /> )} {props.sideMenu !== false && } {props.imageToolbar !== false && ( diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ed447edefe..ce85ef0bb2 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -101,9 +101,9 @@ export function BlockContentWrapper< // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function createReactBlockSpec< - T extends CustomBlockConfig, - I extends InlineContentSchema, - S extends StyleSchema + const T extends CustomBlockConfig, + const I extends InlineContentSchema, + const S extends StyleSchema >( blockConfig: T, blockImplementation: ReactCustomBlockImplementation diff --git a/tests/src/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx index fc533a1e1a..293eb9b540 100644 --- a/tests/src/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -1,74 +1,40 @@ -import { - BlockNoteEditor, - BlockSchema, - defaultBlockSpecs, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteDefaultUI, BlockNoteView, - defaultGetItems, DefaultPositionedSuggestionMenu, + filterSuggestionItems, + getDefaultReactSlashMenuItems, useBlockNote, } from "@blocknote/react"; import { Alert, insertAlert } from "../customblocks/Alert"; -import { Button, insertButton } from "../customblocks/Button"; -import { Embed, insertEmbed } from "../customblocks/Embed"; -import { Image, insertImage } from "../customblocks/Image"; -import { Separator, insertSeparator } from "../customblocks/Separator"; +import { Button } from "../customblocks/Button"; import styles from "./Editor.module.css"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; const blockSpecs = { - ...defaultBlockSpecs, + // ...defaultBlockSpecs, alert: Alert, button: Button, - embed: Embed, - image: Image, - separator: Separator, + // embed: Embed, + // image: Image, + // separator: Separator, // toc: TableOfContents, }; -const getSlashMenuItems = async < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - query: string, - closeMenu: () => void, - clearQuery: () => void -) => { - const defaultItems = await defaultGetItems( - editor, - query, - closeMenu, - clearQuery - ); +const defaultItems = getDefaultReactSlashMenuItems(); - const customItems = [ - insertAlert, - insertButton, - insertEmbed, - insertImage, - insertSeparator, - // insertTableOfContents, - ] - .map((getItem) => getItem(editor, closeMenu, clearQuery)) - .filter( - ({ name, aliases }) => - name.toLowerCase().startsWith(query.toLowerCase()) || - (aliases && - aliases.filter((alias) => - alias.toLowerCase().startsWith(query.toLowerCase()) - ).length !== 0) - ); +const customItems = [ + insertAlert, + // insertButton, + // insertEmbed, + // insertImage, + // insertSeparator, + // insertTableOfContents, +]; - return [...defaultItems, ...customItems]; -}; +const allItems = [...defaultItems, ...customItems]; export default function Editor() { const editor = useBlockNote({ @@ -82,15 +48,17 @@ export default function Editor() { // Give tests a way to get prosemirror instance (window as WindowWithProseMirror).ProseMirror = editor?._tiptapEditor; - + // editor.insertBlocks([{ + // type:"" + // }]) return ( - getSlashMenuItems(editor, query, closeMenu, clearQuery) - } + getItems={async (query) => filterSuggestionItems(allItems, query)} + onItemClick={(i) => i.onItemClick(editor)} + // suggestionMenuComponent={MantineSuggestionMenu} /> ); diff --git a/tests/src/utils/customblocks/Alert.tsx b/tests/src/utils/customblocks/Alert.tsx index 083ad4e5d3..fcf4608c65 100644 --- a/tests/src/utils/customblocks/Alert.tsx +++ b/tests/src/utils/customblocks/Alert.tsx @@ -1,13 +1,12 @@ import { BlockNoteEditor, - BlockSchema, + BlockSchemaWithBlock, + PartialBlock, createBlockSpec, defaultProps, - InlineContentSchema, - StyleSchema, } from "@blocknote/core"; + import { RiAlertFill } from "react-icons/ri"; -import { SuggestionMenuItemProps } from "@blocknote/react"; const values = { warning: { icon: "⚠️", @@ -127,41 +126,29 @@ export const Alert = createBlockSpec( } ); -export const insertAlert = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert Alert", - execute: () => { - closeMenu(); - clearQuery(); +export const insertAlert = { + title: "Insert Alert", + onItemClick: (editor: BlockNoteEditor) => { + const block: PartialBlock< + BlockSchemaWithBlock<"alert", (typeof Alert)["config"]>, + any, + any + > = { + type: "alert", + }; - editor.insertBlocks( - [ - { - type: "alert", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert an alert block to emphasize text", - icon: , - aliases: [ - "alert", - "notification", - "emphasize", - "warning", - "error", - "info", - "success", - ], - group: "Other", - } satisfies SuggestionMenuItemProps); + editor.insertBlocks([block], editor.getTextCursorPosition().block, "after"); + }, + subtext: "Insert an alert block to emphasize text", + icon: , + aliases: [ + "alert", + "notification", + "emphasize", + "warning", + "error", + "info", + "success", + ], + group: "Other", +}; diff --git a/tests/src/utils/customblocks/Button.tsx b/tests/src/utils/customblocks/Button.tsx index 97b9f65e28..e5212ac133 100644 --- a/tests/src/utils/customblocks/Button.tsx +++ b/tests/src/utils/customblocks/Button.tsx @@ -1,12 +1,9 @@ import { BlockNoteEditor, - BlockSchema, createBlockSpec, defaultProps, - InlineContentSchema, - StyleSchema, } from "@blocknote/core"; -import { SuggestionMenuItemProps } from "@blocknote/react"; + import { RiRadioButtonFill } from "react-icons/ri"; export const Button = createBlockSpec( @@ -41,33 +38,21 @@ export const Button = createBlockSpec( } ); -export const insertButton = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert Button", - execute: () => { - closeMenu(); - clearQuery(); - - editor.insertBlocks( - [ - { - type: "button", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert a button which inserts a block below it", - icon: , - aliases: ["button", "click", "action"], - group: "Other", - } satisfies SuggestionMenuItemProps); +export const insertButton = { + title: "Insert Button", + onItemClick: (editor: BlockNoteEditor) => { + editor.insertBlocks( + [ + { + type: "button", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + subtext: "Insert a button which inserts a block below it", + icon: , + aliases: ["button", "click", "action"], + group: "Other", +}; diff --git a/tests/src/utils/customblocks/Embed.tsx b/tests/src/utils/customblocks/Embed.tsx index 1d9507c9d7..e6fefd2268 100644 --- a/tests/src/utils/customblocks/Embed.tsx +++ b/tests/src/utils/customblocks/Embed.tsx @@ -1,12 +1,5 @@ -import { - BlockNoteEditor, - BlockSchema, - createBlockSpec, - InlineContentSchema, - PartialBlock, - StyleSchema, -} from "@blocknote/core"; -import { SuggestionMenuItemProps } from "@blocknote/react"; +import { createBlockSpec } from "@blocknote/core"; + import { RiLayout5Fill } from "react-icons/ri"; export const Embed = createBlockSpec( @@ -35,37 +28,25 @@ export const Embed = createBlockSpec( } ); -export const insertEmbed = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert Embedded Website", - execute: () => { - closeMenu(); - clearQuery(); - - const src = prompt("Enter website URL"); - editor.insertBlocks( - [ - { - type: "embed", - props: { - src: src || "https://www.youtube.com/embed/wjfuB8Xjhc4", - }, - } as PartialBlock, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert an embedded website", - icon: , - aliases: ["embedded", "website", "site", "link", "url"], - group: "Other", - } satisfies SuggestionMenuItemProps); +export const insertEmbed = { + title: "Insert Embedded Website", + onItemClick: (editor: BlockNoteEditor) => { + const src = prompt("Enter website URL"); + editor.insertBlocks( + [ + { + type: "embed", + props: { + src: src || "https://www.youtube.com/embed/wjfuB8Xjhc4", + }, + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + subtext: "Insert an embedded website", + icon: , + aliases: ["embedded", "website", "site", "link", "url"], + group: "Other", +}; diff --git a/tests/src/utils/customblocks/Image.tsx b/tests/src/utils/customblocks/Image.tsx index be8dfed8ef..21817a6400 100644 --- a/tests/src/utils/customblocks/Image.tsx +++ b/tests/src/utils/customblocks/Image.tsx @@ -1,13 +1,9 @@ import { BlockNoteEditor, - BlockSchema, createBlockSpec, defaultProps, - InlineContentSchema, - PartialBlock, - StyleSchema, } from "@blocknote/core"; -import { SuggestionMenuItemProps } from "@blocknote/react"; + import { RiImage2Fill } from "react-icons/ri"; export const Image = createBlockSpec( { @@ -53,38 +49,25 @@ export const Image = createBlockSpec( } ); -export const insertImage = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert Image", - execute: () => { - closeMenu(); - clearQuery(); - - const src = - prompt("Enter image URL") || "https://via.placeholder.com/1000"; - editor.insertBlocks( - [ - { - type: "image", - props: { - src, - }, - } as PartialBlock, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert an image", - icon: , - aliases: ["image", "img", "picture", "media"], - group: "Other", - } satisfies SuggestionMenuItemProps); +export const insertImage = { + title: "Insert Image", + onItemClick: (editor: BlockNoteEditor) => { + const src = prompt("Enter image URL") || "https://via.placeholder.com/1000"; + editor.insertBlocks( + [ + { + type: "image", + props: { + src, + }, + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + subtext: "Insert an image", + icon: , + aliases: ["image", "img", "picture", "media"], + group: "Other", +}; diff --git a/tests/src/utils/customblocks/ReactAlert.tsx b/tests/src/utils/customblocks/ReactAlert.tsx index f50739c5db..d2d3175cb4 100644 --- a/tests/src/utils/customblocks/ReactAlert.tsx +++ b/tests/src/utils/customblocks/ReactAlert.tsx @@ -1,14 +1,5 @@ -import { - BlockNoteEditor, - BlockSchema, - defaultProps, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; -import { - createReactBlockSpec, - SuggestionMenuItemProps, -} from "@blocknote/react"; +import { BlockNoteEditor, defaultProps } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; import { useEffect, useState } from "react"; import { RiAlertFill } from "react-icons/ri"; @@ -122,44 +113,32 @@ export const ReactAlert = createReactBlockSpec( }, } ); -export const insertReactAlert = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert React Alert", - execute: () => { - closeMenu(); - clearQuery(); - - editor.insertBlocks( - [ - { - type: "reactAlert", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert an alert block to emphasize text", - icon: , - aliases: [ - "react", - "reactAlert", - "react alert", - "alert", - "notification", - "emphasize", - "warning", - "error", - "info", - "success", - ], - group: "Other", - } satisfies SuggestionMenuItemProps); +export const insertReactAlert = { + title: "Insert React Alert", + onItemClick: (editor: BlockNoteEditor) => { + editor.insertBlocks( + [ + { + type: "reactAlert", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + subtext: "Insert an alert block to emphasize text", + icon: , + aliases: [ + "react", + "reactAlert", + "react alert", + "alert", + "notification", + "emphasize", + "warning", + "error", + "info", + "success", + ], + group: "Other", +}; diff --git a/tests/src/utils/customblocks/ReactImage.tsx b/tests/src/utils/customblocks/ReactImage.tsx index ed6753bc02..afd1d0facb 100644 --- a/tests/src/utils/customblocks/ReactImage.tsx +++ b/tests/src/utils/customblocks/ReactImage.tsx @@ -1,15 +1,5 @@ -import { - BlockNoteEditor, - BlockSchema, - defaultProps, - InlineContentSchema, - PartialBlock, - StyleSchema, -} from "@blocknote/core"; -import { - createReactBlockSpec, - SuggestionMenuItemProps, -} from "@blocknote/react"; +import { BlockNoteEditor, defaultProps } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; import { RiImage2Fill } from "react-icons/ri"; export const ReactImage = createReactBlockSpec( @@ -46,46 +36,33 @@ export const ReactImage = createReactBlockSpec( } ); -export const insertReactImage = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert React Image", - execute: () => { - closeMenu(); - clearQuery(); - - const src = - prompt("Enter image URL") || "https://via.placeholder.com/1000"; - editor.insertBlocks( - [ - { - type: "reactImage", - props: { - src, - }, - } as PartialBlock, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert an image", - icon: , - aliases: [ - "react", - "reactImage", - "react image", - "image", - "img", - "picture", - "media", - ], - group: "Media", - } satisfies SuggestionMenuItemProps); +export const insertReactImage = { + title: "Insert React Image", + onItemClick: (editor: BlockNoteEditor) => { + const src = prompt("Enter image URL") || "https://via.placeholder.com/1000"; + editor.insertBlocks( + [ + { + type: "reactImage", + props: { + src, + }, + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + subtext: "Insert an image", + icon: , + aliases: [ + "react", + "reactImage", + "react image", + "image", + "img", + "picture", + "media", + ], + group: "Media", +}; diff --git a/tests/src/utils/customblocks/Separator.tsx b/tests/src/utils/customblocks/Separator.tsx index aaf057377b..d1936abcd0 100644 --- a/tests/src/utils/customblocks/Separator.tsx +++ b/tests/src/utils/customblocks/Separator.tsx @@ -1,11 +1,5 @@ -import { - BlockNoteEditor, - BlockSchema, - createBlockSpec, - InlineContentSchema, - StyleSchema, -} from "@blocknote/core"; -import { SuggestionMenuItemProps } from "@blocknote/react"; +import { BlockNoteEditor, createBlockSpec } from "@blocknote/core"; + import { RiSeparator } from "react-icons/ri"; export const Separator = createBlockSpec( @@ -36,33 +30,21 @@ export const Separator = createBlockSpec( } ); -export const insertSeparator = < - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - closeMenu: () => void, - clearQuery: () => void -) => - ({ - name: "Insert Separator", - execute: () => { - closeMenu(); - clearQuery(); - - editor.insertBlocks( - [ - { - type: "separator", - }, - ], - editor.getTextCursorPosition().block, - "after" - ); - }, - subtext: "Insert a button which inserts a block below it", - icon: , - aliases: ["separator", "horizontal", "line", "rule"], - group: "Other", - } satisfies SuggestionMenuItemProps); +export const insertSeparator = { + title: "Insert Separator", + onItemClick: (editor: BlockNoteEditor) => { + editor.insertBlocks( + [ + { + type: "separator", + }, + ], + editor.getTextCursorPosition().block, + "after" + ); + }, + subtext: "Insert a button which inserts a block below it", + icon: , + aliases: ["separator", "horizontal", "line", "rule"], + group: "Other", +}; From 55ad4c02734bb259d820314cf650e2c97bedbf73 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 14 Feb 2024 22:03:21 +0100 Subject: [PATCH 2/8] misc --- .../SuggestionMenu/DefaultSuggestionItem.tsx | 4 ++ .../SuggestionMenu/DefaultSuggestionMenu.tsx | 44 +++++++------------ ...ems.tsx => defaultReactSlashMenuItems.tsx} | 32 +++++++++----- .../react/src/editor/BlockNoteDefaultUI.tsx | 2 +- packages/react/src/index.ts | 4 +- 5 files changed, 42 insertions(+), 44 deletions(-) rename packages/react/src/components/SuggestionMenu/{defaultGetItems.tsx => defaultReactSlashMenuItems.tsx} (87%) diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx index 014090e7b9..61ea83e5ee 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionItem.tsx @@ -1,3 +1,7 @@ +/** + * Although any arbitrary data can be passed as suggestion items, the built-in + * UI components such as `MantineSuggestionMenu` expect a shape that conforms to DefaultSuggestionItem + */ export type DefaultSuggestionItem = { title: string; group?: string; diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx index 2b2ffb264f..f91d4d3240 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx @@ -76,35 +76,21 @@ export function DefaultPositionedSuggestionMenu< return null; } - if (suggestionMenuComponent) { - return ( -
- -
- ); - } else { - return ( -
- -
- ); - } + return ( +
+ +
+ ); } export function DefaultSuggestionMenu< diff --git a/packages/react/src/components/SuggestionMenu/defaultGetItems.tsx b/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx similarity index 87% rename from packages/react/src/components/SuggestionMenu/defaultGetItems.tsx rename to packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx index cf7a0ecf9a..09ebca9a92 100644 --- a/packages/react/src/components/SuggestionMenu/defaultGetItems.tsx +++ b/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx @@ -2,6 +2,7 @@ import { Block, BlockNoteEditor, BlockSchema, + DefaultBlockSchema, formatKeyboardShortcut, imageToolbarPluginKey, InlineContentSchema, @@ -24,6 +25,7 @@ import { // so either a block with inline content or a table. The last block is always a // paragraph, so this function won't try to set the cursor position past the // last block. +// TODO: move to core? function setSelectionToNextContentEditableBlock< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -46,6 +48,7 @@ function setSelectionToNextContentEditableBlock< // updates the current block instead of inserting a new one below. If the new // block doesn't contain editable content, the cursor is moved to the next block // that does. +// TODO: move to core? export function insertOrUpdateBlock< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -83,19 +86,18 @@ export function insertOrUpdateBlock< return insertedBlock; } -// TODO: Not sure on the return type of this. I think it's nice that the items -// can just be plugged as props into SuggestionMenuLabel and SuggestionMenuItem -// components, but the labels make the keyboard selection code more complex. -// TODO: Also probably want an easier way of customizing the items list. export function getDefaultReactSlashMenuItems() { const items = [ { title: "Heading 1", + // Unfortunately, we can't use a more specific BlockNoteEditor type here, + // Typescript seems to get in the way there + // This means that we don't have type checking for calling insertOrUpdateBlock etc. :( onItemClick: (editor: BlockNoteEditor) => { insertOrUpdateBlock(editor, { type: "heading", props: { level: 1 }, - }); + } satisfies PartialBlock); }, subtext: "Used for a top-level heading", icon: , @@ -109,7 +111,7 @@ export function getDefaultReactSlashMenuItems() { insertOrUpdateBlock(editor, { type: "heading", props: { level: 2 }, - }); + } satisfies PartialBlock); }, subtext: "Used for key sections", icon: , @@ -123,7 +125,7 @@ export function getDefaultReactSlashMenuItems() { insertOrUpdateBlock(editor, { type: "heading", props: { level: 3 }, - }); + } satisfies PartialBlock); }, subtext: "Used for subsections and group headings", icon: , @@ -134,7 +136,9 @@ export function getDefaultReactSlashMenuItems() { { title: "Numbered List", onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { type: "numberedListItem" }); + insertOrUpdateBlock(editor, { + type: "numberedListItem", + } satisfies PartialBlock); }, subtext: "Used to display a numbered list", icon: , @@ -145,7 +149,9 @@ export function getDefaultReactSlashMenuItems() { { title: "Bullet List", onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { type: "bulletListItem" }); + insertOrUpdateBlock(editor, { + type: "bulletListItem", + } satisfies PartialBlock); }, subtext: "Used to display an unordered list", icon: , @@ -156,7 +162,9 @@ export function getDefaultReactSlashMenuItems() { { title: "Paragraph", onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { type: "paragraph" }); + insertOrUpdateBlock(editor, { + type: "paragraph", + } satisfies PartialBlock); }, subtext: "Used for the body of your document", icon: , @@ -180,7 +188,7 @@ export function getDefaultReactSlashMenuItems() { }, ], }, - }); + } satisfies PartialBlock); }, subtext: "Used for for tables", icon: , @@ -193,7 +201,7 @@ export function getDefaultReactSlashMenuItems() { onItemClick: (editor: BlockNoteEditor) => { const insertedBlock = insertOrUpdateBlock(editor, { type: "image", - }); + } satisfies PartialBlock); // Immediately open the image toolbar editor._tiptapEditor.view.dispatch( diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index f1745f32d8..4f60747545 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -12,7 +12,7 @@ import { DefaultPositionedSuggestionMenu } from "../components/SuggestionMenu/De import { filterSuggestionItems, getDefaultReactSlashMenuItems, -} from "../components/SuggestionMenu/defaultGetItems"; +} from "../components/SuggestionMenu/defaultReactSlashMenuItems"; import { TableHandlesPositioner } from "../components/TableHandles/TableHandlePositioner"; export function BlockNoteDefaultUI< diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d0e698c136..c8cf0be1a1 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -30,10 +30,10 @@ export * from "./components/SideMenu/DragHandleMenu/DefaultDragHandleMenu"; export * from "./components/SideMenu/DragHandleMenu/DragHandleMenu"; export * from "./components/SideMenu/DragHandleMenu/DragHandleMenuItem"; +export * from "./components/SuggestionMenu/DefaultSuggestionMenu"; export * from "./components/SuggestionMenu/MantineSuggestionMenu"; export * from "./components/SuggestionMenu/MantineSuggestionMenuItem"; -export * from "./components/SuggestionMenu/DefaultSuggestionMenu"; -export * from "./components/SuggestionMenu/defaultGetItems"; +export * from "./components/SuggestionMenu/defaultReactSlashMenuItems"; export * from "./components/SuggestionMenu/hooks/useCloseSuggestionMenuNoItems"; export * from "./components/SuggestionMenu/hooks/useLoadSuggestionMenuItems"; export * from "./components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation"; From ce78cc61ee505ef3079d69cff3ab7fc661b715f1 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 14 Feb 2024 22:11:35 +0100 Subject: [PATCH 3/8] test --- .../DefaultSuggestionMenu.test.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx new file mode 100644 index 0000000000..493f55d466 --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { it } from "vitest"; +import { DefaultPositionedSuggestionMenu } from "./DefaultSuggestionMenu"; + +it("has good typing", () => { + let menu = ( + // @ts-expect-error + [{ name: "hello" }]} + editor={undefined as any} + /> + ); + + // valid, because getItems returns DefaultSuggestionItem so suggestionMenuComponent is optional + menu = ( + [{ title: "hello" }]} + editor={undefined as any} + /> + ); + + // validate type of onItemClick + menu = ( + [{ hello: "hello" }]} + editor={undefined as any} + onItemClick={(item) => { + console.log(item.hello); + }} + /> + ); +}); From 9d4dcbd774c757f512879d6febc5128b8fee43ec Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 14 Feb 2024 22:12:20 +0100 Subject: [PATCH 4/8] tests --- .../src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx index 493f55d466..7b34f18d1a 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx @@ -3,6 +3,7 @@ import { it } from "vitest"; import { DefaultPositionedSuggestionMenu } from "./DefaultSuggestionMenu"; it("has good typing", () => { + // invalid, because DefaultSuggestionItem doesn't have a title property, so the default MantineSuggestionMenu doesn't wrok let menu = ( // @ts-expect-error Date: Wed, 14 Feb 2024 22:23:11 +0100 Subject: [PATCH 5/8] small fixes --- examples/editor/examples/basic/App.tsx | 14 ++------------ .../SuggestionMenu/DefaultSuggestionMenu.test.tsx | 3 +++ tests/src/utils/customblocks/Embed.tsx | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/examples/editor/examples/basic/App.tsx b/examples/editor/examples/basic/App.tsx index c57890df87..6d7c0d1ba3 100644 --- a/examples/editor/examples/basic/App.tsx +++ b/examples/editor/examples/basic/App.tsx @@ -1,9 +1,7 @@ import { - defaultInlineContentSchema, defaultInlineContentSpecs, - InlineContentSchema, InlineContentSpecs, - uploadToTmpFilesDotOrg_DEV_ONLY + uploadToTmpFilesDotOrg_DEV_ONLY, } from "@blocknote/core"; import { BlockNoteDefaultUI, @@ -40,15 +38,8 @@ const customInlineContentSpecs = { ...defaultInlineContentSpecs, mention: MentionInlineContent, } satisfies InlineContentSpecs; -const customInlineContentSchema = { - ...defaultInlineContentSchema, - mention: MentionInlineContent.config, -} satisfies InlineContentSchema; -async function getMentionMenuItems( - - query: string -) { +async function getMentionMenuItems(query: string) { const users = ["Steve", "Bob", "Joe", "Mike"]; const items = users.map((user) => ({ title: user, @@ -57,7 +48,6 @@ async function getMentionMenuItems( })); return filterSuggestionItems(items, query); - ); } export function App() { diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx index 7b34f18d1a..09ec279e78 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx @@ -31,4 +31,7 @@ it("has good typing", () => { }} /> ); + + // prevent typescript unused error + console.log("menu", menu); }); diff --git a/tests/src/utils/customblocks/Embed.tsx b/tests/src/utils/customblocks/Embed.tsx index e6fefd2268..5016237235 100644 --- a/tests/src/utils/customblocks/Embed.tsx +++ b/tests/src/utils/customblocks/Embed.tsx @@ -1,4 +1,4 @@ -import { createBlockSpec } from "@blocknote/core"; +import { BlockNoteEditor, createBlockSpec } from "@blocknote/core"; import { RiLayout5Fill } from "react-icons/ri"; From 7580ff947f7b58168b32578152f07b88faff123a Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 15 Feb 2024 14:45:57 +0100 Subject: [PATCH 6/8] Small naming fix --- .../DefaultPositionedFormattingToolbar.tsx | 4 ++-- .../DefaultPositionedHyperlinkToolbar.tsx | 4 ++-- .../DefaultPositionedImageToolbar.tsx | 4 ++-- .../SideMenu/DefaultPositionedSideMenu.tsx | 4 ++-- .../DefaultPositionedSuggestionMenu.tsx | 15 ++++++++------- ...tPositioning.ts => useUIElementPositioning.ts} | 2 +- 6 files changed, 17 insertions(+), 16 deletions(-) rename packages/react/src/hooks/{useUiElementPositioning.ts => useUIElementPositioning.ts} (96%) diff --git a/packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx index e673f50375..464b0ddf44 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultPositionedFormattingToolbar.tsx @@ -8,7 +8,7 @@ import { flip, offset } from "@floating-ui/react"; import { FC, useState } from "react"; import { useUIPluginState } from "../../hooks/useUIPluginState"; -import { useUiElementPositioning } from "../../hooks/useUiElementPositioning"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; import { useEditorChange } from "../../hooks/useEditorChange"; import { DefaultFormattingToolbar, @@ -67,7 +67,7 @@ export const DefaultPositionedFormattingToolbar = < const state = useUIPluginState( props.editor.formattingToolbar.onUpdate.bind(props.editor.formattingToolbar) ); - const { isMounted, ref, style } = useUiElementPositioning( + const { isMounted, ref, style } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 3000, diff --git a/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx b/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx index 63f62c782e..b6b59a7db4 100644 --- a/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx +++ b/packages/react/src/components/HyperlinkToolbar/DefaultPositionedHyperlinkToolbar.tsx @@ -11,7 +11,7 @@ import { flip, offset } from "@floating-ui/react"; import { FC } from "react"; import { useUIPluginState } from "../../hooks/useUIPluginState"; -import { useUiElementPositioning } from "../../hooks/useUiElementPositioning"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; import { DefaultHyperlinkToolbar, HyperlinkToolbarProps, @@ -35,7 +35,7 @@ export const DefaultPositionedHyperlinkToolbar = < const state = useUIPluginState( props.editor.hyperlinkToolbar.onUpdate.bind(props.editor.hyperlinkToolbar) ); - const { isMounted, ref, style } = useUiElementPositioning( + const { isMounted, ref, style } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 4000, diff --git a/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx b/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx index 3e8d757dbd..6fc7455138 100644 --- a/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx +++ b/packages/react/src/components/ImageToolbar/DefaultPositionedImageToolbar.tsx @@ -7,7 +7,7 @@ import { FC } from "react"; import { flip, offset } from "@floating-ui/react"; import { useUIPluginState } from "../../hooks/useUIPluginState"; -import { useUiElementPositioning } from "../../hooks/useUiElementPositioning"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; import { DefaultImageToolbar, ImageToolbarProps } from "./DefaultImageToolbar"; export const DefaultPositionedImageToolbar = < @@ -19,7 +19,7 @@ export const DefaultPositionedImageToolbar = < const state = useUIPluginState( props.editor.imageToolbar.onUpdate.bind(props.editor.imageToolbar) ); - const { isMounted, ref, style } = useUiElementPositioning( + const { isMounted, ref, style } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 5000, diff --git a/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx b/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx index 903ddb3e0d..bdefa9592a 100644 --- a/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx +++ b/packages/react/src/components/SideMenu/DefaultPositionedSideMenu.tsx @@ -10,7 +10,7 @@ import { import { FC } from "react"; import { useUIPluginState } from "../../hooks/useUIPluginState"; -import { useUiElementPositioning } from "../../hooks/useUiElementPositioning"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; import { DefaultSideMenu, SideMenuProps } from "./DefaultSideMenu"; export const DefaultPositionedSideMenu = < @@ -32,7 +32,7 @@ export const DefaultPositionedSideMenu = < const state = useUIPluginState( props.editor.sideMenu.onUpdate.bind(props.editor.sideMenu) ); - const { isMounted, ref, style } = useUiElementPositioning( + const { isMounted, ref, style } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 1000, diff --git a/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx index f2ffddc05a..03308bf9fb 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx @@ -9,7 +9,7 @@ import { flip, offset, size } from "@floating-ui/react"; import { FC, useCallback } from "react"; import { useUIPluginState } from "../../hooks/useUIPluginState"; -import { useUiElementPositioning } from "../../hooks/useUiElementPositioning"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; import { DefaultSuggestionMenu } from "./DefaultSuggestionMenu"; import { MantineSuggestionMenu } from "./mantine/MantineSuggestionMenu"; import { DefaultSuggestionItem, SuggestionMenuProps } from "./types"; @@ -59,13 +59,14 @@ export function DefaultPositionedSuggestionMenu< clearQuery: editor.suggestionMenus.clearQuery, }; - const state = useUIPluginState((callback: (state: SuggestionMenuState) => void) => - props.editor.suggestionMenus.onUpdate.bind(editor.suggestionMenus)( - triggerCharacter || "/", - callback - ) + const state = useUIPluginState( + (callback: (state: SuggestionMenuState) => void) => + props.editor.suggestionMenus.onUpdate.bind(editor.suggestionMenus)( + triggerCharacter || "/", + callback + ) ); - const { isMounted, ref, style } = useUiElementPositioning( + const { isMounted, ref, style } = useUIElementPositioning( state?.show || false, state?.referencePos || null, 2000, diff --git a/packages/react/src/hooks/useUiElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts similarity index 96% rename from packages/react/src/hooks/useUiElementPositioning.ts rename to packages/react/src/hooks/useUIElementPositioning.ts index d37950ed2d..d691063120 100644 --- a/packages/react/src/hooks/useUiElementPositioning.ts +++ b/packages/react/src/hooks/useUIElementPositioning.ts @@ -7,7 +7,7 @@ import { useEffect, useMemo } from "react"; import { UiComponentPosition } from "../components-shared/UiComponentTypes"; -export function useUiElementPositioning( +export function useUIElementPositioning( show: boolean, referencePos: DOMRect | null, zIndex: number, From 081db98297384b02fae8fe5ce07d47ac35feb809 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 15 Feb 2024 15:18:52 +0100 Subject: [PATCH 7/8] Added vanilla default slash menu items --- examples/editor/examples/basic/App.tsx | 2 +- .../SuggestionMenu/defaultSlashMenuItems.ts | 222 +++++++++++++++ packages/core/src/index.ts | 1 + .../defaultReactSlashMenuItems.tsx | 265 ++---------------- .../react/src/editor/BlockNoteDefaultUI.tsx | 6 +- tests/src/utils/components/Editor.tsx | 2 +- 6 files changed, 245 insertions(+), 253 deletions(-) create mode 100644 packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts diff --git a/examples/editor/examples/basic/App.tsx b/examples/editor/examples/basic/App.tsx index 6d7c0d1ba3..2a1c19e5d7 100644 --- a/examples/editor/examples/basic/App.tsx +++ b/examples/editor/examples/basic/App.tsx @@ -1,5 +1,6 @@ import { defaultInlineContentSpecs, + filterSuggestionItems, InlineContentSpecs, uploadToTmpFilesDotOrg_DEV_ONLY, } from "@blocknote/core"; @@ -8,7 +9,6 @@ import { BlockNoteView, createReactInlineContentSpec, DefaultPositionedSuggestionMenu, - filterSuggestionItems, useBlockNote, } from "@blocknote/react"; import "@blocknote/react/style.css"; diff --git a/packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts new file mode 100644 index 0000000000..c3ced59839 --- /dev/null +++ b/packages/core/src/extensions/SuggestionMenu/defaultSlashMenuItems.ts @@ -0,0 +1,222 @@ +import { + Block, + BlockSchema, + InlineContentSchema, + isStyledTextInlineContent, + PartialBlock, + StyleSchema, +} from "../../schema"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor"; +import { DefaultBlockSchema } from "../../blocks/defaultBlocks"; +import { formatKeyboardShortcut } from "../../util/browser"; +import { imageToolbarPluginKey } from "../ImageToolbar/ImageToolbarPlugin"; + +// 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 +// paragraph, so this function won't try to set the cursor position past the +// last block. +function setSelectionToNextContentEditableBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(editor: BlockNoteEditor) { + let block = editor.getTextCursorPosition().block; + let contentType = editor.blockSchema[block.type].content; + + while (contentType === "none") { + block = editor.getTextCursorPosition().nextBlock!; + contentType = editor.blockSchema[block.type].content as + | "inline" + | "table" + | "none"; + editor.setTextCursorPosition(block, "end"); + } +} + +// Checks if the current block is empty or only contains a slash, and if so, +// updates the current block instead of inserting a new one below. If the new +// block doesn't contain editable content, the cursor is moved to the next block +// that does. +export function insertOrUpdateBlock< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor, + block: PartialBlock +): Block { + const currentBlock = editor.getTextCursorPosition().block; + + if (currentBlock.content === undefined) { + throw new Error("Slash Menu open in a block that doesn't contain content."); + } + + if ( + Array.isArray(currentBlock.content) && + ((currentBlock.content.length === 1 && + isStyledTextInlineContent(currentBlock.content[0]) && + currentBlock.content[0].type === "text" && + currentBlock.content[0].text === "/") || + currentBlock.content.length === 0) + ) { + editor.updateBlock(currentBlock, block); + } else { + editor.insertBlocks([block], currentBlock, "after"); + editor.setTextCursorPosition( + editor.getTextCursorPosition().nextBlock!, + "end" + ); + } + + const insertedBlock = editor.getTextCursorPosition().block; + setSelectionToNextContentEditableBlock(editor); + + return insertedBlock; +} + +export function getDefaultSlashMenuItems() { + return [ + { + title: "Heading 1", + // Unfortunately, we can't use a more specific BlockNoteEditor type here, + // Typescript seems to get in the way there + // This means that we don't have type checking for calling insertOrUpdateBlock etc. :( + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "heading", + props: { level: 1 }, + } satisfies PartialBlock); + }, + subtext: "Used for a top-level heading", + badge: formatKeyboardShortcut("Mod-Alt-1"), + aliases: ["h", "heading1", "h1"], + group: "Headings", + }, + { + title: "Heading 2", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "heading", + props: { level: 2 }, + } satisfies PartialBlock); + }, + subtext: "Used for key sections", + badge: formatKeyboardShortcut("Mod-Alt-2"), + aliases: ["h2", "heading2", "subheading"], + group: "Headings", + }, + { + title: "Heading 3", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "heading", + props: { level: 3 }, + } satisfies PartialBlock); + }, + subtext: "Used for subsections and group headings", + badge: formatKeyboardShortcut("Mod-Alt-3"), + aliases: ["h3", "heading3", "subheading"], + group: "Headings", + }, + { + title: "Numbered List", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "numberedListItem", + } satisfies PartialBlock); + }, + subtext: "Used to display a numbered list", + badge: formatKeyboardShortcut("Mod-Shift-7"), + aliases: ["ol", "li", "list", "numberedlist", "numbered list"], + group: "Basic blocks", + }, + { + title: "Bullet List", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "bulletListItem", + } satisfies PartialBlock); + }, + subtext: "Used to display an unordered list", + badge: formatKeyboardShortcut("Mod-Shift-8"), + aliases: ["ul", "li", "list", "bulletlist", "bullet list"], + group: "Basic blocks", + }, + { + title: "Paragraph", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "paragraph", + } satisfies PartialBlock); + }, + subtext: "Used for the body of your document", + badge: formatKeyboardShortcut("Mod-Alt-0"), + aliases: ["p", "paragraph"], + group: "Basic blocks", + }, + { + title: "Table", + onItemClick: (editor: BlockNoteEditor) => { + insertOrUpdateBlock(editor, { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["", "", ""], + }, + { + cells: ["", "", ""], + }, + ], + }, + } satisfies PartialBlock); + }, + subtext: "Used for for tables", + aliases: ["table"], + group: "Advanced", + badge: undefined, + }, + { + title: "Image", + onItemClick: (editor: BlockNoteEditor) => { + const insertedBlock = insertOrUpdateBlock(editor, { + type: "image", + } satisfies PartialBlock); + + // Immediately open the image toolbar + editor._tiptapEditor.view.dispatch( + editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { + block: insertedBlock, + }) + ); + }, + subtext: "Insert an image", + aliases: [ + "image", + "imageUpload", + "upload", + "img", + "picture", + "media", + "url", + "drive", + "dropbox", + ], + group: "Media", + }, + ] as const; +} + +export function filterSuggestionItems< + T extends { title: string; aliases?: readonly string[] } +>(items: T[], query: string) { + return items.filter( + ({ title, aliases }) => + title.toLowerCase().startsWith(query.toLowerCase()) || + (aliases && + aliases.filter((alias) => + alias.toLowerCase().startsWith(query.toLowerCase()) + ).length !== 0) + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3a795c5157..1c6603c158 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from "./editor/selectionTypes"; export * from "./extensions/FormattingToolbar/FormattingToolbarPlugin"; export * from "./extensions/HyperlinkToolbar/HyperlinkToolbarPlugin"; export * from "./extensions/ImageToolbar/ImageToolbarPlugin"; +export * from "./extensions/SuggestionMenu/defaultSlashMenuItems"; export * from "./extensions/SuggestionMenu/SuggestionPlugin"; export * from "./extensions/SideMenu/SideMenuPlugin"; export * from "./extensions/TableHandles/TableHandlesPlugin"; diff --git a/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx b/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx index 09ebca9a92..34fabe491a 100644 --- a/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx +++ b/packages/react/src/components/SuggestionMenu/defaultReactSlashMenuItems.tsx @@ -1,15 +1,4 @@ -import { - Block, - BlockNoteEditor, - BlockSchema, - DefaultBlockSchema, - formatKeyboardShortcut, - imageToolbarPluginKey, - InlineContentSchema, - isStyledTextInlineContent, - PartialBlock, - StyleSchema, -} from "@blocknote/core"; +import { getDefaultSlashMenuItems } from "@blocknote/core"; import { RiH1, RiH2, @@ -21,241 +10,23 @@ import { RiText, } from "react-icons/ri"; -// 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 -// paragraph, so this function won't try to set the cursor position past the -// last block. -// TODO: move to core? -function setSelectionToNextContentEditableBlock< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->(editor: BlockNoteEditor) { - let block = editor.getTextCursorPosition().block; - let contentType = editor.blockSchema[block.type].content; - - while (contentType === "none") { - block = editor.getTextCursorPosition().nextBlock!; - contentType = editor.blockSchema[block.type].content as - | "inline" - | "table" - | "none"; - editor.setTextCursorPosition(block, "end"); - } -} - -// Checks if the current block is empty or only contains a slash, and if so, -// updates the current block instead of inserting a new one below. If the new -// block doesn't contain editable content, the cursor is moved to the next block -// that does. -// TODO: move to core? -export function insertOrUpdateBlock< - BSchema extends BlockSchema, - I extends InlineContentSchema, - S extends StyleSchema ->( - editor: BlockNoteEditor, - block: PartialBlock -): Block { - const currentBlock = editor.getTextCursorPosition().block; - - if (currentBlock.content === undefined) { - throw new Error("Slash Menu open in a block that doesn't contain content."); - } - - if ( - Array.isArray(currentBlock.content) && - ((currentBlock.content.length === 1 && - isStyledTextInlineContent(currentBlock.content[0]) && - currentBlock.content[0].type === "text" && - currentBlock.content[0].text === "/") || - currentBlock.content.length === 0) - ) { - editor.updateBlock(currentBlock, block); - } else { - editor.insertBlocks([block], currentBlock, "after"); - editor.setTextCursorPosition( - editor.getTextCursorPosition().nextBlock!, - "end" - ); - } - - const insertedBlock = editor.getTextCursorPosition().block; - setSelectionToNextContentEditableBlock(editor); - - return insertedBlock; -} +const icons = { + "Heading 1": RiH1, + "Heading 2": RiH2, + "Heading 3": RiH3, + "Numbered List": RiListOrdered, + "Bullet List": RiListUnordered, + Paragraph: RiText, + Table: RiTable2, + Image: RiImage2Fill, +}; export function getDefaultReactSlashMenuItems() { - const items = [ - { - title: "Heading 1", - // Unfortunately, we can't use a more specific BlockNoteEditor type here, - // Typescript seems to get in the way there - // This means that we don't have type checking for calling insertOrUpdateBlock etc. :( - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "heading", - props: { level: 1 }, - } satisfies PartialBlock); - }, - subtext: "Used for a top-level heading", - icon: , - badge: formatKeyboardShortcut("Mod-Alt-1"), - aliases: ["h", "heading1", "h1"], - group: "Headings", - }, - { - title: "Heading 2", - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "heading", - props: { level: 2 }, - } satisfies PartialBlock); - }, - subtext: "Used for key sections", - icon: , - badge: formatKeyboardShortcut("Mod-Alt-2"), - aliases: ["h2", "heading2", "subheading"], - group: "Headings", - }, - { - title: "Heading 3", - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "heading", - props: { level: 3 }, - } satisfies PartialBlock); - }, - subtext: "Used for subsections and group headings", - icon: , - badge: formatKeyboardShortcut("Mod-Alt-3"), - aliases: ["h3", "heading3", "subheading"], - group: "Headings", - }, - { - title: "Numbered List", - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "numberedListItem", - } satisfies PartialBlock); - }, - subtext: "Used to display a numbered list", - icon: , - badge: formatKeyboardShortcut("Mod-Shift-7"), - aliases: ["ol", "li", "list", "numberedlist", "numbered list"], - group: "Basic blocks", - }, - { - title: "Bullet List", - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "bulletListItem", - } satisfies PartialBlock); - }, - subtext: "Used to display an unordered list", - icon: , - badge: formatKeyboardShortcut("Mod-Shift-8"), - aliases: ["ul", "li", "list", "bulletlist", "bullet list"], - group: "Basic blocks", - }, - { - title: "Paragraph", - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "paragraph", - } satisfies PartialBlock); - }, - subtext: "Used for the body of your document", - icon: , - badge: formatKeyboardShortcut("Mod-Alt-0"), - aliases: ["p", "paragraph"], - group: "Basic blocks", - }, - { - title: "Table", - onItemClick: (editor: BlockNoteEditor) => { - insertOrUpdateBlock(editor, { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["", "", ""], - }, - { - cells: ["", "", ""], - }, - ], - }, - } satisfies PartialBlock); - }, - subtext: "Used for for tables", - icon: , - aliases: ["table"], - group: "Advanced", - badge: undefined, - }, - { - title: "Image", - onItemClick: (editor: BlockNoteEditor) => { - const insertedBlock = insertOrUpdateBlock(editor, { - type: "image", - } satisfies PartialBlock); - - // Immediately open the image toolbar - editor._tiptapEditor.view.dispatch( - editor._tiptapEditor.state.tr.setMeta(imageToolbarPluginKey, { - block: insertedBlock, - }) - ); - }, - subtext: "Insert an image", - icon: , - aliases: [ - "image", - "imageUpload", - "upload", - "img", - "picture", - "media", - "url", - "drive", - "dropbox", - ], - group: "Media", - }, - ]; // satisfies (DefaultSuggestionItem & { onItemClick: any; aliases: any })[]; - - // For testing async - // return new Promise((resolve) => { - // setTimeout(() => { - // console.log("return items"); - // resolve( - // items.filter( - // ({ name, aliases }) => - // name.toLowerCase().startsWith(query.toLowerCase()) || - // (aliases && - // aliases.filter((alias) => - // alias.toLowerCase().startsWith(query.toLowerCase()) - // ).length !== 0) - // ) - // ); - // }, 1000); - // }); - - return items; -} - -export function filterSuggestionItems< - T extends { title: string; aliases?: string[] } ->(items: T[], query: string) { - return items.filter( - ({ title, aliases }) => - title.toLowerCase().startsWith(query.toLowerCase()) || - (aliases && - aliases.filter((alias) => - alias.toLowerCase().startsWith(query.toLowerCase()) - ).length !== 0) - ); + return getDefaultSlashMenuItems().map((item) => { + const Icon = icons[item.title]; + return { + ...item, + icon: , + }; + }); } diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 29b8a0ab42..ebe3e0cc53 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,6 +1,7 @@ import { BlockNoteEditor, BlockSchema, + filterSuggestionItems, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -9,10 +10,7 @@ import { DefaultPositionedHyperlinkToolbar } from "../components/HyperlinkToolba import { DefaultPositionedImageToolbar } from "../components/ImageToolbar/DefaultPositionedImageToolbar"; import { DefaultPositionedSideMenu } from "../components/SideMenu/DefaultPositionedSideMenu"; import { DefaultPositionedSuggestionMenu } from "../components/SuggestionMenu/DefaultPositionedSuggestionMenu"; -import { - filterSuggestionItems, - getDefaultReactSlashMenuItems, -} from "../components/SuggestionMenu/defaultReactSlashMenuItems"; +import { getDefaultReactSlashMenuItems } from "../components/SuggestionMenu/defaultReactSlashMenuItems"; import { DefaultPositionedTableHandles } from "../components/TableHandles/DefaultPositionedTableHandles"; export function BlockNoteDefaultUI< diff --git a/tests/src/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx index 293eb9b540..2bd9441536 100644 --- a/tests/src/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -3,13 +3,13 @@ import { BlockNoteDefaultUI, BlockNoteView, DefaultPositionedSuggestionMenu, - filterSuggestionItems, getDefaultReactSlashMenuItems, useBlockNote, } from "@blocknote/react"; import { Alert, insertAlert } from "../customblocks/Alert"; import { Button } from "../customblocks/Button"; import styles from "./Editor.module.css"; +import { filterSuggestionItems } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; From 9017f0591ab60c2c212c5decc03e89a7a2f85fc1 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 15 Feb 2024 15:25:46 +0100 Subject: [PATCH 8/8] address feedback --- .../DefaultPositionedSuggestionMenu.tsx | 19 +++++-------------- .../DefaultSuggestionMenu.test.tsx | 3 +++ .../SuggestionMenu/DefaultSuggestionMenu.tsx | 14 ++++++++++++-- .../react/src/editor/BlockNoteDefaultUI.tsx | 1 + tests/src/utils/components/Editor.tsx | 3 ++- 5 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx index 03308bf9fb..d4c11ced18 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultPositionedSuggestionMenu.tsx @@ -6,10 +6,10 @@ import { SuggestionMenuState, } from "@blocknote/core"; import { flip, offset, size } from "@floating-ui/react"; -import { FC, useCallback } from "react"; +import { FC } from "react"; -import { useUIPluginState } from "../../hooks/useUIPluginState"; import { useUIElementPositioning } from "../../hooks/useUIElementPositioning"; +import { useUIPluginState } from "../../hooks/useUIPluginState"; import { DefaultSuggestionMenu } from "./DefaultSuggestionMenu"; import { MantineSuggestionMenu } from "./mantine/MantineSuggestionMenu"; import { DefaultSuggestionItem, SuggestionMenuProps } from "./types"; @@ -29,7 +29,7 @@ export function DefaultPositionedSuggestionMenu< >( props: { editor: BlockNoteEditor; - triggerCharacter?: string; + triggerCharacter: string; getItems: GetItemsType; onItemClick?: (item: ItemType) => void; } & (ItemType extends DefaultSuggestionItem @@ -62,7 +62,7 @@ export function DefaultPositionedSuggestionMenu< const state = useUIPluginState( (callback: (state: SuggestionMenuState) => void) => props.editor.suggestionMenus.onUpdate.bind(editor.suggestionMenus)( - triggerCharacter || "/", + triggerCharacter, callback ) ); @@ -88,15 +88,6 @@ export function DefaultPositionedSuggestionMenu< } ); - const clickHandler = useCallback( - (item: ItemType) => { - editor.suggestionMenus.closeMenu(); - editor.suggestionMenus.clearQuery(); - onItemClick?.(item); - }, - [onItemClick, editor.suggestionMenus] - ); - if (!isMounted || !state) { return null; } @@ -111,7 +102,7 @@ export function DefaultPositionedSuggestionMenu< suggestionMenuComponent={ suggestionMenuComponent || MantineSuggestionMenu } - onItemClick={clickHandler} + onItemClick={onItemClick} {...data} {...callbacks} /> diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx index 250cefcd9b..a797d5fe2f 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.test.tsx @@ -9,6 +9,7 @@ it("has good typing", () => { [{ name: "hello" }]} editor={undefined as any} + triggerCharacter="/" /> ); @@ -17,6 +18,7 @@ it("has good typing", () => { [{ title: "hello" }]} editor={undefined as any} + triggerCharacter="/" /> ); @@ -29,6 +31,7 @@ it("has good typing", () => { onItemClick={(item) => { console.log(item.hello); }} + triggerCharacter="/" /> ); diff --git a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx index 6b0af37461..4243c1dde5 100644 --- a/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx +++ b/packages/react/src/components/SuggestionMenu/DefaultSuggestionMenu.tsx @@ -4,7 +4,7 @@ import { InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { FC } from "react"; +import { FC, useCallback } from "react"; import { useCloseSuggestionMenuNoItems } from "./hooks/useCloseSuggestionMenuNoItems"; import { useLoadSuggestionMenuItems } from "./hooks/useLoadSuggestionMenuItems"; @@ -30,10 +30,20 @@ export function DefaultSuggestionMenu< getItems, suggestionMenuComponent, query, + clearQuery, closeMenu, onItemClick, } = props; + const clickHandler = useCallback( + (item: Item) => { + closeMenu(); + clearQuery(); + onItemClick?.(item); + }, + [onItemClick, closeMenu, clearQuery] + ); + const { items, usedQuery, loadingState } = useLoadSuggestionMenuItems( query, getItems @@ -52,7 +62,7 @@ export function DefaultSuggestionMenu< return ( diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index ebe3e0cc53..7358eeacfe 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -44,6 +44,7 @@ export function BlockNoteDefaultUI< onItemClick={(item) => { item.onItemClick(props.editor); }} + triggerCharacter="/" /> )} {props.sideMenu !== false && ( diff --git a/tests/src/utils/components/Editor.tsx b/tests/src/utils/components/Editor.tsx index 2bd9441536..77e3880b6f 100644 --- a/tests/src/utils/components/Editor.tsx +++ b/tests/src/utils/components/Editor.tsx @@ -1,3 +1,4 @@ +import { filterSuggestionItems } from "@blocknote/core"; import "@blocknote/core/style.css"; import { BlockNoteDefaultUI, @@ -9,7 +10,6 @@ import { import { Alert, insertAlert } from "../customblocks/Alert"; import { Button } from "../customblocks/Button"; import styles from "./Editor.module.css"; -import { filterSuggestionItems } from "@blocknote/core"; type WindowWithProseMirror = Window & typeof globalThis & { ProseMirror: any }; @@ -59,6 +59,7 @@ export default function Editor() { getItems={async (query) => filterSuggestionItems(allItems, query)} onItemClick={(i) => i.onItemClick(editor)} // suggestionMenuComponent={MantineSuggestionMenu} + triggerCharacter="/" /> );