diff --git a/examples/03-ui-components/16-link-toolbar-buttons/src/App.tsx b/examples/03-ui-components/16-link-toolbar-buttons/src/App.tsx index f2c26763b1..1714e18a0d 100644 --- a/examples/03-ui-components/16-link-toolbar-buttons/src/App.tsx +++ b/examples/03-ui-components/16-link-toolbar-buttons/src/App.tsx @@ -57,7 +57,7 @@ export default function App() { text={props.text} range={props.range} setToolbarOpen={props.setToolbarOpen} - setToolbarFrozen={props.setToolbarFrozen} + setToolbarPositionFrozen={props.setToolbarPositionFrozen} /> >; floatingUIOptions?: FloatingUIOptions; }) { - const [open, setOpen] = useState(false); - const editor = useBlockNoteEditor(); const comments = useExtension(CommentsExtension); @@ -35,9 +33,6 @@ export default function FloatingComposerController< editor, selector: (state) => state.pendingComment, }); - useEffect(() => { - setOpen(pendingComment); - }, [pendingComment]); const position = useEditorState({ editor, @@ -53,7 +48,7 @@ export default function FloatingComposerController< const floatingUIOptions = useMemo( () => ({ useFloatingOptions: { - open, + open: !!pendingComment, // Needed as hooks like `useDismiss` call `onOpenChange` to change the // open state. onOpenChange: (open) => { @@ -61,8 +56,6 @@ export default function FloatingComposerController< comments.stopPendingComment(); editor.focus(); } - - setOpen(open); }, placement: "bottom", middleware: [offset(10), shift(), flip()], @@ -74,7 +67,7 @@ export default function FloatingComposerController< }, ...props.floatingUIOptions, }), - [comments, editor, open, props.floatingUIOptions], + [comments, editor, pendingComment, props.floatingUIOptions], ); // nice to have improvements would be: diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index a6c74287bf..eaf4911942 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -1,6 +1,6 @@ import { CommentsExtension } from "@blocknote/core/comments"; import { flip, offset, shift } from "@floating-ui/react"; -import { ComponentProps, FC, useEffect, useMemo, useState } from "react"; +import { ComponentProps, FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; @@ -19,8 +19,6 @@ export default function FloatingThreadController(props: { }) { const editor = useBlockNoteEditor(); - const [open, setOpen] = useState(false); - const comments = useExtension(CommentsExtension); const selectedThread = useExtensionState(CommentsExtension, { editor, @@ -32,9 +30,6 @@ export default function FloatingThreadController(props: { } : undefined, }); - useEffect(() => { - setOpen(!!selectedThread); - }, [selectedThread]); const threads = useThreads(); @@ -46,7 +41,7 @@ export default function FloatingThreadController(props: { const floatingUIOptions = useMemo( () => ({ useFloatingOptions: { - open, + open: !!selectedThread, // Needed as hooks like `useDismiss` call `onOpenChange` to change the // open state. onOpenChange: (open, _event, reason) => { @@ -57,8 +52,6 @@ export default function FloatingThreadController(props: { if (!open) { comments.selectThread(undefined); } - - setOpen(open); }, placement: "bottom", middleware: [offset(10), shift(), flip()], @@ -70,7 +63,7 @@ export default function FloatingThreadController(props: { }, ...props.floatingUIOptions, }), - [comments, editor, open, props.floatingUIOptions], + [comments, editor, props.floatingUIOptions, selectedThread], ); // nice to have improvements: diff --git a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx index a533b58dc8..52cef731f4 100644 --- a/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx +++ b/packages/react/src/components/LinkToolbar/DefaultButtons/EditLinkButton.tsx @@ -6,14 +6,16 @@ import { LinkToolbarProps } from "../LinkToolbarProps.js"; export const EditLinkButton = ( props: Pick< LinkToolbarProps, - "url" | "text" | "range" | "setToolbarFrozen" | "setToolbarOpen" + "url" | "text" | "range" | "setToolbarOpen" | "setToolbarPositionFrozen" >, ) => { const Components = useComponentsContext()!; const dict = useDictionary(); return ( - + diff --git a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx index 4577686cb0..ad454fd556 100644 --- a/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx +++ b/packages/react/src/components/LinkToolbar/EditLinkMenuItems.tsx @@ -29,7 +29,7 @@ const validateUrl = (url: string) => { export const EditLinkMenuItems = ( props: Pick< LinkToolbarProps, - "url" | "text" | "range" | "setToolbarOpen" | "setToolbarFrozen" + "url" | "text" | "range" | "setToolbarOpen" | "setToolbarPositionFrozen" > & { showTextField?: boolean; }, @@ -55,7 +55,7 @@ export const EditLinkMenuItems = ( event.preventDefault(); editLink(validateUrl(currentUrl), currentText, props.range.from); props.setToolbarOpen?.(false); - props.setToolbarFrozen?.(false); + props.setToolbarPositionFrozen?.(false); } }, [editLink, currentUrl, currentText, props], @@ -76,7 +76,7 @@ export const EditLinkMenuItems = ( const handleSubmit = useCallback(() => { editLink(validateUrl(currentUrl), currentText, props.range.from); props.setToolbarOpen?.(false); - props.setToolbarFrozen?.(false); + props.setToolbarPositionFrozen?.(false); }, [editLink, currentUrl, currentText, props]); return ( diff --git a/packages/react/src/components/LinkToolbar/LinkToolbar.tsx b/packages/react/src/components/LinkToolbar/LinkToolbar.tsx index 0de23e37f5..435d173df0 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbar.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbar.tsx @@ -26,8 +26,8 @@ export const LinkToolbar = (props: LinkToolbarProps) => { url={props.url} text={props.text} range={props.range} - setToolbarFrozen={props.setToolbarFrozen} setToolbarOpen={props.setToolbarOpen} + setToolbarPositionFrozen={props.setToolbarPositionFrozen} /> ; floatingUIOptions?: FloatingUIOptions; }) => { - const editor = useBlockNoteEditor< - BlockSchema, - InlineContentSchema, - StyleSchema - >(); + const editor = useBlockNoteEditor(); - const [open, setOpen] = useState(false); - const [frozen, setFrozen] = useState(false); + const [toolbarOpen, setToolbarOpen] = useState(false); + const [toolbarPositionFrozen, setToolbarPositionFrozen] = useState(false); const linkToolbar = useExtension(LinkToolbarExtension); - const selectionLink = useEditorState({ - editor, - selector: () => { - const link = linkToolbar.getLinkAtSelection(); - if (!link) { - return undefined; - } - return { - url: link.mark.attrs.href as string, - text: link.text, - range: link.range, - element: linkToolbar.getLinkElementAtPos(link.range.from)!, - }; - }, - }); - useEffect(() => { - if (frozen) { - return; - } - - setOpen(!!selectionLink); - if (selectionLink) { - // Clears the link hovered by the mouse cursor, when the text cursor is - // within a link, to avoid any potential clashes in positioning. - setMouseHoverLink(undefined); - } - }, [frozen, selectionLink]); - - // The `mouseHoverLink` state is completely separate from the `open` state as - // the FloatingUI `useHover` hook handles opening/closing the popover when a - // link is hovered with the mouse cursor. Therefore, we only need to update - // the link when a new one is hovered. - const [mouseHoverLink, setMouseHoverLink] = useState< - | { url: string; text: string; range: Range; element: HTMLAnchorElement } + // Because the toolbar opens with a delay when a link is hovered by the mouse + // cursor, We need separate `toolbarOpen` and `link` states. + const [link, setLink] = useState< + | { + cursorType: "text" | "mouse"; + url: string; + text: string; + range: Range; + element: HTMLAnchorElement; + } | undefined >(undefined); + // Updates the link to show the toolbar for. Uses the link found at the text + // cursor position. If there is none, uses the link hovered by the mouse + // cursor. Otherwise, the toolbar remains closed. useEffect(() => { - const cb = (event: MouseEvent) => { - // Ignores the link hovered by the mouse cursor, when the text cursor is - // within a link, to avoid any potential clashes in positioning. - if (selectionLink) { + const textCursorCallback = () => { + const textCursorLink = linkToolbar.getLinkAtSelection(); + if (!textCursorLink) { + setLink(undefined); + + if (!toolbarPositionFrozen) { + setToolbarOpen(false); + } + + return; + } + + setLink({ + cursorType: "text", + url: textCursorLink.mark.attrs.href as string, + text: textCursorLink.text, + range: textCursorLink.range, + element: linkToolbar.getLinkElementAtPos(textCursorLink.range.from)!, + }); + + if (!toolbarPositionFrozen) { + setToolbarOpen(true); + } + }; + + // At no point in this callback is `setToolbarOpen` called, even though + // hovering a link with the mouse cursor should open the toolbar. This is + // because the FloatingUI `useHover` hook basically does this for us, so we + // only need to update `link` when a new one is hovered. + const mouseCursorCallback = (event: MouseEvent) => { + // Links selected by the text cursor take priority over those hovered by + // the mouse cursor. + if (link !== undefined && link.cursorType === "text") { return; } @@ -78,40 +80,50 @@ export const LinkToolbarController = (props: { return; } - const link = linkToolbar.getLinkAtElement(event.target); - if (!link) { + const mouseCursorLink = linkToolbar.getLinkAtElement(event.target); + if (!mouseCursorLink) { return; } - setMouseHoverLink({ - url: link.mark.attrs.href as string, - text: link.text, - range: link.range, - element: linkToolbar.getLinkElementAtPos(link.range.from)!, + setLink({ + cursorType: "mouse", + url: mouseCursorLink.mark.attrs.href as string, + text: mouseCursorLink.text, + range: mouseCursorLink.range, + element: linkToolbar.getLinkElementAtPos(mouseCursorLink.range.from)!, }); }; - document.body.addEventListener("mouseover", cb); + const destroyOnChangeHandler = editor.onChange(textCursorCallback); + const destroyOnSelectionChangeHandler = + editor.onSelectionChange(textCursorCallback); + + editor.domElement?.addEventListener("mouseover", mouseCursorCallback); return () => { - document.body.removeEventListener("mouseover", cb); - }; - }, [frozen, linkToolbar, mouseHoverLink?.url, selectionLink]); + destroyOnChangeHandler(); + destroyOnSelectionChangeHandler(); - const link = selectionLink || mouseHoverLink; + editor.domElement?.removeEventListener("mouseover", mouseCursorCallback); + }; + }, [editor, linkToolbar, link, toolbarPositionFrozen]); const floatingUIOptions = useMemo( () => ({ useFloatingOptions: { - open, + open: toolbarOpen, onOpenChange: (open, _event, reason) => { - if (frozen) { + if (toolbarPositionFrozen) { return; } // We want to prioritize `selectionLink` over `mouseHoverLink`, so we // ignore opening/closing from hover events. - if (selectionLink && reason === "hover") { + if ( + link !== undefined && + link.cursorType === "text" && + reason === "hover" + ) { return; } @@ -119,7 +131,7 @@ export const LinkToolbarController = (props: { editor.focus(); } - setOpen(open); + setToolbarOpen(open); }, placement: "top-start", middleware: [offset(10), flip()], @@ -127,7 +139,7 @@ export const LinkToolbarController = (props: { useHoverProps: { // `useHover` hook only enabled when a link is hovered with the // mouse. - enabled: !selectionLink && !!mouseHoverLink, + enabled: link !== undefined && link.cursorType === "mouse", delay: { open: 250, close: 250, @@ -141,14 +153,7 @@ export const LinkToolbarController = (props: { }, ...props.floatingUIOptions, }), - [ - editor, - frozen, - mouseHoverLink, - open, - props.floatingUIOptions, - selectionLink, - ], + [editor, link, props.floatingUIOptions, toolbarOpen, toolbarPositionFrozen], ); const reference = useMemo( @@ -169,8 +174,8 @@ export const LinkToolbarController = (props: { url={link.url} text={link.text} range={link.range} - setToolbarFrozen={setFrozen} - setToolbarOpen={setOpen} + setToolbarOpen={setToolbarOpen} + setToolbarPositionFrozen={setToolbarPositionFrozen} /> )} diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarProps.ts b/packages/react/src/components/LinkToolbar/LinkToolbarProps.ts index 1109ea698b..d0b3be7b8d 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarProps.ts +++ b/packages/react/src/components/LinkToolbar/LinkToolbarProps.ts @@ -5,7 +5,7 @@ export type LinkToolbarProps = { url: string; text: string; range: Range; - setToolbarFrozen?: (frozen: boolean) => void; setToolbarOpen?: (open: boolean) => void; + setToolbarPositionFrozen?: (frozen: boolean) => void; children?: ReactNode; };