diff --git a/examples/09-ai/01-minimal/src/App.tsx b/examples/09-ai/01-minimal/src/App.tsx index e78d2b0af8..8c0ce912a2 100644 --- a/examples/09-ai/01-minimal/src/App.tsx +++ b/examples/09-ai/01-minimal/src/App.tsx @@ -77,6 +77,7 @@ export default function App() { // We're disabling some default UI elements formattingToolbar={false} slashMenu={false} + style={{ paddingBottom: "300px" }} > {/* Add the AI Command menu to the editor */} diff --git a/examples/09-ai/02-playground/src/App.tsx b/examples/09-ai/02-playground/src/App.tsx index a180746de5..17ceacd057 100644 --- a/examples/09-ai/02-playground/src/App.tsx +++ b/examples/09-ai/02-playground/src/App.tsx @@ -122,6 +122,7 @@ export default function App() { editor={editor} formattingToolbar={false} slashMenu={false} + style={{ paddingBottom: "300px" }} > {/* Add the AI Command menu to the editor */} diff --git a/examples/09-ai/03-custom-ai-menu-items/src/App.tsx b/examples/09-ai/03-custom-ai-menu-items/src/App.tsx index 19005a17c3..876e371b4d 100644 --- a/examples/09-ai/03-custom-ai-menu-items/src/App.tsx +++ b/examples/09-ai/03-custom-ai-menu-items/src/App.tsx @@ -78,6 +78,7 @@ export default function App() { editor={editor} formattingToolbar={false} slashMenu={false} + style={{ paddingBottom: "300px" }} > {/* Creates a new AIMenu with the default items, as well as our custom ones. */} diff --git a/packages/xl-ai/src/AIExtension.ts b/packages/xl-ai/src/AIExtension.ts index 0e4df22066..6258cdc38f 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -2,6 +2,7 @@ import { Chat } from "@ai-sdk/react"; import { BlockNoteEditor, BlockNoteExtension, + getNodeById, UnreachableCaseError, } from "@blocknote/core"; import { @@ -65,6 +66,9 @@ export class AIExtension extends BlockNoteExtension { } | undefined; + private scrollInProgress = false; + private autoScroll = false; + public static key(): string { return "ai"; } @@ -134,6 +138,31 @@ export class AIExtension extends BlockNoteExtension { options.agentCursor || { name: "AI", color: "#8bc6ff" }, ), ); + + // Listens for `scroll` and `scrollend` events to see if a new scroll was + // started before an existing one ended. This is the most reliable way we + // have of checking if a scroll event was caused by the user and not by + // `scrollIntoView`, as the events are otherwise indistinguishable. If a + // scroll was started before an existing one finished (meaning the user has + // scrolled), auto scrolling is disabled. + document.addEventListener( + "scroll", + () => { + if (this.scrollInProgress) { + this.autoScroll = false; + } + + this.scrollInProgress = true; + }, + true, + ); + document.addEventListener( + "scrollend", + () => { + this.scrollInProgress = false; + }, + true, + ); } /** @@ -148,6 +177,12 @@ export class AIExtension extends BlockNoteExtension { status: "user-input", }, }); + + // Scrolls to the block when the menu opens. + const blockElement = this.editor.domElement?.querySelector( + `[data-node-type="blockContainer"][data-id="${blockID}"]`, + ); + blockElement?.scrollIntoView({ block: "center" }); } /** @@ -371,7 +406,7 @@ export class AIExtension extends BlockNoteExtension { useSelection: opts.useSelection, deleteEmptyCursorBlock: opts.deleteEmptyCursorBlock, streamToolsProvider: opts.streamToolsProvider, - onBlockUpdated: (blockId: string) => { + onBlockUpdated: (blockId) => { // NOTE: does this setState with an anon object trigger unnecessary re-renders? this._store.setState({ aiMenuState: { @@ -379,6 +414,34 @@ export class AIExtension extends BlockNoteExtension { status: "ai-writing", }, }); + + // Scrolls to the block being edited by the AI while auto scrolling is + // enabled. + if (!this.autoScroll) { + return; + } + + const aiMenuState = this._store.getState().aiMenuState; + const aiMenuOpenState = + aiMenuState === "closed" ? undefined : aiMenuState; + if (!aiMenuOpenState || aiMenuOpenState.status !== "ai-writing") { + return; + } + + const nodeInfo = getNodeById( + aiMenuOpenState.blockId, + this.editor.prosemirrorState.doc, + ); + if (!nodeInfo) { + return; + } + + const blockElement = this.editor.prosemirrorView.domAtPos( + nodeInfo.posBeforeNode + 1, + ); + (blockElement.node as HTMLElement).scrollIntoView({ + block: "center", + }); }, }); @@ -387,6 +450,7 @@ export class AIExtension extends BlockNoteExtension { sender, chatRequestOptions: opts.chatRequestOptions, onStart: () => { + this.autoScroll = true; this.setAIResponseStatus("ai-writing"); }, });