From e7d4b142e804112cd27b91831a553a914b2e4d5d Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Mon, 29 Sep 2025 11:51:49 +0200 Subject: [PATCH 1/8] Added AI menu auto scrolling --- docs/app/styles.css | 11 ++- .../components/AIMenu/AIMenuController.tsx | 78 ++++++++++++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/app/styles.css b/docs/app/styles.css index a2c1a7449c..cafaa67c71 100644 --- a/docs/app/styles.css +++ b/docs/app/styles.css @@ -49,6 +49,14 @@ body { box-shadow: unset !important; } +.demo { + overflow: none; +} + +.demo .bn-container { + position: relative; +} + .demo .bn-container:not(.bn-comment-editor), .demo .bn-editor { height: 100%; @@ -61,7 +69,8 @@ body { .demo .bn-editor { overflow: auto; - padding-block: 1rem; + padding-top: 1rem; + padding-bottom: 250px; } .demo .bn-editor a { diff --git a/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx b/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx index fa6d27dce5..1d9488afbb 100644 --- a/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx +++ b/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx @@ -1,5 +1,5 @@ import { useBlockNoteEditor } from "@blocknote/react"; -import { FC } from "react"; +import { FC, useEffect, useState } from "react"; import { useStore } from "zustand"; import { getAIExtension } from "../../AIExtension.js"; @@ -16,6 +16,82 @@ export const AIMenuController = (props: { aiMenu?: FC }) => { const Component = props.aiMenu || AIMenu; + const [aiWriting, setAiWriting] = useState(false); + const [autoScroll, setAutoScroll] = useState(false); + const [scrollInProgress, setScrollInProgress] = useState(false); + + // Converts the `aiMenuState` status to a boolean which shows if the AI is + // writing or not. This allows for proper reactivity in other `useEffect` + // hooks, while using the base `aiMenuState` object would constantly + // retrigger them. + useEffect(() => { + if ( + typeof aiMenuState === "object" && + "status" in aiMenuState && + aiMenuState.status === "ai-writing" + ) { + setAiWriting(true); + } else { + setAiWriting(false); + } + }, [aiMenuState]); + + // Enables auto scrolling when the AI starts writing and disables it when it + // stops writing. + useEffect(() => { + if (aiWriting) { + setAutoScroll(true); + } else { + setAutoScroll(false); + } + }, [aiWriting]); + + // Scrolls to the block being edited by the AI while auto scrolling is + // enabled. + useEffect(() => { + const scrollToBottom = () => { + if (!autoScroll) { + return; + } + + const blockElement = editor.domElement?.querySelector( + `[data-node-type="blockContainer"][data-id="${blockId}"]`, + ); + blockElement?.scrollIntoView({ block: "center" }); + }; + + const destroy = editor.onChange(scrollToBottom); + + return () => destroy(); + }, [autoScroll, blockId, editor]); + + // 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. + useEffect(() => { + const scrollHandler = () => { + if (scrollInProgress) { + setAutoScroll(false); + } + + setScrollInProgress(true); + }; + const scrollEndHandler = () => setScrollInProgress(false); + + // Need to set capture to `true` so the events get handled regardless of + // which element gets scrolled. + document.addEventListener("scroll", scrollHandler, true); + document.addEventListener("scrollend", scrollEndHandler, true); + + return () => { + document.removeEventListener("scroll", scrollHandler, true); + document.removeEventListener("scrollend", scrollEndHandler, true); + }; + }, [scrollInProgress]); + return ( Date: Tue, 30 Sep 2025 16:50:24 +0200 Subject: [PATCH 2/8] Moved auto scroll logic to plugin layer --- examples/09-ai/01-minimal/src/App.tsx | 1 + examples/09-ai/02-playground/src/App.tsx | 1 + .../09-ai/03-custom-ai-menu-items/src/App.tsx | 1 + packages/xl-ai/src/AIExtension.ts | 55 +++++++++++++ .../components/AIMenu/AIMenuController.tsx | 78 +------------------ 5 files changed, 59 insertions(+), 77 deletions(-) diff --git a/examples/09-ai/01-minimal/src/App.tsx b/examples/09-ai/01-minimal/src/App.tsx index 9f56576e67..ea57ab04a1 100644 --- a/examples/09-ai/01-minimal/src/App.tsx +++ b/examples/09-ai/01-minimal/src/App.tsx @@ -102,6 +102,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 a739a8d026..20c613718b 100644 --- a/examples/09-ai/02-playground/src/App.tsx +++ b/examples/09-ai/02-playground/src/App.tsx @@ -209,6 +209,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 2b4b5e9d57..1e2b253a86 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 @@ -104,6 +104,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 e4ba5d6840..5a4b1e250b 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -88,6 +88,9 @@ const PLUGIN_KEY = new PluginKey(`blocknote-ai-plugin`); export class AIExtension extends BlockNoteExtension { private previousRequestOptions: LLMRequestOptions | undefined; + private scrollInProgress = false; + private autoScroll = false; + public static key(): string { return "ai"; } @@ -165,6 +168,51 @@ export class AIExtension extends BlockNoteExtension { options.agentCursor || { name: "AI", color: "#8bc6ff" }, ), ); + + // Scrolls to the block being edited by the AI while auto scrolling is + // enabled. + this.editor.onCreate(() => { + this.editor.onChange(() => { + if (!this.autoScroll) { + return; + } + + const aiMenuState = this._store.getState().aiMenuState; + const aiMenuNonErrorState = + aiMenuState === "closed" ? undefined : aiMenuState; + if (aiMenuNonErrorState?.status === "ai-writing") { + const blockElement = this.editor.domElement?.querySelector( + `[data-node-type="blockContainer"][data-id="${aiMenuNonErrorState.blockId}"]`, + ); + blockElement?.scrollIntoView({ block: "center" }); + } + }); + }); + + // 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, + ); } /** @@ -179,6 +227,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" }); } /** @@ -364,6 +418,7 @@ export class AIExtension extends BlockNoteExtension { ret = await doLLMRequest(this.editor, { ...requestOptions, onStart: () => { + this.autoScroll = true; this.setAIResponseStatus("ai-writing"); opts.onStart?.(); }, diff --git a/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx b/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx index 1d9488afbb..fa6d27dce5 100644 --- a/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx +++ b/packages/xl-ai/src/components/AIMenu/AIMenuController.tsx @@ -1,5 +1,5 @@ import { useBlockNoteEditor } from "@blocknote/react"; -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; import { useStore } from "zustand"; import { getAIExtension } from "../../AIExtension.js"; @@ -16,82 +16,6 @@ export const AIMenuController = (props: { aiMenu?: FC }) => { const Component = props.aiMenu || AIMenu; - const [aiWriting, setAiWriting] = useState(false); - const [autoScroll, setAutoScroll] = useState(false); - const [scrollInProgress, setScrollInProgress] = useState(false); - - // Converts the `aiMenuState` status to a boolean which shows if the AI is - // writing or not. This allows for proper reactivity in other `useEffect` - // hooks, while using the base `aiMenuState` object would constantly - // retrigger them. - useEffect(() => { - if ( - typeof aiMenuState === "object" && - "status" in aiMenuState && - aiMenuState.status === "ai-writing" - ) { - setAiWriting(true); - } else { - setAiWriting(false); - } - }, [aiMenuState]); - - // Enables auto scrolling when the AI starts writing and disables it when it - // stops writing. - useEffect(() => { - if (aiWriting) { - setAutoScroll(true); - } else { - setAutoScroll(false); - } - }, [aiWriting]); - - // Scrolls to the block being edited by the AI while auto scrolling is - // enabled. - useEffect(() => { - const scrollToBottom = () => { - if (!autoScroll) { - return; - } - - const blockElement = editor.domElement?.querySelector( - `[data-node-type="blockContainer"][data-id="${blockId}"]`, - ); - blockElement?.scrollIntoView({ block: "center" }); - }; - - const destroy = editor.onChange(scrollToBottom); - - return () => destroy(); - }, [autoScroll, blockId, editor]); - - // 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. - useEffect(() => { - const scrollHandler = () => { - if (scrollInProgress) { - setAutoScroll(false); - } - - setScrollInProgress(true); - }; - const scrollEndHandler = () => setScrollInProgress(false); - - // Need to set capture to `true` so the events get handled regardless of - // which element gets scrolled. - document.addEventListener("scroll", scrollHandler, true); - document.addEventListener("scrollend", scrollEndHandler, true); - - return () => { - document.removeEventListener("scroll", scrollHandler, true); - document.removeEventListener("scrollend", scrollEndHandler, true); - }; - }, [scrollInProgress]); - return ( Date: Tue, 30 Sep 2025 16:51:37 +0200 Subject: [PATCH 3/8] Removed docs styles --- docs/app/styles.css | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/app/styles.css b/docs/app/styles.css index cafaa67c71..a2c1a7449c 100644 --- a/docs/app/styles.css +++ b/docs/app/styles.css @@ -49,14 +49,6 @@ body { box-shadow: unset !important; } -.demo { - overflow: none; -} - -.demo .bn-container { - position: relative; -} - .demo .bn-container:not(.bn-comment-editor), .demo .bn-editor { height: 100%; @@ -69,8 +61,7 @@ body { .demo .bn-editor { overflow: auto; - padding-top: 1rem; - padding-bottom: 250px; + padding-block: 1rem; } .demo .bn-editor a { From 3546b42c855c9017ba1af41df2730e65f4a9445e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 2 Oct 2025 19:41:24 +0200 Subject: [PATCH 4/8] Implemented PR feedback --- packages/core/src/api/getBlockInfoFromPos.ts | 1 + packages/xl-ai/src/AIExtension.ts | 27 +++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index b0768a2cc8..f9c6c1c819 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,5 +1,6 @@ import { Node, ResolvedPos } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; +import { BlockIdentifier } from "../schema"; type SingleBlockInfo = { node: Node; diff --git a/packages/xl-ai/src/AIExtension.ts b/packages/xl-ai/src/AIExtension.ts index 5a4b1e250b..2ca93d8fba 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -1,6 +1,7 @@ import { BlockNoteEditor, BlockNoteExtension, + getNodeById, UnreachableCaseError, } from "@blocknote/core"; import { @@ -171,8 +172,8 @@ export class AIExtension extends BlockNoteExtension { // Scrolls to the block being edited by the AI while auto scrolling is // enabled. - this.editor.onCreate(() => { - this.editor.onChange(() => { + editor.onCreate(() => + editor.onChange(() => { if (!this.autoScroll) { return; } @@ -181,13 +182,25 @@ export class AIExtension extends BlockNoteExtension { const aiMenuNonErrorState = aiMenuState === "closed" ? undefined : aiMenuState; if (aiMenuNonErrorState?.status === "ai-writing") { - const blockElement = this.editor.domElement?.querySelector( - `[data-node-type="blockContainer"][data-id="${aiMenuNonErrorState.blockId}"]`, + const nodeInfo = getNodeById( + aiMenuNonErrorState.blockId, + editor.prosemirrorState.doc, ); - blockElement?.scrollIntoView({ block: "center" }); + if (!nodeInfo) { + throw new Error( + "Block edited by AI could not be found in the editor.", + ); + } + + const blockElement = editor.prosemirrorView.domAtPos( + nodeInfo.posBeforeNode + 1, + ); + (blockElement.node as HTMLElement).scrollIntoView({ + block: "center", + }); } - }); - }); + }), + ); // 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 From 7696fd1220cd9bfb44ccbbf8921316f8d4fe72da Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 3 Oct 2025 10:41:47 +0200 Subject: [PATCH 5/8] wip --- packages/xl-ai/src/AIExtension.ts | 64 ++++++++++--------- packages/xl-ai/src/api/aiRequest/execute.ts | 3 +- .../formats/base-tools/createAddBlocksTool.ts | 6 +- .../base-tools/createUpdateBlockTool.ts | 5 +- .../src/api/formats/base-tools/delete.ts | 5 +- packages/xl-ai/src/api/formats/index.ts | 3 +- 6 files changed, 48 insertions(+), 38 deletions(-) diff --git a/packages/xl-ai/src/AIExtension.ts b/packages/xl-ai/src/AIExtension.ts index ee025bc037..a70908ca06 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -141,35 +141,35 @@ export class AIExtension extends BlockNoteExtension { // Scrolls to the block being edited by the AI while auto scrolling is // enabled. - editor.onCreate(() => - editor.onChange(() => { - if (!this.autoScroll) { - return; - } - - const aiMenuState = this._store.getState().aiMenuState; - const aiMenuNonErrorState = - aiMenuState === "closed" ? undefined : aiMenuState; - if (aiMenuNonErrorState?.status === "ai-writing") { - const nodeInfo = getNodeById( - aiMenuNonErrorState.blockId, - editor.prosemirrorState.doc, - ); - if (!nodeInfo) { - throw new Error( - "Block edited by AI could not be found in the editor.", - ); - } - - const blockElement = editor.prosemirrorView.domAtPos( - nodeInfo.posBeforeNode + 1, - ); - (blockElement.node as HTMLElement).scrollIntoView({ - block: "center", - }); - } - }), - ); + // editor.onCreate(() => + // editor.onChange(() => { + // if (!this.autoScroll) { + // return; + // } + + // // const aiMenuState = this._store.getState().aiMenuState; + // // const aiMenuNonErrorState = + // // aiMenuState === "closed" ? undefined : aiMenuState; + // // if (aiMenuNonErrorState?.status === "ai-writing") { + // // const nodeInfo = getNodeById( + // // aiMenuNonErrorState.blockId, + // // editor.prosemirrorState.doc, + // // ); + // // if (!nodeInfo) { + // // throw new Error( + // // "Block edited by AI could not be found in the editor.", + // // ); + // // } + + // // const blockElement = editor.prosemirrorView.domAtPos( + // // nodeInfo.posBeforeNode + 1, + // // ); + // // (blockElement.node as HTMLElement).scrollIntoView({ + // // block: "center", + // // }); + // // } + // }), + // ); // 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 @@ -438,7 +438,7 @@ export class AIExtension extends BlockNoteExtension { useSelection: opts.useSelection, deleteEmptyCursorBlock: opts.deleteEmptyCursorBlock, streamToolsProvider: opts.streamToolsProvider, - onBlockUpdated: (blockId: string) => { + onBlockUpdated: (blockId, tr) => { // NOTE: does this setState with an anon object trigger unnecessary re-renders? this._store.setState({ aiMenuState: { @@ -446,6 +446,10 @@ export class AIExtension extends BlockNoteExtension { status: "ai-writing", }, }); + + if (this.autoScroll) { + tr.scrollIntoView(); + } }, }); diff --git a/packages/xl-ai/src/api/aiRequest/execute.ts b/packages/xl-ai/src/api/aiRequest/execute.ts index ae15edf6ae..82b4d7d8e7 100644 --- a/packages/xl-ai/src/api/aiRequest/execute.ts +++ b/packages/xl-ai/src/api/aiRequest/execute.ts @@ -6,6 +6,7 @@ import { isEmptyParagraph } from "../../util/emptyBlock.js"; import { aiDocumentFormats, StreamToolsProvider } from "../index.js"; import { trimEmptyBlocks } from "../promptHelpers/trimEmptyBlocks.js"; import { AIRequest, AIRequestSender } from "./types.js"; +import { Transaction } from "prosemirror-state"; export function buildAIRequest(opts: { editor: BlockNoteEditor; @@ -14,7 +15,7 @@ export function buildAIRequest(opts: { useSelection?: boolean; deleteEmptyCursorBlock?: boolean; streamToolsProvider?: StreamToolsProvider; - onBlockUpdated?: (blockId: string) => void; + onBlockUpdated?: (blockId: string, tr: Transaction) => void; }) { const { useSelection, deleteEmptyCursorBlock, streamToolsProvider } = { useSelection: opts.useSelection ?? false, diff --git a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts index 12a2af55a3..80a3cd0b96 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts @@ -11,6 +11,7 @@ import { RebaseTool } from "../../../prosemirror/rebaseTool.js"; import { Result, streamTool } from "../../../streamTool/streamTool.js"; import { isEmptyParagraph } from "../../../util/emptyBlock.js"; import { validateBlockArray } from "./util/validateBlockArray.js"; +import { Transaction } from "prosemirror-state"; /** * Factory function to create a StreamTool that adds blocks to the document. @@ -74,7 +75,7 @@ export function createAddBlocksTool(config: { options: { idsSuffixed: boolean; withDelays: boolean; - onBlockUpdate?: (blockId: string) => void; + onBlockUpdate?: (blockId: string, tr: Transaction) => void; }, ) => { const schema = @@ -271,10 +272,11 @@ export function createAddBlocksTool(config: { if (options.withDelays) { await delayAgentStep(step); } + const addedBlockId = addedBlockIds[i]; editor.transact((tr) => { applyAgentStep(tr, step); + options.onBlockUpdate?.(addedBlockId, tr); }); - options.onBlockUpdate?.(addedBlockIds[i]); } } diff --git a/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts b/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts index 2bd41b1e00..5a8cb1f55c 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts @@ -9,6 +9,7 @@ import { import { updateToReplaceSteps } from "../../../prosemirror/changeset.js"; import { RebaseTool } from "../../../prosemirror/rebaseTool.js"; import { Result, streamTool } from "../../../streamTool/streamTool.js"; +import { Transaction } from "prosemirror-state"; export type UpdateBlockToolCall = { type: "update"; @@ -83,7 +84,7 @@ export function createUpdateBlockTool(config: { from: number; to: number; }; - onBlockUpdate?: (blockId: string) => void; + onBlockUpdate?: (blockId: string, tr: Transaction) => void; }, ) => { const schema = @@ -249,8 +250,8 @@ export function createUpdateBlockTool(config: { } editor.transact((tr) => { applyAgentStep(tr, step); + options.onBlockUpdate?.(operation.id, tr); }); - options.onBlockUpdate?.(operation.id); } return true; }, diff --git a/packages/xl-ai/src/api/formats/base-tools/delete.ts b/packages/xl-ai/src/api/formats/base-tools/delete.ts index 69642a9ddb..0045ea3bfe 100644 --- a/packages/xl-ai/src/api/formats/base-tools/delete.ts +++ b/packages/xl-ai/src/api/formats/base-tools/delete.ts @@ -5,6 +5,7 @@ import { getStepsAsAgent, } from "../../../prosemirror/agent.js"; import { streamTool } from "../../../streamTool/streamTool.js"; +import { Transaction } from "prosemirror-state"; /** * Factory function to create a StreamTool that deletes a block from the document. @@ -14,7 +15,7 @@ export const deleteBlockTool = ( options: { idsSuffixed: boolean; withDelays: boolean; - onBlockUpdate?: (blockId: string) => void; + onBlockUpdate?: (blockId: string, tr: Transaction) => void; }, ) => streamTool({ @@ -98,8 +99,8 @@ export const deleteBlockTool = ( } editor.transact((tr) => { applyAgentStep(tr, step); + options.onBlockUpdate?.(operation.id, tr); }); - options.onBlockUpdate?.(operation.id); } return true; }, diff --git a/packages/xl-ai/src/api/formats/index.ts b/packages/xl-ai/src/api/formats/index.ts index 8c28b19d4d..8e696e90c3 100644 --- a/packages/xl-ai/src/api/formats/index.ts +++ b/packages/xl-ai/src/api/formats/index.ts @@ -8,6 +8,7 @@ import { HTMLPromptData } from "./html-blocks/htmlPromptData.js"; import { jsonBlockLLMFormat } from "./json/json.js"; import { markdownBlockLLMFormat } from "./markdown-blocks/markdownBlocks.js"; import { PromptBuilder } from "./PromptBuilder.js"; +import { Transaction } from "prosemirror-state"; // Define the tool types export type AddTool = StreamTool>; @@ -39,7 +40,7 @@ export type StreamToolsProvider< to: number; } | boolean, - onBlockUpdate?: (blockId: string) => void, + onBlockUpdate?: (blockId: string, tr: Transaction) => void, ) => StreamToolsResult; }; From 5d1e5baab6df85bdf3d8b6091a9e751b80b0c8dc Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 3 Oct 2025 11:30:45 +0200 Subject: [PATCH 6/8] Changed from using `tr.scrollIntoView` to custom scroll logic --- packages/xl-ai/src/AIExtension.ts | 60 +++++++++---------- packages/xl-ai/src/api/aiRequest/execute.ts | 3 +- .../formats/base-tools/createAddBlocksTool.ts | 6 +- .../base-tools/createUpdateBlockTool.ts | 5 +- .../src/api/formats/base-tools/delete.ts | 5 +- packages/xl-ai/src/api/formats/index.ts | 3 +- 6 files changed, 42 insertions(+), 40 deletions(-) diff --git a/packages/xl-ai/src/AIExtension.ts b/packages/xl-ai/src/AIExtension.ts index ee025bc037..071d4e99d7 100644 --- a/packages/xl-ai/src/AIExtension.ts +++ b/packages/xl-ai/src/AIExtension.ts @@ -139,38 +139,6 @@ export class AIExtension extends BlockNoteExtension { ), ); - // Scrolls to the block being edited by the AI while auto scrolling is - // enabled. - editor.onCreate(() => - editor.onChange(() => { - if (!this.autoScroll) { - return; - } - - const aiMenuState = this._store.getState().aiMenuState; - const aiMenuNonErrorState = - aiMenuState === "closed" ? undefined : aiMenuState; - if (aiMenuNonErrorState?.status === "ai-writing") { - const nodeInfo = getNodeById( - aiMenuNonErrorState.blockId, - editor.prosemirrorState.doc, - ); - if (!nodeInfo) { - throw new Error( - "Block edited by AI could not be found in the editor.", - ); - } - - const blockElement = editor.prosemirrorView.domAtPos( - nodeInfo.posBeforeNode + 1, - ); - (blockElement.node as HTMLElement).scrollIntoView({ - block: "center", - }); - } - }), - ); - // 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 @@ -446,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", + }); }, }); diff --git a/packages/xl-ai/src/api/aiRequest/execute.ts b/packages/xl-ai/src/api/aiRequest/execute.ts index ae15edf6ae..82b4d7d8e7 100644 --- a/packages/xl-ai/src/api/aiRequest/execute.ts +++ b/packages/xl-ai/src/api/aiRequest/execute.ts @@ -6,6 +6,7 @@ import { isEmptyParagraph } from "../../util/emptyBlock.js"; import { aiDocumentFormats, StreamToolsProvider } from "../index.js"; import { trimEmptyBlocks } from "../promptHelpers/trimEmptyBlocks.js"; import { AIRequest, AIRequestSender } from "./types.js"; +import { Transaction } from "prosemirror-state"; export function buildAIRequest(opts: { editor: BlockNoteEditor; @@ -14,7 +15,7 @@ export function buildAIRequest(opts: { useSelection?: boolean; deleteEmptyCursorBlock?: boolean; streamToolsProvider?: StreamToolsProvider; - onBlockUpdated?: (blockId: string) => void; + onBlockUpdated?: (blockId: string, tr: Transaction) => void; }) { const { useSelection, deleteEmptyCursorBlock, streamToolsProvider } = { useSelection: opts.useSelection ?? false, diff --git a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts index 12a2af55a3..80a3cd0b96 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts @@ -11,6 +11,7 @@ import { RebaseTool } from "../../../prosemirror/rebaseTool.js"; import { Result, streamTool } from "../../../streamTool/streamTool.js"; import { isEmptyParagraph } from "../../../util/emptyBlock.js"; import { validateBlockArray } from "./util/validateBlockArray.js"; +import { Transaction } from "prosemirror-state"; /** * Factory function to create a StreamTool that adds blocks to the document. @@ -74,7 +75,7 @@ export function createAddBlocksTool(config: { options: { idsSuffixed: boolean; withDelays: boolean; - onBlockUpdate?: (blockId: string) => void; + onBlockUpdate?: (blockId: string, tr: Transaction) => void; }, ) => { const schema = @@ -271,10 +272,11 @@ export function createAddBlocksTool(config: { if (options.withDelays) { await delayAgentStep(step); } + const addedBlockId = addedBlockIds[i]; editor.transact((tr) => { applyAgentStep(tr, step); + options.onBlockUpdate?.(addedBlockId, tr); }); - options.onBlockUpdate?.(addedBlockIds[i]); } } diff --git a/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts b/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts index 2bd41b1e00..5a8cb1f55c 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts @@ -9,6 +9,7 @@ import { import { updateToReplaceSteps } from "../../../prosemirror/changeset.js"; import { RebaseTool } from "../../../prosemirror/rebaseTool.js"; import { Result, streamTool } from "../../../streamTool/streamTool.js"; +import { Transaction } from "prosemirror-state"; export type UpdateBlockToolCall = { type: "update"; @@ -83,7 +84,7 @@ export function createUpdateBlockTool(config: { from: number; to: number; }; - onBlockUpdate?: (blockId: string) => void; + onBlockUpdate?: (blockId: string, tr: Transaction) => void; }, ) => { const schema = @@ -249,8 +250,8 @@ export function createUpdateBlockTool(config: { } editor.transact((tr) => { applyAgentStep(tr, step); + options.onBlockUpdate?.(operation.id, tr); }); - options.onBlockUpdate?.(operation.id); } return true; }, diff --git a/packages/xl-ai/src/api/formats/base-tools/delete.ts b/packages/xl-ai/src/api/formats/base-tools/delete.ts index 69642a9ddb..0045ea3bfe 100644 --- a/packages/xl-ai/src/api/formats/base-tools/delete.ts +++ b/packages/xl-ai/src/api/formats/base-tools/delete.ts @@ -5,6 +5,7 @@ import { getStepsAsAgent, } from "../../../prosemirror/agent.js"; import { streamTool } from "../../../streamTool/streamTool.js"; +import { Transaction } from "prosemirror-state"; /** * Factory function to create a StreamTool that deletes a block from the document. @@ -14,7 +15,7 @@ export const deleteBlockTool = ( options: { idsSuffixed: boolean; withDelays: boolean; - onBlockUpdate?: (blockId: string) => void; + onBlockUpdate?: (blockId: string, tr: Transaction) => void; }, ) => streamTool({ @@ -98,8 +99,8 @@ export const deleteBlockTool = ( } editor.transact((tr) => { applyAgentStep(tr, step); + options.onBlockUpdate?.(operation.id, tr); }); - options.onBlockUpdate?.(operation.id); } return true; }, diff --git a/packages/xl-ai/src/api/formats/index.ts b/packages/xl-ai/src/api/formats/index.ts index 8c28b19d4d..8e696e90c3 100644 --- a/packages/xl-ai/src/api/formats/index.ts +++ b/packages/xl-ai/src/api/formats/index.ts @@ -8,6 +8,7 @@ import { HTMLPromptData } from "./html-blocks/htmlPromptData.js"; import { jsonBlockLLMFormat } from "./json/json.js"; import { markdownBlockLLMFormat } from "./markdown-blocks/markdownBlocks.js"; import { PromptBuilder } from "./PromptBuilder.js"; +import { Transaction } from "prosemirror-state"; // Define the tool types export type AddTool = StreamTool>; @@ -39,7 +40,7 @@ export type StreamToolsProvider< to: number; } | boolean, - onBlockUpdate?: (blockId: string) => void, + onBlockUpdate?: (blockId: string, tr: Transaction) => void, ) => StreamToolsResult; }; From a223c7a162ad0107d3f9ace7f5180cae2ef7c7d8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 3 Oct 2025 11:36:33 +0200 Subject: [PATCH 7/8] Fix --- packages/xl-ai/src/api/aiRequest/execute.ts | 3 +-- .../xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts | 6 ++---- .../src/api/formats/base-tools/createUpdateBlockTool.ts | 5 ++--- packages/xl-ai/src/api/formats/base-tools/delete.ts | 5 ++--- packages/xl-ai/src/api/formats/index.ts | 3 +-- 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/xl-ai/src/api/aiRequest/execute.ts b/packages/xl-ai/src/api/aiRequest/execute.ts index 82b4d7d8e7..ae15edf6ae 100644 --- a/packages/xl-ai/src/api/aiRequest/execute.ts +++ b/packages/xl-ai/src/api/aiRequest/execute.ts @@ -6,7 +6,6 @@ import { isEmptyParagraph } from "../../util/emptyBlock.js"; import { aiDocumentFormats, StreamToolsProvider } from "../index.js"; import { trimEmptyBlocks } from "../promptHelpers/trimEmptyBlocks.js"; import { AIRequest, AIRequestSender } from "./types.js"; -import { Transaction } from "prosemirror-state"; export function buildAIRequest(opts: { editor: BlockNoteEditor; @@ -15,7 +14,7 @@ export function buildAIRequest(opts: { useSelection?: boolean; deleteEmptyCursorBlock?: boolean; streamToolsProvider?: StreamToolsProvider; - onBlockUpdated?: (blockId: string, tr: Transaction) => void; + onBlockUpdated?: (blockId: string) => void; }) { const { useSelection, deleteEmptyCursorBlock, streamToolsProvider } = { useSelection: opts.useSelection ?? false, diff --git a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts index 80a3cd0b96..12a2af55a3 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createAddBlocksTool.ts @@ -11,7 +11,6 @@ import { RebaseTool } from "../../../prosemirror/rebaseTool.js"; import { Result, streamTool } from "../../../streamTool/streamTool.js"; import { isEmptyParagraph } from "../../../util/emptyBlock.js"; import { validateBlockArray } from "./util/validateBlockArray.js"; -import { Transaction } from "prosemirror-state"; /** * Factory function to create a StreamTool that adds blocks to the document. @@ -75,7 +74,7 @@ export function createAddBlocksTool(config: { options: { idsSuffixed: boolean; withDelays: boolean; - onBlockUpdate?: (blockId: string, tr: Transaction) => void; + onBlockUpdate?: (blockId: string) => void; }, ) => { const schema = @@ -272,11 +271,10 @@ export function createAddBlocksTool(config: { if (options.withDelays) { await delayAgentStep(step); } - const addedBlockId = addedBlockIds[i]; editor.transact((tr) => { applyAgentStep(tr, step); - options.onBlockUpdate?.(addedBlockId, tr); }); + options.onBlockUpdate?.(addedBlockIds[i]); } } diff --git a/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts b/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts index 5a8cb1f55c..2bd41b1e00 100644 --- a/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts +++ b/packages/xl-ai/src/api/formats/base-tools/createUpdateBlockTool.ts @@ -9,7 +9,6 @@ import { import { updateToReplaceSteps } from "../../../prosemirror/changeset.js"; import { RebaseTool } from "../../../prosemirror/rebaseTool.js"; import { Result, streamTool } from "../../../streamTool/streamTool.js"; -import { Transaction } from "prosemirror-state"; export type UpdateBlockToolCall = { type: "update"; @@ -84,7 +83,7 @@ export function createUpdateBlockTool(config: { from: number; to: number; }; - onBlockUpdate?: (blockId: string, tr: Transaction) => void; + onBlockUpdate?: (blockId: string) => void; }, ) => { const schema = @@ -250,8 +249,8 @@ export function createUpdateBlockTool(config: { } editor.transact((tr) => { applyAgentStep(tr, step); - options.onBlockUpdate?.(operation.id, tr); }); + options.onBlockUpdate?.(operation.id); } return true; }, diff --git a/packages/xl-ai/src/api/formats/base-tools/delete.ts b/packages/xl-ai/src/api/formats/base-tools/delete.ts index 0045ea3bfe..69642a9ddb 100644 --- a/packages/xl-ai/src/api/formats/base-tools/delete.ts +++ b/packages/xl-ai/src/api/formats/base-tools/delete.ts @@ -5,7 +5,6 @@ import { getStepsAsAgent, } from "../../../prosemirror/agent.js"; import { streamTool } from "../../../streamTool/streamTool.js"; -import { Transaction } from "prosemirror-state"; /** * Factory function to create a StreamTool that deletes a block from the document. @@ -15,7 +14,7 @@ export const deleteBlockTool = ( options: { idsSuffixed: boolean; withDelays: boolean; - onBlockUpdate?: (blockId: string, tr: Transaction) => void; + onBlockUpdate?: (blockId: string) => void; }, ) => streamTool({ @@ -99,8 +98,8 @@ export const deleteBlockTool = ( } editor.transact((tr) => { applyAgentStep(tr, step); - options.onBlockUpdate?.(operation.id, tr); }); + options.onBlockUpdate?.(operation.id); } return true; }, diff --git a/packages/xl-ai/src/api/formats/index.ts b/packages/xl-ai/src/api/formats/index.ts index 8e696e90c3..8c28b19d4d 100644 --- a/packages/xl-ai/src/api/formats/index.ts +++ b/packages/xl-ai/src/api/formats/index.ts @@ -8,7 +8,6 @@ import { HTMLPromptData } from "./html-blocks/htmlPromptData.js"; import { jsonBlockLLMFormat } from "./json/json.js"; import { markdownBlockLLMFormat } from "./markdown-blocks/markdownBlocks.js"; import { PromptBuilder } from "./PromptBuilder.js"; -import { Transaction } from "prosemirror-state"; // Define the tool types export type AddTool = StreamTool>; @@ -40,7 +39,7 @@ export type StreamToolsProvider< to: number; } | boolean, - onBlockUpdate?: (blockId: string, tr: Transaction) => void, + onBlockUpdate?: (blockId: string) => void, ) => StreamToolsResult; }; From 4118b17032aa50b4504c204d9bac1ee4617352b0 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Fri, 3 Oct 2025 11:37:29 +0200 Subject: [PATCH 8/8] Fix --- packages/core/src/api/getBlockInfoFromPos.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index f9c6c1c819..b0768a2cc8 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,6 +1,5 @@ import { Node, ResolvedPos } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; -import { BlockIdentifier } from "../schema"; type SingleBlockInfo = { node: Node;