feat: replace link popover with dedicated bubble menu

This commit is contained in:
Philipinho
2026-03-16 00:26:03 +00:00
parent 724e37d5b7
commit b0bde4b375
6 changed files with 158 additions and 78 deletions
@@ -10,3 +10,5 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);
@@ -26,7 +26,7 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
@@ -49,6 +49,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
useEffect(() => {
showCommentPopupRef.current = showCommentPopup;
@@ -58,6 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
const editorState = useEditorState({
editor: props.editor,
selector: (ctx) => {
@@ -135,6 +141,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current
) {
return false;
@@ -147,7 +154,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
onHide: () => {
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
@@ -155,11 +161,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return;
if (showAiMenu || showLinkMenu) return;
return (
<BubbleMenu
@@ -189,7 +194,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
@@ -200,7 +204,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsOpen={() => {
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
@@ -224,16 +227,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
))}
</ActionIcon.Group>
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={(value) => {
setIsLinkSelectorOpen(value);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsColorSelectorOpen(false);
}}
/>
<LinkSelector />
<ColorSelector
editor={props.editor}
@@ -242,7 +236,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsNodeSelectorOpen(false);
setIsTextAlignmentOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
@@ -1,68 +1,25 @@
import { Dispatch, FC, SetStateAction, useCallback } from "react";
import { FC } from "react";
import { IconLink } from "@tabler/icons-react";
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { normalizeUrl } from "@/features/editor/components/link/link-view";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
interface LinkSelectorProps {
editor: ReturnType<typeof useEditor>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
export const LinkSelector: FC = () => {
const { t } = useTranslation();
const onLink = useCallback(
(url: string, internal?: boolean) => {
setIsOpen(false);
editor
.chain()
.focus()
.setLink({ href: internal ? url : normalizeUrl(url), internal: !!internal } as any)
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
},
[editor, setIsOpen],
);
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
return (
<Popover
width={320}
opened={isOpen}
trapFocus
offset={{ mainAxis: 35, crossAxis: 0 }}
withArrow
shadow="md"
>
<Popover.Target>
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
style={{
border: "none",
}}
onClick={() => setIsOpen(!isOpen)}
>
<IconLink size={16} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown p="sm">
<LinkEditorPanel onSetLink={onLink} />
</Popover.Dropdown>
</Popover>
<Tooltip label={t("Add link")} withArrow>
<ActionIcon
variant="default"
size="lg"
radius="0"
style={{ border: "none" }}
onClick={() => setShowLinkMenu(true)}
>
<IconLink size={16} />
</ActionIcon>
</Tooltip>
);
};
@@ -36,7 +36,7 @@ export const LinkEditorPanel = ({
includeUsers: false,
includePages: true,
spaceId: space?.id,
limit: state.isSearchQuery ? 10 : 5,
limit: state.isSearchQuery ? 10 : 3,
preload: true,
});
@@ -105,6 +105,7 @@ export const LinkEditorPanel = ({
value={state.url}
onChange={state.onChange}
onKeyDown={handleKeyDown}
data-autofocus
autoFocus
/>
</form>
@@ -0,0 +1,125 @@
import { FC, useCallback, useEffect, useRef } from "react";
import { BubbleMenu } from "@tiptap/react/menus";
import type { Editor } from "@tiptap/react";
import { useAtom } from "jotai";
import { isTextSelected } from "@docmost/editor-ext";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel";
import { normalizeUrl } from "@/features/editor/components/link/link-view";
import { TextSelection } from "@tiptap/pm/state";
type EditorLinkMenuProps = {
editor: Editor;
};
export const EditorLinkMenu: FC<EditorLinkMenuProps> = ({ editor }) => {
const [showLinkMenu, setShowLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
if (showLinkMenu) {
editor.commands.focus();
}
}, [showLinkMenu, editor]);
const focusInput = useCallback(() => {
requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLInputElement>("input")
?.focus({ preventScroll: true });
});
}, []);
const onSetLink = useCallback(
(url: string, internal?: boolean) => {
editor
.chain()
.focus()
.setLink({
href: internal ? url : normalizeUrl(url),
internal: !!internal,
} as any)
.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, tr.selection.to));
return true;
})
.run();
setShowLinkMenu(false);
},
[editor, setShowLinkMenu],
);
useEffect(() => {
if (!showLinkMenu) return;
const dismiss = () => {
setShowLinkMenu(false);
editor.commands.focus();
editor.commands.setTextSelection(editor.state.selection.to);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
dismiss();
}
};
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
dismiss();
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showLinkMenu, setShowLinkMenu]);
if (!showLinkMenu) return null;
return (
<BubbleMenu
editor={editor}
shouldShow={({ editor, state }) => {
const { empty } = state.selection;
return (
showLinkMenuRef.current &&
editor.isEditable &&
!empty &&
isTextSelected(editor)
);
}}
options={{
placement: "bottom",
offset: 8,
onShow: focusInput,
onHide: () => {
setShowLinkMenu(false);
},
}}
style={{ zIndex: 198, position: "relative" }}
>
<div
ref={containerRef}
style={{
width: 320,
padding: "var(--mantine-spacing-sm)",
boxShadow: "0 4px 12px light-dark(#cfcfcf, #0f0f0f)",
borderRadius: 6,
border:
"1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8))",
backgroundColor:
"light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))",
}}
>
<LinkEditorPanel onSetLink={onSetLink} />
</div>
</BubbleMenu>
);
};
@@ -66,6 +66,7 @@ import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
interface PageEditorProps {
@@ -407,6 +408,7 @@ export default function PageEditor({
{editor && editorIsEditable && (
<div>
<EditorAiMenu editor={editor} />
<EditorLinkMenu editor={editor} />
<EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} />