Skip to content
11 changes: 10 additions & 1 deletion docs/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -61,7 +69,8 @@ body {

.demo .bn-editor {
overflow: auto;
padding-block: 1rem;
padding-top: 1rem;
padding-bottom: 250px;
}

.demo .bn-editor a {
Expand Down
78 changes: 77 additions & 1 deletion packages/xl-ai/src/components/AIMenu/AIMenuController.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +16,82 @@ export const AIMenuController = (props: { aiMenu?: FC<AIMenuProps> }) => {

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 (
<BlockPositioner
canDismissViaOutsidePress={
Expand Down
Loading