Skip to content

Commit 9a10c68

Browse files
fix: Menu overflows (#340)
* Fixed overflow for slash menu, color picker, and formatting toolbar dropdown. * Updated screenshots * Added comment for `setTimeout`
1 parent 0097fd9 commit 9a10c68

File tree

10 files changed

+124
-43
lines changed

10 files changed

+124
-43
lines changed

packages/react/src/BlockNoteTheme.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export type ComponentStyles = Partial<{
4141
Editor: CSSObject;
4242
// Wraps Formatting Toolbar & Hyperlink Toolbar
4343
Toolbar: CSSObject;
44-
// Appears on hover for Formatting Toolbar & Hyperlink Toolbar buttons
44+
// Appears on hover for Formatting Toolbar
45+
// & Hyperlink Toolbar buttons
4546
Tooltip: CSSObject;
4647
SlashMenu: CSSObject;
4748
SideMenu: CSSObject;
@@ -105,6 +106,7 @@ export const blockNoteToMantineTheme = (theme: Theme): MantineThemeOverride => {
105106
boxShadow: shadow,
106107
color: theme.colors.menu.text,
107108
padding: "2px",
109+
overflowY: "scroll",
108110
".mantine-Menu-label": {
109111
backgroundColor: theme.colors.menu.background,
110112
color: theme.colors.menu.text,

packages/react/src/FormattingToolbar/components/DefaultButtons/ColorStyleButton.tsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ColorIcon } from "../../../SharedComponents/ColorPicker/components/Colo
66
import { ColorPicker } from "../../../SharedComponents/ColorPicker/components/ColorPicker";
77
import { useEditorContentChange } from "../../../hooks/useEditorContentChange";
88
import { useEditorSelectionChange } from "../../../hooks/useEditorSelectionChange";
9+
import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow";
910

1011
export const ColorStyleButton = <BSchema extends BlockSchema>(props: {
1112
editor: BlockNoteEditor<BSchema>;
@@ -51,8 +52,10 @@ export const ColorStyleButton = <BSchema extends BlockSchema>(props: {
5152
[props.editor]
5253
);
5354

55+
const { ref, updateMaxHeight } = usePreventMenuOverflow();
56+
5457
return (
55-
<Menu>
58+
<Menu onOpen={updateMaxHeight}>
5659
<Menu.Target>
5760
<ToolbarButton
5861
mainTooltip={"Colors"}
@@ -65,14 +68,16 @@ export const ColorStyleButton = <BSchema extends BlockSchema>(props: {
6568
)}
6669
/>
6770
</Menu.Target>
68-
<Menu.Dropdown>
69-
<ColorPicker
70-
textColor={currentTextColor}
71-
setTextColor={setTextColor}
72-
backgroundColor={currentBackgroundColor}
73-
setBackgroundColor={setBackgroundColor}
74-
/>
75-
</Menu.Dropdown>
71+
<div ref={ref}>
72+
<Menu.Dropdown>
73+
<ColorPicker
74+
textColor={currentTextColor}
75+
setTextColor={setTextColor}
76+
backgroundColor={currentBackgroundColor}
77+
setBackgroundColor={setBackgroundColor}
78+
/>
79+
</Menu.Dropdown>
80+
</div>
7681
</Menu>
7782
);
7883
};

packages/react/src/SharedComponents/Toolbar/components/ToolbarDropdown.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ToolbarDropdownItemProps,
55
} from "./ToolbarDropdownItem";
66
import { ToolbarDropdownTarget } from "./ToolbarDropdownTarget";
7+
import { usePreventMenuOverflow } from "../../../hooks/usePreventMenuOverflow";
78

89
export type ToolbarDropdownProps = {
910
items: ToolbarDropdownItemProps[];
@@ -13,24 +14,31 @@ export type ToolbarDropdownProps = {
1314
export function ToolbarDropdown(props: ToolbarDropdownProps) {
1415
const selectedItem = props.items.filter((p) => p.isSelected)[0];
1516

17+
const { ref, updateMaxHeight } = usePreventMenuOverflow();
18+
1619
if (!selectedItem) {
1720
return null;
1821
}
1922

2023
return (
21-
<Menu exitTransitionDuration={0} disabled={props.isDisabled}>
24+
<Menu
25+
exitTransitionDuration={0}
26+
disabled={props.isDisabled}
27+
onOpen={updateMaxHeight}>
2228
<Menu.Target>
2329
<ToolbarDropdownTarget
2430
text={selectedItem.text}
2531
icon={selectedItem.icon}
2632
isDisabled={selectedItem.isDisabled}
2733
/>
2834
</Menu.Target>
29-
<Menu.Dropdown>
30-
{props.items.map((item) => (
31-
<ToolbarDropdownItem key={item.text} {...item} />
32-
))}
33-
</Menu.Dropdown>
35+
<div ref={ref}>
36+
<Menu.Dropdown>
37+
{props.items.map((item) => (
38+
<ToolbarDropdownItem key={item.text} {...item} />
39+
))}
40+
</Menu.Dropdown>
41+
</div>
3442
</Menu>
3543
);
3644
}

packages/react/src/SideMenu/components/DragHandleMenu/DefaultButtons/BlockColorsButton.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@ import { BlockSchema, PartialBlock } from "@blocknote/core";
66
import { DragHandleMenuProps } from "../DragHandleMenu";
77
import { DragHandleMenuItem } from "../DragHandleMenuItem";
88
import { ColorPicker } from "../../../../SharedComponents/ColorPicker/components/ColorPicker";
9+
import { usePreventMenuOverflow } from "../../../../hooks/usePreventMenuOverflow";
910

1011
export const BlockColorsButton = <BSchema extends BlockSchema>(
1112
props: DragHandleMenuProps<BSchema> & { children: ReactNode }
1213
) => {
1314
const [opened, setOpened] = useState(false);
1415

16+
const { ref, updateMaxHeight } = usePreventMenuOverflow();
17+
1518
const menuCloseTimer = useRef<NodeJS.Timeout | undefined>();
1619

1720
const startMenuCloseTimer = useCallback(() => {
@@ -27,8 +30,13 @@ export const BlockColorsButton = <BSchema extends BlockSchema>(
2730
if (menuCloseTimer.current) {
2831
clearTimeout(menuCloseTimer.current);
2932
}
33+
34+
if (!opened) {
35+
updateMaxHeight();
36+
}
37+
3038
setOpened(true);
31-
}, []);
39+
}, [opened, updateMaxHeight]);
3240

3341
if (
3442
!("textColor" in props.block.props) ||
@@ -50,26 +58,28 @@ export const BlockColorsButton = <BSchema extends BlockSchema>(
5058
</Box>
5159
</div>
5260
</Menu.Target>
53-
<Menu.Dropdown
54-
onMouseLeave={startMenuCloseTimer}
55-
onMouseOver={stopMenuCloseTimer}
56-
style={{ marginLeft: "5px" }}>
57-
<ColorPicker
58-
iconSize={18}
59-
textColor={props.block.props.textColor || "default"}
60-
backgroundColor={props.block.props.backgroundColor || "default"}
61-
setTextColor={(color) =>
62-
props.editor.updateBlock(props.block, {
63-
props: { textColor: color },
64-
} as PartialBlock<BSchema>)
65-
}
66-
setBackgroundColor={(color) =>
67-
props.editor.updateBlock(props.block, {
68-
props: { backgroundColor: color },
69-
} as PartialBlock<BSchema>)
70-
}
71-
/>
72-
</Menu.Dropdown>
61+
<div ref={ref}>
62+
<Menu.Dropdown
63+
onMouseLeave={startMenuCloseTimer}
64+
onMouseOver={stopMenuCloseTimer}
65+
style={{ marginLeft: "5px" }}>
66+
<ColorPicker
67+
iconSize={18}
68+
textColor={props.block.props.textColor || "default"}
69+
backgroundColor={props.block.props.backgroundColor || "default"}
70+
setTextColor={(color) =>
71+
props.editor.updateBlock(props.block, {
72+
props: { textColor: color },
73+
} as PartialBlock<BSchema>)
74+
}
75+
setBackgroundColor={(color) =>
76+
props.editor.updateBlock(props.block, {
77+
props: { backgroundColor: color },
78+
} as PartialBlock<BSchema>)
79+
}
80+
/>
81+
</Menu.Dropdown>
82+
</div>
7383
</Menu>
7484
</DragHandleMenuItem>
7585
);

packages/react/src/SideMenu/components/DragHandleMenu/DragHandleMenu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const DragHandleMenu = (props: { children: ReactNode }) => {
1313
});
1414

1515
return (
16-
<Menu.Dropdown className={classes.root}>{props.children}</Menu.Dropdown>
16+
<Menu.Dropdown className={classes.root} style={{ overflow: "visible" }}>
17+
{props.children}
18+
</Menu.Dropdown>
1719
);
1820
};

packages/react/src/SlashMenu/components/SlashMenuPositioner.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { FC, useEffect, useMemo, useRef, useState } from "react";
1010

1111
import { ReactSlashMenuItem } from "../ReactSlashMenuItem";
1212
import { DefaultSlashMenu } from "./DefaultSlashMenu";
13+
import { usePreventMenuOverflow } from "../../hooks/usePreventMenuOverflow";
1314

1415
export type SlashMenuProps<BSchema extends BlockSchema = DefaultBlockSchema> =
1516
Pick<SlashMenuProsemirrorPlugin<BSchema, any>, "itemCallback"> &
@@ -53,6 +54,8 @@ export const SlashMenuPositioner = <
5354
[referencePos.current] // eslint-disable-line
5455
);
5556

57+
const { ref, updateMaxHeight } = usePreventMenuOverflow();
58+
5659
const slashMenuElement = useMemo(() => {
5760
if (!filteredItems || keyboardHoveredItemIndex === undefined) {
5861
return null;
@@ -61,21 +64,25 @@ export const SlashMenuPositioner = <
6164
const SlashMenu = props.slashMenu || DefaultSlashMenu;
6265

6366
return (
64-
<SlashMenu
65-
filteredItems={filteredItems}
66-
itemCallback={(item) => props.editor.slashMenu.itemCallback(item)}
67-
keyboardHoveredItemIndex={keyboardHoveredItemIndex}
68-
/>
67+
<div ref={ref}>
68+
<SlashMenu
69+
filteredItems={filteredItems}
70+
itemCallback={(item) => props.editor.slashMenu.itemCallback(item)}
71+
keyboardHoveredItemIndex={keyboardHoveredItemIndex}
72+
/>
73+
</div>
6974
);
7075
}, [
7176
filteredItems,
7277
keyboardHoveredItemIndex,
7378
props.editor.slashMenu,
7479
props.slashMenu,
80+
ref,
7581
]);
7682

7783
return (
7884
<Tippy
85+
onShow={updateMaxHeight}
7986
appendTo={props.editor.domElement.parentElement!}
8087
content={slashMenuElement}
8188
getReferenceClientRect={getReferenceClientRect}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useRef } from "react";
2+
3+
// This hook is used to stop Mantine Menu.Dropdown components from extending
4+
// beyond the viewport. It does this by dynamically setting the max height of
5+
// the dropdown. To use it, set the ref on a Menu.Dropdown's parent div, and
6+
// call updateMaxHeight() in the Menu's `onOpen` listener. Unfortunately, this
7+
// may mean you have to create an additional parent div, but you cannot set the
8+
// ref on the Menu or Menu.Dropdown components directly so this is a necessary
9+
// workaround.
10+
export function usePreventMenuOverflow() {
11+
const ref = useRef<HTMLDivElement>(null);
12+
13+
return {
14+
ref: ref,
15+
updateMaxHeight: () => {
16+
// Seems a small delay is necessary to get the correct DOM rect - likely
17+
// because the menu is not yet fully rendered when the `onOpen` event is
18+
// fired.
19+
setTimeout(() => {
20+
if (!ref.current) {
21+
return;
22+
}
23+
24+
if (ref.current.childElementCount > 0) {
25+
// Reset any previously set max-height
26+
(ref.current.firstElementChild as HTMLDivElement).style.maxHeight =
27+
"none";
28+
29+
// Get the menu DOM rect
30+
const domRect =
31+
ref.current.firstElementChild!.getBoundingClientRect();
32+
33+
// Set the menu max height, based on the Tippy position. Checking if
34+
// the top of the menu is above the viewport (position < 0) is a quick
35+
// way to check if the placement is "top" or "bottom".
36+
(
37+
ref.current.firstElementChild as HTMLDivElement
38+
).style.maxHeight = `${Math.min(
39+
domRect.top >= 0
40+
? window.innerHeight - domRect.top - 20
41+
: domRect.bottom - 20
42+
)}px`;
43+
}
44+
}, 10);
45+
},
46+
};
47+
}
Loading
Loading
Loading

0 commit comments

Comments
 (0)