mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
657fdf8cb7
* Tiptap3 migration - WIP * fix collaboration * remove unused code * fix flicker * disable duplicate extensions * update tiptap version * Switch to useEditorState - Set shouldRerenderOnTransaction to false * fix editable state * add tippyoptions for reference * merge main * tiptap 3.6.1 * fix bubble menu * fix converter * fix menus * fix collaboration caret css * fix: Set `isInitialized` to force immediate react node view rendering * feat: Migrate tippy.js menus to Floating UI * feat: Update collaboration connection for HocusPocus v3 * fix: Connect/disconnect websocketProvider * cleanup * cleanup * feat: Improved placeholder and upload handling for images * feat: Improved placeholder and upload handling for videos * refactor: Image node and view clean-up * feat: Improved placeholder and upload handling for attachments * fix: Video view styles * fix: Transaction handling on asset upload * fix: Use imageDimensionsFromStream * feat: Multiple file upload, improved placeholders, local previews * fix: Drag & drop, paste upload * fix: Allow media as attachment * * add skeleton pulse animation * add translation strings * fix attachment view responsiveness * fix collab connection status display * Tiptap v3.17.0 * fix suggestion menu exit bug * fix search shortcut * fix history editor css * tiptap 3.17.1 --------- Co-authored-by: Arek Nawo <areknawo@areknawo.com>
229 lines
6.7 KiB
TypeScript
229 lines
6.7 KiB
TypeScript
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
|
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
|
import type { Editor } from "@tiptap/react";
|
|
import { FC, useEffect, useRef, useState } from "react";
|
|
import {
|
|
IconBold,
|
|
IconCode,
|
|
IconItalic,
|
|
IconStrikethrough,
|
|
IconUnderline,
|
|
IconMessage,
|
|
} from "@tabler/icons-react";
|
|
import clsx from "clsx";
|
|
import classes from "./bubble-menu.module.css";
|
|
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
|
import { ColorSelector } from "./color-selector";
|
|
import { NodeSelector } from "./node-selector";
|
|
import { TextAlignmentSelector } from "./text-alignment-selector";
|
|
import {
|
|
draftCommentIdAtom,
|
|
showCommentPopupAtom,
|
|
} from "@/features/comment/atoms/comment-atom";
|
|
import { useAtom } from "jotai";
|
|
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";
|
|
|
|
export interface BubbleMenuItem {
|
|
name: string;
|
|
isActive: () => boolean;
|
|
command: () => void;
|
|
icon: typeof IconBold;
|
|
}
|
|
|
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|
editor: Editor | null;
|
|
};
|
|
|
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|
const { t } = useTranslation();
|
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
|
const showCommentPopupRef = useRef(showCommentPopup);
|
|
|
|
useEffect(() => {
|
|
showCommentPopupRef.current = showCommentPopup;
|
|
}, [showCommentPopup]);
|
|
|
|
const editorState = useEditorState({
|
|
editor: props.editor,
|
|
selector: (ctx) => {
|
|
if (!props.editor) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
isBold: ctx.editor.isActive("bold"),
|
|
isItalic: ctx.editor.isActive("italic"),
|
|
isUnderline: ctx.editor.isActive("underline"),
|
|
isStrike: ctx.editor.isActive("strike"),
|
|
isCode: ctx.editor.isActive("code"),
|
|
isComment: ctx.editor.isActive("comment"),
|
|
};
|
|
},
|
|
});
|
|
|
|
const items: BubbleMenuItem[] = [
|
|
{
|
|
name: "Bold",
|
|
isActive: () => editorState?.isBold,
|
|
command: () => props.editor.chain().focus().toggleBold().run(),
|
|
icon: IconBold,
|
|
},
|
|
{
|
|
name: "Italic",
|
|
isActive: () => editorState?.isItalic,
|
|
command: () => props.editor.chain().focus().toggleItalic().run(),
|
|
icon: IconItalic,
|
|
},
|
|
{
|
|
name: "Underline",
|
|
isActive: () => editorState?.isUnderline,
|
|
command: () => props.editor.chain().focus().toggleUnderline().run(),
|
|
icon: IconUnderline,
|
|
},
|
|
{
|
|
name: "Strike",
|
|
isActive: () => editorState?.isStrike,
|
|
command: () => props.editor.chain().focus().toggleStrike().run(),
|
|
icon: IconStrikethrough,
|
|
},
|
|
{
|
|
name: "Code",
|
|
isActive: () => editorState?.isCode,
|
|
command: () => props.editor.chain().focus().toggleCode().run(),
|
|
icon: IconCode,
|
|
},
|
|
];
|
|
|
|
const commentItem: BubbleMenuItem = {
|
|
name: "Comment",
|
|
isActive: () => editorState?.isComment,
|
|
command: () => {
|
|
const commentId = uuid7();
|
|
|
|
props.editor.chain().focus().setCommentDecoration().run();
|
|
setDraftCommentId(commentId);
|
|
setShowCommentPopup(true);
|
|
},
|
|
icon: IconMessage,
|
|
};
|
|
|
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
|
...props,
|
|
shouldShow: ({ state, editor }) => {
|
|
const { selection } = state;
|
|
const { empty } = selection;
|
|
|
|
if (
|
|
!editor.isEditable ||
|
|
editor.isActive("image") ||
|
|
empty ||
|
|
isNodeSelection(selection) ||
|
|
isCellSelection(selection) ||
|
|
showCommentPopupRef?.current
|
|
) {
|
|
return false;
|
|
}
|
|
return isTextSelected(editor);
|
|
},
|
|
options: {
|
|
placement: "top",
|
|
offset: 8,
|
|
onHide: () => {
|
|
setIsNodeSelectorOpen(false);
|
|
setIsTextAlignmentOpen(false);
|
|
setIsLinkSelectorOpen(false);
|
|
setIsColorSelectorOpen(false);
|
|
},
|
|
},
|
|
};
|
|
|
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
|
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
|
|
|
return (
|
|
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}>
|
|
<div className={classes.bubbleMenu}>
|
|
<NodeSelector
|
|
editor={props.editor}
|
|
isOpen={isNodeSelectorOpen}
|
|
setIsOpen={() => {
|
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
|
setIsTextAlignmentOpen(false);
|
|
setIsLinkSelectorOpen(false);
|
|
setIsColorSelectorOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<TextAlignmentSelector
|
|
editor={props.editor}
|
|
isOpen={isTextAlignmentSelectorOpen}
|
|
setIsOpen={() => {
|
|
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
|
setIsNodeSelectorOpen(false);
|
|
setIsLinkSelectorOpen(false);
|
|
setIsColorSelectorOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<ActionIcon.Group>
|
|
{items.map((item, index) => (
|
|
<Tooltip key={index} label={t(item.name)} withArrow>
|
|
<ActionIcon
|
|
key={index}
|
|
variant="default"
|
|
size="lg"
|
|
radius="0"
|
|
aria-label={t(item.name)}
|
|
className={clsx({ [classes.active]: item.isActive() })}
|
|
style={{ border: "none" }}
|
|
onClick={item.command}
|
|
>
|
|
<item.icon style={{ width: rem(16) }} stroke={2} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
))}
|
|
</ActionIcon.Group>
|
|
|
|
<LinkSelector
|
|
editor={props.editor}
|
|
isOpen={isLinkSelectorOpen}
|
|
setIsOpen={(value) => {
|
|
setIsLinkSelectorOpen(value);
|
|
setIsNodeSelectorOpen(false);
|
|
setIsTextAlignmentOpen(false);
|
|
setIsColorSelectorOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<ColorSelector
|
|
editor={props.editor}
|
|
isOpen={isColorSelectorOpen}
|
|
setIsOpen={() => {
|
|
setIsColorSelectorOpen(!isColorSelectorOpen);
|
|
setIsNodeSelectorOpen(false);
|
|
setIsTextAlignmentOpen(false);
|
|
setIsLinkSelectorOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<ActionIcon
|
|
variant="default"
|
|
size="lg"
|
|
radius="0"
|
|
aria-label={t(commentItem.name)}
|
|
style={{ border: "none" }}
|
|
onClick={commentItem.command}
|
|
>
|
|
<IconMessage size={16} stroke={2} />
|
|
</ActionIcon>
|
|
</div>
|
|
</BubbleMenu>
|
|
);
|
|
};
|