diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index e616c3e4cb..90faec1d20 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -62,6 +62,9 @@ export default function FloatingComposerController< middleware: [offset(10), shift(), flip()], ...props.floatingUIOptions?.useFloatingOptions, }, + focusManagerProps: { + disabled: false, + }, elementProps: { style: { zIndex: 60, diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index 873e90ab0b..c57a1c9295 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -58,6 +58,10 @@ export default function FloatingThreadController(props: { middleware: [offset(10), shift(), flip()], ...props.floatingUIOptions?.useFloatingOptions, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { style: { zIndex: 30, diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx index 96cf7178ce..6beb94ec1e 100644 --- a/packages/react/src/components/FilePanel/FilePanelController.tsx +++ b/packages/react/src/components/FilePanel/FilePanelController.tsx @@ -2,12 +2,12 @@ import { FilePanelExtension } from "@blocknote/core/extensions"; import { flip, offset } from "@floating-ui/react"; import { FC, useMemo } from "react"; -import { FilePanel } from "./FilePanel.js"; -import { FilePanelProps } from "./FilePanelProps.js"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { BlockPopover } from "../Popovers/BlockPopover.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; -import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { FilePanel } from "./FilePanel.js"; +import { FilePanelProps } from "./FilePanelProps.js"; export const FilePanelController = (props: { filePanel?: FC; @@ -37,6 +37,10 @@ export const FilePanelController = (props: { middleware: [offset(10), flip()], ...props.floatingUIOptions?.useFloatingOptions, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { style: { zIndex: 90, diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx index 4d850989a1..20184e626f 100644 --- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx @@ -95,6 +95,10 @@ export const FormattingToolbarController = (props: { middleware: [offset(10), shift(), flip()], ...props.floatingUIOptions?.useFloatingOptions, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { style: { zIndex: 40, diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 1dde4affd0..ea876c24f8 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -150,6 +150,10 @@ export const LinkToolbarController = (props: { handleClose: safePolygon(), ...props.floatingUIOptions?.useHoverProps, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { style: { zIndex: 50, diff --git a/packages/react/src/components/Popovers/FloatingUIOptions.ts b/packages/react/src/components/Popovers/FloatingUIOptions.ts index 2d11726428..065f0339ed 100644 --- a/packages/react/src/components/Popovers/FloatingUIOptions.ts +++ b/packages/react/src/components/Popovers/FloatingUIOptions.ts @@ -1,4 +1,5 @@ import { + FloatingFocusManagerProps, UseDismissProps, UseFloatingOptions, UseHoverProps, @@ -14,4 +15,8 @@ export type FloatingUIOptions = { useDismissProps?: UseDismissProps; useHoverProps?: UseHoverProps; elementProps?: HTMLAttributes; + /** + * Props to pass to the `FloatingFocusManager` component. + */ + focusManagerProps?: Omit; }; diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx index 90c4fd3f94..0bc0b1958d 100644 --- a/packages/react/src/components/Popovers/GenericPopover.tsx +++ b/packages/react/src/components/Popovers/GenericPopover.tsx @@ -1,12 +1,13 @@ import { - useFloating, - useTransitionStyles, + autoUpdate, + FloatingFocusManager, useDismiss, + useFloating, + useHover, useInteractions, useMergeRefs, useTransitionStatus, - autoUpdate, - useHover, + useTransitionStyles, } from "@floating-ui/react"; import { HTMLAttributes, ReactNode, useEffect, useRef } from "react"; @@ -175,6 +176,16 @@ export const GenericPopover = ( ); } + if (!props.focusManagerProps?.disabled) { + return ( + +
+ {props.children} +
+
+ ); + } + return (
{props.children} diff --git a/packages/react/src/components/Popovers/TableCellPopover.tsx b/packages/react/src/components/Popovers/TableCellPopover.tsx deleted file mode 100644 index 4c5ea2b425..0000000000 --- a/packages/react/src/components/Popovers/TableCellPopover.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { getNodeById } from "@blocknote/core"; -import { ReactNode, useMemo } from "react"; - -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { FloatingUIOptions } from "./FloatingUIOptions.js"; -import { GenericPopover } from "./GenericPopover.js"; - -export const TableCellPopover = ( - props: FloatingUIOptions & { - blockId: string | undefined; - colIndex: number | undefined; - rowIndex: number | undefined; - children: ReactNode; - }, -) => { - const { blockId, colIndex, rowIndex, children, ...floatingUIOptions } = props; - - const editor = useBlockNoteEditor(); - - const element = useMemo(() => { - if ( - blockId === undefined || - colIndex === undefined || - rowIndex === undefined - ) { - return undefined; - } - - // TODO use the location API for this - const nodePosInfo = getNodeById(blockId, editor.prosemirrorState.doc); - if (!nodePosInfo) { - return undefined; - } - - const tableBeforePos = nodePosInfo.posBeforeNode + 1; - - const rowBeforePos = editor.prosemirrorState.doc - .resolve(tableBeforePos + 1) - .posAtIndex(rowIndex || 0); - const cellBeforePos = editor.prosemirrorState.doc - .resolve(rowBeforePos + 1) - .posAtIndex(colIndex || 0); - - const { node } = editor.prosemirrorView.domAtPos(cellBeforePos + 1); - if (!(node instanceof Element)) { - return undefined; - } - - return node; - }, [editor, blockId, colIndex, rowIndex]); - - return ( - - {children} - - ); -}; diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx index 50e8fa7de2..7237239b59 100644 --- a/packages/react/src/components/SideMenu/SideMenuController.tsx +++ b/packages/react/src/components/SideMenu/SideMenuController.tsx @@ -36,6 +36,10 @@ export const SideMenuController = (props: { enabled: false, ...props.floatingUIOptions?.useDismissProps, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { style: { zIndex: 20, diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index 08db23e7e4..a2c06b051b 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -133,6 +133,10 @@ export function GridSuggestionMenuController< ], ...props.floatingUIOptions?.useFloatingOptions, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { // Prevents editor blurring when clicking the scroll bar. onMouseDownCapture: (event) => event.preventDefault(), diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 8b16805c16..57e313ce0d 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -128,6 +128,10 @@ export function SuggestionMenuController< ], ...props.floatingUIOptions?.useFloatingOptions, }, + focusManagerProps: { + disabled: true, + ...props.floatingUIOptions?.focusManagerProps, + }, elementProps: { // Prevents editor blurring when clicking the scroll bar. onMouseDownCapture: (event) => event.preventDefault(), diff --git a/packages/react/src/components/TableHandles/TableHandlesController.tsx b/packages/react/src/components/TableHandles/TableHandlesController.tsx index de1e01795c..d898991c64 100644 --- a/packages/react/src/components/TableHandles/TableHandlesController.tsx +++ b/packages/react/src/components/TableHandles/TableHandlesController.tsx @@ -19,9 +19,9 @@ import { } from "../Popovers/GenericPopover.js"; import { ExtendButton } from "./ExtendButton/ExtendButton.js"; import { ExtendButtonProps } from "./ExtendButton/ExtendButtonProps.js"; -import { TableHandle } from "./TableHandle.js"; import { TableCellButton } from "./TableCellButton.js"; import { TableCellButtonProps } from "./TableCellButtonProps.js"; +import { TableHandle } from "./TableHandle.js"; import { TableHandleProps } from "./TableHandleProps.js"; export const TableHandlesController = < @@ -159,6 +159,9 @@ export const TableHandlesController = < placement: "left", middleware: [offset(-10)], }, + focusManagerProps: { + disabled: true, + }, elementProps: { style: { zIndex: 10, @@ -175,6 +178,9 @@ export const TableHandlesController = < placement: "top", middleware: [offset(-12)], }, + focusManagerProps: { + disabled: true, + }, elementProps: { style: { zIndex: 10, @@ -191,6 +197,9 @@ export const TableHandlesController = < placement: "top-end", middleware: [offset({ mainAxis: -15, crossAxis: -1 })], }, + focusManagerProps: { + disabled: true, + }, elementProps: { style: { zIndex: 10, @@ -215,6 +224,9 @@ export const TableHandlesController = < }), ], }, + focusManagerProps: { + disabled: true, + }, elementProps: { style: { zIndex: 10, @@ -239,6 +251,9 @@ export const TableHandlesController = < }), ], }, + focusManagerProps: { + disabled: true, + }, elementProps: { style: { zIndex: 10, diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index ba4178332d..8bea3fbf78 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -3,6 +3,7 @@ import { ComponentType, createContext, CSSProperties, + ForwardedRef, HTMLInputAutoCompleteAttribute, KeyboardEvent, MouseEvent, @@ -274,6 +275,7 @@ export type ComponentProps = { onSubmit?: () => void; autoComplete?: HTMLInputAutoCompleteAttribute; "aria-activedescendant"?: string; + ref?: ForwardedRef; }; }; Menu: { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6b9a7d2697..fdefed5e49 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -51,7 +51,6 @@ export * from "./components/Popovers/BlockPopover.js"; export * from "./components/Popovers/FloatingUIOptions.js"; export * from "./components/Popovers/GenericPopover.js"; export * from "./components/Popovers/PositionPopover.js"; -export * from "./components/Popovers/TableCellPopover.js"; export * from "./components/SideMenu/DefaultButtons/AddBlockButton.js"; export * from "./components/SideMenu/DefaultButtons/DragHandleButton.js"; diff --git a/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx b/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx index 8f0682c7fe..c2e005954b 100644 --- a/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx +++ b/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx @@ -26,71 +26,78 @@ export const AIMenuController = (props: { const blockId = aiMenuState === "closed" ? undefined : aiMenuState.blockId; const floatingUIOptions = useMemo( - () => ({ - ...props.floatingUIOptions, - useFloatingOptions: { - open: aiMenuState !== "closed", - placement: "bottom", - middleware: [ - offset(10), - flip(), - size({ - apply({ rects, elements }) { - Object.assign(elements.floating.style, { - width: `${rects.reference.width}px`, - }); - }, - }), - ], - onOpenChange: (open) => { - if (open || aiMenuState === "closed") { - return; - } + () => + ({ + ...props.floatingUIOptions, + useFloatingOptions: { + open: aiMenuState !== "closed", + placement: "bottom", + middleware: [ + offset(10), + flip(), + size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + }); + }, + }), + ], + onOpenChange: (open) => { + if (open || aiMenuState === "closed") { + return; + } - if (aiMenuState.status === "user-input") { - ai.closeAIMenu(); - } else if ( - aiMenuState.status === "user-reviewing" || - aiMenuState.status === "error" - ) { - ai.rejectChanges(); - } - }, - whileElementsMounted(reference, floating, update) { - return autoUpdate(reference, floating, update, { - animationFrame: true, - }); - }, - ...props.floatingUIOptions?.useFloatingOptions, - }, - useDismissProps: { - enabled: - aiMenuState === "closed" || aiMenuState.status === "user-input", - // We should just be able to set `referencePress: true` instead of - // using this listener, but this doesn't seem to trigger. - // (probably because we don't assign the referenceProps to the reference element) - outsidePress: (event) => { - if (event.target instanceof Element) { - const blockElement = event.target.closest(".bn-block"); - if ( - blockElement && - blockElement.getAttribute("data-id") === blockId - ) { + if (aiMenuState.status === "user-input") { ai.closeAIMenu(); + } else if ( + aiMenuState.status === "user-reviewing" || + aiMenuState.status === "error" + ) { + ai.rejectChanges(); + } + }, + whileElementsMounted(reference, floating, update) { + return autoUpdate(reference, floating, update, { + animationFrame: true, + }); + }, + ...props.floatingUIOptions?.useFloatingOptions, + }, + useDismissProps: { + enabled: + aiMenuState === "closed" || aiMenuState.status === "user-input", + // We should just be able to set `referencePress: true` instead of + // using this listener, but this doesn't seem to trigger. + // (probably because we don't assign the referenceProps to the reference element) + outsidePress: (event) => { + if (event.target instanceof Element) { + const blockElement = event.target.closest(".bn-block"); + if ( + blockElement && + blockElement.getAttribute("data-id") === blockId + ) { + ai.closeAIMenu(); + } } - } - return true; + return true; + }, + ...props.floatingUIOptions?.useDismissProps, + }, + elementProps: { + style: { + zIndex: 100, + }, + ...props.floatingUIOptions?.elementProps, }, - ...props.floatingUIOptions?.useDismissProps, - }, - elementProps: { - style: { - zIndex: 100, + // we use the focus manager instead of `autoFocus={true}` to prevent "page-scrolls-to-top-when-opening-the-floating-element" + // see https://floating-ui.com/docs/floatingfocusmanager#page-scrolls-to-top-when-opening-the-floating-element + focusManagerProps: { + disabled: false, + ...props.floatingUIOptions?.focusManagerProps, }, - ...props.floatingUIOptions?.elementProps, - }, - }), + }) satisfies FloatingUIOptions, [ai, aiMenuState, blockId, props.floatingUIOptions], ); diff --git a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx index 13e2aa4b03..75f9e372cb 100644 --- a/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx +++ b/packages/xl-ai/src/components/AIMenu/PromptSuggestionMenu.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; @@ -30,7 +31,8 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { // const dict = useAIDictionary(); const Components = useComponentsContext()!; - const { onManualPromptSubmit, promptText, onPromptTextChange } = props; + const { onManualPromptSubmit, promptText, onPromptTextChange, disabled } = + props; // Only used internal state when `props.prompText` is undefined (i.e., uncontrolled mode) const [internalPromptText, setInternalPromptText] = useState(""); @@ -95,18 +97,31 @@ export const PromptSuggestionMenu = (props: PromptSuggestionMenuProps) => { setSelectedIndex(0); }, [promptTextToUse, setSelectedIndex]); + const inputRef = useRef(null); + const hasBeenDisabled = useRef(disabled); + + useEffect(() => { + // This effect is used so that after the input has been disabled (for example, when AI results are loaded), + // the input is focused again. + if (inputRef.current && hasBeenDisabled.current && !disabled) { + inputRef.current.focus(); + } + + if (disabled) { + hasBeenDisabled.current = true; + } + }, [disabled]); + return (
{ + await page.goto(AI_URL); +}); + +test.describe("AI Menu Scroll Regression", () => { + test("opening the AI menu should not scroll the page to the top", async ({ + page, + }) => { + // Wait for the editor to be ready + await page.waitForSelector(EDITOR_SELECTOR); + + // Click on the last paragraph so the cursor is near the bottom of the content + const lastParagraph = page + .locator("[data-content-type='paragraph']") + .last(); + await lastParagraph.click(); + + // Ensure the page is scrolled down (editor puts cursor near bottom of content) + // We scroll down explicitly to make sure we're not at the top + await page.evaluate(() => { + window.scrollTo(0, document.body.scrollHeight); + }); + await page.waitForTimeout(200); + + // Record the scroll position before opening the AI menu + const scrollYBefore = await page.evaluate(() => window.scrollY); + + // Sanity check: we should actually be scrolled down + expect(scrollYBefore).toBeGreaterThan(0); + + // Open the AI menu via the slash command + // First, focus back on the editor at the last paragraph + await lastParagraph.click(); + await page.waitForTimeout(100); + + // Type /ai to open the slash menu and select the AI option + await page.keyboard.type("/ai", { delay: 50 }); + await page.waitForTimeout(300); + + // Wait for the suggestion menu to appear + const suggestionMenu = page.locator(".bn-suggestion-menu"); + await suggestionMenu.waitFor({ state: "visible", timeout: 3000 }); + + // Click the AI suggestion menu item to open the AI menu + const aiMenuItem = suggestionMenu + .locator(".bn-suggestion-menu-item") + .first(); + await aiMenuItem.click(); + + // Wait for the AI menu (combobox input) to appear + const aiMenuInput = page.locator( + ".bn-combobox-input input, .bn-combobox input", + ); + await aiMenuInput.waitFor({ state: "visible", timeout: 3000 }); + + // Brief wait for any scroll side effects to take place + await page.waitForTimeout(300); + + // Screenshot after opening AI menu + expect(await page.screenshot()).toMatchSnapshot( + "ai_menu_scroll_position.png", + ); + + // Check that the scroll position has not jumped to the top + const scrollYAfter = await page.evaluate(() => window.scrollY); + expect(scrollYAfter).toBeGreaterThan(0); + expect(scrollYAfter).toBeGreaterThanOrEqual(scrollYBefore * 0.2); + + // Verify the AI menu input is actually focused + await expect(aiMenuInput).toBeFocused(); + }); +}); diff --git a/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-chromium-darwin.png b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-chromium-darwin.png new file mode 100644 index 0000000000..7deffef50c Binary files /dev/null and b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-chromium-darwin.png differ diff --git a/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-chromium-linux.png b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-chromium-linux.png new file mode 100644 index 0000000000..b1da69e580 Binary files /dev/null and b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-chromium-linux.png differ diff --git a/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-firefox-linux.png b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-firefox-linux.png new file mode 100644 index 0000000000..61f6813e86 Binary files /dev/null and b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-firefox-linux.png differ diff --git a/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-webkit-linux.png b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-webkit-linux.png new file mode 100644 index 0000000000..dfd2aab50c Binary files /dev/null and b/tests/src/end-to-end/ai/ai.test.ts-snapshots/ai-menu-scroll-position-webkit-linux.png differ diff --git a/tests/src/utils/const.ts b/tests/src/utils/const.ts index 79b21b16ee..8dc672aee1 100644 --- a/tests/src/utils/const.ts +++ b/tests/src/utils/const.ts @@ -11,6 +11,10 @@ export const ARIAKIT_URL = !process.env.RUN_IN_DOCKER ? `http://localhost:${PORT}/basic/ariakit?hideMenu` : `http://host.docker.internal:${PORT}/basic/ariakit?hideMenu`; +export const AI_URL = !process.env.RUN_IN_DOCKER + ? `http://localhost:${PORT}/ai/minimal?hideMenu` + : `http://host.docker.internal:${PORT}/ai/minimal?hideMenu`; + export const STATIC_URL = !process.env.RUN_IN_DOCKER ? `http://localhost:${PORT}/backend/rendering-static-documents?hideMenu` : `http://host.docker.internal:${PORT}/backend/rendering-static-documents?hideMenu`;