import { LinkToolbarExtension } from "@blocknote/core/extensions"; import { flip, offset, safePolygon } from "@floating-ui/react"; import { Range } from "@tiptap/core"; import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, GenericPopoverReference, } from "../Popovers/GenericPopover.js"; import { LinkToolbar } from "./LinkToolbar.js"; import { LinkToolbarProps } from "./LinkToolbarProps.js"; export const LinkToolbarController = (props: { linkToolbar?: FC; floatingUIOptions?: FloatingUIOptions; }) => { const editor = useBlockNoteEditor(); const [toolbarOpen, setToolbarOpen] = useState(false); const [toolbarPositionFrozen, setToolbarPositionFrozen] = useState(false); const linkToolbar = useExtension(LinkToolbarExtension); // 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 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; } if (!(event.target instanceof HTMLElement)) { return; } const mouseCursorLink = linkToolbar.getLinkAtElement(event.target); if (!mouseCursorLink) { return; } setLink({ cursorType: "mouse", url: mouseCursorLink.mark.attrs.href as string, text: mouseCursorLink.text, range: mouseCursorLink.range, element: linkToolbar.getLinkElementAtPos(mouseCursorLink.range.from)!, }); }; const destroyOnChangeHandler = editor.onChange(textCursorCallback); const destroyOnSelectionChangeHandler = editor.onSelectionChange(textCursorCallback); const domElement = editor.domElement; domElement?.addEventListener("mouseover", mouseCursorCallback); return () => { destroyOnChangeHandler(); destroyOnSelectionChangeHandler(); domElement?.removeEventListener("mouseover", mouseCursorCallback); }; }, [editor, editor.domElement, linkToolbar, link, toolbarPositionFrozen]); const floatingUIOptions = useMemo( () => ({ ...props.floatingUIOptions, useFloatingOptions: { open: toolbarOpen, onOpenChange: (open, _event, reason) => { if (toolbarPositionFrozen) { return; } // We want to prioritize `selectionLink` over `mouseHoverLink`, so we // ignore opening/closing from hover events. if ( link !== undefined && link.cursorType === "text" && reason === "hover" ) { return; } if (reason === "escape-key") { editor.focus(); } setToolbarOpen(open); }, placement: "top-start", middleware: [offset(10), flip()], ...props.floatingUIOptions?.useFloatingOptions, }, useHoverProps: { // `useHover` hook only enabled when a link is hovered with the // mouse. enabled: link !== undefined && link.cursorType === "mouse", delay: { open: 250, close: 250, }, handleClose: safePolygon(), ...props.floatingUIOptions?.useHoverProps, }, focusManagerProps: { disabled: true, ...props.floatingUIOptions?.focusManagerProps, }, elementProps: { style: { zIndex: 50, }, ...props.floatingUIOptions?.elementProps, }, }), [editor, link, props.floatingUIOptions, toolbarOpen, toolbarPositionFrozen], ); const reference = useMemo( () => (link?.element ? { element: link.element } : undefined), [link?.element], ); // TODO: this should be a hook to be reactive if (!editor.isEditable) { return null; } const Component = props.linkToolbar || LinkToolbar; return ( {link && ( )} ); };