mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: replace link popover with dedicated bubble menu
This commit is contained in:
@@ -10,3 +10,5 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
export const showAiMenuAtom = atom(false);
|
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 { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
@@ -49,6 +49,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
const showAiMenuRef = useRef(showAiMenu);
|
const showAiMenuRef = useRef(showAiMenu);
|
||||||
|
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
||||||
|
const showLinkMenuRef = useRef(showLinkMenu);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
@@ -58,6 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
showAiMenuRef.current = showAiMenu;
|
showAiMenuRef.current = showAiMenu;
|
||||||
}, [showAiMenu]);
|
}, [showAiMenu]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showLinkMenuRef.current = showLinkMenu;
|
||||||
|
}, [showLinkMenu]);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
selector: (ctx) => {
|
selector: (ctx) => {
|
||||||
@@ -135,6 +141,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
isNodeSelection(selection) ||
|
isNodeSelection(selection) ||
|
||||||
isCellSelection(selection) ||
|
isCellSelection(selection) ||
|
||||||
showAiMenuRef.current ||
|
showAiMenuRef.current ||
|
||||||
|
showLinkMenuRef.current ||
|
||||||
showCommentPopupRef?.current
|
showCommentPopupRef?.current
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -147,7 +154,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
onHide: () => {
|
onHide: () => {
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -155,11 +161,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
|
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
// Hide the bubble menu immediately when AI menu is shown
|
// Hide the bubble menu immediately when AI menu is shown
|
||||||
if (showAiMenu) return;
|
if (showAiMenu || showLinkMenu) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BubbleMenu
|
<BubbleMenu
|
||||||
@@ -189,7 +194,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||||
setIsTextAlignmentOpen(false);
|
setIsTextAlignmentOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -200,7 +204,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
setIsOpen={() => {
|
setIsOpen={() => {
|
||||||
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
setIsColorSelectorOpen(false);
|
setIsColorSelectorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -224,16 +227,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
))}
|
))}
|
||||||
</ActionIcon.Group>
|
</ActionIcon.Group>
|
||||||
|
|
||||||
<LinkSelector
|
<LinkSelector />
|
||||||
editor={props.editor}
|
|
||||||
isOpen={isLinkSelectorOpen}
|
|
||||||
setIsOpen={(value) => {
|
|
||||||
setIsLinkSelectorOpen(value);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
setIsTextAlignmentOpen(false);
|
|
||||||
setIsColorSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ColorSelector
|
<ColorSelector
|
||||||
editor={props.editor}
|
editor={props.editor}
|
||||||
@@ -242,7 +236,6 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
setIsColorSelectorOpen(!isColorSelectorOpen);
|
setIsColorSelectorOpen(!isColorSelectorOpen);
|
||||||
setIsNodeSelectorOpen(false);
|
setIsNodeSelectorOpen(false);
|
||||||
setIsTextAlignmentOpen(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 { IconLink } from "@tabler/icons-react";
|
||||||
import { ActionIcon, Popover, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { useEditor } from "@tiptap/react";
|
import { useSetAtom } from "jotai";
|
||||||
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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
|
|
||||||
interface LinkSelectorProps {
|
export const LinkSelector: FC = () => {
|
||||||
editor: ReturnType<typeof useEditor>;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LinkSelector: FC<LinkSelectorProps> = ({
|
|
||||||
editor,
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const onLink = useCallback(
|
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
|
||||||
(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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Tooltip label={t("Add link")} withArrow>
|
||||||
width={320}
|
<ActionIcon
|
||||||
opened={isOpen}
|
variant="default"
|
||||||
trapFocus
|
size="lg"
|
||||||
offset={{ mainAxis: 35, crossAxis: 0 }}
|
radius="0"
|
||||||
withArrow
|
style={{ border: "none" }}
|
||||||
shadow="md"
|
onClick={() => setShowLinkMenu(true)}
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<IconLink size={16} />
|
||||||
<Tooltip label={t("Add link")} withArrow>
|
</ActionIcon>
|
||||||
<ActionIcon
|
</Tooltip>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const LinkEditorPanel = ({
|
|||||||
includeUsers: false,
|
includeUsers: false,
|
||||||
includePages: true,
|
includePages: true,
|
||||||
spaceId: space?.id,
|
spaceId: space?.id,
|
||||||
limit: state.isSearchQuery ? 10 : 5,
|
limit: state.isSearchQuery ? 10 : 3,
|
||||||
preload: true,
|
preload: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,6 +105,7 @@ export const LinkEditorPanel = ({
|
|||||||
value={state.url}
|
value={state.url}
|
||||||
onChange={state.onChange}
|
onChange={state.onChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
data-autofocus
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</form>
|
</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 { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
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";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
@@ -407,6 +408,7 @@ export default function PageEditor({
|
|||||||
{editor && editorIsEditable && (
|
{editor && editorIsEditable && (
|
||||||
<div>
|
<div>
|
||||||
<EditorAiMenu editor={editor} />
|
<EditorAiMenu editor={editor} />
|
||||||
|
<EditorLinkMenu editor={editor} />
|
||||||
<EditorBubbleMenu editor={editor} />
|
<EditorBubbleMenu editor={editor} />
|
||||||
<TableMenu editor={editor} />
|
<TableMenu editor={editor} />
|
||||||
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
|
|||||||
Reference in New Issue
Block a user