diff --git a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx index 9ac8ed37f6..2ee1da2771 100644 --- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx +++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx @@ -66,7 +66,6 @@ export default function App() { name: "Alert", type: "alert", icon: RiAlertFill, - isSelected: (block) => block.type === "alert", } satisfies BlockTypeSelectItem, ]} /> diff --git a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx index 21d2003d73..f795be6641 100644 --- a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx +++ b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx @@ -129,9 +129,16 @@ function MUIBlockTypeSelect() { // Gets the selected item. const selectedItem = useMemo( () => - defaultBlockTypeSelectItems.find((item) => - item.isSelected(block as any), - )!, + defaultBlockTypeSelectItems.find((item) => { + const typesMatch = item.type === block.type; + const propsMatch = + Object.entries(item.props || {}).filter( + ([propName, propValue]) => + propValue !== (block as any).props[propName], + ).length === 0; + + return typesMatch && propsMatch; + })!, [defaultBlockTypeSelectItems, block], ); diff --git a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx index 495356ff98..21dc29650a 100644 --- a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx +++ b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx @@ -99,7 +99,6 @@ export default function App() { name: "Alert", type: "alert", icon: RiAlertFill, - isSelected: (block) => block.type === "alert", } satisfies BlockTypeSelectItem, ]} /> diff --git a/examples/06-custom-schema/07-configuring-blocks/src/App.tsx b/examples/06-custom-schema/07-configuring-blocks/src/App.tsx index 476f0401f1..491dc8a1c1 100644 --- a/examples/06-custom-schema/07-configuring-blocks/src/App.tsx +++ b/examples/06-custom-schema/07-configuring-blocks/src/App.tsx @@ -30,7 +30,7 @@ export default function App() { { type: "paragraph", content: - "Notice how only heading levels 1-3 are avaiable, and toggle headings are not shown.", + "Notice how only heading levels 1-3 are available, and toggle headings are not shown.", }, { type: "paragraph", diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index a2d61c7ea6..3aa6f3165d 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -1,11 +1,11 @@ import { - Block, BlockSchema, Dictionary, + editorHasBlockWithType, InlineContentSchema, StyleSchema, } from "@blocknote/core"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import type { IconType } from "react-icons"; import { RiH1, @@ -27,18 +27,13 @@ import { useComponentsContext, } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; -import { useEditorContentOrSelectionChange } from "../../../hooks/useEditorContentOrSelectionChange.js"; import { useSelectedBlocks } from "../../../hooks/useSelectedBlocks.js"; -import { useDictionary } from "../../../i18n/dictionary.js"; export type BlockTypeSelectItem = { name: string; type: string; props?: Record; icon: IconType; - isSelected: ( - block: Block, - ) => boolean; }; export const blockTypeSelectItems = ( @@ -48,139 +43,90 @@ export const blockTypeSelectItems = ( name: dict.slash_menu.paragraph.title, type: "paragraph", icon: RiText, - isSelected: (block) => block.type === "paragraph", }, { name: dict.slash_menu.heading.title, type: "heading", - props: { level: 1 }, + props: { level: 1, isToggleable: false }, icon: RiH1, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 1, }, { name: dict.slash_menu.heading_2.title, type: "heading", - props: { level: 2 }, + props: { level: 2, isToggleable: false }, icon: RiH2, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 2, }, { name: dict.slash_menu.heading_3.title, type: "heading", - props: { level: 3 }, + props: { level: 3, isToggleable: false }, icon: RiH3, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 3, }, { name: dict.slash_menu.heading_4.title, type: "heading", - props: { level: 4 }, + props: { level: 4, isToggleable: false }, icon: RiH4, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 4, }, { name: dict.slash_menu.heading_5.title, type: "heading", - props: { level: 5 }, + props: { level: 5, isToggleable: false }, icon: RiH5, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 5, }, { name: dict.slash_menu.heading_6.title, type: "heading", - props: { level: 6 }, + props: { level: 6, isToggleable: false }, icon: RiH6, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 6, }, { name: dict.slash_menu.toggle_heading.title, type: "heading", props: { level: 1, isToggleable: true }, icon: RiH1, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 1 && - "isToggleable" in block.props && - block.props.isToggleable, }, { name: dict.slash_menu.toggle_heading_2.title, type: "heading", props: { level: 2, isToggleable: true }, icon: RiH2, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 2 && - "isToggleable" in block.props && - block.props.isToggleable, }, { name: dict.slash_menu.toggle_heading_3.title, type: "heading", props: { level: 3, isToggleable: true }, icon: RiH3, - isSelected: (block) => - block.type === "heading" && - "level" in block.props && - block.props.level === 3 && - "isToggleable" in block.props && - block.props.isToggleable, }, { name: dict.slash_menu.quote.title, type: "quote", icon: RiQuoteText, - isSelected: (block) => block.type === "quote", }, { name: dict.slash_menu.toggle_list.title, type: "toggleListItem", icon: RiPlayList2Fill, - isSelected: (block) => block.type === "toggleListItem", }, { name: dict.slash_menu.bullet_list.title, type: "bulletListItem", icon: RiListUnordered, - isSelected: (block) => block.type === "bulletListItem", }, { name: dict.slash_menu.numbered_list.title, type: "numberedListItem", icon: RiListOrdered, - isSelected: (block) => block.type === "numberedListItem", }, { name: dict.slash_menu.check_list.title, type: "checkListItem", icon: RiListCheck3, - isSelected: (block) => block.type === "checkListItem", }, ]; export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const Components = useComponentsContext()!; - const dict = useDictionary(); const editor = useBlockNoteEditor< BlockSchema, @@ -189,50 +135,70 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { >(); const selectedBlocks = useSelectedBlocks(editor); - - const [block, setBlock] = useState(editor.getTextCursorPosition().block); - - const filteredItems: BlockTypeSelectItem[] = useMemo(() => { - return (props.items || blockTypeSelectItems(dict)).filter( - (item) => item.type in editor.schema.blockSchema, - ); - }, [editor, dict, props.items]); - - const shouldShow: boolean = useMemo( - () => filteredItems.find((item) => item.type === block.type) !== undefined, - [block.type, filteredItems], + const firstSelectedBlock = selectedBlocks[0]; + + // Filters out all items in which the block type and props don't conform to + // the schema. + const filteredItems = useMemo( + () => + (props.items || blockTypeSelectItems(editor.dictionary)).filter((item) => + editorHasBlockWithType( + editor, + item.type, + Object.fromEntries( + Object.entries(item.props || {}).map(([propName, propValue]) => [ + propName, + typeof propValue, + ]), + ) as Record, + ), + ), + [editor, props.items], ); - const fullItems: ComponentProps["FormattingToolbar"]["Select"]["items"] = + // Processes `filteredItems` to an array that can be passed to + // `Components.FormattingToolbar.Select`. + const selectItems: ComponentProps["FormattingToolbar"]["Select"]["items"] = useMemo(() => { - const onClick = (item: BlockTypeSelectItem) => { - editor.focus(); - - editor.transact(() => { - for (const block of selectedBlocks) { - editor.updateBlock(block, { - type: item.type as any, - props: item.props as any, - }); - } - }); - }; - return filteredItems.map((item) => { const Icon = item.icon; + const typesMatch = item.type === firstSelectedBlock.type; + const propsMatch = + Object.entries(item.props || {}).filter( + ([propName, propValue]) => + propValue !== firstSelectedBlock.props[propName], + ).length === 0; + return { text: item.name, icon: , - onClick: () => onClick(item), - isSelected: item.isSelected(block), + onClick: () => { + editor.focus(); + editor.transact(() => { + for (const block of selectedBlocks) { + editor.updateBlock(block, { + type: item.type as any, + props: item.props as any, + }); + } + }); + }, + isSelected: typesMatch && propsMatch, }; }); - }, [block, filteredItems, editor, selectedBlocks]); + }, [ + editor, + filteredItems, + firstSelectedBlock.props, + firstSelectedBlock.type, + selectedBlocks, + ]); - useEditorContentOrSelectionChange(() => { - setBlock(editor.getTextCursorPosition().block); - }, editor); + const shouldShow: boolean = useMemo( + () => selectItems.find((item) => item.isSelected) !== undefined, + [selectItems], + ); if (!shouldShow || !editor.isEditable) { return null; @@ -241,7 +207,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { return ( ); };