0bdc9f98f5
Icons are rendered only as <item.icon style={...} stroke={2} />, so type the
field as ComponentType<{ style?; stroke? }> instead of typeof IconBold. stroke is
string|number to match Tabler's own prop type, so Tabler icons and the local
IconStress both satisfy it without the 'as unknown as' cast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
315 lines
9.5 KiB
TypeScript
315 lines
9.5 KiB
TypeScript
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
|
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
|
import type { Editor } from "@tiptap/react";
|
|
import {
|
|
ComponentType,
|
|
CSSProperties,
|
|
FC,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
IconBold,
|
|
IconCode,
|
|
IconItalic,
|
|
IconStrikethrough,
|
|
IconUnderline,
|
|
IconMessage,
|
|
IconEyeOff,
|
|
IconClearFormatting,
|
|
} 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, useAtomValue } 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";
|
|
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
|
import { userAtom } from "@/features/user/atoms/current-user-atom";
|
|
import {
|
|
hasStressAfterSelection,
|
|
toggleStressAccent,
|
|
} from "./stress-accent";
|
|
|
|
// Tabler has no acute-accent glyph (IconGrave is a tombstone), so we ship a
|
|
// tiny local icon that mirrors the Tabler icon API ({ style, stroke }).
|
|
function IconStress({
|
|
style,
|
|
stroke = 2,
|
|
}: {
|
|
style?: React.CSSProperties;
|
|
stroke?: string | number;
|
|
}) {
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={stroke}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
style={style}
|
|
>
|
|
<path d="M5 19l5 -12l5 12" />
|
|
<path d="M7.5 14h5" />
|
|
<path d="M13 5l4 -3" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export interface BubbleMenuItem {
|
|
name: string;
|
|
isActive: () => boolean;
|
|
command: () => void;
|
|
// Rendered as <item.icon style={...} stroke={2} />, so the real contract is
|
|
// just { style?, stroke? }. stroke is string|number to match Tabler's own prop
|
|
// type; Tabler icons and the local IconStress both satisfy it (no cast needed).
|
|
icon: ComponentType<{ style?: CSSProperties; stroke?: string | number }>;
|
|
}
|
|
|
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
|
|
editor: Editor | null;
|
|
templateMode?: boolean;
|
|
};
|
|
|
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|
const { templateMode = false } = props;
|
|
const { t } = useTranslation();
|
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
|
const user = useAtomValue(userAtom);
|
|
const editorToolbarEnabled =
|
|
user?.settings?.preferences?.editorToolbar ?? false;
|
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
|
const showCommentPopupRef = useRef(showCommentPopup);
|
|
const [showLinkMenu] = useAtom(showLinkMenuAtom);
|
|
const showLinkMenuRef = useRef(showLinkMenu);
|
|
|
|
useEffect(() => {
|
|
showCommentPopupRef.current = showCommentPopup;
|
|
}, [showCommentPopup]);
|
|
|
|
useEffect(() => {
|
|
showLinkMenuRef.current = showLinkMenu;
|
|
}, [showLinkMenu]);
|
|
|
|
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"),
|
|
isSpoiler: ctx.editor.isActive("spoiler"),
|
|
// A stress accent already sits right after the selection end.
|
|
isStress: hasStressAfterSelection(ctx.editor.state),
|
|
};
|
|
},
|
|
});
|
|
|
|
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,
|
|
},
|
|
{
|
|
name: "Spoiler",
|
|
isActive: () => editorState?.isSpoiler,
|
|
command: () => props.editor.chain().focus().toggleSpoiler().run(),
|
|
icon: IconEyeOff,
|
|
},
|
|
{
|
|
name: "Stress",
|
|
isActive: () => editorState?.isStress,
|
|
// Toggle the U+0301 combining accent right after the selected letter.
|
|
// The whole toggle is a single transaction, so one Ctrl+Z reverts it.
|
|
command: () => {
|
|
const editor = props.editor;
|
|
editor.view.dispatch(toggleStressAccent(editor.state));
|
|
editor.view.focus();
|
|
},
|
|
icon: IconStress,
|
|
},
|
|
{
|
|
name: "Clear formatting",
|
|
// Action, not a toggle — never show an active/highlighted state.
|
|
isActive: () => false,
|
|
// Mirror the fixed-toolbar behavior: strip all inline marks from the selection.
|
|
command: () => props.editor.chain().focus().unsetAllMarks().run(),
|
|
icon: IconClearFormatting,
|
|
},
|
|
];
|
|
|
|
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) ||
|
|
showLinkMenuRef.current ||
|
|
showCommentPopupRef?.current
|
|
) {
|
|
return false;
|
|
}
|
|
return isTextSelected(editor);
|
|
},
|
|
options: {
|
|
placement: editorToolbarEnabled ? "bottom" : "top",
|
|
offset: 8,
|
|
onHide: () => {
|
|
setIsNodeSelectorOpen(false);
|
|
setIsTextAlignmentOpen(false);
|
|
setIsColorSelectorOpen(false);
|
|
},
|
|
},
|
|
};
|
|
|
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
|
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
|
|
|
// Hide the bubble menu immediately when the link menu is shown
|
|
if (showLinkMenu) return;
|
|
|
|
return (
|
|
<BubbleMenu
|
|
{...bubbleMenuProps}
|
|
style={{ zIndex: 199, position: "relative" }}
|
|
>
|
|
<div className={classes.bubbleMenu}>
|
|
{!editorToolbarEnabled && (
|
|
<>
|
|
<NodeSelector
|
|
editor={props.editor}
|
|
isOpen={isNodeSelectorOpen}
|
|
setIsOpen={() => {
|
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
|
setIsTextAlignmentOpen(false);
|
|
setIsColorSelectorOpen(false);
|
|
}}
|
|
/>
|
|
|
|
<TextAlignmentSelector
|
|
editor={props.editor}
|
|
isOpen={isTextAlignmentSelectorOpen}
|
|
setIsOpen={() => {
|
|
setIsTextAlignmentOpen(!isTextAlignmentSelectorOpen);
|
|
setIsNodeSelectorOpen(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>
|
|
|
|
<ColorSelector
|
|
editor={props.editor}
|
|
isOpen={isColorSelectorOpen}
|
|
setIsOpen={() => {
|
|
setIsColorSelectorOpen(!isColorSelectorOpen);
|
|
setIsNodeSelectorOpen(false);
|
|
setIsTextAlignmentOpen(false);
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<LinkSelector />
|
|
|
|
{!templateMode && (
|
|
<Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
|
|
<ActionIcon
|
|
variant="default"
|
|
size="lg"
|
|
radius="6px"
|
|
aria-label={t(commentItem.name)}
|
|
style={{ border: "none" }}
|
|
onClick={commentItem.command}
|
|
>
|
|
<IconMessage size={16} stroke={2} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</BubbleMenu>
|
|
);
|
|
};
|