Skip to content

Commit 3f44b1d

Browse files
committed
feat: wip
1 parent 4f85602 commit 3f44b1d

File tree

5 files changed

+909
-27
lines changed

5 files changed

+909
-27
lines changed

packages/react/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,16 @@
6161
"@blocknote/core": "0.41.1",
6262
"@emoji-mart/data": "^1.2.1",
6363
"@floating-ui/react": "^0.27.16",
64+
"@floating-ui/utils": "0.2.10",
6465
"@tiptap/core": "^3.7.2",
6566
"@tiptap/pm": "^3.7.2",
6667
"@tiptap/react": "^3.7.2",
68+
"@types/use-sync-external-store": "1.5.0",
6769
"emoji-mart": "^5.6.0",
70+
"fast-deep-equal": "^3.1.3",
6871
"lodash.merge": "^4.6.2",
69-
"react-icons": "^5.5.0"
72+
"react-icons": "^5.5.0",
73+
"use-sync-external-store": "1.6.0"
7074
},
7175
"devDependencies": {
7276
"@types/emoji-mart": "^3.0.14",
Lines changed: 241 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,116 @@
11
import {
2+
BlockNoteEditor,
23
BlockSchema,
34
DefaultBlockSchema,
45
DefaultInlineContentSchema,
56
DefaultStyleSchema,
67
InlineContentSchema,
78
StyleSchema,
89
} from "@blocknote/core";
9-
import { UseFloatingOptions, flip, offset } from "@floating-ui/react";
10-
import { FC } from "react";
10+
import {
11+
UseFloatingOptions,
12+
autoUpdate,
13+
flip,
14+
offset,
15+
useDismiss,
16+
useFloating,
17+
useInteractions,
18+
useTransitionStyles,
19+
} from "@floating-ui/react";
20+
import { FC, useEffect, useState } from "react";
1121

1222
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
13-
import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js";
14-
import { useUIPluginState } from "../../hooks/useUIPluginState.js";
1523
import { LinkToolbar } from "./LinkToolbar.js";
1624
import { LinkToolbarProps } from "./LinkToolbarProps.js";
25+
import { getMarkRange, posToDOMRect } from "@tiptap/core";
26+
import { isEventTargetWithin } from "@floating-ui/react/utils";
27+
import { useEditorState } from "../../hooks/useEditorState.js";
28+
import { useElementHover } from "../../hooks/useElementHover.js";
29+
30+
function getLinkElementAtPos(
31+
editor: BlockNoteEditor<any, any, any>,
32+
pos: number,
33+
) {
34+
let currentNode = editor.prosemirrorView.nodeDOM(pos);
35+
while (currentNode && currentNode.parentElement) {
36+
if (currentNode.nodeName === "A") {
37+
return currentNode as HTMLAnchorElement;
38+
}
39+
currentNode = currentNode.parentElement;
40+
}
41+
return null;
42+
}
43+
44+
function getLinkAtElement(
45+
editor: BlockNoteEditor<any, any, any>,
46+
element: HTMLElement,
47+
) {
48+
return editor.transact(() => {
49+
const posAtElement = editor.prosemirrorView.posAtDOM(element, 0) + 1;
50+
return getMarkAtPos(editor, posAtElement, "link");
51+
});
52+
}
53+
54+
function getLinkAtSelection(editor: BlockNoteEditor<any, any, any>) {
55+
return editor.transact((tr) => {
56+
const selection = tr.selection;
57+
return getMarkAtPos(editor, selection.anchor, "link");
58+
});
59+
}
60+
61+
function getMarkAtPos(
62+
editor: BlockNoteEditor<any, any, any>,
63+
pos: number,
64+
markType: string,
65+
) {
66+
return editor.transact((tr) => {
67+
const resolvedPos = tr.doc.resolve(pos);
68+
const mark = resolvedPos
69+
.marks()
70+
.find((mark) => mark.type.name === markType);
71+
72+
if (!mark) {
73+
return;
74+
}
75+
76+
const markRange = getMarkRange(resolvedPos, mark.type);
77+
if (!markRange) {
78+
return;
79+
}
80+
81+
return {
82+
range: markRange,
83+
mark,
84+
get text() {
85+
return tr.doc.textBetween(markRange.from, markRange.to);
86+
},
87+
get position() {
88+
// toJSON is always a new reference, so we remove it
89+
const { toJSON, ...position } = posToDOMRect(
90+
editor.prosemirrorView,
91+
markRange.from,
92+
markRange.to,
93+
);
94+
return position;
95+
},
96+
};
97+
});
98+
}
99+
100+
function isWithinEditor(
101+
editor: BlockNoteEditor,
102+
element: HTMLElement | EventTarget,
103+
) {
104+
const editorWrapper = editor.prosemirrorView.dom.parentElement;
105+
if (!editorWrapper) {
106+
return false;
107+
}
108+
109+
return (
110+
editorWrapper === (element as Node) ||
111+
editorWrapper.contains(element as Node)
112+
);
113+
}
17114

18115
export const LinkToolbarController = <
19116
BSchema extends BlockSchema = DefaultBlockSchema,
@@ -24,45 +121,163 @@ export const LinkToolbarController = <
24121
floatingOptions?: Partial<UseFloatingOptions>;
25122
}) => {
26123
const editor = useBlockNoteEditor<BSchema, I, S>();
124+
const linkAtSelection = useEditorState({
125+
editor,
126+
selector: ({ editor }) => {
127+
return getLinkAtSelection(editor);
128+
// if (!linkAtSelection) {
129+
// return;
130+
// }
131+
// const { range, text, mark, position } = linkAtSelection;
132+
// console.log(position);
133+
// return { range, text, mark };
134+
},
135+
});
27136

28137
const callbacks = {
29138
deleteLink: editor.linkToolbar.deleteLink,
30139
editLink: editor.linkToolbar.editLink,
31140
startHideTimer: editor.linkToolbar.startHideTimer,
32141
stopHideTimer: editor.linkToolbar.stopHideTimer,
33142
};
143+
const [show, setShow] = useState(false);
34144

35-
const state = useUIPluginState(
36-
editor.linkToolbar.onUpdate.bind(editor.linkToolbar),
37-
);
38-
const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning(
39-
state?.show || false,
40-
state?.referencePos || null,
41-
4000,
42-
{
43-
placement: "top-start",
44-
middleware: [offset(10), flip()],
45-
onOpenChange: (open) => {
46-
if (!open) {
47-
editor.linkToolbar.closeMenu();
48-
editor.focus();
49-
}
50-
},
51-
...props.floatingOptions,
145+
const {
146+
refs: { setFloating: ref, setReference },
147+
context,
148+
floatingStyles,
149+
} = useFloating({
150+
open: show,
151+
placement: "top-start",
152+
middleware: [offset(10), flip()],
153+
onOpenChange: (open, event, reason) => {
154+
console.log("openChange", open, event, reason);
155+
setShow(open);
52156
},
53-
);
157+
whileElementsMounted: autoUpdate,
158+
...props.floatingOptions,
159+
});
160+
161+
const { isMounted, styles } = useTransitionStyles(context);
54162

55-
if (!isMounted || !state) {
163+
// handle "escape" and other dismiss events, these will add some listeners to
164+
// getFloatingProps which need to be attached to the floating element
165+
const dismiss = useDismiss(context, {
166+
outsidePress: (e) =>
167+
!isEventTargetWithin(e, editor.prosemirrorView.dom.parentElement),
168+
});
169+
170+
console.log(linkAtSelection);
171+
// Create element hover hook for link detection
172+
const elementHover = useElementHover(context, {
173+
enabled: !linkAtSelection,
174+
attachTo() {
175+
return editor.prosemirrorView.dom;
176+
},
177+
delay: { open: 250, close: 0 },
178+
restMs: 0,
179+
mouseOnly: true,
180+
getElementAtHover: (target) => {
181+
// Check if there's a link at the current selection first
182+
if (getLinkAtSelection(editor)) {
183+
return null; // Disable hover when link is selected
184+
}
185+
186+
// Check for link at the hovered element
187+
const linkAtElement = getLinkAtElement(editor, target as HTMLElement);
188+
if (linkAtElement) {
189+
return getLinkElementAtPos(editor, linkAtElement.range.from);
190+
}
191+
192+
return null;
193+
},
194+
onHover: (element) => {
195+
if (element) {
196+
setReference(element);
197+
setShow(true);
198+
} else {
199+
setReference(null);
200+
setShow(false);
201+
}
202+
},
203+
});
204+
205+
const { getReferenceProps, getFloatingProps } = useInteractions([
206+
dismiss,
207+
elementHover,
208+
]);
209+
210+
useEffect(() => {
211+
const abortController = new AbortController();
212+
const props = getReferenceProps();
213+
214+
for (const [key, eventListener] of Object.entries(props)) {
215+
if (typeof eventListener === "function" && key.startsWith("on")) {
216+
editor.prosemirrorView.dom.addEventListener(
217+
// e.g. "onKeyDown" -> "keydown"
218+
key.slice(2).toLowerCase() as keyof HTMLElementEventMap,
219+
eventListener as (e: Event) => void,
220+
{
221+
signal: abortController.signal,
222+
},
223+
);
224+
}
225+
}
226+
227+
return () => {
228+
abortController.abort();
229+
};
230+
}, [editor, getReferenceProps]);
231+
232+
useEffect(() => {
233+
if (!linkAtSelection) {
234+
setReference(null);
235+
return;
236+
}
237+
238+
setReference(getLinkElementAtPos(editor, linkAtSelection.range.from));
239+
setShow(true);
240+
}, [editor, linkAtSelection, setReference]);
241+
const style = {
242+
display: "flex",
243+
...styles,
244+
...floatingStyles,
245+
zIndex: 4000,
246+
};
247+
// const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning(
248+
// state?.show || false,
249+
// state?.referencePos || null,
250+
// 4000,
251+
// // {
252+
// // placement: "top-start",
253+
// // middleware: [offset(10), flip()],
254+
// // onOpenChange: (open) => {
255+
// // if (!open) {
256+
// // editor.linkToolbar.closeMenu();
257+
// // editor.focus();
258+
// // }
259+
// // },
260+
// // ...props.floatingOptions,
261+
// // },
262+
// );
263+
// console.log(show, linkAtSelection);
264+
if (!isMounted) {
56265
return null;
57266
}
58267

59-
const { show, referencePos, ...data } = state;
60-
61268
const Component = props.linkToolbar || LinkToolbar;
62269

270+
console.log("showing");
63271
return (
64272
<div ref={ref} style={style} {...getFloatingProps()}>
65-
<Component {...data} {...callbacks} />
273+
<Component
274+
url={String(linkAtSelection?.mark.attrs.href || "")}
275+
text={linkAtSelection?.text || ""}
276+
deleteLink={callbacks.deleteLink}
277+
editLink={callbacks.editLink}
278+
startHideTimer={callbacks.startHideTimer}
279+
stopHideTimer={callbacks.stopHideTimer}
280+
/>
66281
</div>
67282
);
68283
};

0 commit comments

Comments
 (0)