diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 2366a4ea46..840178a50f 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -3,7 +3,6 @@ import { SuggestionMenu, SuggestionMenuOptions, } from "@blocknote/core/extensions"; -import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; @@ -14,6 +13,7 @@ import { } from "../../../hooks/useExtension.js"; import { PortalElementOverride } from "../../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../../Popovers/FloatingUIOptions.js"; +import { suggestionMenuMiddleware } from "../suggestionMenuMiddleware.js"; import { GenericPopover, GenericPopoverReference, @@ -135,22 +135,7 @@ export function GridSuggestionMenuController< } }, placement: "bottom-start", - middleware: [ - offset(10), - // Flips the menu placement to maximize the space available, and prevents - // the menu from being cut off by the confines of the screen. - autoPlacement({ - allowedPlacements: ["bottom-start", "top-start"], - padding: 10, - }), - shift(), - size({ - apply({ elements, availableHeight }) { - elements.floating.style.maxHeight = `${Math.max(0, availableHeight)}px`; - }, - padding: 10, - }), - ], + middleware: suggestionMenuMiddleware, ...props.floatingUIOptions?.useFloatingOptions, }, focusManagerProps: { diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index d2ac15b22e..6df8677288 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -4,7 +4,6 @@ import { SuggestionMenuOptions, filterSuggestionItems, } from "@blocknote/core/extensions"; -import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -12,6 +11,7 @@ import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { PortalElementOverride } from "../../editor/PortalElementOverride.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; +import { suggestionMenuMiddleware } from "./suggestionMenuMiddleware.js"; import { GenericPopover, GenericPopoverReference, @@ -128,22 +128,7 @@ export function SuggestionMenuController< } }, placement: "bottom-start", - middleware: [ - offset(10), - // Flips the menu placement to maximize the space available, and prevents - // the menu from being cut off by the confines of the screen. - autoPlacement({ - allowedPlacements: ["bottom-start", "top-start"], - padding: 10, - }), - shift(), - size({ - apply({ elements, availableHeight }) { - elements.floating.style.maxHeight = `${Math.max(0, availableHeight)}px`; - }, - padding: 10, - }), - ], + middleware: suggestionMenuMiddleware, ...props.floatingUIOptions?.useFloatingOptions, }, focusManagerProps: { diff --git a/packages/react/src/components/SuggestionMenu/suggestionMenuMiddleware.ts b/packages/react/src/components/SuggestionMenu/suggestionMenuMiddleware.ts new file mode 100644 index 0000000000..f367b9767f --- /dev/null +++ b/packages/react/src/components/SuggestionMenu/suggestionMenuMiddleware.ts @@ -0,0 +1,111 @@ +import { Middleware, offset, Placement, shift, size } from "@floating-ui/react"; + +/** The gap (px) between the menu and its trigger character. */ +const OFFSET = 10; + +/** The space (px) kept between the menu and the edge of the screen. */ +const PADDING = 10; + +/** + * The most height (px) the menu takes, even with more room: a longer list + * scrolls, or the user filters it. + */ +const MAX_HEIGHT = 600; + +/** + * The least room (px) the menu needs below its trigger. With less room, the + * menu goes above instead, if there is more room there. + */ +const MIN_HEIGHT = 200; + +/** + * Places the menu below its trigger, unless there is less than `MIN_HEIGHT` + * of room there and more room above. Replaces Floating UI's `autoPlacement`. + * + * The decision uses a fixed height, not the menu's own (as `autoPlacement` + * does): `size` caps the menu + * to the room on its side (so a capped menu always fits where it is), and + * the items load asynchronously (so a menu that just opened can still be + * nearly empty). Thus the decision depends only on the trigger and the + * visible area: while the user filters the items, the menu keeps its side. + * When the trigger moves (e.g. the page scrolls) or the window is resized, + * Floating UI runs the middleware again and the side can change. + */ +const placeBelowUnlessCramped: Middleware = { + name: "placeBelowUnlessCramped", + async fn(state) { + const { rects, placement, platform } = state; + // A menu of `MIN_HEIGHT`, at the position of each side. + const floating = { ...rects.floating, height: MIN_HEIGHT }; + const below = await platform.detectOverflow( + { + ...state, + rects: { ...rects, floating }, + y: rects.reference.y + rects.reference.height + OFFSET, + }, + { padding: PADDING }, + ); + + let wanted: Placement = "bottom-start"; + if (below.bottom > 0) { + // The menu does not fit below: it goes above if there is more room + // there. + const above = await platform.detectOverflow( + { + ...state, + rects: { ...rects, floating }, + y: rects.reference.y - OFFSET - MIN_HEIGHT, + }, + { padding: PADDING }, + ); + if (above.top < below.bottom) { + wanted = "top-start"; + } + } + + // The menu is already on that side: nothing to change. + if (wanted === placement) { + return {}; + } + // Floating UI runs the middleware again with the menu on that side. + return { reset: { placement: wanted } }; + }, +}; + +/** + * Keeps the menu at the widest it has been since it opened. The menu is as + * wide as its widest item, so without this it would shrink and grow while + * the user filters the items. It can still grow, e.g. when items load + * asynchronously. Each time the menu opens, it is a new floating element, so + * it starts at its own width again. + * + * The minimum width is on the floating element, which wraps the menu: the + * menu grows to fill it (see `.bn-suggestion-menu` in `styles.css`). + */ +function keepWidestWidth(floating: HTMLElement) { + const width = floating.getBoundingClientRect().width; + // The current width includes the minimum set before, so the minimum only + // grows. It changes only by whole pixels, so rounding cannot loop. + if (width >= (parseFloat(floating.style.minWidth) || 0) + 1) { + floating.style.minWidth = `${Math.ceil(width)}px`; + } +} + +/** + * The Floating UI middleware of the suggestion menus: below the trigger + * unless that is too cramped, within the screen, no taller than the room on + * its side or `MAX_HEIGHT` (the menu scrolls), and no narrower than it has + * been since it opened. + */ +export const suggestionMenuMiddleware: Middleware[] = [ + placeBelowUnlessCramped, + offset(OFFSET), + shift(), + size({ + apply({ elements, availableHeight }) { + elements.floating.style.maxHeight = `${Math.max(0, Math.min(availableHeight, MAX_HEIGHT))}px`; + keepWidestWidth(elements.floating); + }, + padding: PADDING, + }), +]; diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index f72b760521..1bb4fb4a86 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -262,6 +262,14 @@ SideMenuController offsets its position to keep it centered on the line. */ height: 30px; } +/* Suggestion menus fill their floating element, which keeps the widest width + the menu has had since it opened (`suggestionMenuMiddleware.ts`). Without + this, a menu would shrink and grow as the user filters its items. */ +.bn-suggestion-menu, +.bn-grid-suggestion-menu { + flex-grow: 1; +} + /* Thread sidebar styling */ .bn-threads-sidebar { border-radius: var(--bn-border-radius-medium); diff --git a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-chromium-linux.png b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-chromium-linux.png index e1efe0e03c..5de8883883 100644 Binary files a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-chromium-linux.png and b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-chromium-linux.png differ diff --git a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-firefox-linux.png b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-firefox-linux.png index dafa559cc4..783674148f 100644 Binary files a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-firefox-linux.png and b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-firefox-linux.png differ diff --git a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-webkit-linux.png b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-webkit-linux.png index 97fa0e3170..d19b3abfab 100644 Binary files a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-webkit-linux.png and b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_down-webkit-linux.png differ diff --git a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-chromium-linux.png b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-chromium-linux.png index b647cc183f..8653c13946 100644 Binary files a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-chromium-linux.png and b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-chromium-linux.png differ diff --git a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-firefox-linux.png b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-firefox-linux.png index a55c42d12a..4616178af8 100644 Binary files a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-firefox-linux.png and b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-firefox-linux.png differ diff --git a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-webkit-linux.png b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-webkit-linux.png index 121450b52b..a6a81e6e52 100644 Binary files a/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-webkit-linux.png and b/tests/src/end-to-end/slashmenu/__screenshots__/slashmenu.test.tsx/slash_menu_page_up-webkit-linux.png differ diff --git a/tests/src/end-to-end/slashmenu/suggestionMenuPlacement.test.tsx b/tests/src/end-to-end/slashmenu/suggestionMenuPlacement.test.tsx new file mode 100644 index 0000000000..a4a6c307cd --- /dev/null +++ b/tests/src/end-to-end/slashmenu/suggestionMenuPlacement.test.tsx @@ -0,0 +1,284 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { filterSuggestionItems } from "@blocknote/core/extensions"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + getDefaultReactSlashMenuItems, + SuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; +import { afterEach, expect, test, vi } from "vite-plus/test"; +import { cleanup, render } from "vitest-browser-react"; + +import { page, userEvent } from "../../utils/context.js"; +import { sleep, waitForSelector } from "../../utils/editor.js"; + +// Where the slash menu goes relative to its trigger (#2771): below, unless +// there is too little room there and more room above. It keeps its side +// while the user filters the items. + +/** How long the slash menu items take to load, with `slowItems`. */ +const ITEMS_DELAY = 500; + +let editor: BlockNoteEditor; + +function Editor(props: { slowItems?: boolean }) { + editor = useCreateBlockNote({ + initialContent: Array.from({ length: 40 }, (_, i) => ({ + type: "paragraph" as const, + content: `Paragraph ${i}`, + })), + }); + if (!props.slowItems) { + return ; + } + // Items that load asynchronously, e.g. from a server. + return ( + + { + await sleep(ITEMS_DELAY); + return filterSuggestionItems( + getDefaultReactSlashMenuItems(editor), + query, + ); + }} + /> + + ); +} + +afterEach(async () => { + await cleanup(); +}); + +/** Opens the slash menu at the end of the paragraph at height `y`. */ +async function openSlashMenuAt( + y: number, + options: { slowItems?: boolean; scrollTop?: number } = {}, +) { + await render(); + await waitForSelector(".bn-editor"); + if (options.scrollTop) { + document.scrollingElement!.scrollTop = options.scrollTop; + await sleep(100); + } + const menu = await typeSlashAt(y); + await sleep(200); + return menu; +} + +/** Types "/" at the end of the paragraph at height `y`, in the editor. */ +async function typeSlashAt(y: number) { + const paragraph = [ + ...document.querySelectorAll('[data-content-type="paragraph"]'), + ].find((element) => { + const rect = element.getBoundingClientRect(); + return rect.top <= y && rect.bottom >= y; + })!; + // Through the API rather than a click: a window resized with + // `page.viewport` is scaled, so mouse coordinates would miss. + editor.setTextCursorPosition( + paragraph + .closest('[data-node-type="blockContainer"]')! + .getAttribute("data-id")!, + "end", + ); + editor.focus(); + await userEvent.keyboard("/"); + return waitForSelector(".bn-suggestion-menu"); +} + +/** The gap (px) between the menu and its trigger. */ +function gap(menu: HTMLElement): number { + const trigger = document + .querySelector("[data-decoration-id]")! + .getBoundingClientRect(); + const rect = menu.getBoundingClientRect(); + return side(menu) === "below" + ? rect.top - trigger.bottom + : trigger.top - rect.bottom; +} + +/** The side of the trigger the menu is on. */ +function side(menu: HTMLElement): "above" | "below" { + const trigger = document + .querySelector("[data-decoration-id]")! + .getBoundingClientRect(); + return menu.getBoundingClientRect().top > trigger.top ? "below" : "above"; +} + +test("stays below the trigger when there is enough room, even with more room above", async () => { + // About 300px below the trigger, more above. + const menu = await openSlashMenuAt(innerHeight * 0.55); + expect(side(menu)).toBe("below"); +}); + +test("is no taller than its maximum height, even with more room", async () => { + // A taller window than the default 720px, so the room below the trigger + // (about 850px) is more than the maximum height. + await page.viewport(1280, 1000); + try { + const menu = await openSlashMenuAt(innerHeight * 0.1); + expect(side(menu)).toBe("below"); + const height = menu.getBoundingClientRect().height; + expect(height).toBeLessThanOrEqual(600); + expect(height).toBeGreaterThan(550); + // The full list is longer, so it scrolls. + expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight); + } finally { + await page.viewport(1280, 720); + } +}); + +test("goes above the trigger when there is too little room below", async () => { + const menu = await openSlashMenuAt(innerHeight * 0.85); + expect(side(menu)).toBe("above"); + // Not squeezed: it gets the room above. + expect(menu.getBoundingClientRect().height).toBeGreaterThan(200); +}); + +test("keeps its side while the user filters the items", async () => { + // About 150px below the trigger: the full list goes above, and the + // filtered list would fit below. + const menu = await openSlashMenuAt(innerHeight - 170); + expect(side(menu)).toBe("above"); + + for (const key of ["q", "u", "o", "t", "e"]) { + await userEvent.keyboard(key); + await sleep(100); + expect(side(menu)).toBe("above"); + // #2155: the shorter menu stays next to the trigger. + expect(gap(menu)).toBeLessThan(15); + } + for (let i = 0; i < 5; i++) { + await userEvent.keyboard("{Backspace}"); + await sleep(100); + expect(side(menu)).toBe("above"); + } +}); + +test("goes above the trigger at once when the items load asynchronously", async () => { + const menu = await openSlashMenuAt(innerHeight * 0.85, { slowItems: true }); + // The items are still loading: the menu is nearly empty, and still goes + // above. + expect(menu.querySelectorAll(".bn-suggestion-menu-item")).toHaveLength(0); + expect(side(menu)).toBe("above"); + + await sleep(ITEMS_DELAY + 300); + expect( + menu.querySelectorAll(".bn-suggestion-menu-item").length, + ).toBeGreaterThan(5); + expect(side(menu)).toBe("above"); +}); + +test("chooses its side again when the page scrolls", async () => { + // The page is scrolled down first, so it can scroll back up below. + const menu = await openSlashMenuAt(innerHeight * 0.5, { scrollTop: 300 }); + expect(side(menu)).toBe("below"); + + // Scrolling up moves the trigger down, near the bottom of the screen. + document.scrollingElement!.scrollTop = 300 - innerHeight * 0.35; + // WebKit delivers the scroll event late (about a second). + await vi.waitFor(() => expect(side(menu)).toBe("above"), { timeout: 3000 }); +}); + +test("chooses its side again when the window is resized", async () => { + try { + // About 300px below the trigger: the menu goes below. + const menu = await openSlashMenuAt(innerHeight * 0.55); + expect(side(menu)).toBe("below"); + + // A shorter window (e.g. a phone's keyboard opens): little room is left + // below the trigger, and more above. + await page.viewport(1280, 480); + await sleep(300); + expect(side(menu)).toBe("above"); + } finally { + await page.viewport(1280, 720); + } +}); + +test("shows the last item when it is selected near the bottom of the screen", async () => { + const menu = await openSlashMenuAt(innerHeight * 0.85); + + // ArrowUp from the first item selects the last one. + await userEvent.keyboard("{ArrowUp}"); + await sleep(300); + + const selected = menu.querySelector('[aria-selected="true"]')!; + const item = selected.getBoundingClientRect(); + const visible = menu.getBoundingClientRect(); + expect(item.top).toBeGreaterThanOrEqual(visible.top - 1); + expect(item.bottom).toBeLessThanOrEqual(visible.bottom + 1); +}); + +/** The menu's width (px). */ +function width(menu: HTMLElement): number { + return Math.round(menu.getBoundingClientRect().width); +} + +test("keeps its width while the user filters the items", async () => { + const menu = await openSlashMenuAt(innerHeight * 0.15); + const opened = width(menu); + + // "quo" leaves only short items, which on their own are narrower. + for (const key of ["q", "u", "o"]) { + await userEvent.keyboard(key); + await sleep(100); + expect(width(menu)).toBe(opened); + } + for (let i = 0; i < 3; i++) { + await userEvent.keyboard("{Backspace}"); + await sleep(100); + expect(width(menu)).toBe(opened); + } +}); + +test("grows when the items load asynchronously, then keeps its width while filtering", async () => { + const menu = await openSlashMenuAt(innerHeight * 0.15, { slowItems: true }); + const loading = width(menu); + + await sleep(ITEMS_DELAY + 300); + const loaded = width(menu); + // The items are wider than the menu while it loads: it grows. + expect(loaded).toBeGreaterThan(loading); + + // Each query loads its items again. + await userEvent.keyboard("quo"); + await sleep(ITEMS_DELAY + 300); + expect(menu.querySelectorAll(".bn-suggestion-menu-item").length).toBeLessThan( + 5, + ); + expect(width(menu)).toBe(loaded); +}); + +test("chooses its side and height again when opened again elsewhere", async () => { + const first = await openSlashMenuAt(innerHeight * 0.15); + expect(side(first)).toBe("below"); + + await userEvent.keyboard("{Escape}"); + await sleep(400); + const second = await typeSlashAt(innerHeight * 0.85); + await sleep(200); + expect(side(second)).toBe("above"); + // The room above, not the height it had below. + expect(second.getBoundingClientRect().height).toBeGreaterThan(400); +}); + +test("starts at its own width when opened again", async () => { + const first = await openSlashMenuAt(innerHeight * 0.15, { + slowItems: true, + }); + await sleep(ITEMS_DELAY + 300); + const loaded = width(first); + + await userEvent.keyboard("{Escape}"); + // After the menu has faded out. + await sleep(400); + const second = await typeSlashAt(innerHeight * 0.3); + // The items are loading again: the menu is as narrow as it was then, not + // as wide as it was with its items. + expect(width(second)).toBeLessThan(loaded); +}); diff --git a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-chromium-linux.png b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-chromium-linux.png index 87aa204a2f..f0358b4ef9 100644 Binary files a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-chromium-linux.png and b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-chromium-linux.png differ diff --git a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-firefox-linux.png b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-firefox-linux.png index 7f4e150f92..04bb1de310 100644 Binary files a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-firefox-linux.png and b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-firefox-linux.png differ diff --git a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-webkit-linux.png b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-webkit-linux.png index 957b8b3053..684b6e1a50 100644 Binary files a/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-webkit-linux.png and b/tests/src/end-to-end/theming/__screenshots__/theming.test.tsx/dark-slash-menu-webkit-linux.png differ