Skip to content
1 change: 1 addition & 0 deletions examples/09-ai/01-minimal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}
<AIMenuController />
Expand Down
1 change: 1 addition & 0 deletions examples/09-ai/02-playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}
<AIMenuController />
Expand Down
1 change: 1 addition & 0 deletions examples/09-ai/03-custom-ai-menu-items/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
Expand Down
66 changes: 65 additions & 1 deletion packages/xl-ai/src/AIExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Chat } from "@ai-sdk/react";
import {
BlockNoteEditor,
BlockNoteExtension,
getNodeById,
UnreachableCaseError,
} from "@blocknote/core";
import {
Expand Down Expand Up @@ -65,6 +66,9 @@ export class AIExtension extends BlockNoteExtension {
}
| undefined;

private scrollInProgress = false;
private autoScroll = false;

public static key(): string {
return "ai";
}
Expand Down Expand Up @@ -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,
);
}

/**
Expand All @@ -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" });
}

/**
Expand Down Expand Up @@ -371,14 +406,42 @@ 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: {
blockId,
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",
});
},
});

Expand All @@ -387,6 +450,7 @@ export class AIExtension extends BlockNoteExtension {
sender,
chatRequestOptions: opts.chatRequestOptions,
onStart: () => {
this.autoScroll = true;
this.setAIResponseStatus("ai-writing");
},
});
Expand Down
Loading