Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export default function App() {
name: "Alert",
type: "alert",
icon: RiAlertFill,
isSelected: (block) => block.type === "alert",
} satisfies BlockTypeSelectItem,
]}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ export default function App() {
name: "Alert",
type: "alert",
icon: RiAlertFill,
isSelected: (block) => block.type === "alert",
} satisfies BlockTypeSelectItem,
]}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this with fresh eyes again, and I'm thinking that the editorHasBlockWithType type guards should maybe be gated within the BlockTypeSelect component's filteredItems instead. Take this for example:

{
      name: editor.dictionary.slash_menu.quote.title,
      type: "quote",
      icon: RiQuoteText,
      isSelected: (block) => block.type === "quote",
    }

Like, can't we know exactly what block this is trying to select based on the type & optional props object? This would allow us to filter the list down & cut down on the type guards needed.

Maybe this is over complicating it, but having a filter for this sort of thing is sort of useful generically, like in the slash menu, block type select & other places surely

Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string, boolean | number | string>;
icon: IconType;
isSelected: (
block: Block<BlockSchema, InlineContentSchema, StyleSchema>,
) => boolean;
};

export const blockTypeSelectItems = (
Expand All @@ -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,
Expand All @@ -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<string, "string" | "number" | "boolean">,
),
),
[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: <Icon size={16} />,
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;
Expand All @@ -241,7 +207,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => {
return (
<Components.FormattingToolbar.Select
className={"bn-select"}
items={fullItems}
items={selectItems}
/>
);
};
Loading