Skip to content

Commit 02d54e1

Browse files
committed
Refactored/tested/fixed AI menu and other misc changes
1 parent d9c85f1 commit 02d54e1

File tree

13 files changed

+139
-157
lines changed

13 files changed

+139
-157
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export default function App() {
8787

8888
// Add the model string to the request body
8989
ai.options.setState({
90+
...ai.options.state,
9091
chatRequestOptions: {
9192
body: {
9293
model,

packages/react/src/components/LinkToolbar/LinkToolbarController.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ export const LinkToolbarController = (props: {
159159
// an x, y, height, and width of 0. This is why we instead use a virtual
160160
// element for the reference, and use the last obtained `DOMRect` from when
161161
// the link element was still mounted to the DOM.
162+
// TODO: Logic should be in `GenericPopover`.
162163
const mountedBoundingClientRect = useRef(new DOMRect());
163164
if (link?.element && editor.prosemirrorView.root.contains(link.element)) {
164165
mountedBoundingClientRect.current = link.element.getBoundingClientRect();
Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getNodeById } from "@blocknote/core";
2-
import { ReactNode, useMemo } from "react";
2+
import { ClientRectObject, VirtualElement } from "@floating-ui/react";
3+
import { ReactNode, useMemo, useRef } from "react";
34

45
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
56
import { FloatingUIOptions } from "./FloatingUIOptions.js";
@@ -11,41 +12,51 @@ export const BlockPopover = (
1112
children: ReactNode;
1213
},
1314
) => {
14-
const { blockId, children, ...rest } = props;
15+
const { blockId, children, ...floatingUIOptions } = props;
1516

1617
const editor = useBlockNoteEditor<any, any, any>();
1718

18-
// Seems like unlike with virtual elements, we don't need to use the last
19-
// defined reference element in case it's currently undefined. FloatingUI
20-
// appears to already do this internally.
21-
const element = useMemo(
19+
// Stores the last `boundingClientRect` to use from when the block's DOM
20+
// element was still mounted. This is because `DOMRect`s returned from
21+
// calling `getBoundingClientRect` on unmounted elements will have x, y,
22+
// height, and width of 0. This can cause issues when the popover is
23+
// transitioning out.
24+
// TODO: Move this logic to the `GenericPopover`.
25+
const mountedBoundingClientRect = useRef<ClientRectObject>(new DOMRect());
26+
const virtualElement = useMemo(
2227
() =>
2328
editor.transact((tr) => {
29+
const virtualElement: VirtualElement = {
30+
getBoundingClientRect: () => mountedBoundingClientRect.current,
31+
};
32+
2433
if (!blockId) {
25-
return undefined;
34+
return virtualElement;
2635
}
2736

2837
// TODO use the location API for this
2938
const nodePosInfo = getNodeById(blockId, tr.doc);
3039
if (!nodePosInfo) {
31-
return undefined;
40+
return virtualElement;
3241
}
3342

3443
const { node } = editor.prosemirrorView.domAtPos(
3544
nodePosInfo.posBeforeNode + 1,
3645
);
3746
if (!(node instanceof Element)) {
38-
return undefined;
47+
return virtualElement;
3948
}
4049

41-
return node;
50+
mountedBoundingClientRect.current = node.getBoundingClientRect();
51+
52+
return virtualElement;
4253
}),
4354
[editor, blockId],
4455
);
4556

4657
return (
47-
<GenericPopover reference={element} {...rest}>
48-
{children}
58+
<GenericPopover reference={virtualElement} {...floatingUIOptions}>
59+
{blockId !== undefined && children}
4960
</GenericPopover>
5061
);
5162
};

packages/react/src/components/Popovers/GenericPopover.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,9 @@ export const GenericPopover = (
7171
}
7272
}
7373
},
74-
// All of `props` is included in the deps, as we also need to run this
75-
// effect whenever the passed children change, since it's ultimately the
76-
// HTML of the children that we're storing.
77-
[status, props],
74+
// `props.children` is added to the deps, since it's ultimately the HTML of
75+
// the children that we're storing.
76+
[status, props.reference, props.children],
7877
);
7978

8079
if (!isMounted) {

packages/react/src/components/Popovers/PositionPopover.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ClientRectObject } from "@floating-ui/react";
12
import { posToDOMRect } from "@tiptap/core";
23
import { ReactNode, useMemo, useRef } from "react";
34

@@ -11,15 +12,16 @@ export const PositionPopover = (
1112
children: ReactNode;
1213
},
1314
) => {
14-
const { position, children, ...rest } = props;
15+
const { position, children, ...floatingUIOptions } = props;
1516
const { from, to } = position || {};
1617

1718
const editor = useBlockNoteEditor<any, any, any>();
1819
const { headless } = editor;
1920

2021
// Stores the last created `boundingClientRect` to use in case `position` is
2122
// not defined.
22-
const boundingClientRect = useRef<DOMRect>(new DOMRect());
23+
// TODO: Move this logic to the `GenericPopover`.
24+
const boundingClientRect = useRef<ClientRectObject>(new DOMRect());
2325
const virtualElement = useMemo(
2426
() => ({
2527
getBoundingClientRect: () => {
@@ -47,8 +49,8 @@ export const PositionPopover = (
4749
);
4850

4951
return (
50-
<GenericPopover reference={virtualElement} {...rest}>
51-
{children}
52+
<GenericPopover reference={virtualElement} {...floatingUIOptions}>
53+
{position !== undefined && children}
5254
</GenericPopover>
5355
);
5456
};

packages/react/src/components/Popovers/TableCellPopover.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const TableCellPopover = (
1313
children: ReactNode;
1414
},
1515
) => {
16-
const { blockId, colIndex, rowIndex, children, ...rest } = props;
16+
const { blockId, colIndex, rowIndex, children, ...floatingUIOptions } = props;
1717

1818
const editor = useBlockNoteEditor<any, any, any>();
1919

@@ -50,7 +50,7 @@ export const TableCellPopover = (
5050
}, [editor, blockId, colIndex, rowIndex]);
5151

5252
return (
53-
<GenericPopover reference={element} {...rest}>
53+
<GenericPopover reference={element} {...floatingUIOptions}>
5454
{children}
5555
</GenericPopover>
5656
);

packages/react/src/components/SideMenu/SideMenuController.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SideMenuExtension as SideMenuExtension } from "@blocknote/core/extensions";
1+
import { SideMenuExtension } from "@blocknote/core/extensions";
22
import { FC, useMemo } from "react";
33

44
import { useExtensionState } from "../../hooks/useExtension.js";
@@ -47,7 +47,7 @@ export const SideMenuController = (props: {
4747

4848
return (
4949
<BlockPopover blockId={block?.id} {...floatingUIOptions}>
50-
<Component />
50+
{block?.id && <Component />}
5151
</BlockPopover>
5252
);
5353
};

packages/react/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export * from "./components/LinkToolbar/LinkToolbar.js";
4747
export * from "./components/LinkToolbar/LinkToolbarController.js";
4848
export * from "./components/LinkToolbar/LinkToolbarProps.js";
4949

50+
export * from "./components/Popovers/BlockPopover.js";
51+
export * from "./components/Popovers/FloatingUIOptions.js";
52+
export * from "./components/Popovers/GenericPopover.js";
53+
export * from "./components/Popovers/PositionPopover.js";
54+
export * from "./components/Popovers/TableCellPopover.js";
55+
5056
export * from "./components/SideMenu/DefaultButtons/AddBlockButton.js";
5157
export * from "./components/SideMenu/DefaultButtons/DragHandleButton.js";
5258
export * from "./components/SideMenu/SideMenu.js";

packages/xl-ai/src/AIExtension.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -383,41 +383,38 @@ export const AIExtension = createExtension(
383383
deleteEmptyCursorBlock: opts.deleteEmptyCursorBlock,
384384
streamToolsProvider: opts.streamToolsProvider,
385385
onBlockUpdated: (blockId) => {
386-
// NOTE: does this setState with an anon object trigger unnecessary re-renders?
387-
store.setState({
388-
aiMenuState: {
389-
blockId,
390-
status: "ai-writing",
391-
},
392-
});
393-
394-
// Scrolls to the block being edited by the AI while auto scrolling is
395-
// enabled.
396-
if (!autoScroll) {
397-
return;
398-
}
399-
400386
const aiMenuState = store.state.aiMenuState;
401387
const aiMenuOpenState =
402388
aiMenuState === "closed" ? undefined : aiMenuState;
403389
if (!aiMenuOpenState || aiMenuOpenState.status !== "ai-writing") {
404390
return;
405391
}
406392

393+
// TODO: Sometimes, the updated block doesn't actually exist in
394+
// the editor. I don't know why this happens, seems like a bug?
407395
const nodeInfo = getNodeById(
408-
aiMenuOpenState.blockId,
396+
blockId,
409397
editor.prosemirrorState.doc,
410398
);
411399
if (!nodeInfo) {
412400
return;
413401
}
414402

415-
const blockElement = editor.prosemirrorView.domAtPos(
416-
nodeInfo.posBeforeNode + 1,
417-
);
418-
(blockElement.node as HTMLElement).scrollIntoView({
419-
block: "center",
403+
store.setState({
404+
aiMenuState: {
405+
blockId,
406+
status: "ai-writing",
407+
},
420408
});
409+
410+
if (autoScroll) {
411+
const blockElement = editor.prosemirrorView.domAtPos(
412+
nodeInfo.posBeforeNode + 1,
413+
);
414+
(blockElement.node as HTMLElement).scrollIntoView({
415+
block: "center",
416+
});
417+
}
421418
},
422419
});
423420

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { useBlockNoteEditor } from "@blocknote/react";
2-
import { FC } from "react";
1+
import {
2+
BlockPopover,
3+
FloatingUIOptions,
4+
useBlockNoteEditor,
5+
} from "@blocknote/react";
6+
import { FC, useMemo } from "react";
37
import { useExtension, useExtensionState } from "@blocknote/react";
8+
import { offset, size } from "@floating-ui/react";
49

510
import { AIExtension } from "../../AIExtension.js";
611
import { AIMenu, AIMenuProps } from "./AIMenu.js";
7-
import { BlockPositioner } from "./BlockPositioner.js";
812

9-
export const AIMenuController = (props: { aiMenu?: FC<AIMenuProps> }) => {
13+
export const AIMenuController = (props: {
14+
aiMenu?: FC<AIMenuProps>;
15+
floatingUIOptions?: FloatingUIOptions;
16+
}) => {
1017
const editor = useBlockNoteEditor();
1118
const ai = useExtension(AIExtension);
1219

@@ -17,29 +24,71 @@ export const AIMenuController = (props: { aiMenu?: FC<AIMenuProps> }) => {
1724

1825
const blockId = aiMenuState === "closed" ? undefined : aiMenuState.blockId;
1926

27+
const floatingUIOptions = useMemo<FloatingUIOptions>(
28+
() => ({
29+
useFloatingOptions: {
30+
open: aiMenuState !== "closed",
31+
placement: "bottom",
32+
middleware: [
33+
offset(10),
34+
// flip(),
35+
size({
36+
apply({ rects, elements }) {
37+
Object.assign(elements.floating.style, {
38+
width: `${rects.reference.width}px`,
39+
});
40+
},
41+
}),
42+
],
43+
onOpenChange: (open) => {
44+
if (open || aiMenuState === "closed") {
45+
return;
46+
}
47+
48+
if (aiMenuState.status === "user-input") {
49+
ai.closeAIMenu();
50+
} else if (
51+
aiMenuState.status === "user-reviewing" ||
52+
aiMenuState.status === "error"
53+
) {
54+
ai.rejectChanges();
55+
}
56+
},
57+
},
58+
useDismissProps: {
59+
enabled:
60+
aiMenuState === "closed" || aiMenuState.status === "user-input",
61+
// We should just be able to set `referencePress: true` instead of
62+
// using this listener, but for some reason it doesn't seem to trigger.
63+
outsidePress: (event) => {
64+
if (event.target instanceof Element) {
65+
const blockElement = event.target.closest(".bn-block");
66+
if (
67+
blockElement &&
68+
blockElement.getAttribute("data-id") === blockId
69+
) {
70+
ai.closeAIMenu();
71+
}
72+
}
73+
74+
return true;
75+
},
76+
},
77+
elementProps: {
78+
style: {
79+
zIndex: 100,
80+
},
81+
},
82+
...props.floatingUIOptions,
83+
}),
84+
[ai, aiMenuState, blockId, props.floatingUIOptions],
85+
);
86+
2087
const Component = props.aiMenu || AIMenu;
2188

2289
return (
23-
<BlockPositioner
24-
canDismissViaOutsidePress={
25-
aiMenuState === "closed" || aiMenuState.status === "user-input"
26-
}
27-
blockID={blockId}
28-
onOpenChange={(open) => {
29-
if (open || aiMenuState === "closed") {
30-
return;
31-
}
32-
if (aiMenuState.status === "user-input") {
33-
ai.closeAIMenu();
34-
} else if (
35-
aiMenuState.status === "user-reviewing" ||
36-
aiMenuState.status === "error"
37-
) {
38-
ai.rejectChanges();
39-
}
40-
}}
41-
>
42-
<Component />
43-
</BlockPositioner>
90+
<BlockPopover blockId={blockId} {...floatingUIOptions}>
91+
{aiMenuState !== "closed" && <Component />}
92+
</BlockPopover>
4493
);
4594
};

0 commit comments

Comments
 (0)