diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 25d9332b..8982765e 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -10,3 +10,5 @@ export const readOnlyEditorAtom = atom(null); export const yjsConnectionStatusAtom = atom(""); export const showAiMenuAtom = atom(false); + +export const showLinkMenuAtom = atom(false); diff --git a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx index e7bc89f3..ec7e7baa 100644 --- a/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/bubble-menu.tsx @@ -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 = (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 = (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 = (props) => { isNodeSelection(selection) || isCellSelection(selection) || showAiMenuRef.current || + showLinkMenuRef.current || showCommentPopupRef?.current ) { return false; @@ -147,7 +154,6 @@ export const EditorBubbleMenu: FC = (props) => { onHide: () => { setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); - setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); }, }, @@ -155,11 +161,10 @@ export const EditorBubbleMenu: FC = (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 ( = (props) => { setIsOpen={() => { setIsNodeSelectorOpen(!isNodeSelectorOpen); setIsTextAlignmentOpen(false); - setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); }} /> @@ -200,7 +204,6 @@ export const EditorBubbleMenu: FC = (props) => { setIsOpen={() => { setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen); setIsNodeSelectorOpen(false); - setIsLinkSelectorOpen(false); setIsColorSelectorOpen(false); }} /> @@ -224,16 +227,7 @@ export const EditorBubbleMenu: FC = (props) => { ))} - { - setIsLinkSelectorOpen(value); - setIsNodeSelectorOpen(false); - setIsTextAlignmentOpen(false); - setIsColorSelectorOpen(false); - }} - /> + = (props) => { setIsColorSelectorOpen(!isColorSelectorOpen); setIsNodeSelectorOpen(false); setIsTextAlignmentOpen(false); - setIsLinkSelectorOpen(false); }} /> diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx index df442e4e..fdacf6a1 100644 --- a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -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; - isOpen: boolean; - setIsOpen: Dispatch>; -} - -export const LinkSelector: FC = ({ - 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 ( - - - - setIsOpen(!isOpen)} - > - - - - - - - - - + + setShowLinkMenu(true)} + > + + + ); }; diff --git a/apps/client/src/features/editor/components/link/link-editor-panel.tsx b/apps/client/src/features/editor/components/link/link-editor-panel.tsx index d2cce213..54e36401 100644 --- a/apps/client/src/features/editor/components/link/link-editor-panel.tsx +++ b/apps/client/src/features/editor/components/link/link-editor-panel.tsx @@ -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 /> diff --git a/apps/client/src/features/editor/components/link/link-menu.tsx b/apps/client/src/features/editor/components/link/link-menu.tsx new file mode 100644 index 00000000..57598f39 --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-menu.tsx @@ -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 = ({ editor }) => { + const [showLinkMenu, setShowLinkMenu] = useAtom(showLinkMenuAtom); + const showLinkMenuRef = useRef(showLinkMenu); + + const containerRef = useRef(null); + + useEffect(() => { + showLinkMenuRef.current = showLinkMenu; + if (showLinkMenu) { + editor.commands.focus(); + } + }, [showLinkMenu, editor]); + + const focusInput = useCallback(() => { + requestAnimationFrame(() => { + containerRef.current + ?.querySelector("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 ( + { + 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" }} + > +
+ +
+
+ ); +}; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 250d47a3..a226d3d2 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -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 && (
+