Skip to content

Commit 5ffc5a4

Browse files
committed
feat: virtual element hooks
1 parent 916fc8c commit 5ffc5a4

File tree

1 file changed

+118
-0
lines changed

1 file changed

+118
-0
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { BlockNoteEditor, getNodeById } from "@blocknote/core";
2+
import { VirtualElement } from "@floating-ui/utils";
3+
import { posToDOMRect } from "@tiptap/core";
4+
import { useEditorState } from "./useEditorState.js";
5+
6+
/**
7+
* Flattens a DOMRect to the nearest integer values
8+
*/
9+
export function flattenDOMRect(domRect: DOMRect): DOMRect {
10+
const { x, y, width, height } = domRect;
11+
return new DOMRect(
12+
Math.round(x),
13+
Math.round(y),
14+
Math.round(width),
15+
Math.round(height),
16+
);
17+
}
18+
19+
/**
20+
* Based on a position (or range), returns a virtual element that can be used as a reference for floating UI.
21+
*/
22+
export function useVirtualElementAtPos(
23+
editor: BlockNoteEditor<any, any, any>,
24+
whichPos: (ctx: { editor: BlockNoteEditor<any, any, any> }) =>
25+
| undefined
26+
| {
27+
// TODO use the location API for this
28+
from: number;
29+
to?: number;
30+
element?: HTMLElement;
31+
},
32+
): VirtualElement | undefined {
33+
const { domRect, contextElement } = useEditorState({
34+
editor,
35+
selector: (ctx) => {
36+
const range = whichPos(ctx);
37+
if (!range) {
38+
return { domRect: undefined, contextElement: undefined };
39+
}
40+
return {
41+
// flatten to JSON to avoid re-renders
42+
domRect: flattenDOMRect(
43+
posToDOMRect(
44+
editor.prosemirrorView,
45+
range.from,
46+
range.to ?? range.from,
47+
),
48+
),
49+
contextElement: range.element ?? editor.prosemirrorView.dom,
50+
};
51+
},
52+
});
53+
54+
if (!domRect) {
55+
return undefined;
56+
}
57+
58+
return {
59+
getBoundingClientRect: () => domRect,
60+
contextElement,
61+
};
62+
}
63+
64+
/**
65+
* Gets a virtual element at a block (either before, after, or across the block)
66+
*/
67+
export function useVirtualElementAtBlock(
68+
editor: BlockNoteEditor<any, any, any>,
69+
blockID: string,
70+
placement: "before" | "after" | "across" = "across",
71+
): VirtualElement | undefined {
72+
return useVirtualElementAtPos(editor, () => {
73+
return editor.transact((tr) => {
74+
// TODO use the location API for this
75+
const nodePosInfo = getNodeById(blockID, tr.doc);
76+
if (!nodePosInfo) {
77+
return undefined;
78+
}
79+
80+
const startPos = nodePosInfo.posBeforeNode + 1;
81+
const endPos =
82+
nodePosInfo.posBeforeNode + nodePosInfo.node.content.size + 1;
83+
// TODO should not need to know the DOM structure here
84+
const blockElement = editor.prosemirrorView.dom.querySelector(
85+
`[data-id="${blockID}"]`,
86+
);
87+
88+
switch (placement) {
89+
case "before": {
90+
return {
91+
from: startPos,
92+
contextElement: blockElement,
93+
};
94+
}
95+
case "after": {
96+
return {
97+
from: endPos,
98+
contextElement: blockElement,
99+
};
100+
}
101+
case "across": {
102+
return {
103+
from: startPos,
104+
to: endPos,
105+
contextElement: blockElement,
106+
};
107+
}
108+
default: {
109+
throw new Error(`Invalid placement: ${placement}`);
110+
}
111+
}
112+
});
113+
});
114+
}
115+
116+
// I'm unsure at this point whether the above should be implemented as a hook, or just as utility functions (and on call of `getBoundingClientRect` it can calculate the DOMRect)
117+
// But, I think that the principle will be the same, select what we need as positions, and then calculate the DOMRect on demand, re-evaluate when we need to.
118+
// These should be able to help with hooks that can actually position a menu at a given position.

0 commit comments

Comments
 (0)