forked from github/plane
updated bubble menu to use core package's utilities
This commit is contained in:
parent
37d5e83e92
commit
6cb9f73006
@ -1,121 +0,0 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
|
||||||
import { FC, useState } from "react";
|
|
||||||
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { NodeSelector } from "./node-selector";
|
|
||||||
import { LinkSelector } from "./link-selector";
|
|
||||||
import { cn } from "../../../lib/utils";
|
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
|
||||||
name: string;
|
|
||||||
isActive: () => boolean;
|
|
||||||
command: () => void;
|
|
||||||
icon: typeof BoldIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
|
||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
|
||||||
const items: BubbleMenuItem[] = [
|
|
||||||
{
|
|
||||||
name: "bold",
|
|
||||||
isActive: () => props.editor?.isActive("bold"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
|
||||||
icon: BoldIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "italic",
|
|
||||||
isActive: () => props.editor?.isActive("italic"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
|
||||||
icon: ItalicIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underline",
|
|
||||||
isActive: () => props.editor?.isActive("underline"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleUnderline().run(),
|
|
||||||
icon: UnderlineIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strike",
|
|
||||||
isActive: () => props.editor?.isActive("strike"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
|
||||||
icon: StrikethroughIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "code",
|
|
||||||
isActive: () => props.editor?.isActive("code"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleCode().run(),
|
|
||||||
icon: CodeIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
|
||||||
...props,
|
|
||||||
shouldShow: ({ editor }) => {
|
|
||||||
if (!editor.isEditable) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (editor.isActive("image")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return editor.view.state.selection.content().size > 0;
|
|
||||||
},
|
|
||||||
tippyOptions: {
|
|
||||||
moveTransition: "transform 0.15s ease-out",
|
|
||||||
onHidden: () => {
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BubbleMenu
|
|
||||||
{...bubbleMenuProps}
|
|
||||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
|
||||||
>
|
|
||||||
{!props.editor.isActive("table") && (
|
|
||||||
<NodeSelector
|
|
||||||
editor={props.editor!}
|
|
||||||
isOpen={isNodeSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
|
||||||
setIsLinkSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<LinkSelector
|
|
||||||
editor={props.editor!!}
|
|
||||||
isOpen={isLinkSelectorOpen}
|
|
||||||
setIsOpen={() => {
|
|
||||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
|
||||||
setIsNodeSelectorOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={item.command}
|
|
||||||
className={cn(
|
|
||||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
|
||||||
{
|
|
||||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4", {
|
|
||||||
"text-custom-text-100": item.isActive(),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</BubbleMenu>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,93 +0,0 @@
|
|||||||
import { cn } from "../../../lib/utils";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Check, Trash } from "lucide-react";
|
|
||||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
|
||||||
import isValidHttpUrl from "./utils";
|
|
||||||
|
|
||||||
interface LinkSelectorProps {
|
|
||||||
editor: Editor;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const onLinkSubmit = useCallback(() => {
|
|
||||||
const input = inputRef.current;
|
|
||||||
const url = input?.value;
|
|
||||||
if (url && isValidHttpUrl(url)) {
|
|
||||||
editor.chain().focus().setLink({ href: url }).run();
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}, [editor, inputRef, setIsOpen]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current && inputRef.current?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
|
||||||
{ "bg-custom-background-100": isOpen }
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-base">↗</p>
|
|
||||||
<p
|
|
||||||
className={cn("underline underline-offset-4", {
|
|
||||||
"text-custom-text-100": editor.isActive("link"),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Link
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
onLinkSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="url"
|
|
||||||
placeholder="Paste a link"
|
|
||||||
className="flex-1 bg-custom-background-100 border-r border-custom-border-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
|
||||||
defaultValue={editor.getAttributes("link").href || ""}
|
|
||||||
/>
|
|
||||||
{editor.getAttributes("link").href ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
|
||||||
onClick={() => {
|
|
||||||
editor.chain().focus().unsetLink().run();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onLinkSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,130 +0,0 @@
|
|||||||
import { cn } from "../../../lib/utils";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
Heading1,
|
|
||||||
Heading2,
|
|
||||||
Heading3,
|
|
||||||
TextQuote,
|
|
||||||
ListOrdered,
|
|
||||||
TextIcon,
|
|
||||||
Code,
|
|
||||||
CheckSquare,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Dispatch, FC, SetStateAction } from "react";
|
|
||||||
|
|
||||||
import { BubbleMenuItem } from ".";
|
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
|
||||||
editor: Editor;
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
|
||||||
const items: BubbleMenuItem[] = [
|
|
||||||
{
|
|
||||||
name: "Text",
|
|
||||||
icon: TextIcon,
|
|
||||||
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
|
|
||||||
isActive: () =>
|
|
||||||
editor.isActive("paragraph") &&
|
|
||||||
!editor.isActive("bulletList") &&
|
|
||||||
!editor.isActive("orderedList"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "H1",
|
|
||||||
icon: Heading1,
|
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
||||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "H2",
|
|
||||||
icon: Heading2,
|
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
||||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "H3",
|
|
||||||
icon: Heading3,
|
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
|
||||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "To-do List",
|
|
||||||
icon: CheckSquare,
|
|
||||||
command: () => editor.chain().focus().toggleTaskList().run(),
|
|
||||||
isActive: () => editor.isActive("taskItem"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Bullet List",
|
|
||||||
icon: ListOrdered,
|
|
||||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
|
||||||
isActive: () => editor.isActive("bulletList"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Numbered List",
|
|
||||||
icon: ListOrdered,
|
|
||||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
|
||||||
isActive: () => editor.isActive("orderedList"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Quote",
|
|
||||||
icon: TextQuote,
|
|
||||||
command: () =>
|
|
||||||
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
|
||||||
isActive: () => editor.isActive("blockquote"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Code",
|
|
||||||
icon: Code,
|
|
||||||
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
|
||||||
isActive: () => editor.isActive("codeBlock"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
|
||||||
name: "Multiple",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-full">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
|
||||||
>
|
|
||||||
<span>{activeItem?.name}</span>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
item.command();
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="rounded-sm border border-custom-border-300 p-1">
|
|
||||||
<item.icon className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<span>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,11 +0,0 @@
|
|||||||
export default function isValidHttpUrl(string: string): boolean {
|
|
||||||
let url: URL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
url = new URL(string);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
|
||||||
}
|
|
@ -1,10 +1,10 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
import { BoldIcon } from "lucide-react";
|
||||||
|
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { LinkSelector } from "./link-selector";
|
import { LinkSelector } from "./link-selector";
|
||||||
import { cn } from "@plane/editor-core";
|
import { BoldItem, cn, CodeItem, ItalicItem, StrikeThroughItem, UnderLineItem } from "@plane/editor-core";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -17,36 +17,11 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
|||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
const items: BubbleMenuItem[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
BoldItem(props.editor),
|
||||||
name: "bold",
|
ItalicItem(props.editor),
|
||||||
isActive: () => props.editor?.isActive("bold"),
|
UnderLineItem(props.editor),
|
||||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
StrikeThroughItem(props.editor),
|
||||||
icon: BoldIcon,
|
CodeItem(props.editor),
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "italic",
|
|
||||||
isActive: () => props.editor?.isActive("italic"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
|
||||||
icon: ItalicIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "underline",
|
|
||||||
isActive: () => props.editor?.isActive("underline"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleUnderline().run(),
|
|
||||||
icon: UnderlineIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "strike",
|
|
||||||
isActive: () => props.editor?.isActive("strike"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
|
||||||
icon: StrikethroughIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "code",
|
|
||||||
isActive: () => props.editor?.isActive("code"),
|
|
||||||
command: () => props.editor?.chain().focus().toggleCode().run(),
|
|
||||||
icon: CodeIcon,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
|
Loading…
Reference in New Issue
Block a user