Skip to content

Commit 4837087

Browse files
feat: AI menu auto scrolling (#2039)
* Added AI menu auto scrolling * Moved auto scroll logic to plugin layer * Removed docs styles * Implemented PR feedback * wip * Changed from using `tr.scrollIntoView` to custom scroll logic * Fix * Fix --------- Co-authored-by: Nick the Sick <[email protected]>
1 parent 8306ede commit 4837087

File tree

4 files changed

+68
-1
lines changed

4 files changed

+68
-1
lines changed

examples/09-ai/01-minimal/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export default function App() {
7777
// We're disabling some default UI elements
7878
formattingToolbar={false}
7979
slashMenu={false}
80+
style={{ paddingBottom: "300px" }}
8081
>
8182
{/* Add the AI Command menu to the editor */}
8283
<AIMenuController />

examples/09-ai/02-playground/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export default function App() {
122122
editor={editor}
123123
formattingToolbar={false}
124124
slashMenu={false}
125+
style={{ paddingBottom: "300px" }}
125126
>
126127
{/* Add the AI Command menu to the editor */}
127128
<AIMenuController />

examples/09-ai/03-custom-ai-menu-items/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export default function App() {
7878
editor={editor}
7979
formattingToolbar={false}
8080
slashMenu={false}
81+
style={{ paddingBottom: "300px" }}
8182
>
8283
{/* Creates a new AIMenu with the default items,
8384
as well as our custom ones. */}

packages/xl-ai/src/AIExtension.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Chat } from "@ai-sdk/react";
22
import {
33
BlockNoteEditor,
44
BlockNoteExtension,
5+
getNodeById,
56
UnreachableCaseError,
67
} from "@blocknote/core";
78
import {
@@ -65,6 +66,9 @@ export class AIExtension extends BlockNoteExtension {
6566
}
6667
| undefined;
6768

69+
private scrollInProgress = false;
70+
private autoScroll = false;
71+
6872
public static key(): string {
6973
return "ai";
7074
}
@@ -134,6 +138,31 @@ export class AIExtension extends BlockNoteExtension {
134138
options.agentCursor || { name: "AI", color: "#8bc6ff" },
135139
),
136140
);
141+
142+
// Listens for `scroll` and `scrollend` events to see if a new scroll was
143+
// started before an existing one ended. This is the most reliable way we
144+
// have of checking if a scroll event was caused by the user and not by
145+
// `scrollIntoView`, as the events are otherwise indistinguishable. If a
146+
// scroll was started before an existing one finished (meaning the user has
147+
// scrolled), auto scrolling is disabled.
148+
document.addEventListener(
149+
"scroll",
150+
() => {
151+
if (this.scrollInProgress) {
152+
this.autoScroll = false;
153+
}
154+
155+
this.scrollInProgress = true;
156+
},
157+
true,
158+
);
159+
document.addEventListener(
160+
"scrollend",
161+
() => {
162+
this.scrollInProgress = false;
163+
},
164+
true,
165+
);
137166
}
138167

139168
/**
@@ -148,6 +177,12 @@ export class AIExtension extends BlockNoteExtension {
148177
status: "user-input",
149178
},
150179
});
180+
181+
// Scrolls to the block when the menu opens.
182+
const blockElement = this.editor.domElement?.querySelector(
183+
`[data-node-type="blockContainer"][data-id="${blockID}"]`,
184+
);
185+
blockElement?.scrollIntoView({ block: "center" });
151186
}
152187

153188
/**
@@ -371,14 +406,42 @@ export class AIExtension extends BlockNoteExtension {
371406
useSelection: opts.useSelection,
372407
deleteEmptyCursorBlock: opts.deleteEmptyCursorBlock,
373408
streamToolsProvider: opts.streamToolsProvider,
374-
onBlockUpdated: (blockId: string) => {
409+
onBlockUpdated: (blockId) => {
375410
// NOTE: does this setState with an anon object trigger unnecessary re-renders?
376411
this._store.setState({
377412
aiMenuState: {
378413
blockId,
379414
status: "ai-writing",
380415
},
381416
});
417+
418+
// Scrolls to the block being edited by the AI while auto scrolling is
419+
// enabled.
420+
if (!this.autoScroll) {
421+
return;
422+
}
423+
424+
const aiMenuState = this._store.getState().aiMenuState;
425+
const aiMenuOpenState =
426+
aiMenuState === "closed" ? undefined : aiMenuState;
427+
if (!aiMenuOpenState || aiMenuOpenState.status !== "ai-writing") {
428+
return;
429+
}
430+
431+
const nodeInfo = getNodeById(
432+
aiMenuOpenState.blockId,
433+
this.editor.prosemirrorState.doc,
434+
);
435+
if (!nodeInfo) {
436+
return;
437+
}
438+
439+
const blockElement = this.editor.prosemirrorView.domAtPos(
440+
nodeInfo.posBeforeNode + 1,
441+
);
442+
(blockElement.node as HTMLElement).scrollIntoView({
443+
block: "center",
444+
});
382445
},
383446
});
384447

@@ -387,6 +450,7 @@ export class AIExtension extends BlockNoteExtension {
387450
sender,
388451
chatRequestOptions: opts.chatRequestOptions,
389452
onStart: () => {
453+
this.autoScroll = true;
390454
this.setAIResponseStatus("ai-writing");
391455
},
392456
});

0 commit comments

Comments
 (0)